/* 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'), categorySelect: $('#category-select'), tagCloud: $('#tag-cloud'), notesList: $('#notes-list'), notesCount: $('#notes-count'), noteTitle: $('#note-title'), noteCategory: $('#note-category'), editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'), gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'), preview: $('#preview'), previewFrame: $('#preview-frame'), 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'), }; const EXT = { txt: '.txt', md: '.md', html: '.html' }; const state = { note: null, // metadados da nota aberta (null = rascunho não salvo) content: '', dirty: false, mode: 'edit', // 'edit' | 'preview' filter: { category: '*', tag: '', q: '' }, notes: [], selectedRev: null, lastSaved: null, }; /* ------------------------------------------------------------ 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) throw new ApiError(data.error || resp.statusText, resp.status); 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 currentFormat = () => E.formatSelect.value; const currentTitle = () => { const t = E.noteTitle.value.trim(); if (t) return t; const first = (E.editor.value.trim().split('\n')[0] || '').slice(0, 42); return first || 'Sem título'; }; function confirmDiscard() { if (!state.dirty) return Promise.resolve(true); return modal({ title: 'Descartar alterações?', bodyHTML: 'As alterações não salvas desta nota serão perdidas.', withCancel: true, okLabel: 'Descartar', }); } function modal({ title, bodyHTML, okLabel = 'OK', 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.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'); } }); /* ------------------------------------------------------------ dados */ async function loadNotes() { const f = state.filter; const q = new URLSearchParams(); if (f.category !== '*') q.set('category', f.category); 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')]); // tag cloud if (!tags.length) { E.tagCloud.innerHTML = 'nenhuma tag ainda'; } else { 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); } } // categorias no seletor const want = state.filter.category; E.categorySelect.textContent = ''; const mk = (value, label) => { const o = document.createElement('option'); o.value = value; o.textContent = label; E.categorySelect.appendChild(o); }; mk('*', 'Todas as categorias'); mk('', 'Sem categoria'); for (const c of cats) if (c.category) mk(c.category, c.category + ' (' + c.count + ')'); E.categorySelect.value = [...E.categorySelect.options].some((o) => o.value === want) ? want : '*'; } function renderNotesList() { E.notesList.textContent = ''; E.notesCount.textContent = String(state.notes.length); for (const n of state.notes) { const li = document.createElement('li'); li.className = n.id === (state.note && state.note.id) ? 'active' : ''; 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.category) bits.push(n.category); if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' ')); sub.textContent = bits.join(' · '); li.appendChild(title); li.appendChild(sub); li.addEventListener('click', () => openNote(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); } } async function openNote(id) { if (!(await confirmDiscard())) return; const data = await api('GET', '/api/notes/' + id); state.note = data; state.content = data.content; state.dirty = false; state.lastSaved = data.updated_at; fillEditor(); if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {}); renderNotesList(); updateStatus(); } function fillEditor() { const n = state.note; E.noteTitle.value = n ? (n.title || '') : ''; E.noteCategory.value = n ? (n.category || '') : ''; E.formatSelect.value = n ? n.format : 'md'; E.editor.value = state.content; updateToolbar(); updateStatus(); renderEditorVisual(); refreshPreviewIfOpen(); } async function newDraft(force = false) { if (!force && !(await confirmDiscard())) return; state.note = null; state.content = ''; state.dirty = false; state.lastSaved = null; state.selectedRev = null; E.revisionsList.textContent = ''; E.versionDetail.classList.add('hidden'); fillEditor(); E.editor.focus(); renderNotesList(); } async function save() { const body = { title: currentTitle(), format: currentFormat(), category: E.noteCategory.value.trim(), content: E.editor.value, author: E.authorInput.value.trim(), message: E.revMessage.value.trim(), }; let data; try { if (state.note) data = await api('PUT', '/api/notes/' + state.note.id, body); else data = await api('POST', '/api/notes', body); } catch (err) { toast('Falha ao salvar: ' + err.message); return; } state.note = data; state.content = data.content; state.dirty = false; state.lastSaved = new Date().toISOString(); E.revMessage.value = ''; renderNotesList(); loadNotes().catch(() => {}); loadAggregates().catch(() => {}); if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {}); updateStatus(); toast('Salvo — revisão ' + data.hash.slice(0, 8)); } async function deleteNote() { if (!state.note) { toast('Nenhuma nota aberta.'); return; } const ok = await modal({ title: 'Excluir nota?', bodyHTML: `A nota ${esc(state.note.title || 'Sem título')} e todo o seu histórico serão apagados.`, withCancel: true, okLabel: 'Excluir', }); if (!ok) return; try { await api('DELETE', '/api/notes/' + state.note.id); toast('Nota excluída.'); await newDraft(true); loadNotes().catch(() => {}); loadAggregates().catch(() => {}); } catch (err) { toast('Falha ao excluir: ' + err.message); } } function exportNote() { if (!state.note) { toast('Salve a nota antes de baixar.'); return; } if (state.dirty) toast('Atenção: o arquivo baixado é a última versão salva.'); window.location.assign('/api/notes/' + state.note.id + '/raw'); } /* ------------------------------------------------------- preferências */ const PREFS_KEY = 'notas_prefs'; const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, font: 'mono', size: 14, theme: 'claro' }; const THEMES = ['claro', 'escuro', 'solar-claro', 'solar-escuro']; 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)); 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.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); 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'; } 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 && state.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'), 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.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.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(); }); /* ------------------------------------------------------------ editor */ function markDirty() { if (!state.dirty) { state.dirty = true; updateStatus(); } } E.editor.addEventListener('input', () => { state.content = E.editor.value; markDirty(); updateStatus(); scheduleRender(); }); ['keyup', 'mouseup', 'select', 'click'].forEach((ev) => E.editor.addEventListener(ev, updateStatus)); E.noteTitle.addEventListener('input', () => { markDirty(); updateStatus(); }); E.noteCategory.addEventListener('input', markDirty); E.formatSelect.addEventListener('change', () => { markDirty(); updateToolbar(); updateStatus(); scheduleRender(); refreshPreviewIfOpen(); }); // Tab insere 4 espaços em vez de sair do textarea. E.editor.addEventListener('keydown', (e) => { if (e.key === 'Tab') { e.preventDefault(); insertText(' '); } }); function insertText(text) { const ta = E.editor; ta.focus(); if (!document.execCommand || !document.execCommand('insertText', false, text)) { // fallback sem integração com undo nativo const s = ta.selectionStart, en = ta.selectionEnd; ta.setRangeText(text, s, en, 'end'); } state.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); state.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) { 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'); } /* ------------------------------------------------------------ modos */ function setMode(mode) { state.mode = mode; E.modeToggle.querySelectorAll('.seg').forEach((b) => b.classList.toggle('active', b.dataset.mode === mode)); const editing = mode === 'edit'; E.editorWrap.classList.toggle('hidden', !editing); if (editing) { E.preview.classList.add('hidden'); E.previewFrame.classList.add('hidden'); E.editor.focus(); updateMarker(); return; } E.colMarker.classList.add('hidden'); refreshPreviewIfOpen(); } function refreshPreviewIfOpen() { if (state.mode !== 'preview') return; const fmt = currentFormat(); E.editorWrap.classList.add('hidden'); 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 (state.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 (show) loadRevisions().catch((err) => toast('Histórico: ' + err.message)); } let revReq = 0; async function loadRevisions() { const req = ++revReq; if (!state.note) { E.revisionsList.textContent = ''; E.versionDetail.classList.add('hidden'); return; } const revs = await api('GET', '/api/notes/' + state.note.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) { 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/${state.note.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 = state.note && rev.hash === state.note.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 () => { if (!state.note || !state.selectedRev) return; const ok = await modal({ title: 'Restaurar versão?', bodyHTML: `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.`, withCancel: true, okLabel: 'Restaurar', }); if (!ok) return; try { const data = await api('POST', `/api/notes/${state.note.id}/revisions/${state.selectedRev}/restore`, { author: E.authorInput.value.trim() }); state.note = data; state.content = data.content; state.dirty = false; fillEditor(); 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)); /* ------------------------------------------------------------ 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) menu.classList.add('open'); }); }); document.addEventListener('click', () => document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'))); const ACTIONS = { 'new-note': () => newDraft(), 'save': () => save(), 'export': exportNote, 'delete-note': deleteNote, 'undo': () => { E.editor.focus(); document.execCommand('undo'); state.content = E.editor.value; markDirty(); updateStatus(); }, 'redo': () => { E.editor.focus(); document.execCommand('redo'); state.content = E.editor.value; markDirty(); updateStatus(); }, 'select-all': () => { E.editor.focus(); E.editor.setSelectionRange(0, E.editor.value.length); }, 'toggle-sidebar': () => document.body.classList.toggle('sidebar-off'), 'toggle-gutter': () => { prefs.gutter = !prefs.gutter; persistPrefs(); applyPrefs(); }, 'toggle-wrap': () => { prefs.wrap = !prefs.wrap; persistPrefs(); applyPrefs(); }, 'prefs': openPrefs, 'toggle-edit': () => setMode('edit'), '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.

Atalhos: Ctrl+S salvar · Ctrl+N nova · Ctrl+E editar · Ctrl+R preview · Ctrl+H histórico · F9 barra lateral · Ctrl+B/I/K formatação.

`, }), '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', () => newDraft()); E.btnSave.addEventListener('click', () => save()); E.btnExport.addEventListener('click', exportNote); E.btnVersions.addEventListener('click', () => toggleVersionsPanel()); E.btnSidebar.addEventListener('click', ACTIONS['toggle-sidebar']); /* ------------------------------------------------------------ teclado */ document.addEventListener('keydown', (e) => { const mod = e.ctrlKey || e.metaKey; if (e.key === 'F9') { e.preventDefault(); document.body.classList.toggle('sidebar-off'); return; } if (!mod) return; switch (e.key.toLowerCase()) { case 's': e.preventDefault(); save(); break; case 'n': e.preventDefault(); newDraft(); break; case 'e': e.preventDefault(); setMode('edit'); break; case 'r': e.preventDefault(); setMode('preview'); break; case 'h': e.preventDefault(); toggleVersionsPanel(); 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.dirty) { e.preventDefault(); e.returnValue = ''; } }); /* ------------------------------------------------------------ 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 = 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]; if (state.note) { E.stFile.textContent = (E.noteTitle.value.trim() || state.note.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 (state.dirty) { E.stSaved.textContent = '● não salvo'; E.stSaved.className = 'st-item dirty'; } else { E.stSaved.textContent = state.lastSaved ? 'salvo ' + relTime(state.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); }); E.categorySelect.addEventListener('change', () => { state.filter.category = E.categorySelect.value; loadNotes().catch(() => {}); }); /* ------------------------------------------------------------ autor */ E.authorInput.value = localStorage.getItem('notas_author') || ''; E.authorInput.addEventListener('change', () => localStorage.setItem('notas_author', E.authorInput.value.trim())); /* ------------------------------------------------------------ boot */ async function boot() { applyPrefs(); await Promise.all([loadNotes(), loadAggregates()]); if (state.notes.length) await openNote(state.notes[0].id); else await newDraft(true); updateStatus(); } 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); } })(); })();