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

+24 -9
View File
@@ -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 `<iframe sandbox>`; texto puro é exibido como está.
- **Modo Edição ↔ WYSIWYG ↔ Preview** (`Ctrl+E/M/R`): o WYSIWYG (`Ctrl+M`) renderiza a nota num
`contenteditable` e sincroniza com o fonte a cada tecla — Markdown vira HTML editável (negrito,
títulos, listas, citação, código, links, tabelas) e volta a Markdown ao editar/alternar; HTML é
editado visualmente com tags/atributos preservados (scripts e `on*` são removidos ao abrir).
O Preview renderiza Markdown no servidor (goldmark, GFM, HTML cru escapado); HTML em
`<iframe sandbox>`; texto puro como está. Em texto puro o WYSIWYG não se aplica.
- **Categorias como árvore de pastas**: o painel lateral traz um explorador de arquivos —
categorias aninhadas (subpastas), expansão/colapso, contagem de notas por pasta e menu de
contexto (Nova subpasta / Renomear / Excluir, sem ciclos). Uma nota pode pertencer a
@@ -78,18 +82,27 @@ ocupa a tela inteira; menus e toolbar rolam horizontalmente com botões só de
(autor, data, mensagem, hash SHA-256 e parent). O painel de histórico mostra o **diff unificado**
de cada revisão e permite **restaurar** qualquer versão anterior (a restauração vira uma nova
revisão — nada se perde). Conteúdo idêntico é deduplicado (content-addressed).
- **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:
*Recarregar do servidor* (descarta o texto local) ou *Salvar por cima* (a versão remota fica
no histórico e é restaurável).
- Abas **sem edição pendente** são recarregadas automaticamente quando a janela volta ao foco
(`visibilitychange`/`focus`, com debounce), com aviso discreto.
- **Autenticação por token**: sem token, a interface exibe um diálogo pedindo o token; ele é
guardado em **cookie HttpOnly/SameSite=Lax** (30 dias). A API também aceita
`Authorization: Bearer <token>` para scripts.
- **Responsivo para celular** (≤760px): barra lateral vira gaveta com backdrop (☰ fecha ao
tocar uma nota), painel de versões ocupa a tela toda, toolbar e menus rolam horizontalmente
sem rótulos, alvos de toque ampliados, diálogos em largura total, fonte do editor com mínimo
de 16px (evita zoom automático do iOS), altura em `dvh` e `safe-area-inset` para notch.
sem rótulos, **dropdowns dos menus posicionados como `fixed`** (o `overflow-x` da menubar não
os corta mais em telas pequenas), alvos de toque ampliados, diálogos em largura total, fonte
do editor com mínimo de 16px (evita zoom automático do iOS), altura em `dvh` e
`safe-area-inset` para notch.
- **Estilo Mousepad**: tema claro Greybird-like, menus nativos (Arquivo/Editar/Exibir/Ajuda),
atalhos `Ctrl+S`, `Ctrl+N`, `Ctrl+E/R` (editar/preview), `Ctrl+H` (histórico), `F9` (barra
lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), `Alt+W`/`Alt+←/→`/`Alt+1..9`
(abas), barra de status com arquivo, formato, linha/coluna, contagem de palavras e estado de
salvamento.
atalhos `Ctrl+S`, `Ctrl+N`, `Ctrl+E/M/R` (edição/wysiwyg/preview), `Ctrl+H` (histórico), `F9`
(barra lateral), `Ctrl+B/I/K` (formatação), `Ctrl+,` (preferências), `Alt+W`/`Alt+←/→`/
`Alt+1..9` (abas), barra de status com arquivo, formato, linha/coluna, contagem de palavras e
estado de salvamento.
## Estrutura do projeto
@@ -160,8 +173,10 @@ curl -H "Authorization: Bearer $T" $B/api/notes/<id> # nota + conteú
curl -s -X POST -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"# Oi #linux"}' $B/api/notes
curl -X PUT -H "Authorization: Bearer $T" -H 'Content-Type: application/json' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"editado","author":"lg","message":"revisão 2"}' \
-d '{"title":"Olá","format":"md","categories":["<id>"],"content":"editado","author":"lg","message":"revisão 2","base_hash":"<hash carregado>"}' \
$B/api/notes/<id>
# base_hash é opcional: se enviado e diferente do atual → 409 {error, server_hash, updated_at}
# (omitir/enviar vazio = força o salvamento)
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions # histórico
curl -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/diff # diff unificado
curl -X POST -H "Authorization: Bearer $T" $B/api/notes/<id>/revisions/<hash>/restore \
+42
View File
@@ -335,6 +335,48 @@ func TestCategoryCRUDViaAPI(t *testing.T) {
resp.Body.Close()
}
func TestSaveConflictViaAPI(t *testing.T) {
srv := newTestServer(t)
call := func(method, path, body string) *http.Response {
return do(t, srv, method, path, body, testToken, "")
}
resp := call("POST", "/api/notes", `{"title":"Sync","format":"txt","categories":[],"content":"v1","author":"","message":""}`)
var created noteView
decode(t, resp, &created)
// dispositivo B salva primeiro (sem base_hash = força)
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v2-B","author":"B","message":"B ganhou"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("save B: %s", resp.Status)
}
var afterB noteView
decode(t, resp, &afterB)
// dispositivo A salva com base antiga → 409 + server_hash
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v2-A","author":"A","message":"A atrasado","base_hash":"`+created.Hash+`"}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("esperava 409, obtive %s", resp.Status)
}
var conflict struct {
Error string `json:"error"`
ServerHash string `json:"server_hash"`
}
decode(t, resp, &conflict)
if conflict.ServerHash != afterB.Hash {
t.Errorf("409 deveria trazer o hash atual: %q != %q", conflict.ServerHash, afterB.Hash)
}
// A resolve: força sem base_hash
resp = call("PUT", "/api/notes/"+created.ID,
`{"title":"Sync","format":"txt","categories":[],"content":"v3-A forçado","author":"A","message":"force"}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("force: %s", resp.Status)
}
resp.Body.Close()
}
func TestInvalidPayloads(t *testing.T) {
srv := newTestServer(t)
resp := do(t, srv, "POST", "/api/notes", `{"format":"rtf","content":"x"}`, testToken, "")
+16 -5
View File
@@ -1,6 +1,7 @@
package api
import (
"errors"
"io/fs"
"net/http"
"strings"
@@ -28,14 +29,15 @@ type noteInput struct {
Content string `json:"content"`
Author string `json:"author"`
Message string `json:"message"`
BaseHash string `json:"base_hash"` // hash em que a edição se baseia (opcional; evita sobrescrever mudanças remotas)
}
func (in noteInput) fields() (string, string, []string, string, string, string) {
func (in noteInput) fields() (string, string, []string, string, string, string, string) {
f := in.Format
if f == "" {
f = store.FormatTxt
}
return in.Title, f, in.Categories, in.Content, in.Author, in.Message
return in.Title, f, in.Categories, in.Content, in.Author, in.Message, in.BaseHash
}
// noteView é a representação completa (meta + conteúdo) de uma nota.
@@ -69,7 +71,7 @@ func (s *Server) handleCreateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
title, format, categories, content, author, message, _ := in.fields()
n, err := s.st.Create(title, format, categories, content, author, message)
if err != nil {
mapStoreError(w, err)
@@ -93,8 +95,17 @@ func (s *Server) handleUpdateNote(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "corpo JSON inválido")
return
}
title, format, categories, content, author, message := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message)
title, format, categories, content, author, message, baseHash := in.fields()
n, err := s.st.Update(r.PathValue("id"), title, format, categories, content, author, message, baseHash)
if errors.Is(err, store.ErrConflict) {
// 409 com o estado atual: o cliente decide entre recarregar ou forçar.
writeJSON(w, http.StatusConflict, map[string]any{
"error": "a nota foi alterada em outro dispositivo",
"server_hash": n.Hash,
"updated_at": n.UpdatedAt,
})
return
}
if err != nil {
mapStoreError(w, err)
return
+7 -1
View File
@@ -49,6 +49,7 @@ var (
ErrInvalid = errors.New("dados inválidos")
ErrTooLarge = errors.New("conteúdo excede o limite de tamanho")
ErrUnchanged = errors.New("conteúdo idêntico à versão atual")
ErrConflict = errors.New("a nota foi alterada desde a última sincronização")
)
// Note é o estado atual de uma nota (sem o conteúdo).
@@ -617,7 +618,9 @@ func (s *Store) Content(id string) (string, *Note, error) {
// Update salva metadados e, se o conteúdo mudou, uma nova revisão.
// Um conteúdo inalterado atualiza apenas título/categorias/formato.
func (s *Store) Update(id, title, format string, categories []string, content, author, message string) (*Note, error) {
// baseHash != "" ativa controle otimista de concorrência: se o servidor já
// mudou desde essa base, devolve ErrConflict (e a nota atual) em vez de sobrescrever.
func (s *Store) Update(id, title, format string, categories []string, content, author, message, baseHash string) (*Note, error) {
if err := cleanNoteFields(&title, &format); err != nil {
return nil, err
}
@@ -635,6 +638,9 @@ func (s *Store) Update(id, title, format string, categories []string, content, a
if err != nil {
return nil, err
}
if baseHash != "" && baseHash != n.Hash {
return n, ErrConflict
}
newHash := hashContent(content)
contentChanged := newHash != n.Hash
if contentChanged {
+32 -6
View File
@@ -70,7 +70,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
// Só metadados: nenhuma revisão nova (troca de categoria/título).
house := mustCat(t, s, "casa", "")
if _, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev, house}, "v1", "lg", ""); err != nil {
if _, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev, house}, "v1", "lg", "", ""); err != nil {
t.Fatal(err)
}
if h, _ := s.History(n.ID); len(h) != 1 {
@@ -82,7 +82,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
}
// Conteúdo novo: segunda revisão com parent correto.
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final")
n2, err := s.Update(n.ID, "renomeada", FormatTxt, []string{dev}, "v2", "lg", "ajuste final", "")
if err != nil {
t.Fatal(err)
}
@@ -105,7 +105,7 @@ func TestUpdateCreatesRevisionOnlyOnContentChange(t *testing.T) {
func TestRestoreAppendsRevisionWithOldContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("r", FormatMD, nil, "primeiro", "", "")
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", ""); err != nil {
if _, err := s.Update(n.ID, "r", FormatMD, nil, "segundo", "", "", ""); err != nil {
t.Fatal(err)
}
first, err := s.History(n.ID)
@@ -216,7 +216,7 @@ func TestCategoryTreeOperations(t *testing.T) {
}
// categoria inexistente em nota → ErrInvalid
if _, err := s.Update(n1.ID, "deploy", FormatMD, []string{"naoexiste1"}, "x", "", ""); !errors.Is(err, ErrInvalid) {
if _, err := s.Update(n1.ID, "deploy", FormatMD, []string{"naoexiste1"}, "x", "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("categoria fantasma: %v", err)
}
// busca por nome de categoria: só n3 ainda é "infra" (n1 ficou órfã com a exclusão)
@@ -268,7 +268,7 @@ func TestListFiltersAndSorts(t *testing.T) {
s := newTestStore(t)
dev := mustCat(t, s, "dev", "")
a, _ := s.Create("Alpha linux", FormatMD, []string{dev}, "conteúdo #linux", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, []string{dev}, "conteúdo #linux editado", "", "")
s.Update(a.ID, "Alpha linux", FormatMD, []string{dev}, "conteúdo #linux editado", "", "", "")
b, _ := s.Create("Beta", FormatTxt, nil, "compras", "", "")
c, _ := s.Create("Gamma docker", FormatHTML, []string{dev}, "<p>#devops #linux</p>", "", "")
@@ -347,7 +347,7 @@ func TestInvalidFormatAndOversizeContent(t *testing.T) {
if _, err := s.Create("x", "rtf", nil, "", "", ""); !errors.Is(err, ErrInvalid) {
t.Errorf("formato inválido: %v", err)
}
if _, err := s.Update("naoexiste1", "x", FormatTxt, nil, "a", "", ""); !errors.Is(err, ErrNotFound) {
if _, err := s.Update("naoexiste1", "x", FormatTxt, nil, "a", "", "", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("update em nota ausente: %v", err)
}
if _, err := s.Create("x", FormatTxt, nil, strings.Repeat("a", MaxContentBytes+1), "", ""); !errors.Is(err, ErrTooLarge) {
@@ -355,6 +355,32 @@ func TestInvalidFormatAndOversizeContent(t *testing.T) {
}
}
func TestUpdateBaseHashConflict(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("sync", FormatTxt, nil, "v1", "", "")
// outro dispositivo salva primeiro
other, err := s.Update(n.ID, "sync", FormatTxt, nil, "v2-outro", "", "", "")
if err != nil {
t.Fatal(err)
}
// meu save com base antiga → ErrConflict e nota atual devolvida
cur, err := s.Update(n.ID, "sync", FormatTxt, nil, "v2-meu", "", "", n.Hash)
if !errors.Is(err, ErrConflict) {
t.Fatalf("esperava ErrConflict, obtive %v", err)
}
if cur.Hash != other.Hash {
t.Errorf("ErrConflict deveria devolver a nota atual do servidor: %+v", cur)
}
content, _, _ := s.Content(n.ID)
if content != "v2-outro" {
t.Errorf("conteúdo não deveria ter mudado: %q", content)
}
// base vazio força (compat com clientes antigos)
if _, err := s.Update(n.ID, "sync", FormatTxt, nil, "v3-forçado", "", "", ""); err != nil {
t.Errorf("force save: %v", err)
}
}
func TestFilenameAndRevisionContent(t *testing.T) {
s := newTestStore(t)
n, _ := s.Create("minha/nota: ruim", FormatMD, nil, "oi #t", "", "")
+31 -15
View File
@@ -373,27 +373,27 @@ body.no-gutter #gutter { display: none; }
}
#preview-frame { flex: 1 1 auto; width: 100%; border: 0; background: #fff; }
/* preview markdown --------------------------------------------------- */
#preview.md { white-space: normal; line-height: 1.55; }
#preview.md h1, #preview.md h2, #preview.md h3 { margin: 18px 0 8px; line-height: 1.25; }
#preview.md h1 { font-size: 1.7em; border-bottom: 1px solid var(--border-soft); padding-bottom: 4px; }
#preview.md h2 { font-size: 1.35em; }
#preview.md p { margin: 8px 0; }
#preview.md ul, #preview.md ol { margin: 8px 0; padding-left: 26px; }
#preview.md blockquote {
/* preview markdown + wysiwyg ------------------------------------------ */
#preview.md, #wysiwyg { white-space: normal; line-height: 1.55; }
#preview.md h1, #wysiwyg h1, #preview.md h2, #wysiwyg h2, #preview.md h3, #wysiwyg h3 { margin: 18px 0 8px; line-height: 1.25; }
#preview.md h1, #wysiwyg h1 { font-size: 1.7em; border-bottom: 1px solid var(--border-soft); padding-bottom: 4px; }
#preview.md h2, #wysiwyg h2 { font-size: 1.35em; }
#preview.md p, #wysiwyg p { margin: 8px 0; }
#preview.md ul, #wysiwyg ul, #preview.md ol, #wysiwyg ol { margin: 8px 0; padding-left: 26px; }
#preview.md blockquote, #wysiwyg blockquote {
margin: 10px 0; padding: 4px 14px; color: var(--muted);
border-left: 3px solid var(--accent); background: var(--panel-2);
}
#preview.md pre {
#preview.md pre, #wysiwyg pre {
background: var(--panel-2); border: 1px solid var(--border-soft);
border-radius: 4px; padding: 10px 12px; overflow-x: auto;
}
#preview.md code { background: var(--panel-2); padding: 1px 4px; border-radius: 3px; }
#preview.md pre code { background: none; padding: 0; }
#preview.md table { border-collapse: collapse; margin: 10px 0; }
#preview.md th, #preview.md td { border: 1px solid var(--border); padding: 4px 10px; }
#preview.md a { color: var(--accent-dark); }
#preview.md hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; }
#preview.md code, #wysiwyg code { background: var(--panel-2); padding: 1px 4px; border-radius: 3px; }
#preview.md pre code, #wysiwyg pre code { background: none; padding: 0; }
#preview.md table, #wysiwyg table { border-collapse: collapse; margin: 10px 0; }
#preview.md th, #wysiwyg th, #preview.md td, #wysiwyg td { border: 1px solid var(--border); padding: 4px 10px; }
#preview.md a, #wysiwyg a { color: var(--accent-dark); }
#preview.md hr, #wysiwyg hr { border: 0; border-top: 1px solid var(--border); margin: 16px 0; }
/* painel de versões --------------------------------------------------- */
#versions-panel {
@@ -903,3 +903,19 @@ html[data-theme="solar-escuro"] #revisions-list li:hover { background: #0c4450;
.dialog input { font-size: 16px; }
.tree-menu { visibility: visible; } /* toque não tem hover */
}
/* ------------------------------------------------- WYSIWYG (contenteditable) */
#wysiwyg {
flex: 1 1 auto;
overflow-y: auto;
padding: 16px 22px;
background: var(--panel);
color: var(--fg);
outline: none;
min-height: 0;
caret-color: var(--accent-dark);
font-family: var(--sans);
font-size: 15px;
}
#wysiwyg:empty::before { content: 'Comece a escrever…'; color: var(--muted); }
#wysiwyg [contenteditable="true"]:focus { outline: none; }
+3
View File
@@ -131,6 +131,7 @@
<li data-action="toggle-wrap">Quebrar linha</li>
<li class="menu-sep"></li>
<li data-action="toggle-edit">Modo edição<span class="accel">Ctrl+E</span></li>
<li data-action="toggle-wysiwyg">Modo WYSIWYG<span class="accel">Ctrl+M</span></li>
<li data-action="toggle-preview">Modo visualização<span class="accel">Ctrl+R</span></li>
<li class="menu-sep"></li>
<li data-action="toggle-versions">Painel de versões<span class="accel">Ctrl+H</span></li>
@@ -179,6 +180,7 @@
<div class="tb-group">
<div class="segmented" id="mode-toggle">
<button type="button" class="seg active" data-mode="edit" title="Editar (Ctrl+E)">Editar</button>
<button type="button" class="seg" data-mode="wysiwyg" title="WYSIWYG (Ctrl+M)">WYSIWYG</button>
<button type="button" class="seg" data-mode="preview" title="Visualizar (Ctrl+R)">Preview</button>
</div>
<button type="button" class="btn btn-icon" id="btn-versions" title="Painel de versões (Ctrl+H)">🕘<span class="tb-label">Versões</span></button>
@@ -233,6 +235,7 @@
</div>
</div>
<div id="preview" class="hidden"></div>
<div id="wysiwyg" class="hidden" contenteditable="true" spellcheck="false"></div>
<iframe id="preview-frame" class="hidden" sandbox="allow-scripts" title="Pré-visualização HTML"></iframe>
</div>
</section>
+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) => {