feat(files): move files between folders by drag and drop

Arrasta um arquivo da lista para uma pasta da árvore ou para a raiz.
A movimentação preserva o id, então a referência da nota continua valendo.
This commit is contained in:
lgcosta committed 2026-11-07 19:13:44 -03:00
1 parent db14f75389
commit 08dce738e5
3 files changed
+103 -2

No files matched your search

+4
View File
@@ -327,6 +327,10 @@ arquivo insere no editor e vincula à nota).
funciona no editor e no gerenciador; no WYSIWYG a inserção é visual (`<img>`/
`<video>`) e volta a Markdown na serialização; preview no gerenciador abre
lightbox com `<img>`/`<video>`/`<audio>`/PDF (iframe) ou texto puro.
- **Mover entre pastas arrastando**: arraste um arquivo da lista para uma pasta
da árvore (ou para **Raiz**) para mudá-lo de lugar. A movimentação preserva o
**id** do arquivo, então as referências das notas (`/api/files/static/{id}`)
continuam válidas. Soltar na pasta em que o arquivo já está não faz nada.
### Instruções de teste
+2
View File
@@ -1848,6 +1848,8 @@ html[data-theme="carbon-paper"] #revisions-list li:hover { background: #e6e5dd;
.fitem-meta { font-size: 11.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fitem-acts { display: flex; gap: 3px; flex: none; opacity: 0; transition: opacity .12s ease; }
.fitem:hover .fitem-acts, .fitem:focus-within .fitem-acts { opacity: 1; }
.fitem.dragging { opacity: .5; border-style: dashed; }
.tree-row.drop-hl { background: color-mix(in oklab, var(--panel), var(--accent) 24%); box-shadow: inset 3px 0 0 var(--accent); }
.dropzone.drop-hl, #editor-host.drop-hl {
outline: 2px dashed var(--accent);
outline-offset: -4px;
+97 -2
View File
@@ -2075,6 +2075,19 @@
expanded: new Set(JSON.parse(localStorage.getItem('notas_files_expanded') || '[]')),
};
// arrastar-e-soltar: o arquivo arrastado mantém o mesmo id, então a
// referência da nota (/api/files/static/{id}) continua válida ao mudar de pasta.
const DND_MIME = 'application/x-notas-file';
let dragFileId = '';
function dndIsInternalDrag(e) {
const dt = e.dataTransfer;
if (!dt) return false;
return (dt.types && Array.from(dt.types).includes(DND_MIME)) || !!dragFileId;
}
function clearDropHighlights() {
for (const el of document.querySelectorAll('.drop-hl')) el.classList.remove('drop-hl');
}
const iconFor = (mime) => mime == null ? '📄'
: mime.startsWith('image/') ? '🖼️'
: mime.startsWith('video/') ? '🎬'
@@ -2144,6 +2157,64 @@
mk('note', '📌 Nota atual');
}
// expande a pasta e todos os ancestrais para o arquivo ficar visível
function expandAncestors(folderId) {
const chain = [];
(function walk(nodes, trail) {
for (const n of nodes) {
const next = trail.concat([n.id]);
if (n.id === folderId) { chain.push(...next); return true; }
if (walk(n.children || [], next)) return true;
}
return false;
})(filesState.tree, []);
for (const id of chain) filesState.expanded.add(id);
localStorage.setItem('notas_files_expanded', JSON.stringify([...filesState.expanded]));
}
// move o arquivo para a pasta alvo (id vazio = raiz) e recarrega a árvore
async function moveFileToFolder(fileId, folderId, folderLabel) {
try {
await api('PUT', '/api/files/' + fileId + '/move', { folder: folderId || '' });
if (filesState.folder !== '__all__') filesState.folder = folderId || '';
if (folderId) expandAncestors(folderId);
await loadFiles();
toast('Movido para ' + folderLabel + '.');
} catch (err) {
toast('Mover: ' + err.message);
await loadFiles().catch(() => {});
}
}
// torna uma linha da árvore um destino de soltar (pasta ou raiz)
function makeDropTarget(row, folderId, folderLabel) {
row.addEventListener('dragover', (e) => {
// preventDefault sempre: sem isso o navegador abre/navega com o drop
e.preventDefault();
e.stopPropagation();
if (!dndIsInternalDrag(e)) return;
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
row.classList.add('drop-hl');
});
row.addEventListener('dragleave', (e) => {
if (!dndIsInternalDrag(e)) return;
e.stopPropagation();
row.classList.remove('drop-hl');
});
row.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (!dndIsInternalDrag(e)) return; // drop de arquivo do sistema: ignorado
row.classList.remove('drop-hl');
const id = (e.dataTransfer && e.dataTransfer.getData(DND_MIME)) || dragFileId;
dragFileId = '';
if (!id) return;
const f = filesState.files.get(id);
if (f && (f.folder || '') === (folderId || '')) return; // já está nesta pasta
moveFileToFolder(id, folderId || '', folderLabel);
});
}
function folderRow(node, depth) {
const row = document.createElement('div');
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
@@ -2201,6 +2272,7 @@
});
row.appendChild(acts);
makeDropTarget(row, node.id, node.name);
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
return row;
}
@@ -2215,6 +2287,7 @@
nm.className = 'tree-name';
nm.textContent = label;
row.appendChild(nm);
if (value === '') makeDropTarget(row, '', 'a raiz');
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
F.tree.appendChild(row);
};
@@ -2233,6 +2306,24 @@
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
row.tabIndex = 0;
if (!filesState.picker) {
row.draggable = true;
row.addEventListener('dragstart', (e) => {
dragFileId = f.id;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData(DND_MIME, f.id);
e.dataTransfer.setData('text/plain', f.name);
}
row.classList.add('dragging');
});
row.addEventListener('dragend', () => {
dragFileId = '';
row.classList.remove('dragging');
clearDropHighlights();
});
}
const ic = document.createElement('span');
ic.className = 'fitem-icon';
ic.textContent = iconFor(f.mime);
@@ -2576,11 +2667,15 @@
});
// drag & drop no gerenciador
['dragenter', 'dragover'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.add('drop-hl');
e.preventDefault(); // evita que o navegador navegue com o drop
if (dndIsInternalDrag(e)) return; // arrasto interno: só a árvore é destino
F.list.classList.add('drop-hl');
}));
['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => {
e.preventDefault(); F.list.classList.remove('drop-hl');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length) handleFiles(e.dataTransfer.files, 'manager');
if (ev === 'drop' && e.dataTransfer && e.dataTransfer.files.length && !dndIsInternalDrag(e)) {
handleFiles(e.dataTransfer.files, 'manager');
}
}));
// drag & drop no editor (textarea + wysiwyg)
const host = $('#editor-host');