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:
1 parent
8b355e8edb
commit
3b91c1a509
8 files changed
+456
-57
No files matched your search
+301
-21
@@ -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 += ' || '') + ')'; 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) => {
|
||||
|
||||
Reference in new issue
Block a user