/* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 ${esc(tab.title || 'Sem título')} 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 = 'nenhuma tag ainda'; 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?', `${esc(cat.name)} 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('', '', 'negrito'); break; case 'italic': md ? wrapSel('*', '*', 'itálico') : wrapSel('', '', 'itálico'); break; case 'strike': md ? wrapSel('~~', '~~', 'tachado') : wrapSel('', '', 'tachado'); break; case 'code': md ? wrapSel('`', '`', 'código') : wrapSel('', '', 'código'); break; case 'block': md ? wrapSel('\n```\n', '\n```\n', 'código em bloco') : wrapSel('\n
', '
\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(`${sel || 'link'}`); break; } case 'quote': md ? eachLine((l) => togglePrefix(l, '> ')) : wrapSel('
', '
', 'citação'); break; case 'h1': md ? eachLine((l) => toggleHeading(l, '# ')) : eachLine((l) => wrapLine(l, '

', '

')); break; case 'h2': md ? eachLine((l) => toggleHeading(l, '## ')) : eachLine((l) => wrapLine(l, '

', '

')); break; case 'list-ul': md ? eachLine((l) => togglePrefix(l, '- ')) : (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n'); replaceSelection(''); })(); break; case 'list-ol': md ? eachLine((l, i) => `${i + 1}. ${l.replace(/^(\d+\.|- )\s*/, '')}`) : (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n'); replaceSelection('
    \n' + items.map((i) => '
  1. ' + i + '
  2. ').join('\n') + '\n
'); })(); 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); 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('' + html + '', '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, '' + esc(sel || 'código') + ''); } else if (kind === 'block') { const sel = getSelection().toString(); document.execCommand('insertHTML', false, '
' + esc(sel || 'código em bloco') + '


'); } 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 ${esc(state.selectedRev.slice(0, 8))}. 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 outro dispositivo enquanto você editava.
` + `Servidor: ${esc((t.remoteHash || '').slice(0, 8))} · sua base: ${esc(t.hash.slice(0, 8))}.
` + `Recarregar traz o texto do servidor (descarta seu texto não salvo); ` + `Salvar por cima 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 ${esc(n.title || 'Sem título')} 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: `

Editor de notas minimalista inspirado no Mousepad (XFCE).

Backend em Go, 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.

Categorias viram uma árvore de pastas (barra lateral); notas podem ter várias categorias e abrir em várias abas. O modo WYSIWYG edita Markdown/HTML renderizados e sincroniza com o fonte.

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.

Atalhos: Ctrl+S salvar · Ctrl+N nova aba · Alt+W fechar aba · Alt+←/→ trocar aba · Ctrl+E/M/R editar/wysiwyg/preview · Ctrl+H histórico · F9 barra lateral · Ctrl+B/I/K formatação · Ctrl+, preferências.

