Files
notas-online/web/js/app.js
T
lgcosta 6e0ec29888 fix(security): corrige achados da varredura — render, brute-force, upload, WYSIWYG, cookie, sessões
1. /api/render: cap de 512 KiB na entrada (amplificação ~2× medida);
   decodeJSON agora amarra http.MaxBytesReader ao ResponseWriter (413
   real ao cliente em todos os endpoints JSON)
2. Login com rate-limit por IP em memória (5 falhas/15 min → 429, acerto
   reseta, RemoteAddr puro — X-F-F não é confiável) + log de falha com IP
3. Upload: MaxBytesReader no corpo inteiro (64 MiB + 64 KiB) antes do
   parse do multipart; quota global de 2 GiB (MaxFilesTotalBytes) em AddFile
4. sanitizeRich neutraliza href/src com javascript:/vbscript:/data:
   (case/whitespace-insensível); link legítimo preservado
5. Cookie Secure passa a ser configuração explícita (NOTES_SECURE_COOKIE)
   — X-Forwarded-Proto é spoofável; documentado em .env.example e compose
6. Cookie agora carrega id de sessão opaco (crypto/rand 32B, TTL 30d,
   revogação no logout e no restart); token mestre só vale via Bearer —
   furo pego pelo próprio teste novo (token no cookie era aceito)
   Testes: rate-limit, revogação, canais separados, render 413, upload cap
   sw v16
2026-11-05 18:58:10 -03:00

