- Backend Go: API REST, auth por token (Bearer + cookie HttpOnly), storage em disco com revisões content-addressed (estilo Git: hash, autor, data, diff, restauração) - Editor txt/md/html com preview, tags #tag, categorias, painel de histórico - Preferências: números de linha, marcador de coluna (padrão 80), quebra de linha, fonte/tamanho, 4 temas e colorização de sintaxe (overlay no textarea) - Frontend vanilla embutido via go:embed; Dockerfile multi-stage + docker-compose
1082 lines
40 KiB
JavaScript
1082 lines
40 KiB
JavaScript
/* 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 = '<span class="muted">nenhuma tag ainda</span>';
|
|
} 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 <b>${esc(state.note.title || 'Sem título')}</b> 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)) {
|
|
const j = line.indexOf('-->', lt);
|
|
if (j < 0) { children.push(sp('tk-comment', line.slice(lt))); inComment = true; break; }
|
|
children.push(sp('tk-comment', line.slice(lt, j + 3))); i = j + 3; continue;
|
|
}
|
|
const gt = line.indexOf('>', lt);
|
|
if (gt < 0) { children.push(...tagUrlEnt(line.slice(lt))); break; }
|
|
children.push(...tagTokens(line.slice(lt, gt + 1)));
|
|
i = gt + 1;
|
|
}
|
|
out.push(lineEl(children));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/* ------------------------------------------------- render do overlay */
|
|
|
|
let lastGutterCount = -1;
|
|
let chW = 8;
|
|
let rafPending = false;
|
|
|
|
function renderEditorVisual() {
|
|
const v = E.editor.value;
|
|
const lines = v.split('\n');
|
|
const plain = !prefs.hl || v.length > HL_MAX;
|
|
let rowEls;
|
|
if (plain) rowEls = lines.map((l) => lineEl([sp(null, l)]));
|
|
else if (currentFormat() === 'md') rowEls = buildMdLines(lines);
|
|
else if (currentFormat() === 'html') rowEls = buildHtmlLines(lines);
|
|
else rowEls = lines.map((l) => lineEl(tagUrlEnt(l)));
|
|
const frag = document.createDocumentFragment();
|
|
for (const r of rowEls) frag.appendChild(r);
|
|
E.hl.replaceChildren(frag);
|
|
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('<b>', '</b>', 'negrito'); break;
|
|
case 'italic': md ? wrapSel('*', '*', 'itálico') : wrapSel('<i>', '</i>', 'itálico'); break;
|
|
case 'strike': md ? wrapSel('~~', '~~', 'tachado') : wrapSel('<s>', '</s>', 'tachado'); break;
|
|
case 'code': md ? wrapSel('`', '`', 'código') : wrapSel('<code>', '</code>', 'código'); break;
|
|
case 'block':
|
|
md ? wrapSel('\n```\n', '\n```\n', 'código em bloco')
|
|
: wrapSel('\n<pre><code>', '</code></pre>\n', 'código em bloco');
|
|
break;
|
|
case 'link': {
|
|
const { sel } = selectedRange();
|
|
const url = window.prompt('Endereço do link (URL):', 'https://');
|
|
if (!url) return;
|
|
md ? wrapSel('[', `](${url})`, sel || 'link')
|
|
: replaceSelection(`<a href="${url}">${sel || 'link'}</a>`);
|
|
break;
|
|
}
|
|
case 'quote':
|
|
md ? eachLine((l) => togglePrefix(l, '> '))
|
|
: wrapSel('<blockquote>', '</blockquote>', 'citação');
|
|
break;
|
|
case 'h1': md ? eachLine((l) => toggleHeading(l, '# ')) : eachLine((l) => wrapLine(l, '<h1>', '</h1>')); break;
|
|
case 'h2': md ? eachLine((l) => toggleHeading(l, '## ')) : eachLine((l) => wrapLine(l, '<h2>', '</h2>')); break;
|
|
case 'list-ul':
|
|
md ? eachLine((l) => togglePrefix(l, '- '))
|
|
: (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n');
|
|
replaceSelection('<ul>\n' + items.map((i) => ' <li>' + i + '</li>').join('\n') + '\n</ul>'); })();
|
|
break;
|
|
case 'list-ol':
|
|
md ? eachLine((l, i) => `${i + 1}. ${l.replace(/^(\d+\.|- )\s*/, '')}`)
|
|
: (() => { const { sel } = selectedRange(); const items = (sel || 'item').split('\n');
|
|
replaceSelection('<ol>\n' + items.map((i) => ' <li>' + i + '</li>').join('\n') + '\n</ol>'); })();
|
|
break;
|
|
}
|
|
}
|
|
|
|
function togglePrefix(line, prefix) {
|
|
return line.startsWith(prefix) ? line.slice(prefix.length) : prefix + line;
|
|
}
|
|
function toggleHeading(line, prefix) {
|
|
if (line.startsWith(prefix)) return line.slice(prefix.length);
|
|
return prefix + line.replace(/^#{1,6}\s*/, '');
|
|
}
|
|
function wrapLine(line, before, after) {
|
|
const trimmed = line.trim();
|
|
if (trimmed.startsWith(before) && trimmed.endsWith(after)) {
|
|
return line.replace(before, '').replace(new RegExp(after + '$'), '');
|
|
}
|
|
return before + (trimmed || 'título') + after;
|
|
}
|
|
|
|
document.querySelectorAll('#fmt-tools .fmt').forEach((btn) => {
|
|
btn.addEventListener('click', () => applyFmt(btn.dataset.fmt));
|
|
});
|
|
|
|
function updateToolbar() {
|
|
E.fmtTools.classList.toggle('disabled', currentFormat() === 'txt');
|
|
}
|
|
|
|
/* ------------------------------------------------------------ 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 <code>${esc(state.selectedRev.slice(0, 8))}</code>. 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:
|
|
`<p>Editor de notas minimalista inspirado no <b>Mousepad</b> (XFCE).</p>
|
|
<p>Backend em <b>Go</b>, armazenamento em disco com revisões endereçadas por hash SHA-256 (estilo Git): cada salvamento de conteúdo gera um snapshot, com autor, data e diff.</p>
|
|
<p>Atalhos: <code>Ctrl+S</code> salvar · <code>Ctrl+N</code> nova · <code>Ctrl+E</code> editar · <code>Ctrl+R</code> preview · <code>Ctrl+H</code> histórico · <code>F9</code> barra lateral · <code>Ctrl+B/I/K</code> formatação.</p>`,
|
|
}),
|
|
'logout': async () => {
|
|
await fetch('/api/logout', { method: 'POST' }).catch(() => {});
|
|
location.reload();
|
|
},
|
|
};
|
|
document.querySelectorAll('.menu-dropdown [data-action]').forEach((li) =>
|
|
li.addEventListener('click', () => {
|
|
document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'));
|
|
ACTIONS[li.dataset.action]();
|
|
}));
|
|
|
|
E.btnNew.addEventListener('click', () => 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);
|
|
}
|
|
})();
|
|
})();
|