diff --git a/README.md b/README.md
index ecec9c8..4aacb5f 100644
--- a/README.md
+++ b/README.md
@@ -327,6 +327,10 @@ arquivo insere no editor e vincula à nota).
funciona no editor e no gerenciador; no WYSIWYG a inserção é visual (` `/
``) e volta a Markdown na serialização; preview no gerenciador abre
lightbox com ` `/``/``/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
diff --git a/web/css/app.css b/web/css/app.css
index 5532cb1..2bb3fc2 100644
--- a/web/css/app.css
+++ b/web/css/app.css
@@ -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;
diff --git a/web/js/app.js b/web/js/app.js
index 27c993a..7b0416d 100644
--- a/web/js/app.js
+++ b/web/js/app.js
@@ -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');