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:
1 parent
db14f75389
commit
08dce738e5
3 files changed
+103
-2
No files matched your search
@@ -327,6 +327,10 @@ arquivo insere no editor e vincula à nota).
|
|||||||
funciona no editor e no gerenciador; no WYSIWYG a inserção é visual (`<img>`/
|
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
|
`<video>`) e volta a Markdown na serialização; preview no gerenciador abre
|
||||||
lightbox com `<img>`/`<video>`/`<audio>`/PDF (iframe) ou texto puro.
|
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
|
### Instruções de teste
|
||||||
|
|
||||||
|
|||||||
@@ -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-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-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: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 {
|
.dropzone.drop-hl, #editor-host.drop-hl {
|
||||||
outline: 2px dashed var(--accent);
|
outline: 2px dashed var(--accent);
|
||||||
outline-offset: -4px;
|
outline-offset: -4px;
|
||||||
|
|||||||
+97
-2
@@ -2075,6 +2075,19 @@
|
|||||||
expanded: new Set(JSON.parse(localStorage.getItem('notas_files_expanded') || '[]')),
|
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 ? '📄'
|
const iconFor = (mime) => mime == null ? '📄'
|
||||||
: mime.startsWith('image/') ? '🖼️'
|
: mime.startsWith('image/') ? '🖼️'
|
||||||
: mime.startsWith('video/') ? '🎬'
|
: mime.startsWith('video/') ? '🎬'
|
||||||
@@ -2144,6 +2157,64 @@
|
|||||||
mk('note', '📌 Nota atual');
|
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) {
|
function folderRow(node, depth) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
|
row.className = 'tree-row ftree-row' + (filesState.folder === node.id ? ' active' : '');
|
||||||
@@ -2201,6 +2272,7 @@
|
|||||||
});
|
});
|
||||||
row.appendChild(acts);
|
row.appendChild(acts);
|
||||||
|
|
||||||
|
makeDropTarget(row, node.id, node.name);
|
||||||
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
|
row.addEventListener('click', () => { filesState.folder = node.id; renderFilesUI(); });
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -2215,6 +2287,7 @@
|
|||||||
nm.className = 'tree-name';
|
nm.className = 'tree-name';
|
||||||
nm.textContent = label;
|
nm.textContent = label;
|
||||||
row.appendChild(nm);
|
row.appendChild(nm);
|
||||||
|
if (value === '') makeDropTarget(row, '', 'a raiz');
|
||||||
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
|
row.addEventListener('click', () => { filesState.folder = value; renderFilesUI(); });
|
||||||
F.tree.appendChild(row);
|
F.tree.appendChild(row);
|
||||||
};
|
};
|
||||||
@@ -2233,6 +2306,24 @@
|
|||||||
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
|
row.className = 'fitem' + (filesState.picker ? ' pickable' : '');
|
||||||
row.tabIndex = 0;
|
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');
|
const ic = document.createElement('span');
|
||||||
ic.className = 'fitem-icon';
|
ic.className = 'fitem-icon';
|
||||||
ic.textContent = iconFor(f.mime);
|
ic.textContent = iconFor(f.mime);
|
||||||
@@ -2576,11 +2667,15 @@
|
|||||||
});
|
});
|
||||||
// drag & drop no gerenciador
|
// drag & drop no gerenciador
|
||||||
['dragenter', 'dragover'].forEach((ev) => F.list.addEventListener(ev, (e) => {
|
['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) => {
|
['dragleave', 'drop'].forEach((ev) => F.list.addEventListener(ev, (e) => {
|
||||||
e.preventDefault(); F.list.classList.remove('drop-hl');
|
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)
|
// drag & drop no editor (textarea + wysiwyg)
|
||||||
const host = $('#editor-host');
|
const host = $('#editor-host');
|
||||||
|
|||||||
Reference in new issue
Block a user