/* 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);
}
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' : '');
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); });
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 = 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());
}
state.tabs = opened;
const want = saved && Number.isInteger(saved.active) ? Math.min(saved.active, opened.length - 1) : 0;
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 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(' · ');
li.appendChild(title);
li.appendChild(sub);
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, '
' + esc(sel || 'código') + '');
} else if (kind === 'block') {
const sel = getSelection().toString();
document.execCommand('insertHTML', false,
'' + esc(sel || 'código em bloco') + '${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.${esc((t.remoteHash || '').slice(0, 8))} · sua base: ${esc(t.hash.slice(0, 8))}.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.