feat(ui): reordenar abas com arraste, botão 🗂 Arquivos na toolbar, lixeira na lista

- 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
This commit is contained in:
lgcosta committed 2026-11-05 12:45:34 -03:00
1 parent 82417d2e79
commit 1b9ce995ba
4 files changed
+121 -13

No files matched your search

+24
View File
@@ -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;
+1
View File
@@ -224,6 +224,7 @@
<button type="button" class="btn btn-icon" id="btn-save" title="Salvar (Ctrl+S)">💾<span class="tb-label">Salvar</span></button>
<button type="button" class="btn btn-icon" id="btn-export" title="Baixar arquivo">⬇<span class="tb-label">Baixar</span></button>
<button type="button" class="btn btn-icon" id="btn-attach" title="Inserir anexo (📎)">📎<span class="tb-label">Anexo</span></button>
<button type="button" class="btn btn-icon" id="btn-files" title="Gerenciador de arquivos (Ctrl+J)">🗂<span class="tb-label">Arquivos</span></button>
</div>
<span class="tb-sep"></span>
<div class="tb-group" id="fmt-tools">
+95 -12
View File
@@ -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 <b>${esc(t.title || 'Sem título')}</b> e todo o seu histórico serão apagados.`, 'Excluir');
`A nota <b>${esc(n.title || 'Sem título')}</b> 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);
+1 -1
View File
@@ -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',