feat(autosave): grava por ociosidade (10s padrão, configurável) e em silêncio

- debounce de 1,5s → 10s SEM nenhuma alteração (cada tecla reinicia);
  seletor 5/10/30/60s no diálogo de Preferências (autosaveDelay, clamp 5–120)
- autosave silencioso: sem toast, só o status 'salvando…/salvo'; Ctrl+S
  manual mantém o toast com o hash da revisão
- flush de pendências ao trocar de aba do app e em visibilitychange=hidden
  /pagehide (melhor esforço) — nada se perde ao sair antes do idle
- README atualizado
This commit is contained in:
lgcosta committed 2026-11-04 17:33:30 -03:00
1 parent 993b341fc1
commit 11fc26390d
3 files changed
+35 -9

No files matched your search

+6 -3
View File
@@ -100,9 +100,12 @@ ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de
automaticamente o diálogo *Conflito de edição* (recarregar | salvar por cima). A própria
sessão que salvou ignora o evento; reconexão do `EventSource` é automática e o fallback
continua sendo a checagem no foco da janela.
- **Autosave**: ligado por padrão — após ~1,5 s sem digitar, a alteração é salva como revisão
`autosave` (rascunho novo vira nota criada automaticamente; status *"salvando…"* na barra).
Desligável em Preferências. Conflitos durante autosave pausam o ciclo até a resolução.
- **Autosave por ociosidade**: só grava após um período **sem nenhuma alteração** — padrão
**10 s**, configurável (5/10/30/60 s em Preferências); cada tecla reinicia a contagem, então
digitação contínua nunca é interrompida por gravações. Silencioso (sem toast — apenas o
status *"salvando…/salvo"*; toast continua no `Ctrl+S` manual). A gravação também é feita
ao **trocar de aba do app** ou ao **minimizar/trocar de janela** (`visibilitychange`/
`pagehide`), para não perder edição pendente. Conflitos pausam o ciclo até a resolução.
- **Sincronia entre dispositivos** (celular + desktop no mesmo servidor):
- Cada aba lembra o hash em que se baseia (`base_hash`). Ao salvar, se o servidor mudou desde
então, a API responde **409** e a UI abre o diálogo **Conflito de edição** com as duas saídas:
+8 -1
View File
@@ -34,7 +34,14 @@
<label class="row"><input type="checkbox" id="pf-colmark"> Marcar coluna
<input type="number" id="pf-col" min="20" max="320" step="1" class="num"> (padrão 80)</label>
<label class="row"><input type="checkbox" id="pf-hl"> Colorir sintaxe (Markdown e HTML)</label>
<label class="row"><input type="checkbox" id="pf-autosave"> Autosave — salva automaticamente ~1,5 s após parar de digitar</label>
<label class="row"><input type="checkbox" id="pf-autosave"> Autosave — salva após período de inatividade
<select id="pf-autosave-delay" class="compact">
<option value="5">5 s</option>
<option value="10" selected>10 s</option>
<option value="30">30 s</option>
<option value="60">60 s</option>
</select>
</label>
<label class="row">Tecla Tab
<select id="pf-tabmode" class="compact">
<option value="spaces">insere espaços</option>
+21 -5
View File
@@ -287,7 +287,10 @@
function activateTab(key) {
const tab = state.tabs.find((t) => t.key === key);
if (!tab) return;
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();
@@ -773,13 +776,21 @@
}
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);
}, 1500);
}, 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', () => {
@@ -947,7 +958,7 @@
/* ------------------------------------------------------- preferências */
const PREFS_KEY = 'notas_prefs';
const PREFS_DEFAULT = { gutter: true, wrap: true, colmark: true, col: 80, hl: true, autosave: true, tabSpaces: true, tabSize: 4, font: 'mono', size: 14, theme: 'claro' };
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',
@@ -973,6 +984,7 @@
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;
@@ -1201,7 +1213,7 @@
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'),
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'),
@@ -1215,6 +1227,7 @@
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;
@@ -1229,6 +1242,7 @@
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';
@@ -1635,7 +1649,7 @@
loadNotes().catch(() => {});
loadAggregates().catch(() => {});
if (!E.versionsPanel.classList.contains('hidden')) loadRevisions().catch(() => {});
toast('Salvo — revisão ' + data.hash.slice(0, 8));
if (!auto) toast('Salvo — revisão ' + data.hash.slice(0, 8));
}
// Conflito: o servidor mudou desde a base da aba. Recarregar ou sobrescrever.
@@ -1873,6 +1887,7 @@
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') checkFreshness();
else flushAutosave(); // janela minimizada/trocou de app: grava pendências já
});
window.addEventListener('focus', checkFreshness);
@@ -1937,6 +1952,7 @@
}
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',