feat: menus mobile corrigidos, sincronia multi-dispositivo e editor WYSIWYG

- Menus: dropdowns agora com position:fixed posicionado via JS — no celular
  o overflow-x:auto da menubar cortava os itens (bug relatado)
- Sincronia celular/desktop: PUT aceita base_hash opcional; divergência →
  409 {server_hash} e diálogo "Conflito de edição" (recarregar | salvar por
  cima); abas limpas recarregam sozinhas no focus/visibilitychange (debounce)
- WYSIWYG (Ctrl+M): contenteditable com render md→html no servidor e
  serialização html→md própria (títulos, negrito/itálico/tachado, listas
  com task, citação, código, blocos, links, tabelas, entidades); HTML edita
  preservando tags/atributos, sanitizando script/on* na entrada; toolbar
  usa execCommand no modo rico; colar é sempre texto puro; txt não aplica
- store/api: ErrConflict + teste de conflito na store e na API
- README: WYSIWYG, sincronia, base_hash, nota do fix de menu
This commit is contained in:
lgcosta committed 2026-11-04 13:03:58 -03:00
1 parent 8b355e8edb
commit 3b91c1a509
8 files changed
+456 -57

No files matched your search

+301 -21
View File
@@ -19,7 +19,7 @@
noteTitle: $('#note-title'), btnCats: $('#btn-cats'), catPicker: $('#cat-picker'),
editor: $('#editor'), editorWrap: $('#editor-wrap'), gutter: $('#gutter'),
gutterInner: $('#gutter-inner'), hl: $('#hl'), colMarker: $('#col-marker'),
preview: $('#preview'), previewFrame: $('#preview-frame'),
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'),
@@ -80,7 +80,11 @@
if (resp.status === 401) { showLogin(); throw new ApiError('não autenticado', 401); }
if (resp.status === 204) return null;
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new ApiError(data.error || resp.statusText, resp.status);
if (!resp.ok) {
const err = new ApiError(data.error || resp.statusText, resp.status);
err.data = data;
throw err;
}
return data;
}
@@ -113,7 +117,8 @@
const currentTitle = () => {
const t = E.noteTitle.value.trim();
if (t) return t;
const first = (E.editor.value.trim().split('\n')[0] || '').slice(0, 42);
const src = (activeTab() && activeTab().content) || '';
const first = (src.trim().split('\n')[0] || '').slice(0, 42);
return first || 'Sem título';
};
@@ -127,11 +132,12 @@
`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', withCancel = false }) {
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) => {
@@ -643,7 +649,8 @@
updateToolbar();
updateStatus();
renderEditorVisual();
refreshPreviewIfOpen();
if (modeState.mode === 'wysiwyg') enterWysiwyg();
else refreshPreviewIfOpen();
}
function markTabDirty() {
@@ -684,7 +691,12 @@
updateStatus();
renderTabs();
scheduleRender();
refreshPreviewIfOpen();
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).
@@ -754,6 +766,7 @@
}
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';
@@ -1134,22 +1147,188 @@
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));
const editing = mode === 'edit';
E.editorWrap.classList.toggle('hidden', !editing);
if (editing) {
E.preview.classList.add('hidden');
E.previewFrame.classList.add('hidden');
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);
});
return doc.body.innerHTML;
}
function enterWysiwyg() {
const t = activeTab();
if (!t) return;
if (currentFormat() === 'html') {
E.wysiwyg.innerHTML = sanitizeRich(t.content);
return;
}
E.wysiwyg.textContent = '…carregando';
api('POST', '/api/render', { content: t.content, format: 'md' })
.then((r) => {
if (modeState.mode === 'wysiwyg') E.wysiwyg.innerHTML = r.html;
})
.catch((err) => { E.wysiwyg.textContent = 'erro ao carregar: ' + err.message; });
}
function escMd(s) {
return s.replace(/[\\`~_*\[\]]/g, (c) => '\\' + c)
.replace(/^#(?=\s|#)/gm, '\\#');
}
function inlineMd(node) {
if (node.nodeType === 3) return escMd(node.nodeValue);
let out = '';
for (const c of node.childNodes) {
if (c.nodeType === 3) { out += escMd(c.nodeValue); continue; }
if (c.nodeType !== 1) continue;
const el = c;
const tag = el.tagName;
if (tag === 'BR') { out += '\n'; continue; }
const inner = inlineMd(el);
switch (tag) {
case 'STRONG': case 'B': out += '**' + inner + '**'; break;
case 'EM': case 'I': out += '*' + inner + '*'; break;
case 'DEL': case 'S': case 'STRIKE': out += '~~' + inner + '~~'; break;
case 'CODE': out += '`' + inner.replace(/`/g, '\\`') + '`'; break;
case 'A': out += '[' + inner + '](' + (el.getAttribute('href') || '') + ')'; break;
case 'IMG': out += '![' + (el.getAttribute('alt') || '') + '](' + (el.getAttribute('src') || '') + ')'; break;
case 'INPUT': out = out; break; // checkboxes tratados no bloco LI
default: out += inner;
}
}
return out;
}
function tableMd(el) {
const rows = [...el.querySelectorAll('tr')];
if (!rows.length) return '';
const cells = (tr) => [...tr.children].map((td) => inlineMd(td).trim().replace(/\|/g, '\\|'));
const head = cells(rows[0]);
const body = rows.slice(1).map(cells);
const line = (cs) => '| ' + cs.join(' | ') + ' |';
return [line(head), line(head.map(() => '---')), ...body.map(line)].join('\n');
}
function blockMd(root) {
const parts = [];
for (const el of root.childNodes) {
if (el.nodeType === 3) {
const tx = escMd(el.nodeValue).trim();
if (tx) parts.push(tx);
continue;
}
if (el.nodeType !== 1) continue;
const tag = el.tagName;
let chunk = null;
if (/^H[1-6]$/.test(tag)) chunk = '#'.repeat(+tag[1]) + ' ' + inlineMd(el).trim();
else if (tag === 'P' || tag === 'DIV') {
// execCommand pode aninhar ul/blockquote/pre dentro de p: trata como blocos
const nested = el.querySelector('ul,ol,blockquote,pre,table');
chunk = (nested ? blockMd(el) : inlineMd(el)).trim();
}
else if (tag === 'UL' || tag === 'OL') {
chunk = [...el.children].map((li, i) => {
const marker = tag === 'UL' ? '- ' : (i + 1) + '. ';
const cb = li.querySelector('input[type="checkbox"]');
const text = inlineMd(li).trim();
return cb ? marker + (cb.checked ? '[x] ' : '[ ] ') + text : marker + text;
}).join('\n');
}
else if (tag === 'BLOCKQUOTE') {
const inner = el.children.length ? blockMd(el) : inlineMd(el).trim();
chunk = inner.split('\n').map((l) => '> ' + l).join('\n');
}
else if (tag === 'PRE') chunk = '```\n' + el.textContent.replace(/\n+$/, '') + '\n```';
else if (tag === 'HR') chunk = '---';
else if (tag === 'TABLE') chunk = tableMd(el);
else if (tag === 'BR') chunk = null;
else { const s = inlineMd(el).trim(); chunk = s; }
if (chunk !== null && chunk !== '') parts.push(chunk);
}
return parts.join('\n\n');
}
function serializeWysiwyg() {
const t = activeTab();
if (!t) return '';
if (t.format === 'html') return E.wysiwyg.innerHTML;
const md = blockMd(E.wysiwyg);
return md ? md + '\n' : '';
}
E.wysiwyg.addEventListener('input', () => {
const t = activeTab();
if (!t) return;
t.content = serializeWysiwyg();
markDirty();
updateStatus();
persistTabs();
});
E.wysiwyg.addEventListener('paste', (e) => {
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');
@@ -1286,9 +1465,10 @@
tab.tags = data.tags || [];
tab.content = data.content;
tab.hash = data.hash;
tab.remoteHash = null;
tab.dirty = false;
tab.lastSaved = new Date().toISOString();
fillEditor();
if (tab.key === state.activeKey) fillEditor();
renderTabs();
persistTabs();
}
@@ -1302,15 +1482,24 @@
title: currentTitle(),
format: currentFormat(),
categories: t.categories,
content: E.editor.value,
content: t.content,
author: E.authorInput.value.trim(),
message: E.revMessage.value.trim(),
};
let data;
try {
if (t.id) data = await api('PUT', '/api/notes/' + t.id, body);
else data = await api('POST', '/api/notes', body);
if (t.id) {
body.base_hash = t.hash;
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);
return;
}
@@ -1322,6 +1511,44 @@
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 (!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(),
};
}
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);
}
}
async function deleteNote() {
const t = activeTab();
if (!t || !t.id) { toast('Nenhuma nota aberta.'); return; }
@@ -1357,11 +1584,27 @@
const menu = btn.parentElement;
const wasOpen = menu.classList.contains('open');
document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open'));
if (!wasOpen) menu.classList.add('open');
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';
});
});
document.addEventListener('click', () =>
document.querySelectorAll('.menu.open').forEach((m) => m.classList.remove('open')));
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()) {
@@ -1403,6 +1646,7 @@
'toggle-wrap': () => { prefs.wrap = !prefs.wrap; persistPrefs(); applyPrefs(); },
'prefs': openPrefs,
'toggle-edit': () => setMode('edit'),
'toggle-wysiwyg': () => setMode('wysiwyg'),
'toggle-preview': () => setMode('preview'),
'toggle-versions': () => toggleVersionsPanel(),
'about': () => modal({
@@ -1410,8 +1654,9 @@
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>.</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</code> editar · <code>Ctrl+R</code> preview · <code>Ctrl+H</code> histórico · <code>F9</code> barra lateral · <code>Ctrl+B/I/K</code> formatação · <code>Ctrl+,</code> preferências.</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(() => {});
@@ -1450,6 +1695,7 @@
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 ',': 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;
@@ -1464,6 +1710,39 @@
}
});
/* ------------------------------------------------ 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();
});
window.addEventListener('focus', checkFreshness);
/* ------------------------------------------------------------ status */
function updateStatus() {
@@ -1472,7 +1751,7 @@
const lines = upto.split('\n');
E.stPos.textContent = `Ln ${lines.length}, Col ${lines[lines.length - 1].length + 1}`;
const text = ta.value;
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`;
@@ -1518,6 +1797,7 @@
await Promise.all([loadNotes(), loadAggregates()]);
await restoreTabs();
updateStatus();
state.booted = true;
}
window.addEventListener('unhandledrejection', (e) => {