From 1b9ce995ba364ac63b99f963503df78af51bcad2 Mon Sep 17 00:00:00 2001 From: lgcosta Date: Thu, 5 Nov 2026 12:45:34 -0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20reordenar=20abas=20com=20arraste,?= =?UTF-8?q?=20bot=C3=A3o=20=F0=9F=97=82=20Arquivos=20na=20toolbar,=20lixei?= =?UTF-8?q?ra=20na=20lista?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Abas: HTML5 drag-and-drop — arrastar uma aba sobre outra mostra marca de inserção (borda accent à esquerda/direita conforme o lado) e solta reordenando; soltar na área vazia da aba-barra move para o fim; ordem persiste em localStorage (passa a valer após o debounce de 400ms de persistTabs); ✕ da aba não inicia arraste (dragstart cancelado) - Toolbar: botão 🗂 Arquivos abre o gerenciador no modo normal (não seletor), equivalente ao Ctrl+J - Lista de notas (sidebar): botão 🗑 em cada linha abre o mesmo diálogo de confirmação do menu e exclui a nota mesmo sem aba aberta; se houver aba da nota, ela é fechada e a aba vizinha ativada; no mobile o botão fica sempre visível (alvo 32px, sem depender de hover) - deleteNote() agora delega para deleteNoteById(n) (reuso do fluxo com presença/persistTabs) - sw v15 --- web/css/app.css | 24 +++++++++++ web/index.html | 1 + web/js/app.js | 107 ++++++++++++++++++++++++++++++++++++++++++------ web/sw.js | 2 +- 4 files changed, 121 insertions(+), 13 deletions(-) diff --git a/web/css/app.css b/web/css/app.css index bb3b851..f600b9b 100644 --- a/web/css/app.css +++ b/web/css/app.css @@ -278,7 +278,26 @@ body.sidebar-off #sidebar { display: none; } padding: 7px 10px; border-bottom: 1px solid var(--border-soft); cursor: pointer; + display: flex; + align-items: center; + gap: 6px; } +.n-main { flex: 1 1 auto; min-width: 0; } +.n-del { + flex: none; + border: 0; + background: none; + cursor: pointer; + font-size: 13px; + line-height: 1; + color: var(--muted); + padding: 4px; + border-radius: 0; + opacity: 0; + transition: opacity .12s ease, color .12s ease; +} +#notes-list li:hover .n-del, #notes-list li:focus-within .n-del { opacity: 1; } +.n-del:hover { color: var(--danger); } #notes-list li:hover { background: color-mix(in oklab, var(--panel-2), var(--fg) 9%); } #notes-list li.active { background: color-mix(in oklab, var(--panel), var(--accent) 24%); box-shadow: inset 3px 0 0 var(--accent); } .n-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -593,12 +612,16 @@ body.no-gutter #gutter { display: none; } background: var(--panel-2); font: inherit; font-size: 12.5px; + user-select: none; color: var(--muted); padding: 6px 8px 6px 12px; cursor: pointer; white-space: nowrap; max-width: 240px; } +.tab.dragging { opacity: .45; } +.tab.drop-before { box-shadow: inset 2px 0 0 var(--accent); } +.tab.drop-after { box-shadow: inset -2px 0 0 var(--accent); } .tab.active { background: var(--panel); color: var(--fg); @@ -730,6 +753,7 @@ body.no-gutter #gutter { display: none; } #menubar::-webkit-scrollbar { display: none; } .menu-btn { padding: 0 9px; } .menu-dropdown li { padding: 9px 14px; } + .n-del { opacity: 1; min-height: 32px; min-width: 32px; } #toolbar { height: auto; diff --git a/web/index.html b/web/index.html index 30b8e96..bfd2283 100644 --- a/web/index.html +++ b/web/index.html @@ -224,6 +224,7 @@ +
diff --git a/web/js/app.js b/web/js/app.js index 55cd322..57ac047 100644 --- a/web/js/app.js +++ b/web/js/app.js @@ -343,6 +343,36 @@ activateTab(next.key); } + let dragTabKey = null; + function clearTabDropMarks() { + E.tabs.querySelectorAll('.drop-before, .drop-after').forEach((x) => x.classList.remove('drop-before', 'drop-after')); + } + function moveTab(srcKey, dstKey, before) { + const from = state.tabs.findIndex((t) => t.key === srcKey); + if (from < 0) return; + const [tab] = state.tabs.splice(from, 1); + let to = state.tabs.findIndex((t) => t.key === dstKey); + if (to < 0) { state.tabs.splice(from, 0, tab); return; } + state.tabs.splice(before ? to : to + 1, 0, tab); + renderTabs(); + persistTabs(); + } + // soltar na área vazia da aba-barra move para o fim + E.tabs.addEventListener('dragover', (e) => { if (dragTabKey) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }); + E.tabs.addEventListener('drop', (e) => { + if (!dragTabKey) return; + e.preventDefault(); + const tab = state.tabs.find((t) => t.key === dragTabKey); + if (tab) { + state.tabs.splice(state.tabs.indexOf(tab), 1); + state.tabs.push(tab); + renderTabs(); + persistTabs(); + } + dragTabKey = null; + clearTabDropMarks(); + }); + function renderTabs() { E.tabs.textContent = ''; for (const t of state.tabs) { @@ -351,6 +381,7 @@ el.className = 'tab' + (t.key === state.activeKey ? ' active' : '') + (t.dirty ? ' dirty' : ''); el.dataset.key = t.key; el.title = (t.title || 'Sem título') + EXT[t.format] + (t.dirty ? ' — não salvo' : ''); + el.draggable = true; const label = document.createElement('span'); label.className = 'tab-label'; label.textContent = (t.title || 'Sem título') + EXT[t.format]; @@ -371,6 +402,38 @@ else activateTab(t.key); }); el.addEventListener('auxclick', (e) => { if (e.button === 1) closeTab(t.key); }); + el.addEventListener('dragstart', (e) => { + if (e.target === close) { e.preventDefault(); return; } + dragTabKey = t.key; + el.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + try { e.dataTransfer.setData('text/plain', t.key); } catch { /* ignore */ } + }); + el.addEventListener('dragend', () => { + dragTabKey = null; + el.classList.remove('dragging'); + clearTabDropMarks(); + }); + el.addEventListener('dragover', (e) => { + if (!dragTabKey || dragTabKey === t.key) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const r = el.getBoundingClientRect(); + const before = e.clientX < r.left + r.width / 2; + clearTabDropMarks(); + el.classList.add(before ? 'drop-before' : 'drop-after'); + }); + el.addEventListener('dragleave', () => el.classList.remove('drop-before', 'drop-after')); + el.addEventListener('drop', (e) => { + if (!dragTabKey || dragTabKey === t.key) return; + e.preventDefault(); + e.stopPropagation(); + const r = el.getBoundingClientRect(); + moveTab(dragTabKey, t.key, e.clientX < r.left + r.width / 2); + dragTabKey = null; + clearTabDropMarks(); + }); E.tabs.appendChild(el); } } @@ -476,6 +539,8 @@ for (const n of state.notes) { const li = document.createElement('li'); li.className = n.id === openId ? 'active' : ''; + const main = document.createElement('div'); + main.className = 'n-main'; const title = document.createElement('div'); title.className = 'n-title'; title.textContent = n.title || 'Sem título'; @@ -490,8 +555,16 @@ if (n.tags && n.tags.length) bits.push(n.tags.map((t) => '#' + t).join(' ')); if (state.presence[n.id] && state.presence[n.id].length) bits.push('👥 aberta por outra sessão'); sub.textContent = bits.join(' · '); - li.appendChild(title); - li.appendChild(sub); + main.appendChild(title); + main.appendChild(sub); + li.appendChild(main); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'n-del'; + del.textContent = '🗑'; + del.title = 'Excluir nota'; + del.addEventListener('click', (e) => { e.stopPropagation(); deleteNoteById(n); }); + li.appendChild(del); li.addEventListener('click', () => openNoteTab(n.id)); E.notesList.appendChild(li); } @@ -1707,20 +1780,23 @@ } } - async function deleteNote() { - const t = activeTab(); - if (!t || !t.id) { toast('Nenhuma nota aberta.'); return; } + async function deleteNoteById(n) { const ok = await confirmModal('Excluir nota?', - `A nota ${esc(t.title || 'Sem título')} e todo o seu histórico serão apagados.`, 'Excluir'); + `A nota ${esc(n.title || 'Sem título')} e todo o seu histórico serão apagados.`, 'Excluir'); if (!ok) return; try { - await api('DELETE', '/api/notes/' + t.id); + await api('DELETE', '/api/notes/' + n.id); toast('Nota excluída.'); - const idx = state.tabs.indexOf(t); - state.tabs.splice(idx, 1); - sendPresence(); - if (!state.tabs.length) addTab(newTab()); - else activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key); + const tab = state.tabs.find((t) => t.id === n.id); + if (tab) { + const idx = state.tabs.indexOf(tab); + state.tabs.splice(idx, 1); + sendPresence(); + if (!state.tabs.length) addTab(newTab()); + else if (state.activeKey === tab.key) activateTab(state.tabs[Math.min(idx, state.tabs.length - 1)].key); + else renderTabs(); + persistTabs(); + } loadNotes().catch(() => {}); loadAggregates().catch(() => {}); } catch (err) { @@ -1728,6 +1804,12 @@ } } + async function deleteNote() { + const t = activeTab(); + if (!t || !t.id) { toast('Nenhuma nota aberta.'); return; } + await deleteNoteById({ id: t.id, title: t.title }); + } + function exportNote() { const t = activeTab(); if (!t || !t.id) { toast('Salve a nota antes de baixar.'); return; } @@ -1847,6 +1929,7 @@ E.btnSave.addEventListener('click', () => save()); E.btnExport.addEventListener('click', exportNote); $('#btn-attach').addEventListener('click', () => openFiles(true)); + $('#btn-files').addEventListener('click', () => openFiles(false)); E.btnVersions.addEventListener('click', () => toggleVersionsPanel()); E.btnSidebar.addEventListener('click', toggleSidebar); diff --git a/web/sw.js b/web/sw.js index c71a53c..7653c6b 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,7 +1,7 @@ /* Service Worker do Notas — caches versionados. * BUMP de CACHE em todo deploy que mude shell/assets (o activate limpa os antigos). */ -const CACHE = 'notas-shell-v14'; +const CACHE = 'notas-shell-v15'; const SHELL = [ '/', '/css/app.css',