From 3b91c1a50962838bd45be617d42626b8e9d239d7 Mon Sep 17 00:00:00 2001 From: Luiz Gustavo Costa Date: Wed, 4 Nov 2026 13:03:58 -0300 Subject: [PATCH] feat: menus mobile corrigidos, sincronia multi-dispositivo e editor WYSIWYG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 33 +++- internal/api/api_test.go | 42 +++++ internal/api/notes.go | 21 ++- internal/store/store.go | 8 +- internal/store/store_test.go | 38 ++++- web/css/app.css | 46 +++-- web/index.html | 3 + web/js/app.js | 322 ++++++++++++++++++++++++++++++++--- 8 files changed, 456 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 210df31..40cc201 100644 --- a/README.md +++ b/README.md @@ -41,8 +41,12 @@ ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de - **Barra de ferramentas de formatação**: negrito, itálico, tachado, títulos H1/H2, listas, citação, link, código inline e bloco de código — inserindo sintaxe Markdown **ou** HTML conforme o formato ativo (desabilitada em texto puro). -- **Modo Edição ↔ Preview**: Markdown é renderizado no servidor (goldmark, GFM, HTML cru escapado); - HTML é pré-visualizado em ` diff --git a/web/js/app.js b/web/js/app.js index e84ef24..92cf918 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -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 ${esc(tab.title || 'Sem título')} 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('' + html + '', '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, '' + esc(sel || 'código') + ''); + } else if (kind === 'block') { + const sel = getSelection().toString(); + document.execCommand('insertHTML', false, + '
' + esc(sel || 'código em bloco') + '


'); + } + 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 outro dispositivo enquanto você editava.
` + + `Servidor: ${esc((t.remoteHash || '').slice(0, 8))} · sua base: ${esc(t.hash.slice(0, 8))}.
` + + `Recarregar traz o texto do servidor (descarta seu texto não salvo); ` + + `Salvar por cima 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: `

Editor de notas minimalista inspirado no Mousepad (XFCE).

Backend em Go, armazenamento em disco com revisões endereçadas por hash SHA-256 (estilo Git): cada salvamento de conteúdo gera um snapshot, com autor, data e diff.

-

Categorias viram uma árvore de pastas (barra lateral); notas podem ter várias categorias e abrir em várias abas.

-

Atalhos: Ctrl+S salvar · Ctrl+N nova aba · Alt+W fechar aba · Alt+←/→ trocar aba · Ctrl+E editar · Ctrl+R preview · Ctrl+H histórico · F9 barra lateral · Ctrl+B/I/K formatação · Ctrl+, preferências.

`, +

Categorias viram uma árvore de pastas (barra lateral); notas podem ter várias categorias e abrir em várias abas. O modo WYSIWYG edita Markdown/HTML renderizados e sincroniza com o fonte.

+

Edite no celular e no desktop: ao salvar, um conflito é detectado (a nota mudou em outro dispositivo) e você escolhe recarregar ou sobrescrever; abas limpas são recarregadas automaticamente ao voltar para a janela.

+

Atalhos: Ctrl+S salvar · Ctrl+N nova aba · Alt+W fechar aba · Alt+←/→ trocar aba · Ctrl+E/M/R editar/wysiwyg/preview · Ctrl+H histórico · F9 barra lateral · Ctrl+B/I/K formatação · Ctrl+, preferências.

`, }), '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) => {