`, }), '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') || '[]')), }; // arrastar-e-soltar: o arquivo arrastado mantém o mesmo id, então a // referência da nota (/api/files/static/{id}) continua válida ao mudar de pasta. const DND_MIME = 'application/x-notas-file'; let dragFileId = ''; function dndIsInternalDrag(e) { const dt = e.dataTransfer; if (!dt) return false; return (dt.types && Array.from(dt.types).includes(DND_MIME)) || !!dragFileId; } function clearDropHighlights() { for (const el of document.querySelectorAll('.drop-hl')) el.classList.remove('drop-hl'); } 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'); } // expande a pasta e todos os ancestrais para o arquivo ficar visível function expandAncestors(folderId) { const chain = []; (function walk(nodes, trail) { for (const n of nodes) { const next = trail.concat([n.id]); if (n.id === folderId) { chain.push(...next); return true; } if (walk(n.children || [], next)) return true; } return false; })(filesState.tree, []); for (const id of chain) filesState.expanded.add(id); localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded])); } // move o arquivo para a pasta alvo (id vazio = raiz) e recarrega a árvore async function moveFileToFolder(fileId, folderId, folderLabel) { try { await api('PUT', '/api/files/' + fileId + '/move', { folder: folderId || '' }); if (filesState.folder !== '__all__') filesState.folder = folderId || ''; if (folderId) expandAncestors(folderId); await loadFiles(); toast('Movido para ' + folderLabel + '.'); } catch (err) { toast('Mover: ' + err.message); await loadFiles().catch(() => {}); } } // torna uma linha da árvore um destino de soltar (pasta ou raiz) function makeDropTarget(row, folderId, folderLabel) { row.addEventListener('dragover', (e) => { // preventDefault sempre: sem isso o navegador abre/navega com o drop e.preventDefault(); e.stopPropagation(); if (!dndIsInternalDrag(e)) return; if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; row.classList.add('drop-hl'); }); row.addEventListener('dragleave', (e) => { if (!dndIsInternalDrag(e)) return; e.stopPropagation(); row.classList.remove('drop-hl'); }); row.addEventListener('drop', (e) => { e.preventDefault(); e.stopPropagation(); if (!dndIsInternalDrag(e)) return; // drop de arquivo do sistema: ignorado row.classList.remove('drop-hl'); const id = (e.dataTransfer && e.dataTransfer.getData(DND_MIME)) || dragFileId; dragFileId = ''; if (!id) return; const f = filesState.files.get(id); if (f && (f.folder || '') === (folderId || '')) return; // já está nesta pasta moveFileToFolder(id, folderId || '', folderLabel); }); } 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?', `${esc(node.name)}, 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); makeDropTarget(row, node.id, node.name); 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); if (value === '') makeDropTarget(row, '', 'a raiz'); 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; if (!filesState.picker) { row.draggable = true; row.addEventListener('dragstart', (e) => { dragFileId = f.id; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData(DND_MIME, f.id); e.dataTransfer.setData('text/plain', f.name); } row.classList.add('dragging'); }); row.addEventListener('dragend', () => { dragFileId = ''; row.classList.remove('dragging'); clearDropHighlights(); }); } 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?', `${esc(f.name)} 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 attachmentKind(f) { const mime = f.mime || ''; if (mime.startsWith('image/') && mime !== 'image/svg+xml') return 'img'; if (mime.startsWith('video/')) return 'vid'; if (mime.startsWith('audio/')) return 'aud'; return 'doc'; } function attachmentSyntax(f, fmt) { const kind = attachmentKind(f); if (fmt === 'html') return attachmentHtml(f); if (kind === 'img') return `![${f.name}](${f.url})`; return `[${(kind === 'vid' ? '▶ ' : kind === 'aud' ? ' ' : '') + f.name}](${f.url})`; } function attachmentHtml(f) { const kind = attachmentKind(f); if (kind === 'img') return `${esc(f.name)}`; if (kind === 'vid') return ``; if (kind === 'aud') return ``; return `${esc(f.name)}`; } 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 = ''; if (isPreviewableImage(f)) { const el = document.createElement('img'); el.src = f.url; el.alt = f.name; F.lbBody.appendChild(el); } else if (isSupportedArchive(f)) { await previewArchive(f); } else if (isArchiveFile(f)) { previewNotice(`Não foi possível mostrar o conteúdo de “${f.name}”.\nEsse formato de compactado não tem listagem suportada (vale para .zip, .tar, .tar.gz e .gz). Use Baixar para abrir o arquivo.`); } else { const kind = attachmentKind(f); if (kind !== 'doc' || (f.mime || '').includes('pdf')) { let el; if (kind === 'img') { el = document.createElement('img'); el.src = f.url; el.alt = f.name; } else if (kind === 'vid') { el = document.createElement('video'); el.src = f.url; el.controls = true; } else if (kind === 'aud') { 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'); } function isPreviewableImage(f) { const mime = f.mime || ''; // svg é servido como attachment (anti-XSS), então não dá para exibir inline return mime.startsWith('image/') && mime !== 'image/svg+xml'; } function archiveExt(name) { const l = (name || '').toLowerCase(); if (l.endsWith('.tar.gz')) return '.tar.gz'; const i = l.lastIndexOf('.'); return i >= 0 ? l.slice(i) : ''; } const SUPPORTED_ARCHIVE_EXTS = new Set(['.zip', '.tar', '.tar.gz', '.tgz', '.gz']); const ARCHIVE_EXTS = new Set(['.zip', '.tar', '.tar.gz', '.tgz', '.gz', '.rar', '.7z', '.bz2', '.xz', '.cab', '.iso']); function isSupportedArchive(f) { const mime = f.mime || ''; if (SUPPORTED_ARCHIVE_EXTS.has(archiveExt(f.name))) return true; return mime.includes('zip') || mime.includes('x-tar') || mime.includes('x-gzip') || mime.includes('gzip') || mime.includes('compressed'); } function isArchiveFile(f) { if (isSupportedArchive(f)) return true; return ARCHIVE_EXTS.has(archiveExt(f.name)); } function previewNotice(msg) { const div = document.createElement('div'); div.className = 'lightbox-empty'; const icon = document.createElement('div'); icon.className = 'lightbox-empty-icon'; icon.textContent = '📦'; const p = document.createElement('p'); p.textContent = msg; div.append(icon, p); F.lbBody.appendChild(div); } async function previewArchive(f) { const loading = document.createElement('div'); loading.className = 'lightbox-empty'; loading.textContent = 'Listando conteúdo…'; F.lbBody.appendChild(loading); let data; try { data = await api('GET', '/api/files/archive/' + f.id); } catch (err) { loading.textContent = ''; previewNotice(err.message || `Não foi possível listar “${f.name}”. Use Baixar para abrir o arquivo.`); return; } F.lbBody.textContent = ''; const box = document.createElement('div'); box.className = 'lightbox-archive'; const title = document.createElement('div'); title.className = 'archive-title'; title.textContent = '🗜️ ' + f.name; const meta = document.createElement('div'); meta.className = 'archive-meta'; const total = data.total || 0; meta.textContent = total === 0 ? 'Arquivo vazio.' : total === 1 ? '1 item · ' + fmtBytes(f.size || 0) : `${total} itens · ` + fmtBytes(f.size || 0); box.append(title, meta); const entries = data.entries || []; if (!entries.length) { const p = document.createElement('p'); p.className = 'archive-note'; p.textContent = total === 0 ? 'Nada para listar neste compactado.' : 'Nada para exibir.'; box.appendChild(p); } else { const ul = document.createElement('ul'); ul.className = 'archive-list'; for (const e of entries) { const li = document.createElement('li'); const ic = document.createElement('span'); ic.className = 'archive-icon'; ic.textContent = e.dir ? '📁' : '📄'; const nm = document.createElement('span'); nm.className = 'archive-name'; nm.textContent = e.name; nm.title = e.name; const sz = document.createElement('span'); sz.className = 'archive-size'; sz.textContent = e.dir ? '' : fmtBytes(e.size < 0 ? 0 : e.size); li.append(ic, nm, sz); ul.appendChild(li); } box.appendChild(ul); } if (data.truncated) { const note = document.createElement('p'); note.className = 'archive-note'; note.textContent = `Mostrando os primeiros ${entries.length} de ${total} itens. Baixe o arquivo para ver o restante.`; box.appendChild(note); } F.lbBody.appendChild(box); } // 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(); // evita que o navegador navegue com o drop if (dndIsInternalDrag(e)) return; // arrasto interno: só a árvore é destino 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 && !dndIsInternalDrag(e)) { 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 = '

No Firefox para Android:

  1. Toque no menu ⋮ da barra de endereço;
  2. ' + '
  3. Toque em Instalar → Adicionar automaticamente;
  4. ' + '
  5. O Notas entra na tela inicial e abre em modo aplicativo (standalone).
'; } else if (isWindows) { steps = '

No Firefox para Windows (143+):

  1. Clique no ícone da barra de endereço ' + '(Adicionar aba à barra de tarefas);
  2. ' + '
  3. O site fica fixado na barra de tarefas e abre em janela própria.
'; } else if (/Linux/.test(ua)) { steps = '

No Firefox para Linux (155+, vem desligado):

    ' + '
  1. Abra about:config e ajuste browser.taskbarTabs.enabled para true;
  2. ' + '
  3. Reinicie o Firefox;
  4. ' + '
  5. Nesta página, clique em Adicionar aba à barra de tarefas na barra de endereço ' + '— o Notas fica fixado no dock/painel e abre em janela de app.
'; } else { steps = '

No Firefox para macOS 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.

'; } modal({ title: 'Instalar o Notas no Firefox', bodyHTML: steps + '

Em qualquer caso o app funciona offline (instalado ou em aba): a interface é ' + 'carregada pelo Service Worker mesmo sem servidor. As alterações só sincronizam com o ' + 'servidor acessível.

', }); } 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); } })(); })();