2631 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Notas — frontend estilo Mousepad (vanilla JS, sem dependências). */
'use strict';
(() => {
const $ = (s) => document.querySelector(s);
const E = {
loginOverlay: $('#login-overlay'), loginForm: $('#login-form'), loginToken: $('#login-token'),
loginError: $('#login-error'),
modalOverlay: $('#modal-overlay'), modalTitle: $('#modal-title'), modalBody: $('#modal-body'),
modalOk: $('#modal-ok'), modalCancel: $('#modal-cancel'),
toolbar: $('#toolbar'), fmtTools: $('#fmt-tools'), formatSelect: $('#format-select'),
modeToggle: $('#mode-toggle'),
btnNew: $('#btn-new'), btnSave: $('#btn-save'), btnExport: $('#btn-export'),
btnVersions: $('#btn-versions'), btnSidebar: $('#btn-sidebar'), btnCloseVersions: $('#btn-close-versions'),
searchInput: $('#search-input'), tagCloud: $('#tag-cloud'),
catTree: $('#cat-tree'), btnNewCat: $('#btn-new-cat'),
notesList: $('#notes-list'), notesCount: $('#notes-count'),
tabs: $('#tabs'), btnTabNew: $('#btn-tab-new'),
noteTitle: $('#note-title'), btnCats: $('#btn-cats'), catPicker: $('#cat-picker'),
presenceBadge: $('#presence-badge'),
editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'),
gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'),
preview: $('#preview'), previewFrame: $('#preview-frame'), wysiwyg: $('#wysiwyg'),
versionsPanel: $('#versions-panel'), revisionsList: $('#revisions-list'),
versionDetail: $('#version-detail'), versionInfo: $('#version-info'), versionDiff: $('#version-diff'),
btnRestore: $('#btn-restore'), revMessage: $('#rev-message'), authorInput: $('#author-input'),
stFile: $('#st-file'), stFormat: $('#st-format'), stPos: $('#st-pos'),
stStats: $('#st-stats'), stSaved: $('#st-saved'),
toast: $('#toast'), backdrop: $('#backdrop'), ctxMenu: $('#ctx-menu'),
};
const EXT = { txt: '.txt', md: '.md', html: '.html' };
const TABS_KEY = 'notas_tabs';
const EXPAND_KEY = 'notas_expanded';
const DRAFT_MAX = 200 * 1024;
// id de sessão por aba do navegador (sessionStorage): presença + eventos
const SID = (() => {
let v = sessionStorage.getItem('notas_sid');
if (!v) {
v = (crypto.randomUUID ? crypto.randomUUID() : 's' + Date.now() + '-' + Math.random().toString(36).slice(2));
sessionStorage.setItem('notas_sid', v);
}
return v;
})();
const state = {
tabs: [], // abas abertas (nota ou rascunho)
activeKey: null,
filter: { cat: '', tag: '', q: '' }, // cat: '' | 'none' | id
notes: [],
cats: [], // lista plana de CategoryView {id,name,parent,note_count,has_child}
expanded: new Set(JSON.parse(localStorage.getItem(EXPAND_KEY) || '[]')),
selectedRev: null,
ctxCat: null,
presence: {}, // noteID → [session,...] (exclui a própria sessão)
booted: false,
};
let tabSeq = 0;
const activeTab = () => state.tabs.find((t) => t.key === state.activeKey) || null;
function newTab(init) {
return Object.assign({
key: 't' + (++tabSeq),
id: null, note: null,
title: '', format: 'md', categories: [], tags: [],
content: '', dirty: false, hash: '', lastSaved: null,
}, init);
}
function tabFromNote(data) {
return newTab({
id: data.id, note: data, title: data.title, format: data.format,
categories: data.categories || [], tags: data.tags || [],
content: data.content, hash: data.hash, lastSaved: data.updated_at,
});
}
/* ------------------------------------------------------------ util */
class ApiError extends Error {
constructor(msg, status) { super(msg); this.name = 'ApiError'; this.status = status; }
}
async function api(method, path, body) {
const opt = { method, headers: {} };
if (body !== undefined) {
opt.headers['Content-Type'] = 'application/json';
opt.body = JSON.stringify(body);
}
const resp = await fetch(path, opt);
if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); }
if (resp.status === 204) return null;
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
const err = new ApiError(data.error || resp.statusText, resp.status);
err.data = data;
throw err;
}
return data;
}
let toastTimer = 0;
function toast(msg) {
E.toast.textContent = msg;
E.toast.classList.remove('hidden');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => E.toast.classList.add('hidden'), 2800);
}
function relTime(iso) {
const t = new Date(iso).getTime();
const d = Math.floor((Date.now() - t) / 1000);
if (d < 45) return 'agora mesmo';
if (d < 3600) return `há ${Math.floor(d / 60)} min`;
if (d < 86400) return `há ${Math.floor(d / 3600)} h`;
if (d < 7 * 86400) return `há ${Math.floor(d / 86400)} d`;
return new Date(t).toLocaleDateString('pt-BR');
}
function esc(s) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
const isMobile = () => window.matchMedia('(max-width: 760px)').matches;
const catName = (id) => { const c = state.cats.find((x) => x.id === id); return c ? c.name : '?'; };
const currentFormat = () => E.formatSelect.value;
const currentTitle = () => {
const t = E.noteTitle.value.trim();
if (t) return t;
const src = (activeTab() && activeTab().content) || '';
const first = (src.trim().split('\n')[0] || '').slice(0, 42);
return first || 'Sem título';
};
function confirmModal(title, bodyHTML, okLabel) {
return modal({ title, bodyHTML, withCancel: true, okLabel });
}
function confirmDiscardTab(tab) {
if (!tab || !tab.dirty) return Promise.resolve(true);
return confirmModal('Descartar alterações?',
`As alterações não salvas de <b>${esc(tab.title || 'Sem título')}</b> serão perdidas.`, 'Descartar');
}
function modal({ title, bodyHTML, okLabel = 'OK', cancelLabel = 'Cancelar', withCancel = false }) {
return new Promise((resolve) => {
E.modalTitle.textContent = title;
E.modalBody.innerHTML = bodyHTML; // conteúdo é sempre texto autor do app
E.modalOk.textContent = okLabel;
E.modalCancel.textContent = cancelLabel;
E.modalCancel.classList.toggle('hidden', !withCancel);
E.modalOverlay.classList.remove('hidden');
const done = (ok) => {
E.modalOverlay.classList.add('hidden');
E.modalOk.removeEventListener('click', okClick);
E.modalCancel.removeEventListener('click', cancelClick);
resolve(ok);
};
const okClick = () => done(true);
const cancelClick = () => done(false);
E.modalOk.addEventListener('click', okClick);
E.modalCancel.addEventListener('click', cancelClick);
});
}
/* ------------------------------------------------------------ login */
function showLogin() {
E.loginOverlay.classList.remove('hidden');
E.loginToken.focus();
}
function hideLogin() {
E.loginOverlay.classList.add('hidden');
E.loginError.classList.add('hidden');
E.loginToken.value = '';
}
E.loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const token = E.loginToken.value.trim();
if (!token) return;
const resp = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
}).catch(() => null);
if (resp && resp.ok) {
hideLogin();
await boot();
} else {
E.loginError.textContent = resp ? 'Token inválido. Tente novamente.' : 'Servidor indisponível.';
E.loginError.classList.remove('hidden');
}
});
/* --------------------------------------------- presença + SSE (multi-sessão) */
const openNoteIds = () => state.tabs.filter((t) => t.id).map((t) => t.id);
function sendPresence() {
fetch('/api/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: SID, notes: openNoteIds() }),
}).catch(() => { /* offline: heartbeat tenta de novo */ });
}
let es = null;
function connectEvents() {
if (es) es.close();
es = new EventSource('/api/events?session=' + encodeURIComponent(SID));
es.onmessage = (e) => {
let ev;
try { ev = JSON.parse(e.data); } catch { return; }
handleServerEvent(ev);
};
// onerror: EventSource reconecta sozinho; o checkFreshness no focus é a rede de segurança
}
function handleServerEvent(ev) {
if (ev.type === 'hello' || ev.type === 'presence') { applyPresence(ev.sessions || []); return; }
if (ev.type !== 'saved') return;
const t = state.tabs.find((x) => x.id === ev.note_id);
if (!t || ev.session === SID) return; // nota não aberta aqui, ou fui eu que salvei
if (ev.deleted) {
if (!t.dirty) {
closeTab(t.key, true);
toast('“' + (ev.title || 'Sem título') + '” foi excluída por outra sessão.');
} else {
toast('Cuidado: outra sessão excluiu esta nota; o texto local não salvo não poderá ser gravado.');
}
return;
}
if (t.hash === ev.hash) return;
t.remoteHash = ev.hash;
if (!t.dirty) {
// aba limpa: acompanha o servidor em tempo real
api('GET', '/api/notes/' + t.id)
.then((d) => {
applyNoteToTab(t, d);
if (t.key === state.activeKey) {
toast('“' + (d.title || 'Sem título') + '” atualizada por outra sessão — recarregada');
}
})
.catch(() => {});
} else if (t.key === state.activeKey) {
resolveConflict(t, null);
} else {
renderTabs();
}
}
function applyPresence(sessions) {
const map = {};
for (const s of sessions) {
if (s.session === SID) continue;
for (const n of s.notes || []) (map[n] = map[n] || []).push(s.session);
}
state.presence = map;
renderTabs();
renderNotesList();
updatePresenceBadge();
}
function updatePresenceBadge() {
const t = activeTab();
const others = t && t.id ? (state.presence[t.id] || []) : [];
E.presenceBadge.classList.toggle('hidden', !others.length);
if (others.length) {
E.presenceBadge.textContent = others.length === 1
? '👥 outra sessão aberta aqui'
: `👥 ${others.length} outras sessões abertas aqui`;
}
}
/* -------------------------------------------------------------- abas */
function addTab(tab, activate = true) {
state.tabs.push(tab);
sendPresence();
if (activate) activateTab(tab.key);
else renderTabs();
}
function activateTab(key) {
const tab = state.tabs.find((t) => t.key === key);
if (!tab || tab.key === state.activeKey) return;
// trocar de aba = fim da edição da anterior: grava pendência (silencioso)
const prev = activeTab();
if (prev && prev.dirty && !prev.conflictPending && prefs.autosave) save(true);
state.activeKey = key;
state.selectedRev = null;
fillEditor();
renderTabs();
renderNotesList();
persistTabs();
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
}
async function openNoteTab(id) {
const existing = state.tabs.find((t) => t.id === id);
if (existing) { activateTab(existing.key); closeDrawer(); return; }
let data;
try {
data = await api('GET', '/api/notes/' + id);
} catch (err) {
toast('Abrir nota: ' + err.message);
return;
}
addTab(tabFromNote(data));
closeDrawer();
}
async function newDraftTab() {
addTab(newTab());
E.editor.focus();
closeDrawer();
}
async function closeTab(key, force) {
const idx = state.tabs.findIndex((t) => t.key === key);
if (idx < 0) return;
if (!force && !(await confirmDiscardTab(state.tabs[idx]))) return;
state.tabs.splice(idx, 1);
sendPresence();
if (!state.tabs.length) { addTab(newTab()); return; }
if (state.activeKey === key) {
const next = state.tabs[Math.min(idx, state.tabs.length - 1)];
activateTab(next.key);
} else {
renderTabs();
}
persistTabs();
}
function cycleTab(dir) {
if (state.tabs.length < 2) return;
const i = state.tabs.findIndex((t) => t.key === state.activeKey);
const next = state.tabs[(i + dir + state.tabs.length) % state.tabs.length];
activateTab(next.key);
}
let dragTabKey = null;
function clearTabDropMarks() {
E.tabs.querySelectorAll('.drop-before, .drop-after').forEach((x) => x.classList.remove('drop-before', 'drop-after'));
}
function moveTab(srcKey, dstKey, before) {
const from = state.tabs.findIndex((t) => t.key === srcKey);
if (from < 0) return;
const [tab] = state.tabs.splice(from, 1);
let to = state.tabs.findIndex((t) => t.key === dstKey);
if (to < 0) { state.tabs.splice(from, 0, tab); return; }
state.tabs.splice(before ? to : to + 1, 0, tab);
renderTabs();
persistTabs();
}
// soltar na área vazia da aba-barra move para o fim
E.tabs.addEventListener('dragover', (e) => { if (dragTabKey) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } });
E.tabs.addEventListener('drop', (e) => {
if (!dragTabKey) return;
e.preventDefault();
const tab = state.tabs.find((t) => t.key === dragTabKey);
if (tab) {
state.tabs.splice(state.tabs.indexOf(tab), 1);
state.tabs.push(tab);
renderTabs();
persistTabs();
}
dragTabKey = null;
clearTabDropMarks();
});
function renderTabs() {
E.tabs.textContent = '';
for (const t of state.tabs) {
const el = document.createElement('button');
el.type = 'button';
el.className = 'tab' + (t.key === state.activeKey ? ' active' : '') + (t.dirty ? ' dirty' : '');
el.dataset.key = t.key;
el.title = (t.title || 'Sem título') + EXT[t.format] + (t.dirty ? ' — não salvo' : '');
el.draggable = true;
const label = document.createElement('span');
label.className = 'tab-label';
label.textContent = (t.title || 'Sem título') + EXT[t.format];
el.appendChild(label);
if (t.id && state.presence[t.id] && state.presence[t.id].length) {
const pres = document.createElement('span');
pres.className = 'tab-pres';
pres.textContent = '👥';
pres.title = 'outra(s) sessão(ões) com esta nota aberta';
el.appendChild(pres);
}
const close = document.createElement('span');
close.className = 'tab-close';
close.textContent = '✕';
el.appendChild(close);
el.addEventListener('click', (e) => {
if (e.target === close) { e.stopPropagation(); closeTab(t.key); }
else activateTab(t.key);
});
el.addEventListener('auxclick', (e) => { if (e.button === 1) closeTab(t.key); });
el.addEventListener('dragstart', (e) => {
if (e.target === close) { e.preventDefault(); return; }
dragTabKey = t.key;
el.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', t.key); } catch { /* ignore */ }
});
el.addEventListener('dragend', () => {
dragTabKey = null;
el.classList.remove('dragging');
clearTabDropMarks();
});
el.addEventListener('dragover', (e) => {
if (!dragTabKey || dragTabKey === t.key) return;
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'move';
const r = el.getBoundingClientRect();
const before = e.clientX < r.left + r.width / 2;
clearTabDropMarks();
el.classList.add(before ? 'drop-before' : 'drop-after');
});
el.addEventListener('dragleave', () => el.classList.remove('drop-before', 'drop-after'));
el.addEventListener('drop', (e) => {
if (!dragTabKey || dragTabKey === t.key) return;
e.preventDefault();
e.stopPropagation();
const r = el.getBoundingClientRect();
moveTab(dragTabKey, t.key, e.clientX < r.left + r.width / 2);
dragTabKey = null;
clearTabDropMarks();
});
E.tabs.appendChild(el);
}
}
let persistTimer = 0;
function persistTabs() {
clearTimeout(persistTimer);
persistTimer = setTimeout(() => {
try {
const open = state.tabs.map((t) => {
if (t.id) return { id: t.id };
const content = t.content.length > DRAFT_MAX ? t.content.slice(0, DRAFT_MAX) : t.content;
return { draft: { title: t.title, format: t.format, categories: t.categories, content } };
});
const active = Math.max(0, state.tabs.findIndex((t) => t.key === state.activeKey));
localStorage.setItem(TABS_KEY, JSON.stringify({ open, active }));
} catch { /* quota cheia: ignora */ }
}, 400);
}
async function restoreTabs() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem(TABS_KEY) || 'null'); } catch { /* ignore */ }
const opened = [];
if (saved && Array.isArray(saved.open)) {
for (const item of saved.open) {
if (item.id) {
try { opened.push(tabFromNote(await api('GET', '/api/notes/' + item.id))); } catch { /* nota excluída */ }
} else if (item.draft) {
const d = item.draft;
opened.push(newTab({
title: d.title || '', format: d.format || 'md',
categories: Array.isArray(d.categories) ? d.categories : [],
content: d.content || '', dirty: !!(d.content || d.title),
}));
}
}
}
if (!opened.length) {
if (state.notes.length) {
try { opened.push(tabFromNote(await api('GET', '/api/notes/' + state.notes[0].id))); } catch { /* ignore */ }
}
if (!opened.length) opened.push(newTab());
}
// índice salvo pode ser inválido (-1 de sessão encerrada sem aba ativa, etc.)
state.tabs = opened;
const raw = saved && Number.isInteger(saved.active) ? saved.active : 0;
const want = Math.min(Math.max(0, raw), opened.length - 1);
activateTab(opened[want].key);
}
/* ------------------------------------------------------------ dados */
async function loadNotes() {
const f = state.filter;
const q = new URLSearchParams();
if (f.cat) q.set('category', f.cat);
if (f.tag) q.set('tag', f.tag);
if (f.q) q.set('q', f.q);
const suffix = q.toString() ? '?' + q.toString() : '';
state.notes = await api('GET', '/api/notes' + suffix);
renderNotesList();
}
async function loadAggregates() {
const [tags, cats] = await Promise.all([api('GET', '/api/tags'), api('GET', '/api/categories')]);
state.cats = cats;
renderTagCloud(tags);
renderCatTree();
renderCatPicker();
updateCatButton();
}
function renderTagCloud(tags) {
if (!tags.length) {
E.tagCloud.innerHTML = '<span class="muted">nenhuma tag ainda</span>';
return;
}
E.tagCloud.textContent = '';
for (const t of tags) {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tag-chip' + (state.filter.tag === t.tag ? ' active' : '');
b.dataset.tag = t.tag;
b.textContent = '#' + t.tag;
const n = document.createElement('span');
n.className = 'tag-n';
n.textContent = String(t.count);
b.appendChild(n);
b.addEventListener('click', () => {
state.filter.tag = state.filter.tag === t.tag ? '' : t.tag;
loadAggregates().catch(() => {});
loadNotes().catch(() => {});
});
E.tagCloud.appendChild(b);
}
}
function renderNotesList() {
E.notesList.textContent = '';
E.notesCount.textContent = String(state.notes.length);
const openId = activeTab() && activeTab().id;
for (const n of state.notes) {
const li = document.createElement('li');
li.className = n.id === openId ? 'active' : '';
const main = document.createElement('div');
main.className = 'n-main';
const title = document.createElement('div');
title.className = 'n-title';
title.textContent = n.title || 'Sem título';
const ext = document.createElement('span');
ext.className = 'n-ext';
ext.textContent = ' ' + EXT[n.format];
title.appendChild(ext);
const sub = document.createElement('div');
sub.className = 'n-sub';
const bits = [relTime(n.updated_at)];
if (n.categories && n.categories.length) bits.push(n.categories.map(catName).join(' · '));
if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' '));
if (state.presence[n.id] && state.presence[n.id].length) bits.push('👥 aberta por outra sessão');
sub.textContent = bits.join(' · ');
main.appendChild(title);
main.appendChild(sub);
li.appendChild(main);
const del = document.createElement('button');
del.type = 'button';
del.className = 'n-del';
del.textContent = '🗑';
del.title = 'Excluir nota';
del.addEventListener('click', (e) => { e.stopPropagation(); deleteNoteById(n); });
li.appendChild(del);
li.addEventListener('click', () => openNoteTab(n.id));
E.notesList.appendChild(li);
}
if (!state.notes.length) {
const li = document.createElement('li');
li.textContent = 'nenhuma nota encontrada';
li.classList.add('muted');
E.notesList.appendChild(li);
}
}
/* ------------------------------------------------- árvore de categorias */
function childrenOf(parent) {
return state.cats
.filter((c) => c.parent === parent)
.sort((a, b) => a.name.localeCompare(b.name, 'pt-BR'));
}
function treeRow(cat, depth) {
const row = document.createElement('div');
row.className = 'tree-row' + (state.filter.cat === cat.id ? ' active' : '');
row.style.paddingLeft = (8 + depth * 16) + 'px';
const caret = document.createElement('button');
caret.type = 'button';
caret.className = 'tree-caret';
if (cat.has_child) {
caret.textContent = state.expanded.has(cat.id) ? '▾' : '▸';
caret.addEventListener('click', (e) => {
e.stopPropagation();
if (state.expanded.has(cat.id)) state.expanded.delete(cat.id);
else state.expanded.add(cat.id);
localStorage.setItem(EXPAND_KEY, JSON.stringify([...state.expanded]));
renderCatTree();
});
} else caret.textContent = '·';
row.appendChild(caret);
const icon = document.createElement('span');
icon.className = 'tree-icon';
icon.textContent = cat.has_child && state.expanded.has(cat.id) ? '📂' : '📁';
row.appendChild(icon);
const name = document.createElement('span');
name.className = 'tree-name';
name.textContent = cat.name;
row.appendChild(name);
const count = document.createElement('span');
count.className = 'tree-count';
count.textContent = String(cat.note_count);
row.appendChild(count);
const menu = document.createElement('button');
menu.type = 'button';
menu.className = 'tree-menu';
menu.textContent = '⋯';
menu.title = 'Ações da categoria';
menu.addEventListener('click', (e) => {
e.stopPropagation();
openCatMenu(cat.id, menu.getBoundingClientRect());
});
row.appendChild(menu);
row.addEventListener('click', () => {
state.filter.cat = state.filter.cat === cat.id ? '' : cat.id;
if (cat.has_child) {
state.expanded.add(cat.id);
localStorage.setItem(EXPAND_KEY, JSON.stringify([...state.expanded]));
}
renderCatTree();
loadNotes().catch(() => {});
});
return row;
}
function renderCatTree() {
E.catTree.textContent = '';
const special = (value, label, icon) => {
const row = document.createElement('div');
row.className = 'tree-row tree-special' + (state.filter.cat === value ? ' active' : '');
row.style.paddingLeft = '8px';
const ic = document.createElement('span');
ic.className = 'tree-icon';
ic.textContent = icon;
const nm = document.createElement('span');
nm.className = 'tree-name';
nm.textContent = label;
row.appendChild(ic);
row.appendChild(nm);
row.addEventListener('click', () => {
state.filter.cat = value;
renderCatTree();
loadNotes().catch(() => {});
});
E.catTree.appendChild(row);
};
special('', 'Todas as notas', '🗂');
special('none', 'Sem categoria', '🗄');
const walk = (parent, depth) => {
for (const c of childrenOf(parent)) {
E.catTree.appendChild(treeRow(c, depth));
if (c.has_child && state.expanded.has(c.id)) walk(c.id, depth + 1);
}
};
walk('', 0);
if (!state.cats.length) {
const hint = document.createElement('span');
hint.className = 'muted';
hint.textContent = 'nenhuma categoria — use “+ pasta”';
E.catTree.appendChild(hint);
}
}
E.btnNewCat.addEventListener('click', async () => {
const name = window.prompt('Nome da nova categoria raiz:');
if (!name || !name.trim()) return;
try {
await api('POST', '/api/categories', { name: name.trim(), parent: '' });
await Promise.all([loadAggregates(), loadNotes()]);
toast('Categoria criada.');
} catch (err) {
toast('Criar categoria: ' + err.message);
}
});
/* --------------------------------------------- menu de contexto pasta */
function openCatMenu(id, rect) {
state.ctxCat = id;
E.ctxMenu.classList.remove('hidden');
const mw = E.ctxMenu.offsetWidth || 180;
const mh = E.ctxMenu.offsetHeight || 100;
let x = rect.left;
let y = rect.bottom + 2;
if (x + mw > window.innerWidth) x = window.innerWidth - mw - 6;
if (y + mh > window.innerHeight) y = rect.top - mh - 2;
E.ctxMenu.style.left = x + 'px';
E.ctxMenu.style.top = y + 'px';
}
function closeCatMenu() {
E.ctxMenu.classList.add('hidden');
state.ctxCat = null;
}
E.ctxMenu.querySelectorAll('[data-ctx]').forEach((li) =>
li.addEventListener('click', async () => {
const id = state.ctxCat;
closeCatMenu();
if (!id) return;
const cat = state.cats.find((c) => c.id === id);
if (!cat) return;
try {
if (li.dataset.ctx === 'sub') {
const name = window.prompt(`Nova subpasta dentro de “${cat.name}”:`);
if (!name || !name.trim()) return;
await api('POST', '/api/categories', { name: name.trim(), parent: id });
state.expanded.add(id);
localStorage.setItem(EXPAND_KEY, JSON.stringify([...state.expanded]));
} else if (li.dataset.ctx === 'rename') {
const name = window.prompt('Renomear categoria:', cat.name);
if (!name || !name.trim() || name.trim() === cat.name) return;
await api('PUT', '/api/categories/' + id, { name: name.trim() });
} else if (li.dataset.ctx === 'delete') {
const ok = await confirmModal('Excluir categoria?',
`<b>${esc(cat.name)}</b> e suas subpastas serão removidas. As notas continuam existindo, apenas sem essas categorias.`, 'Excluir');
if (!ok) return;
await api('DELETE', '/api/categories/' + id);
if (state.filter.cat === id) state.filter.cat = '';
}
await Promise.all([loadAggregates(), loadNotes()]);
// categorias excluídas não podem permanecer nas abas abertas
const valid = new Set(state.cats.map((c) => c.id));
for (const t of state.tabs) {
const kept = t.categories.filter((x) => valid.has(x));
if (kept.length !== t.categories.length) t.categories = kept;
}
updateCatButton();
} catch (err) {
toast('Categoria: ' + err.message);
}
}));
document.addEventListener('click', (e) => {
if (!E.ctxMenu.classList.contains('hidden') && !E.ctxMenu.contains(e.target)) closeCatMenu();
});
/* ------------------------------------------------ seletor de categorias */
function updateCatButton() {
const t = activeTab();
const n = t ? t.categories.length : 0;
E.btnCats.textContent = n === 0 ? '🗀 Sem categoria'
: n === 1 ? '🗀 ' + catName(t.categories[0])
: `🗀 ${catName(t.categories[0])} +${n - 1}`;
}
function renderCatPicker() {
if (E.catPicker.classList.contains('hidden')) return;
E.catPicker.textContent = '';
if (!state.cats.length) {
const s = document.createElement('div');
s.className = 'picker-empty muted';
s.textContent = 'Nenhuma categoria. Crie pela barra lateral.';
E.catPicker.appendChild(s);
return;
}
const t = activeTab();
const walk = (parent, depth) => {
for (const c of childrenOf(parent)) {
const label = document.createElement('label');
label.className = 'picker-row';
label.style.paddingLeft = (8 + depth * 16) + 'px';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.checked = !!t && t.categories.includes(c.id);
cb.addEventListener('change', () => {
const tab = activeTab();
if (!tab) return;
if (cb.checked) {
if (!tab.categories.includes(c.id)) tab.categories.push(c.id);
} else {
tab.categories = tab.categories.filter((x) => x !== c.id);
}
tab.dirty = true;
updateCatButton();
markTabDirty();
});
const ic = document.createElement('span');
ic.textContent = '📁';
const nm = document.createElement('span');
nm.textContent = c.name;
label.appendChild(cb);
label.appendChild(ic);
label.appendChild(nm);
E.catPicker.appendChild(label);
if (c.has_child) walk(c.id, depth + 1);
}
};
walk('', 0);
}
E.btnCats.addEventListener('click', (e) => {
e.stopPropagation();
const open = E.catPicker.classList.toggle('hidden');
if (!open) renderCatPicker();
});
document.addEventListener('click', (e) => {
if (!E.catPicker.classList.contains('hidden') && !E.catPicker.contains(e.target) && e.target !== E.btnCats) {
E.catPicker.classList.add('hidden');
}
});
/* ------------------------------------------------------- editor <-> abas */
function fillEditor() {
const t = activeTab();
E.noteTitle.value = t ? t.title : '';
E.formatSelect.value = t ? t.format : 'md';
E.editor.value = t ? t.content : '';
updateCatButton();
updateToolbar();
updateStatus();
updatePresenceBadge();
renderEditorVisual();
if (modeState.mode === 'wysiwyg') enterWysiwyg();
else refreshPreviewIfOpen();
}
function markTabDirty() {
const t = activeTab();
if (t && !t.dirty) t.dirty = true;
renderTabs();
updateStatus();
}
function markDirty() {
const t = activeTab();
if (t) t.dirty = true;
updateStatus();
persistTabs();
scheduleAutosave();
}
let autosaveTimer = 0;
// Ociosidade: só grava após N segundos SEM nenhuma alteração; cada tecla
// reinicia a contagem. Ao perder o foco da janela, a pendência é gravada.
function scheduleAutosave() {
if (!prefs.autosave) return;
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => {
const t = activeTab();
if (t && t.dirty && !t.conflictPending) save(true);
}, Math.max(5, prefs.autosaveDelay) * 1000);
}
function flushAutosave() {
clearTimeout(autosaveTimer);
const t = activeTab();
if (t && t.dirty && !t.conflictPending) save(true);
}
E.editor.addEventListener('input', () => {
const t = activeTab();
if (t) t.content = E.editor.value;
markDirty();
updateStatus();
scheduleRender();
});
['keyup', 'mouseup', 'select', 'click'].forEach((ev) =>
E.editor.addEventListener(ev, updateStatus));
E.noteTitle.addEventListener('input', () => {
const t = activeTab();
if (t) t.title = E.noteTitle.value;
markDirty();
renderTabs();
updateStatus();
});
E.formatSelect.addEventListener('change', () => {
const t = activeTab();
if (t) t.format = currentFormat();
markDirty();
updateToolbar();
updateStatus();
renderTabs();
scheduleRender();
if (modeState.mode === 'wysiwyg') {
if (currentFormat() === 'txt') setMode('edit');
else enterWysiwyg();
} else {
refreshPreviewIfOpen();
}
});
// Tab: insere N espaços ou \t conforme as preferências (nunca troca de foco).
E.editor.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
insertText(prefs.tabSpaces ? ' '.repeat(prefs.tabSize) : '\t');
}
});
function insertText(text) {
const ta = E.editor;
ta.focus();
if (!document.execCommand || !document.execCommand('insertText', false, text)) {
const s = ta.selectionStart, en = ta.selectionEnd;
ta.setRangeText(text, s, en, 'end');
}
const t = activeTab();
if (t) t.content = ta.value;
markDirty();
updateStatus();
}
function selectedRange() {
const ta = E.editor;
return { s: ta.selectionStart, e: ta.selectionEnd, sel: ta.value.slice(ta.selectionStart, ta.selectionEnd) };
}
// Substitui a seleção (ou `range` explícito, se dado) por `text`.
function replaceSelection(text, selStart, selEnd, range) {
const ta = E.editor;
ta.focus();
if (range) ta.setSelectionRange(range.s, range.e);
const { s, e } = selectedRange();
if (document.execCommand) {
document.execCommand('insertText', false, text);
} else {
ta.setRangeText(text, s, e, 'preserve');
}
if (selStart !== undefined) ta.setSelectionRange(selStart, selEnd);
const t = activeTab();
if (t) t.content = ta.value;
markDirty();
updateStatus();
}
function wrapSel(before, after, placeholder) {
const { s, e, sel } = selectedRange();
const text = sel || placeholder || 'texto';
replaceSelection(before + text + after, s + before.length, s + before.length + text.length);
}
function expandToLines() {
const ta = E.editor;
const v = ta.value;
const s = v.lastIndexOf('\n', Math.max(ta.selectionStart - 1, 0)) + 1;
let e = v.indexOf('\n', ta.selectionEnd);
if (e === -1) e = v.length;
return { s, e, block: v.slice(s, e) };
}
function eachLine(prefixFn) {
const { s, e, block } = expandToLines();
const lines = block.split('\n');
const out = lines.map(prefixFn).join('\n');
replaceSelection(out, s, s + out.length, { s, e });
}
function applyFmt(kind) {
if (modeState.mode === 'wysiwyg') { applyFmtRich(kind); return; }
const fmt = currentFormat();
if (fmt === 'txt') { toast('Em texto puro não há formatação — mude o formato para Markdown ou HTML.'); return; }
const md = fmt === 'md';
switch (kind) {
case 'bold': md ? wrapSel('**', '**', 'negrito') : wrapSel('<b>', '</b>', 'negrito'); break;
case 'italic': md ? wrapSel('*', '*', 'itálico') : wrapSel('<i>', '</i>', 'itálico'); break;
case 'strike': md ? wrapSel('~~', '~~', 'tachado') : wrapSel('<s>', '</s>', 'tachado'); break;
case 'code': md ? wrapSel('`', '`', 'código') : wrapSel('<code>', '</code>', 'código'); break;
case 'block':
md ? wrapSel('\n```\n', '\n```\n', 'código em bloco')
: wrapSel('\n<pre><code>', '</code></pre>\n', 'código em bloco');
break;
case 'link': {
const { sel } = selectedRange();
const url = window.prompt('Endereço do link (URL):', 'https://');
if (!url) return;
md ? wrapSel('[', `](${url})`, sel || 'link')
: replaceSelection(`<a href="${url}">${sel || 'link'}</a>`);
break;
}
case 'quote':
md ? eachLine((l) => togglePrefix(l, '> '))
: wrapSel('<blockquote>', '</blockquote>', 'citação');
break;
case 'h1': md ? eachLine((l) => toggleHeading(l, '# ')) : eachLine((l) => wrapLine(l, '<h1>', '</h1>')); break;
case 'h2': md ? eachLine((l) => toggleHeading(l, '## ')) : eachLine((l) => wrapLine(l, '<h2>', '</h2>')); break;
case 'list-ul':
md ? eachLine((l) => togglePrefix(l, '- '))
: (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n');
replaceSelection('<ul>\n' + items.map((i) => ' <li>' + i + '</li>').join('\n') + '\n</ul>'); })();
break;
case 'list-ol':
md ? eachLine((l, i) => `${i + 1}. ${l.replace(/^(\d+\.|- )\s*/, '')}`)
: (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n');
replaceSelection('<ol>\n' + items.map((i) => ' <li>' + i + '</li>').join('\n') + '\n</ol>'); })();
break;
}
}
function togglePrefix(line, prefix) {
return line.startsWith(prefix) ? line.slice(prefix.length) : prefix + line;
}
function toggleHeading(line, prefix) {
if (line.startsWith(prefix)) return line.slice(prefix.length);
return prefix + line.replace(/^#{1,6}\s*/, '');
}
function wrapLine(line, before, after) {
const trimmed = line.trim();
if (trimmed.startsWith(before) && trimmed.endsWith(after)) {
return line.replace(before, '').replace(new RegExp(after + '$'), '');
}
return before + (trimmed || 'título') + after;
}
document.querySelectorAll('#fmt-tools .fmt').forEach((btn) => {
btn.addEventListener('click', () => applyFmt(btn.dataset.fmt));
});
function updateToolbar() {
E.fmtTools.classList.toggle('disabled', currentFormat() === 'txt');
}
/* ------------------------------------------------------- preferências */
const PREFS_KEY = 'notas_prefs';
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, autosave: true, autosaveDelay: 10, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro',
// iTerm2-Color-Schemes (mbadolato, MIT)
'dracula', 'nord', 'one-dark', 'monokai', 'gruvbox-dark', 'tokyo-night',
'catppuccin-mocha', 'tomorrow-night', 'ayu-mirage', 'kanagawa-wave',
'catppuccin-latte', 'gruvbox-light', 'one-light', 'nord-light',
'carbon-paper', 'tomorrow', 'ayu-light'];
const FONT_STACKS = {
mono: '"DejaVu Sans Mono", "Ubuntu Mono", "Cascadia Code", Consolas, monospace',
ubuntu: '"Ubuntu Mono", "DejaVu Sans Mono", monospace',
cascadia: '"Cascadia Code", "DejaVu Sans Mono", monospace',
liberation: '"Liberation Mono", "DejaVu Sans Mono", monospace',
courier: '"Courier New", "DejaVu Sans Mono", monospace',
system: 'ui-monospace, "Cascadia Mono", Consolas, "DejaVu Sans Mono", monospace',
};
function loadPrefs() {
let raw = {};
try { raw = JSON.parse(localStorage.getItem(PREFS_KEY) || '{}'); } catch { /* usa padrões */ }
const p = { ...PREFS_DEFAULT };
for (const k of Object.keys(PREFS_DEFAULT)) {
if (typeof raw[k] === typeof PREFS_DEFAULT[k]) p[k] = raw[k];
}
p.col = Math.min(320, Math.max(20, Math.round(p.col) || 80));
p.size = Math.min(32, Math.max(10, Math.round(p.size) || 14));
p.tabSize = Math.min(16, Math.max(1, Math.round(p.tabSize) || 4));
p.autosaveDelay = Math.min(120, Math.max(5, Math.round(p.autosaveDelay) || 10));
if (!FONT_STACKS[p.font]) p.font = 'mono';
if (!THEMES.includes(p.theme)) p.theme = 'claro';
return p;
}
let prefs = loadPrefs();
function persistPrefs() { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); }
function applyPrefs() {
const root = document.documentElement;
root.style.setProperty('--editor-font', FONT_STACKS[prefs.font]);
root.style.setProperty('--editor-size', prefs.size + 'px');
root.style.setProperty('--line-h', Math.round(prefs.size * 1.55) + 'px');
root.style.setProperty('--tab-size', String(prefs.tabSize));
root.dataset.theme = prefs.theme;
document.body.classList.toggle('no-gutter', !prefs.gutter);
if (prefs.wrap) E.editor.removeAttribute('wrap');
else E.editor.setAttribute('wrap', 'off');
E.hl.classList.toggle('nowrap', !prefs.wrap);
lastGutterCount = -1; // força rebuild do gutter (altura/fonte mudaram)
renderEditorVisual();
measureCh();
renderEditorVisual(); // segunda passada com chW correto p/ marcador
}
/* --------------------------------------------------- colorização (hl) */
const HL_MAX = 200 * 1024; // acima disso, sem tokens (mantém o editor ágil)
function sp(cls, text) {
const s = document.createElement('span');
if (cls) s.className = cls;
s.textContent = text;
return s;
}
function lineEl(children) {
const d = document.createElement('div');
d.className = 'hl-line';
if (!children.length) children = [sp(null, '\u200b')];
for (const c of children) d.appendChild(c);
return d;
}
const TAG_URL_RE = /(#[\p{L}][\p{L}\p{N}_-]*)|(https?:\/\/[^\s<>"')\]]+)|(&[\w#]+;)/gu;
// Segmentos comuns: #tags, URLs e entidades HTML.
function tagUrlEnt(seg) {
const out = [];
let last = 0, m;
TAG_URL_RE.lastIndex = 0;
while ((m = TAG_URL_RE.exec(seg))) {
if (m.index > last) out.push(sp(null, seg.slice(last, m.index)));
out.push(sp(m[1] ? 'tk-tag' : m[2] ? 'tk-url' : 'tk-ent', m[0]));
last = m.index + m[0].length;
}
if (last < seg.length) out.push(sp(null, seg.slice(last)));
return out;
}
function mdInline(line) {
const re = /(`[^`\n]*`?)|(\*\*[^*\n]+\*\*|__[^_\n]+__)|(\*[^*\n]+\*|_[^_\n]+_)|(~~[^~\n]+~~)|(\[[^\]\n]*\]\([^)\n]*\))|(#[\p{L}][\p{L}\p{N}_-]*)|(https?:\/\/[^\s<>"')\]]+)|(<[^>\n]*>)/gu;
const out = [];
let last = 0, m;
while ((m = re.exec(line))) {
if (m.index > last) out.push(...tagUrlEnt(line.slice(last, m.index)));
const t = m[0];
if (m[1]) out.push(sp('tk-code', t));
else if (m[2]) out.push(sp('tk-strong', t));
else if (m[3]) out.push(sp('tk-em', t));
else if (m[4]) out.push(sp('tk-strike', t));
else if (m[5]) {
const mm = /^\[([^\]]*)\]\(([^)]*)\)$/.exec(t);
out.push(sp('tk-punc', '['), sp('tk-link', mm[1]), sp('tk-punc', ']('), sp('tk-url', mm[2]), sp('tk-punc', ')'));
} else if (m[6]) out.push(sp('tk-tag', t));
else if (m[7]) out.push(sp('tk-url', t));
else out.push(sp('tk-html', t));
last = m.index + t.length;
}
if (last < line.length) out.push(...tagUrlEnt(line.slice(last)));
return out;
}
function buildMdLines(lines) {
const out = [];
let fence = null;
for (const line of lines) {
let children;
const f = /^\s*(```|~~~)/.exec(line);
if (fence) {
if (new RegExp('^\\s*' + fence).test(line)) { children = [sp('tk-fence', line)]; fence = null; }
else children = [sp('tk-fline', line)];
} else if (f) { fence = f[1]; children = [sp('tk-fence', line)]; }
else if (/^#{1,6}\s/.test(line)) children = [sp('tk-h', line)];
else if (/^\s*>/.test(line)) children = [sp('tk-quote', line)];
else {
const lm = /^(\s*)([-*+]\s|\d+\.\s)(.*)$/.exec(line);
children = lm ? [sp(null, lm[1]), sp('tk-mark', lm[2]), ...mdInline(lm[3])] : mdInline(line);
}
out.push(lineEl(children));
}
return out;
}
function tagTokens(t) {
const out = [];
const open = /^<\/?[a-zA-Z!]/.test(t) ? (t[1] === '/' ? 2 : 1) : 0;
out.push(sp('tk-punc', t.slice(0, open)));
let i = open, nameDone = false;
while (i < t.length) {
const rest = t.slice(i);
let m;
if ((m = /^\s+/.exec(rest))) out.push(sp(null, m[0]));
else if ((m = /^"[^"]*"|^'[^']*'/.exec(rest))) out.push(sp('tk-str', m[0]));
else if ((m = /^[a-zA-Z!][\w:.-]*/.exec(rest))) { out.push(sp(nameDone ? 'tk-attr' : 'tk-html', m[0])); nameDone = true; }
else if ((m = /^[=/>]+/.exec(rest))) out.push(sp('tk-punc', m[0]));
else { out.push(sp(null, rest[0])); i++; continue; }
i += m[0].length;
}
return out;
}
function buildHtmlLines(lines) {
const out = [];
let inComment = false;
for (const line of lines) {
const children = [];
let i = 0;
while (i < line.length) {
if (inComment) {
const j = line.indexOf('-->', i);
if (j < 0) { children.push(sp('tk-comment', line.slice(i))); i = line.length; }
else { children.push(sp('tk-comment', line.slice(i, j + 3))); i = j + 3; inComment = false; }
continue;
}
const lt = line.indexOf('<', i);
if (lt < 0) { children.push(...tagUrlEnt(line.slice(i))); break; }
if (lt > i) children.push(...tagUrlEnt(line.slice(i, lt)));
if (line.startsWith('<!--', lt)) {
const j = line.indexOf('-->', lt);
if (j < 0) { children.push(sp('tk-comment', line.slice(lt))); inComment = true; break; }
children.push(sp('tk-comment', line.slice(lt, j + 3))); i = j + 3; continue;
}
const gt = line.indexOf('>', lt);
if (gt < 0) { children.push(...tagUrlEnt(line.slice(lt))); break; }
children.push(...tagTokens(line.slice(lt, gt + 1)));
i = gt + 1;
}
out.push(lineEl(children));
}
return out;
}
/* ------------------------------------------------- render do overlay */
let lastGutterCount = -1;
let chW = 8;
let rafPending = false;
function renderEditorVisual() {
const v = E.editor.value;
const lines = v.split('\n');
const plain = !prefs.hl || v.length > HL_MAX;
let rowEls;
if (plain) rowEls = lines.map((l) => lineEl([sp(null, l)]));
else if (currentFormat() === 'md') rowEls = buildMdLines(lines);
else if (currentFormat() === 'html') rowEls = buildHtmlLines(lines);
else rowEls = lines.map((l) => lineEl(tagUrlEnt(l)));
const frag = document.createDocumentFragment();
for (const r of rowEls) frag.appendChild(r);
E.hl.replaceChildren(frag);
// paridade de largura com o textarea: com scrollbar-gutter:stable o valor é
// constante por plataforma (clássico ~15-17px; overlay 0) e o espelha aqui
const sbw = E.editor.offsetWidth - E.editor.clientWidth;
E.hl.style.setProperty('--sbw', sbw > 0 ? sbw + 'px' : '0px');
if (lines.length !== lastGutterCount) {
const gf = document.createDocumentFragment();
for (let i = 1; i <= lines.length; i++) {
const d = document.createElement('div');
d.className = 'ln';
d.textContent = String(i);
gf.appendChild(d);
}
E.gutterInner.replaceChildren(gf);
lastGutterCount = lines.length;
}
if (prefs.wrap) {
const rows = E.gutterInner.children;
for (let i = 0; i < rowEls.length; i++) rows[i].style.height = E.hl.children[i].offsetHeight + 'px';
}
// replaceChildren zera o scroll do overlay/gutter — reassincroniza com o textarea
E.hl.scrollTop = E.editor.scrollTop;
E.hl.scrollLeft = E.editor.scrollLeft;
E.gutter.scrollTop = E.editor.scrollTop;
updateMarker();
}
function scheduleRender() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => { rafPending = false; renderEditorVisual(); });
}
function measureCh() {
const s = document.createElement('span');
s.className = 'hl-line';
s.textContent = '0'.repeat(64);
E.hl.appendChild(s);
const w = s.getBoundingClientRect().width;
s.remove();
if (w > 0) chW = w / 64;
}
function updateMarker() {
const show = prefs.colmark && !prefs.wrap && modeState.mode === 'edit';
if (!show) { E.colMarker.classList.add('hidden'); return; }
const ta = E.editor;
const padX = parseFloat(getComputedStyle(ta).paddingLeft) || 16;
const left = padX + prefs.col * chW - ta.scrollLeft;
const visible = left >= 0 && left <= ta.clientWidth;
E.colMarker.classList.toggle('hidden', !visible);
if (visible) E.colMarker.style.left = left + 'px';
}
E.editor.addEventListener('scroll', () => {
E.hl.scrollTop = E.editor.scrollTop;
E.hl.scrollLeft = E.editor.scrollLeft;
E.gutter.scrollTop = E.editor.scrollTop;
updateMarker();
});
window.addEventListener('resize', () => { updateMarker(); scheduleRender(); });
/* --------------------------------------------------- diálogo preferências */
const PF = {
overlay: $('#prefs-overlay'), form: $('#prefs-form'), gutter: $('#pf-gutter'),
wrap: $('#pf-wrap'), colmark: $('#pf-colmark'), col: $('#pf-col'), hl: $('#pf-hl'),
autosave: $('#pf-autosave'), autosaveDelay: $('#pf-autosave-delay'),
tabmode: $('#pf-tabmode'), tabsize: $('#pf-tabsize'),
font: $('#pf-font'), size: $('#pf-size'), theme: $('#pf-theme'),
reset: $('#pf-reset'), cancel: $('#pf-cancel'),
};
let prefsSnapshot = null;
function syncPrefsInputs() {
PF.gutter.checked = prefs.gutter;
PF.wrap.checked = prefs.wrap;
PF.colmark.checked = prefs.colmark;
PF.col.value = prefs.col;
PF.hl.checked = prefs.hl;
PF.autosave.checked = prefs.autosave;
PF.autosaveDelay.value = String(prefs.autosaveDelay);
PF.tabmode.value = prefs.tabSpaces ? 'spaces' : 'tab';
PF.tabsize.value = prefs.tabSize;
PF.font.value = prefs.font;
PF.size.value = prefs.size;
PF.theme.value = prefs.theme;
}
function readPrefsInputs() {
prefs.gutter = PF.gutter.checked;
prefs.wrap = PF.wrap.checked;
prefs.colmark = PF.colmark.checked;
prefs.col = Math.min(320, Math.max(20, parseInt(PF.col.value, 10) || 80));
prefs.hl = PF.hl.checked;
prefs.autosave = PF.autosave.checked;
prefs.autosaveDelay = Math.min(120, Math.max(5, parseInt(PF.autosaveDelay.value, 10) || 10));
prefs.tabSpaces = PF.tabmode.value !== 'tab';
prefs.tabSize = Math.min(16, Math.max(1, parseInt(PF.tabsize.value, 10) || 4));
prefs.font = FONT_STACKS[PF.font.value] ? PF.font.value : 'mono';
prefs.size = Math.min(32, Math.max(10, parseInt(PF.size.value, 10) || 14));
prefs.theme = THEMES.includes(PF.theme.value) ? PF.theme.value : 'claro';
}
function openPrefs() {
prefsSnapshot = { ...prefs };
syncPrefsInputs();
PF.overlay.classList.remove('hidden');
PF.gutter.focus();
}
function closePrefs() {
PF.overlay.classList.add('hidden');
prefsSnapshot = null;
}
PF.form.addEventListener('input', () => { readPrefsInputs(); applyPrefs(); });
PF.form.addEventListener('submit', (e) => {
e.preventDefault();
persistPrefs();
closePrefs();
toast('Preferências aplicadas.');
});
PF.cancel.addEventListener('click', () => {
if (prefsSnapshot) prefs = prefsSnapshot;
applyPrefs();
closePrefs();
});
PF.reset.addEventListener('click', () => {
prefs = { ...PREFS_DEFAULT };
syncPrefsInputs();
applyPrefs();
});
/* ------------------------------------------------------------ modos */
const modeState = { mode: 'edit' };
function setMode(mode) {
if (mode === 'wysiwyg' && currentFormat() === 'txt') {
toast('WYSIWYG não se aplica a texto puro — use o modo edição.');
return;
}
modeState.mode = mode;
E.modeToggle.querySelectorAll('.seg').forEach((b) =>
b.classList.toggle('active', b.dataset.mode === mode));
E.editorWrap.classList.toggle('hidden', mode !== 'edit');
E.wysiwyg.classList.toggle('hidden', mode !== 'wysiwyg');
E.preview.classList.add('hidden');
E.previewFrame.classList.add('hidden');
if (mode === 'edit') {
const t = activeTab();
if (t && E.editor.value !== t.content) E.editor.value = t.content;
E.editor.focus();
renderEditorVisual();
updateMarker();
return;
}
E.colMarker.classList.add('hidden');
if (mode === 'wysiwyg') { enterWysiwyg(); return; }
refreshPreviewIfOpen();
}
function cycleMode() {
const order = ['edit', 'preview'];
if (currentFormat() !== 'txt') order.splice(1, 0, 'wysiwyg');
const i = order.indexOf(modeState.mode);
setMode(order[(i + 1) % order.length]);
}
/* ------------------------------------------------------------- wysiwyg */
function sanitizeRich(html) {
// remove vetores de execução antes de editar no DOM principal
const doc = new DOMParser().parseFromString('<body>' + html + '</body>', 'text/html');
doc.body.querySelectorAll('script,iframe,object,embed').forEach((n) => n.remove());
doc.body.querySelectorAll('*').forEach((n) => {
for (const a of [...n.attributes]) if (/^on/i.test(a.name)) n.removeAttribute(a.name);
});
// href/src com esquema perigoso (javascript:, data:text/html, vbscript:…)
// não executam inline, mas um clique/copy basta para o vetor — neutra
doc.body.querySelectorAll('[href],[src]').forEach((n) => {
const attr = n.hasAttribute('href') ? 'href' : 'src';
const v = n.getAttribute(attr);
if (v && /^[ \t\r\n]*(javascript|vbscript|data)[ \t\r\n]*:/i.test(v)) n.setAttribute(attr, '#');
});
return doc.body.innerHTML;
}
function enterWysiwyg() {
const t = activeTab();
if (!t) return;
if (currentFormat() === 'html') {
E.wysiwyg.innerHTML = sanitizeRich(t.content);
return;
}
E.wysiwyg.textContent = '…carregando';
api('POST', '/api/render', { content: t.content, format: 'md' })
.then((r) => {
if (modeState.mode === 'wysiwyg') E.wysiwyg.innerHTML = r.html;
})
.catch((err) => { E.wysiwyg.textContent = 'erro ao carregar: ' + err.message; });
}
function escMd(s) {
return s.replace(/[\\`~_*\[\]]/g, (c) => '\\' + c)
.replace(/^#(?=\s|#)/gm, '\\#');
}
function inlineMd(node) {
if (node.nodeType === 3) return escMd(node.nodeValue);
let out = '';
for (const c of node.childNodes) {
if (c.nodeType === 3) { out += escMd(c.nodeValue); continue; }
if (c.nodeType !== 1) continue;
const el = c;
const tag = el.tagName;
if (tag === 'BR') { out += '\n'; continue; }
const inner = inlineMd(el);
switch (tag) {
case 'STRONG': case 'B': out += '**' + inner + '**'; break;
case 'EM': case 'I': out += '*' + inner + '*'; break;
case 'DEL': case 'S': case 'STRIKE': out += '~~' + inner + '~~'; break;
case 'CODE': out += '`' + inner.replace(/`/g, '\\`') + '`'; break;
case 'A': out += '[' + inner + '](' + (el.getAttribute('href') || '') + ')'; break;
case 'IMG': out += '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')'; break;
case 'INPUT': out = out; break; // checkboxes tratados no bloco LI
default: out += inner;
}
}
return out;
}
function tableMd(el) {
const rows = [...el.querySelectorAll('tr')];
if (!rows.length) return '';
const cells = (tr) => [...tr.children].map((td) => inlineMd(td).trim().replace(/\|/g, '\\|'));
const head = cells(rows[0]);
const body = rows.slice(1).map(cells);
const line = (cs) => '| ' + cs.join(' | ') + ' |';
return [line(head), line(head.map(() => '---')), ...body.map(line)].join('\n');
}
function blockMd(root) {
const parts = [];
for (const el of root.childNodes) {
if (el.nodeType === 3) {
const tx = escMd(el.nodeValue).trim();
if (tx) parts.push(tx);
continue;
}
if (el.nodeType !== 1) continue;
const tag = el.tagName;
let chunk = null;
if (/^H[1-6]$/.test(tag)) chunk = '#'.repeat(+tag[1]) + ' ' + inlineMd(el).trim();
else if (tag === 'P' || tag === 'DIV') {
// execCommand pode aninhar ul/blockquote/pre dentro de p: trata como blocos
const nested = el.querySelector('ul,ol,blockquote,pre,table');
chunk = (nested ? blockMd(el) : inlineMd(el)).trim();
}
else if (tag === 'UL' || tag === 'OL') {
chunk = [...el.children].map((li, i) => {
const marker = tag === 'UL' ? '- ' : (i + 1) + '. ';
const cb = li.querySelector('input[type="checkbox"]');
const text = inlineMd(li).trim();
return cb ? marker + (cb.checked ? '[x] ' : '[ ] ') + text : marker + text;
}).join('\n');
}
else if (tag === 'BLOCKQUOTE') {
const inner = el.children.length ? blockMd(el) : inlineMd(el).trim();
chunk = inner.split('\n').map((l) => '> ' + l).join('\n');
}
else if (tag === 'PRE') chunk = '```\n' + el.textContent.replace(/\n+$/, '') + '\n```';
else if (tag === 'HR') chunk = '---';
else if (tag === 'TABLE') chunk = tableMd(el);
else if (tag === 'BR') chunk = null;
else { const s = inlineMd(el).trim(); chunk = s; }
if (chunk !== null && chunk !== '') parts.push(chunk);
}
return parts.join('\n\n');
}
function serializeWysiwyg() {
const t = activeTab();
if (!t) return '';
if (t.format === 'html') return E.wysiwyg.innerHTML;
const md = blockMd(E.wysiwyg);
return md ? md + '\n' : '';
}
E.wysiwyg.addEventListener('input', () => {
const t = activeTab();
if (!t) return;
t.content = serializeWysiwyg();
markDirty();
updateStatus();
persistTabs();
});
E.wysiwyg.addEventListener('paste', (e) => {
const files = e.clipboardData && e.clipboardData.files;
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); return; }
e.preventDefault(); // sempre texto puro: nada de HTML grudento de fora
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
E.wysiwyg.focus();
document.execCommand('insertText', false, text);
});
function applyFmtRich(kind) {
E.wysiwyg.focus();
const simple = { bold: 'bold', italic: 'italic', strike: 'strikeThrough' }[kind];
if (simple) document.execCommand(simple);
else if (kind === 'h1') document.execCommand('formatBlock', false, 'H1');
else if (kind === 'h2') document.execCommand('formatBlock', false, 'H2');
else if (kind === 'list-ul') document.execCommand('insertUnorderedList');
else if (kind === 'list-ol') document.execCommand('insertOrderedList');
else if (kind === 'quote') document.execCommand('formatBlock', false, 'BLOCKQUOTE');
else if (kind === 'link') {
const url = window.prompt('Endereço do link (URL):', 'https://');
if (url) document.execCommand('createLink', false, url);
} else if (kind === 'code') {
const sel = getSelection().toString();
document.execCommand('insertHTML', false, '<code>' + esc(sel || 'código') + '</code>');
} else if (kind === 'block') {
const sel = getSelection().toString();
document.execCommand('insertHTML', false,
'<pre><code>' + esc(sel || 'código em bloco') + '</code></pre><p><br></p>');
}
E.wysiwyg.dispatchEvent(new Event('input', { bubbles: true }));
}
function refreshPreviewIfOpen() {
if (modeState.mode !== 'preview') return;
E.editorWrap.classList.add('hidden');
const fmt = currentFormat();
if (fmt === 'html') {
E.preview.classList.add('hidden');
E.previewFrame.classList.remove('hidden');
E.previewFrame.srcdoc = E.editor.value;
return;
}
E.previewFrame.classList.add('hidden');
E.preview.classList.remove('hidden');
if (fmt === 'txt') {
E.preview.classList.remove('md');
E.preview.textContent = E.editor.value;
return;
}
E.preview.classList.add('md');
E.preview.textContent = '…renderizando';
api('POST', '/api/render', { content: E.editor.value, format: 'md' })
.then((r) => { if (modeState.mode === 'preview') E.preview.innerHTML = r.html; })
.catch((err) => { E.preview.textContent = 'erro ao renderizar: ' + err.message; });
}
E.modeToggle.querySelectorAll('.seg').forEach((b) =>
b.addEventListener('click', () => setMode(b.dataset.mode)));
/* ------------------------------------------------------------- versões */
function toggleVersionsPanel(open) {
const show = open ?? E.versionsPanel.classList.contains('hidden');
E.versionsPanel.classList.toggle('hidden', !show);
if (isMobile()) {
if (show) setBackdrop(true);
else if (!document.body.classList.contains('sidebar-open')) setBackdrop(false);
}
if (show) loadRevisions().catch((err) => toast('Histórico: ' + err.message));
}
let revReq = 0;
async function loadRevisions() {
const req = ++revReq;
const t = activeTab();
if (!t || !t.id) {
E.revisionsList.textContent = '';
E.versionDetail.classList.add('hidden');
return;
}
const revs = await api('GET', '/api/notes/' + t.id + '/revisions');
if (req !== revReq) return; // resposta obsoleta: uma carga mais nova assumiu
E.revisionsList.textContent = '';
for (const r of revs) {
const li = document.createElement('li');
li.dataset.hash = r.hash;
li.className = state.selectedRev === r.hash ? 'active' : '';
const top = document.createElement('div');
top.className = 'rev-top';
const author = document.createElement('span');
author.textContent = r.author;
const hash = document.createElement('span');
hash.className = 'rev-hash';
hash.textContent = r.hash.slice(0, 8);
top.appendChild(author);
top.appendChild(hash);
const msg = document.createElement('div');
msg.className = 'rev-msg';
msg.textContent = r.message;
const when = document.createElement('div');
when.className = 'rev-when';
when.textContent = r.when.slice(0, 16).replace('T', ' ') + ' UTC · ' + r.bytes + ' B';
li.appendChild(top); li.appendChild(msg); li.appendChild(when);
li.addEventListener('click', () => showRevisionDiff(r));
E.revisionsList.appendChild(li);
}
}
async function showRevisionDiff(rev) {
const t = activeTab();
if (!t) return;
state.selectedRev = rev.hash;
E.revisionsList.querySelectorAll('li').forEach((li) =>
li.classList.toggle('active', li.dataset.hash === rev.hash));
try {
const dv = await api('GET', `/api/notes/${t.id}/revisions/${rev.hash}/diff`);
E.versionDetail.classList.remove('hidden');
E.versionInfo.textContent =
`${dv.from} → ${dv.to} · ${dv.author} · +${dv.added}/-${dv.removed}`;
renderDiff(dv.diff || '(sem alterações)');
E.btnRestore.disabled = rev.hash === t.hash;
} catch (err) {
toast('Diff: ' + err.message);
}
}
function renderDiff(diffText) {
E.versionDiff.textContent = '';
for (const line of diffText.split('\n')) {
const span = document.createElement('span');
if (line.startsWith('@@')) span.className = 'd-hunk';
else if (line.startsWith('---') || line.startsWith('+++')) span.className = 'd-head';
else if (line.startsWith('+')) span.className = 'd-add';
else if (line.startsWith('-')) span.className = 'd-del';
span.textContent = line + '\n';
E.versionDiff.appendChild(span);
}
}
E.btnRestore.addEventListener('click', async () => {
const t = activeTab();
if (!t || !t.id || !state.selectedRev) return;
const ok = await confirmModal('Restaurar versão?',
`O conteúdo da nota será substituído pelo da revisão <code>${esc(state.selectedRev.slice(0, 8))}</code>. O estado atual continuará no histórico.`, 'Restaurar');
if (!ok) return;
try {
const data = await api('POST',
`/api/notes/${t.id}/revisions/${state.selectedRev}/restore`,
{ author: E.authorInput.value.trim() });
applyNoteToTab(t, data);
await Promise.all([loadNotes(), loadRevisions(), loadAggregates()]);
toast('Revisão restaurada com sucesso.');
} catch (err) {
toast('Restauração: ' + err.message);
}
});
E.btnCloseVersions.addEventListener('click', () => toggleVersionsPanel(false));
function applyNoteToTab(tab, data) {
tab.id = data.id;
tab.note = data;
tab.title = data.title;
tab.format = data.format;
tab.categories = data.categories || [];
tab.tags = data.tags || [];
tab.content = data.content;
tab.hash = data.hash;
tab.remoteHash = null;
tab.dirty = false;
tab.lastSaved = new Date().toISOString();
if (tab.key === state.activeKey) fillEditor();
renderTabs();
persistTabs();
}
/* ------------------------------------------------------------ salvar */
async function save(auto) {
const t = activeTab();
if (!t || t.conflictPending) return;
clearTimeout(autosaveTimer);
const body = {
title: currentTitle(),
format: currentFormat(),
categories: t.categories,
content: t.content,
author: E.authorInput.value.trim(),
message: E.revMessage.value.trim() || (auto ? 'autosave' : ''),
base_hash: t.hash || '',
session: SID,
};
E.stSaved.textContent = 'salvando…';
E.stSaved.className = 'st-item pending';
let data;
try {
if (t.id) data = await api('PUT', '/api/notes/' + t.id, body);
else data = await api('POST', '/api/notes', body);
} catch (err) {
if (err.status === 409 && err.data && err.data.server_hash) {
t.remoteHash = err.data.server_hash;
await resolveConflict(t, body);
return;
}
toast('Falha ao salvar: ' + err.message);
updateStatus();
return;
}
const wasDraft = !t.id;
applyNoteToTab(t, data);
E.revMessage.value = '';
if (wasDraft) sendPresence(); // rascunho virou nota: presença passa a reportá-la
loadNotes().catch(() => {});
loadAggregates().catch(() => {});
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
if (!auto) toast('Salvo — revisão ' + data.hash.slice(0, 8));
}
// Conflito: o servidor mudou desde a base da aba. Recarregar ou sobrescrever.
async function resolveConflict(t, body) {
if (t.conflictPending) return;
t.conflictPending = true;
if (!body) {
body = {
title: t.title || currentTitle(),
format: t.format,
categories: t.categories,
content: t.content,
author: E.authorInput.value.trim(),
message: E.revMessage.value.trim(),
session: SID,
};
}
const reload = await modal({
title: 'Conflito de edição',
bodyHTML: `Esta nota foi salva em <b>outro dispositivo</b> enquanto você editava.<br>` +
`Servidor: <code>${esc((t.remoteHash || '').slice(0, 8))}</code> · sua base: <code>${esc(t.hash.slice(0, 8))}</code>.<br>` +
`<b>Recarregar</b> traz o texto do servidor (descarta seu texto não salvo); ` +
`<b>Salvar por cima</b> mantém o seu e cria uma nova revisão (o texto do servidor fica no histórico).`,
withCancel: true, okLabel: 'Recarregar do servidor', cancelLabel: 'Salvar por cima',
});
try {
if (reload) {
const data = await api('GET', '/api/notes/' + t.id);
applyNoteToTab(t, data);
await Promise.all([loadNotes(), loadRevisions().catch(() => {})]);
toast('Nota recarregada do servidor.');
} else {
const force = Object.assign({}, body, { base_hash: '' });
const data = await api('PUT', '/api/notes/' + t.id, force);
applyNoteToTab(t, data);
loadNotes().catch(() => {});
toast('Salvo por cima — a versão anterior está no histórico.');
}
} catch (err) {
toast('Conflito: ' + err.message);
} finally {
t.conflictPending = false;
}
}
async function deleteNoteById(n) {
const ok = await confirmModal('Excluir nota?',
`A nota <b>${esc(n.title || 'Sem título')}</b> e todo o seu histórico serão apagados.`, 'Excluir');
if (!ok) return;
try {
await api('DELETE', '/api/notes/' + n.id);
toast('Nota excluída.');
const tab = state.tabs.find((t) => t.id === n.id);
if (tab) {
const idx = state.tabs.indexOf(tab);
state.tabs.splice(idx, 1);
sendPresence();
if (!state.tabs.length) addTab(newTab());
else if (state.activeKey === tab.key) activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key);
else renderTabs();
persistTabs();
}
loadNotes().catch(() => {});
loadAggregates().catch(() => {});
} catch (err) {
toast('Falha ao excluir: ' + err.message);
}
}
async function deleteNote() {
const t = activeTab();
if (!t || !t.id) { toast('Nenhuma nota aberta.'); return; }
await deleteNoteById({ id: t.id, title: t.title });
}
function exportNote() {
const t = activeTab();
if (!t || !t.id) { toast('Salve a nota antes de baixar.'); return; }
if (t.dirty) toast('Atenção: o arquivo baixado é a última versão salva.');
window.location.assign('/api/notes/' + t.id + '/raw');
}
/* ------------------------------------------------------------ menus */
document.querySelectorAll('.menu .menu-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const menu = btn.parentElement;
const wasOpen = menu.classList.contains('open');
document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'));
if (wasOpen) return;
menu.classList.add('open');
// Em mobile a menubar tem overflow-x:auto, que CLIPA dropdowns absolute.
// Posicionar como fixed (fora do contêiner de scroll) resolve em todos os tamanhos.
const dd = menu.querySelector('.menu-dropdown');
const r = btn.getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.left = '0px';
dd.style.top = '0px';
const w = dd.offsetWidth;
const h = dd.offsetHeight;
const left = Math.max(4, Math.min(r.left, window.innerWidth - w - 6));
const top = r.bottom + h > window.innerHeight ? Math.max(4, r.top - h - 2) : r.bottom + 2;
dd.style.left = left + 'px';
dd.style.top = top + 'px';
});
});
const closeMenus = () => document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'));
document.addEventListener('click', closeMenus);
window.addEventListener('resize', closeMenus);
window.addEventListener('scroll', () => closeMenus(), { passive: true });
function toggleSidebar() {
if (isMobile()) {
const open = document.body.classList.toggle('sidebar-open');
setBackdrop(open);
} else {
document.body.classList.toggle('sidebar-off');
}
}
function closeDrawer() {
if (isMobile()) {
document.body.classList.remove('sidebar-open');
if (!E.versionsPanel.classList.contains('hidden')) return; // backdrop continua p/ versões
setBackdrop(false);
}
}
function setBackdrop(on) {
E.backdrop.classList.toggle('hidden', !on);
}
E.backdrop.addEventListener('click', () => {
document.body.classList.remove('sidebar-open');
E.versionsPanel.classList.add('hidden');
E.catPicker.classList.add('hidden');
closeCatMenu();
setBackdrop(false);
});
const ACTIONS = {
'new-note': () => newDraftTab(),
'save': () => save(),
'export': exportNote,
'files': () => openFiles(false),
'close-tab': () => closeTab(state.activeKey),
'delete-note': deleteNote,
'undo': () => { E.editor.focus(); document.execCommand('undo'); const t = activeTab(); if (t) t.content = E.editor.value; markDirty(); scheduleRender(); updateStatus(); },
'redo': () => { E.editor.focus(); document.execCommand('redo'); const t = activeTab(); if (t) t.content = E.editor.value; markDirty(); scheduleRender(); updateStatus(); },
'select-all': () => { E.editor.focus(); E.editor.setSelectionRange(0, E.editor.value.length); },
'toggle-sidebar': toggleSidebar,
'toggle-gutter': () => { prefs.gutter = !prefs.gutter; persistPrefs(); applyPrefs(); },
'toggle-wrap': () => { prefs.wrap = !prefs.wrap; persistPrefs(); applyPrefs(); },
'prefs': openPrefs,
'install': async () => {
if (deferredPrompt) {
const p = deferredPrompt;
deferredPrompt = null;
refreshInstallUI();
p.prompt();
const { outcome } = await p.userChoice;
if (outcome === 'accepted') toast('Instalando o Notas como aplicativo…');
return;
}
if (isFirefox) { firefoxInstallHelp(); return; }
toast('Use o ícone de instalação da barra de endereço.');
},
'toggle-edit': () => setMode('edit'),
'toggle-wysiwyg': () => setMode('wysiwyg'),
'toggle-preview': () => setMode('preview'),
'toggle-versions': () => toggleVersionsPanel(),
'about': () => modal({
title: 'Sobre o Notas',
bodyHTML:
`<p>Editor de notas minimalista inspirado no <b>Mousepad</b> (XFCE).</p>
<p>Backend em <b>Go</b>, armazenamento em disco com revisões endereçadas por hash SHA-256 (estilo Git): cada salvamento de conteúdo gera um snapshot, com autor, data e diff.</p>
<p>Categorias viram uma <b>árvore de pastas</b> (barra lateral); notas podem ter <b>várias categorias</b> e abrir em <b>várias abas</b>. O modo <b>WYSIWYG</b> edita Markdown/HTML renderizados e sincroniza com o fonte.</p>
<p>Edite no celular e no desktop: ao salvar, um conflito é detectado (a nota mudou em outro dispositivo) e você escolhe recarregar ou sobrescrever; abas limpas são recarregadas automaticamente ao voltar para a janela.</p>
<p>Atalhos: <code>Ctrl+S</code> salvar · <code>Ctrl+N</code> nova aba · <code>Alt+W</code> fechar aba · <code>Alt+←/→</code> trocar aba · <code>Ctrl+E/M/R</code> editar/wysiwyg/preview · <code>Ctrl+H</code> histórico · <code>F9</code> barra lateral · <code>Ctrl+B/I/K</code> formatação · <code>Ctrl+,</code> preferências.</p>`,
}),
'logout': async () => {
await fetch('/api/logout', { method: 'POST' }).catch(() => {});
location.reload();
},
};
document.querySelectorAll('.menu-dropdown [data-action]').forEach((li) =>
li.addEventListener('click', () => {
document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'));
ACTIONS[li.dataset.action]();
}));
E.btnNew.addEventListener('click', () => newDraftTab());
E.btnTabNew.addEventListener('click', () => newDraftTab());
E.btnSave.addEventListener('click', () => save());
E.btnExport.addEventListener('click', exportNote);
$('#btn-attach').addEventListener('click', () => openFiles(true));
$('#btn-files').addEventListener('click', () => openFiles(false));
E.btnVersions.addEventListener('click', () => toggleVersionsPanel());
E.btnSidebar.addEventListener('click', toggleSidebar);
/* ------------------------------------------------------------ teclado */
document.addEventListener('keydown', (e) => {
const mod = e.ctrlKey || e.metaKey;
if (e.key === 'F9') { e.preventDefault(); toggleSidebar(); return; }
if (e.altKey && !e.ctrlKey && !e.metaKey) {
if (e.key === 'w' || e.key === 'W') { e.preventDefault(); closeTab(state.activeKey); return; }
if (e.key === 'ArrowRight') { e.preventDefault(); cycleTab(1); return; }
if (e.key === 'ArrowLeft') { e.preventDefault(); cycleTab(-1); return; }
const n = parseInt(e.key, 10);
if (n >= 1 && n <= 9 && state.tabs[n - 1]) { e.preventDefault(); activateTab(state.tabs[n - 1].key); return; }
}
if (!mod) return;
switch (e.key.toLowerCase()) {
case 's': e.preventDefault(); save(); break;
case 'n': e.preventDefault(); newDraftTab(); break;
case 'e': e.preventDefault(); setMode('edit'); break;
case 'r': e.preventDefault(); setMode('preview'); break;
case 'h': e.preventDefault(); toggleVersionsPanel(); break;
case 'm': e.preventDefault(); cycleMode(); break;
case 'j': e.preventDefault(); openFiles(false); break;
case ',': e.preventDefault(); openPrefs(); break;
case 'b': if (e.target === E.editor) { e.preventDefault(); applyFmt('bold'); } break;
case 'i': if (e.target === E.editor) { e.preventDefault(); applyFmt('italic'); } break;
case 'k': if (e.target === E.editor) { e.preventDefault(); applyFmt('link'); } break;
}
});
window.addEventListener('beforeunload', (e) => {
if (state.tabs.some((t) => t.dirty)) {
e.preventDefault();
e.returnValue = '';
}
});
/* ------------------------------------------------ sincronia multi-dispositivo */
let lastFreshCheck = 0;
async function checkFreshness() {
if (!state.booted) return;
const now = Date.now();
if (now - lastFreshCheck < 4000) return;
lastFreshCheck = now;
let list;
try { list = await api('GET', '/api/notes'); } catch { return; }
const map = new Map(list.map((n) => [n.id, n]));
for (const t of state.tabs) {
if (!t.id) continue;
const n = map.get(t.id);
if (!n || n.hash === t.hash) continue;
if (!t.dirty) {
// sem edição local: acompanha o servidor silenciosamente
try {
applyNoteToTab(t, await api('GET', '/api/notes/' + t.id));
toast(`“${n.title || 'Sem título'}” mudou em outro dispositivo — recarregada`);
} catch { /* ignora */ }
} else if (t.key === state.activeKey) {
t.remoteHash = n.hash;
resolveConflict(t, null);
}
}
}
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkFreshness();
else flushAutosave(); // janela minimizada/trocou de app: grava pendências já
});
window.addEventListener('focus', checkFreshness);
/* ------------------------------------------------------------ status */
function updateStatus() {
const ta = E.editor;
const upto = ta.value.slice(0, ta.selectionStart);
const lines = upto.split('\n');
E.stPos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`;
const text = modeState.mode === 'wysiwyg' ? (activeTab() ? activeTab().content : '') : ta.value;
const words = (text.match(/\S+/g) || []).length;
E.stStats.textContent = `${text.length} caracteres · ${words} palavras · ${text.split('\n').length} linhas`;
const fmt = currentFormat();
E.stFormat.textContent = EXT[fmt];
const t = activeTab();
if (t && t.id) {
E.stFile.textContent = (E.noteTitle.value.trim() || t.title || 'Sem título') + EXT[fmt];
} else {
E.stFile.textContent = E.noteTitle.value.trim() ? E.noteTitle.value.trim() + EXT[fmt] + ' (nova)' : 'rascunho sem salvar';
}
if (t && t.dirty) {
E.stSaved.textContent = '● não salvo';
E.stSaved.className = 'st-item dirty';
} else {
E.stSaved.textContent = t && t.lastSaved ? 'salvo ' + relTime(t.lastSaved) : '—';
E.stSaved.className = 'st-item ok';
}
}
/* ------------------------------------------------------------ filtros */
let searchTimer = 0;
E.searchInput.addEventListener('input', () => {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
state.filter.q = E.searchInput.value.trim();
loadNotes().catch(() => {});
}, 250);
});
/* ------------------------------------------------------------ autor */
E.authorInput.value = localStorage.getItem('notas_author') || '';
E.authorInput.addEventListener('change', () =>
localStorage.setItem('notas_author', E.authorInput.value.trim()));
/* ------------------------------------------------ gerenciador de arquivos */
const F = {
overlay: $('#files-overlay'), tree: $('#files-tree'), list: $('#files-list'),
empty: $('#files-empty'), filters: $('#files-filters'), search: $('#files-search'),
input: $('#files-input'), uploadBtn: $('#files-upload-btn'), newFolderBtn: $('#files-newfolder'),
closeBtn: $('#files-close'), lightbox: $('#lightbox'), lbBody: $('#lightbox-body'),
lbDl: $('#lightbox-dl'), lbClose: $('#lightbox-close'),
};
const filesState = {
tree: [], rootFiles: [], folders: new Map(), files: new Map(),
folder: '', // '' = raiz | '__all__' = todos | id
filter: 'all', // all | attach | loose | note
search: '',
picker: false,
expanded: new Set(JSON.parse(localStorage.getItem('notas_files_expanded') || '[]')),
};
const iconFor = (mime) => mime == null ? '📄'
: mime.startsWith('image/') ? '🖼️'
: mime.startsWith('video/') ? '🎬'
: mime.startsWith('audio/') ? '🎵'
: mime.includes('pdf') ? '📕'
: mime.includes('zip') || mime.includes('compressed') ? '🗜️'
: mime.startsWith('text/') ? '📝' : '📄';
const fmtBytes = (n) => n < 1024 ? n + ' B'
: n < 1048576 ? (n / 1024).toFixed(1) + ' KiB' : (n / 1048576).toFixed(1) + ' MiB';
async function loadFiles() {
const d = await api('GET', '/api/files/tree');
filesState.tree = d.tree || [];
filesState.rootFiles = d.rootFiles || [];
filesState.folders = new Map();
filesState.files = new Map();
(function walk(nodes) {
for (const n of nodes) {
filesState.folders.set(n.id, n);
(n.files || []).forEach((f) => filesState.files.set(f.id, f));
walk(n.children || []);
}
})(filesState.tree);
filesState.rootFiles.forEach((f) => filesState.files.set(f.id, f));
renderFilesUI();
}
function filesInScope() {
let arr;
if (filesState.folder === '__all__') arr = [...filesState.files.values()];
else if (filesState.folder === '') arr = filesState.rootFiles.slice();
else {
const n = filesState.folders.get(filesState.folder);
arr = n ? (n.files || []).slice() : [];
}
const t = activeTab();
if (filesState.filter === 'attach') arr = arr.filter((f) => f.note);
else if (filesState.filter === 'loose') arr = arr.filter((f) => !f.note);
else if (filesState.filter === 'note') arr = [...filesState.files.values()].filter((f) => t && f.note === t.id);
if (filesState.search) {
const q = filesState.search.toLowerCase();
arr = arr.filter((f) => f.name.toLowerCase().includes(q));
}
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
}
function renderFilesUI() {
renderFilesFilters();
renderFilesTree();
renderFilesList();
}
function renderFilesFilters() {
F.filters.textContent = '';
const mk = (value, label) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'tag-chip' + (filesState.filter === value ? ' active' : '');
b.textContent = label;
b.addEventListener('click', () => { filesState.filter = value; renderFilesUI(); });
F.filters.appendChild(b);
};
mk('all', 'Todos');
mk('attach', 'Anexos');
mk('loose', 'Avulsos');
mk('note', '📌 Nota atual');
}
function folderRow(node, depth) {
const row = document.createElement('div');
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
row.style.paddingLeft = (8 + depth * 14) + 'px';
const caret = document.createElement('button');
caret.type = 'button';
caret.className = 'tree-caret';
const hasKids = (node.children || []).length > 0;
caret.textContent = hasKids ? (filesState.expanded.has(node.id) ? '▾' : '▸') : '·';
caret.addEventListener('click', (e) => {
e.stopPropagation();
if (!hasKids) return;
if (filesState.expanded.has(node.id)) filesState.expanded.delete(node.id);
else filesState.expanded.add(node.id);
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
renderFilesTree();
});
row.appendChild(caret);
const nm = document.createElement('span');
nm.className = 'tree-name';
nm.textContent = '📁 ' + node.name;
row.appendChild(nm);
const count = document.createElement('span');
count.className = 'tree-count';
count.textContent = String((node.files || []).length);
row.appendChild(count);
const acts = document.createElement('span');
acts.className = 'ftree-acts';
const mkAct = (label, title, fn) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'ftree-act';
b.textContent = label;
b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
acts.appendChild(b);
};
mkAct('+', 'Nova subpasta', () => createFolder(node.id));
mkAct('✎', 'Renomear', async () => {
const name = window.prompt('Novo nome da pasta:', node.name);
if (!name || !name.trim() || name.trim() === node.name) return;
try { await api('PUT', '/api/folders/' + node.id, { name: name.trim() }); await loadFiles(); }
catch (err) { toast('Renomear pasta: ' + err.message); }
});
mkAct('✕', 'Excluir pasta', async () => {
const ok = await confirmModal('Excluir pasta?',
`<b>${esc(node.name)}</b>, suas subpastas e TODOS os arquivos dentro serão apagados (inclusive anexos usados em notas).`, 'Excluir');
if (!ok) return;
try { await api('DELETE', '/api/folders/' + node.id); await loadFiles(); toast('Pasta excluída.'); }
catch (err) { toast('Excluir pasta: ' + err.message); }
});
row.appendChild(acts);
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
return row;
}
function renderFilesTree() {
F.tree.textContent = '';
const special = (value, label) => {
const row = document.createElement('div');
row.className = 'tree-row' + (filesState.folder === value ? ' active' : '');
row.style.paddingLeft = '8px';
const nm = document.createElement('span');
nm.className = 'tree-name';
nm.textContent = label;
row.appendChild(nm);
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
F.tree.appendChild(row);
};
special('__all__', '🗂 Todos os arquivos');
special('', '🏠 Raiz');
(function walk(nodes, depth) {
for (const n of nodes) {
F.tree.appendChild(folderRow(n, depth));
if (filesState.expanded.has(n.id)) walk(n.children || [], depth + 1);
}
})(filesState.tree, 0);
}
function fileRow(f) {
const row = document.createElement('div');
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
row.tabIndex = 0;
const ic = document.createElement('span');
ic.className = 'fitem-icon';
ic.textContent = iconFor(f.mime);
row.appendChild(ic);
const main = document.createElement('div');
main.className = 'fitem-main';
const nm = document.createElement('div');
nm.className = 'fitem-name';
nm.textContent = f.name;
main.appendChild(nm);
const meta = document.createElement('div');
meta.className = 'fitem-meta';
const bits = [fmtBytes(f.size), f.mime, relTime(f.created_at)];
if (f.note) {
const tn = state.notes.find((n) => n.id === f.note);
bits.push('📌 ' + (tn ? tn.title || 'Sem título' : 'nota?'));
}
meta.textContent = bits.join(' · ');
main.appendChild(meta);
row.appendChild(main);
const acts = document.createElement('div');
acts.className = 'fitem-acts';
const mkAct = (label, title, fn) => {
const b = document.createElement('button');
b.type = 'button';
b.className = 'btn btn-small';
b.textContent = label;
b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
acts.appendChild(b);
};
mkAct('👁', 'Visualizar', () => previewFile(f));
if (!filesState.picker) {
mkAct('📌', 'Associar à nota atual', async () => {
const t = activeTab();
if (!t || !t.id) { toast('Salve a nota primeiro para associar anexos.'); return; }
try { await api('PUT', '/api/files/' + f.id + '/move', { note: t.id }); await loadFiles(); }
catch (err) { toast('Associar: ' + err.message); }
});
if (f.note) {
mkAct('📎-', 'Desvincular da nota', async () => {
try { await api('PUT', '/api/files/' + f.id + '/move', { note: '' }); await loadFiles(); }
catch (err) { toast('Desvincular: ' + err.message); }
});
}
mkAct('📂', 'Mover de pasta', async () => {
const opts = ['(nenhuma mudança)'];
const lines = ['0: Raiz'];
let i = 1;
(function walk(nodes) {
for (const n of nodes) { lines.push(i + ': ' + n.name); opts.push(n.id); i++; walk(n.children || []); }
})(filesState.tree);
const ans = window.prompt('Mover para:\n' + lines.join('\n'), '0');
if (ans == null) return;
const idx = parseInt(ans, 10);
if (isNaN(idx) || idx < 0 || idx >= opts.length) return;
try { await api('PUT', '/api/files/' + f.id + '/move', { folder: opts[idx] || '' }); await loadFiles(); }
catch (err) { toast('Mover: ' + err.message); }
});
mkAct('✎', 'Renomear', async () => {
const name = window.prompt('Novo nome:', f.name);
if (!name || !name.trim() || name.trim() === f.name) return;
try { await api('PUT', '/api/files/' + f.id + '/move', { name: name.trim() }); await loadFiles(); }
catch (err) { toast('Renomear: ' + err.message); }
});
mkAct('✕', 'Excluir arquivo', async () => {
const ok = await confirmModal('Excluir arquivo?', `<b>${esc(f.name)}</b> será apagado permanentemente.`, 'Excluir');
if (!ok) return;
try { await api('DELETE', '/api/files/' + f.id); await loadFiles(); toast('Arquivo excluído.'); }
catch (err) { toast('Excluir: ' + err.message); }
});
}
row.appendChild(acts);
row.addEventListener('click', () => (filesState.picker ? pickFile(f) : previewFile(f)));
row.addEventListener('keydown', (e) => { if (e.key === 'Enter') filesState.picker ? pickFile(f) : previewFile(f); });
return row;
}
function renderFilesList() {
F.list.textContent = '';
const arr = filesInScope();
if (!arr.length) {
const d = document.createElement('div');
d.className = 'files-empty muted';
d.textContent = filesState.picker
? 'Nenhum arquivo aqui — envie um ou navegue pelas pastas.'
: 'Solte arquivos aqui ou use “⬆ Enviar”.';
F.list.appendChild(d);
return;
}
for (const f of arr) F.list.appendChild(fileRow(f));
}
async function createFolder(parent) {
const name = window.prompt(parent ? 'Nome da subpasta:' : 'Nome da nova pasta:');
if (!name || !name.trim()) return;
try {
await api('POST', '/api/folders', { name: name.trim(), parent: parent || '' });
if (parent) filesState.expanded.add(parent);
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
await loadFiles();
} catch (err) { toast('Criar pasta: ' + err.message); }
}
async function uploadOne(file, folder, note) {
const fd = new FormData();
fd.append('file', file, file.name);
if (folder) fd.append('folder_id', folder);
if (note) fd.append('note_id', note);
const resp = await fetch('/api/files/upload', { method: 'POST', body: fd });
if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); }
const data = await resp.json().catch(() => ({}));
if (!resp.ok) { const e = new ApiError(data.error || resp.statusText, resp.status); e.data = data; throw e; }
return data;
}
async function handleFiles(fileList, target) {
const files = [...fileList];
if (!files.length) return;
const t = activeTab();
const note = t && t.id ? t.id : '';
const folder = target === 'manager' ? (filesState.folder === '__all__' ? '' : filesState.folder) : '';
toast(`Enviando ${files.length} arquivo(s)…`);
const done = [];
for (const file of files) {
try { done.push(await uploadOne(file, folder, note)); }
catch (err) { toast(`Falha em ${file.name}: ${err.message}`); }
}
if (target === 'manager') { await loadFiles(); toast(`${done.length} arquivo(s) no gerenciador.`); return; }
for (const f of done) insertAttachment(f);
await loadFiles().catch(() => {});
if (done.length) toast(done.length === files.length
? 'Anexo(s) inserido(s) no editor' : `Inseridos ${done.length}/${files.length}`);
}
function attachmentSyntax(f, fmt) {
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (fmt === 'html') {
if (isImg) return `<img src="${f.url}" alt="${esc(f.name)}">`;
if (isVid) return `<video controls src="${f.url}"></video>`;
if (isAud) return `<audio controls src="${f.url}"></audio>`;
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
}
if (isImg) return `![${f.name}](${f.url})`;
return `[${(isVid ? '▶ ' : isAud ? ' ' : '') + f.name}](${f.url})`;
}
function attachmentHtml(f) {
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (isImg) return `<img src="${f.url}" alt="${esc(f.name)}">`;
if (isVid) return `<video controls src="${f.url}"></video>`;
if (isAud) return `<audio controls src="${f.url}"></audio>`;
return `<a href="${f.url}" target="_blank" rel="noopener">${esc(f.name)}</a>`;
}
function insertAttachment(f) {
const fmt = currentFormat();
if (modeState.mode === 'wysiwyg') {
E.wysiwyg.focus();
document.execCommand('insertHTML', false, attachmentHtml(f));
E.wysiwyg.dispatchEvent(new Event('input', { bubbles: true }));
return;
}
if (modeState.mode === 'preview') setMode('edit');
const snippet = attachmentSyntax(f, fmt);
E.editor.focus();
insertText(snippet);
markDirty();
scheduleAutosave();
}
function pickFile(f) {
insertAttachment(f);
if (f.note !== (activeTab() && activeTab().id) && activeTab() && activeTab().id) {
api('PUT', '/api/files/' + f.id + '/move', { note: activeTab().id }).catch(() => {});
}
closeFiles();
}
function openFiles(picker) {
filesState.picker = !!picker;
F.overlay.classList.remove('hidden');
F.overlay.setAttribute('aria-hidden', 'false');
if (picker) filesState.filter = 'all';
loadFiles().catch((err) => toast('Arquivos: ' + err.message));
}
function closeFiles() {
filesState.picker = false;
F.overlay.classList.add('hidden');
F.overlay.setAttribute('aria-hidden', 'true');
}
async function previewFile(f) {
F.lbDl.href = f.url;
F.lbDl.setAttribute('download', f.name);
F.lbBody.textContent = '';
const isImg = f.mime.startsWith('image/') && f.mime !== 'image/svg+xml';
const isVid = f.mime.startsWith('video/');
const isAud = f.mime.startsWith('audio/');
if (isImg || isVid || isAud || f.mime.includes('pdf')) {
let el;
if (isImg) { el = document.createElement('img'); el.src = f.url; el.alt = f.name; }
else if (isVid) { el = document.createElement('video'); el.src = f.url; el.controls = true; }
else if (isAud) { el = document.createElement('audio'); el.src = f.url; el.controls = true; }
else { el = document.createElement('iframe'); el.src = f.url; el.title = f.name; }
F.lbBody.appendChild(el);
} else {
const pre = document.createElement('pre');
pre.className = 'lightbox-text';
pre.textContent = 'carregando…';
F.lbBody.appendChild(pre);
try {
const r = await fetch(f.url);
pre.textContent = (await r.text()).slice(0, 200000);
} catch { pre.textContent = '(sem conexão para pré-visualizar)'; }
}
F.lightbox.classList.remove('hidden');
}
// wiring de eventos do gerenciador
F.closeBtn.addEventListener('click', closeFiles);
F.lbClose.addEventListener('click', () => F.lightbox.classList.add('hidden'));
F.lightbox.addEventListener('click', (e) => { if (e.target === F.lightbox) F.lightbox.classList.add('hidden'); });
F.uploadBtn.addEventListener('click', () => F.input.click());
F.input.addEventListener('change', () => { handleFiles(F.input.files, 'manager'); F.input.value = ''; });
F.newFolderBtn.addEventListener('click', () =>
createFolder(filesState.folder !== '__all__' ? filesState.folder : ''));
F.search.addEventListener('input', () => { filesState.search = F.search.value.trim(); renderFilesList(); });
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (!F.lightbox.classList.contains('hidden')) F.lightbox.classList.add('hidden');
else if (!F.overlay.classList.contains('hidden')) closeFiles();
}
});
// drag & drop no gerenciador
['dragenter', 'dragover'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.add('drop-hl');
}));
['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.remove('drop-hl');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files, 'manager');
}));
// drag & drop no editor (textarea + wysiwyg)
const host = $('#editor-host');
['dragenter', 'dragover'].forEach((ev) => host.addEventListener(ev, (e) => {
if (e.dataTransfer && [...e.dataTransfer.types].includes('Files')) { e.preventDefault(); host.classList.add('drop-hl'); }
}));
['dragleave', 'drop'].forEach((ev) => host.addEventListener(ev, (e) => {
host.classList.remove('drop-hl');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) {
e.preventDefault();
handleFiles(e.dataTransfer.files, 'editor');
}
}));
// paste com arquivos no textarea (o wysiwyg tem handler próprio que cai aqui)
E.editor.addEventListener('paste', (e) => {
const files = e.clipboardData && e.clipboardData.files;
if (files && files.length) { e.preventDefault(); handleFiles(files, 'editor'); }
});
/* ------------------------------------------------------------ PWA */
const isStandalone = () =>
window.matchMedia('(display-mode: standalone)').matches ||
window.matchMedia('(display-mode: window-controls-overlay)').matches ||
window.navigator.standalone === true;
// beforeinstallprompt só existe em Chromium (Firefox/Safari nunca disparam o
// evento nem 'appinstalled') — checagem de propriedade, não de evento.
const canBIP = 'onbeforeinstallprompt' in window;
const ua = navigator.userAgent;
const isFirefox = /Firefox\//.test(ua);
const isAndroid = /Android/.test(ua);
const isWindows = /Windows/.test(ua);
let deferredPrompt = null;
const miInstall = document.querySelector('.menu-dropdown [data-action="install"]');
function refreshInstallUI() {
if (!miInstall) return;
const show = !isStandalone() && (canBIP ? !!deferredPrompt : isFirefox);
miInstall.classList.toggle('hidden', !show);
}
function firefoxInstallHelp() {
let steps;
if (isAndroid) {
steps = '<p>No <b>Firefox para Android</b>:</p><ol><li>Toque no menu <b>⋮</b> da barra de endereço;</li>' +
'<li>Toque em <b>Instalar</b> → <em>Adicionar automaticamente</em>;</li>' +
'<li>O Notas entra na tela inicial e abre em modo aplicativo (standalone).</li></ol>';
} else if (isWindows) {
steps = '<p>No <b>Firefox para Windows</b> (143+):</p><ol><li>Clique no ícone da barra de endereço ' +
'(<em>Adicionar aba à barra de tarefas</em>);</li>' +
'<li>O site fica fixado na barra de tarefas e abre em janela própria.</li></ol>';
} else if (/Linux/.test(ua)) {
steps = '<p>No <b>Firefox para Linux</b> (155+, vem desligado):</p><ol>' +
'<li>Abra <code>about:config</code> e ajuste <code>browser.taskbarTabs.enabled</code> para <b>true</b>;</li>' +
'<li>Reinicie o Firefox;</li>' +
'<li>Nesta página, clique em <em>Adicionar aba à barra de tarefas</em> na barra de endereço ' +
'— o Notas fica fixado no dock/painel e abre em janela de app.</li></ol>';
} else {
steps = '<p>No <b>Firefox para macOS</b> a instalação nativa ainda não está disponível ' +
'(a Mozilla mantém o recurso em desenvolvimento). Use como aba ou favorito — o app ' +
'funciona offline do mesmo jeito.</p>';
}
modal({
title: 'Instalar o Notas no Firefox',
bodyHTML: steps +
'<p>Em qualquer caso o app <b>funciona offline</b> (instalado ou em aba): a interface é ' +
'carregada pelo Service Worker mesmo sem servidor. As alterações só sincronizam com o ' +
'servidor acessível.</p>',
});
}
if (canBIP) {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // prompt próprio via menu Ajuda
deferredPrompt = e;
refreshInstallUI();
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
refreshInstallUI();
toast('Notas instalado ✔ procure pelo menu do sistema');
});
}
if ('serviceWorker' in navigator && location.protocol.startsWith('http')) {
navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => { /* sem SW: app continua */ });
navigator.serviceWorker.addEventListener('message', (e) => {
if (e.data === 'notas:updated') toast('App atualizado para a nova versão');
});
}
window.addEventListener('offline', () => toast('Sem conexão — o app abre offline; salvar precisa do servidor'));
window.addEventListener('online', () => { toast('Conexão restabelecida'); checkFreshness(); });
const wantsNew = new URLSearchParams(location.search).get('new') === '1';
if (wantsNew) history.replaceState(null, '', location.pathname);
/* ------------------------------------------------------------ boot */
async function boot() {
applyPrefs();
await Promise.all([loadNotes(), loadAggregates()]);
try {
await restoreTabs();
} catch (err) {
console.error('restoreTabs falhou', err);
state.tabs = [newTab()];
activateTab(state.tabs[0].key);
}
updateStatus();
state.booted = true;
refreshInstallUI();
if (wantsNew) await newDraftTab();
connectEvents();
sendPresence();
setInterval(sendPresence, 10000); // heartbeat de presença
}
window.addEventListener('pagehide', () => {
flushAutosave(); // melhor esforço: grava antes de a página morrer
// remove a sessão do registro assim que a aba fecha (keepalive sobrevive ao unload)
fetch('/api/presence', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session: SID, notes: [] }),
keepalive: true,
}).catch(() => {});
});
window.addEventListener('unhandledrejection', (e) => {
if (e.reason instanceof ApiError && e.reason.status === 401) return; // login já exibido
console.error(e.reason);
if (e.reason instanceof ApiError) toast('Erro: ' + e.reason.message);
});
(async () => {
try {
await api('GET', '/api/me');
await boot();
} catch (err) {
if (err instanceof ApiError && err.status === 401) showLogin();
else toast('Erro ao iniciar: ' + err.message);
}
})();
})();