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

+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, "")