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