fix(dragdrop): amélioration sélection répertoire cible, déplacement arborescence et ouverture auto IA (#89)
This commit is contained in:
Vendored
+30
-15
@@ -13,6 +13,7 @@ import { t } from './i18n.js';
|
|||||||
import { showToast } from './ui.js';
|
import { showToast } from './ui.js';
|
||||||
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
import { readDataTransferEntries, handleVaultDropUpload } from './upload.js';
|
import { readDataTransferEntries, handleVaultDropUpload } from './upload.js';
|
||||||
|
import booksLM from './bookslm.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// State
|
// State
|
||||||
@@ -52,15 +53,14 @@ function getDropTarget(el) {
|
|||||||
// Directory tree item
|
// Directory tree item
|
||||||
const dirItem = closest('.tree-item[data-path]', el);
|
const dirItem = closest('.tree-item[data-path]', el);
|
||||||
if (dirItem) {
|
if (dirItem) {
|
||||||
// Only directories are valid targets
|
const isExplicitDir = dirItem.dataset.type === 'directory';
|
||||||
const icon = dirItem.querySelector('[data-lucide]');
|
const icon = dirItem.querySelector('[data-lucide], i');
|
||||||
const isDir = icon && (icon.getAttribute('data-lucide') === 'folder'
|
const iconName = icon?.getAttribute('data-lucide');
|
||||||
|| icon.getAttribute('data-lucide') === 'chevron-right'
|
const isFolderIcon = iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down';
|
||||||
|| icon.getAttribute('data-lucide') === 'chevron-down');
|
|
||||||
// Heuristic: if the tree item has a child container sibling, it's a directory
|
|
||||||
const hasChildren = dirItem.nextElementSibling
|
const hasChildren = dirItem.nextElementSibling
|
||||||
&& dirItem.nextElementSibling.classList.contains('tree-children');
|
&& dirItem.nextElementSibling.classList.contains('tree-children');
|
||||||
if (isDir || hasChildren) {
|
|
||||||
|
if (isExplicitDir || isFolderIcon || hasChildren) {
|
||||||
return {
|
return {
|
||||||
vault: dirItem.dataset.vault,
|
vault: dirItem.dataset.vault,
|
||||||
targetDir: dirItem.dataset.path,
|
targetDir: dirItem.dataset.path,
|
||||||
@@ -149,9 +149,10 @@ function onDragStart(e) {
|
|||||||
if (!vault || path === undefined) return;
|
if (!vault || path === undefined) return;
|
||||||
|
|
||||||
// Determine type: directory or file
|
// Determine type: directory or file
|
||||||
|
const isExplicitDir = treeItem.dataset.type === 'directory';
|
||||||
const hasChildren = treeItem.nextElementSibling
|
const hasChildren = treeItem.nextElementSibling
|
||||||
&& treeItem.nextElementSibling.classList.contains('tree-children');
|
&& treeItem.nextElementSibling.classList.contains('tree-children');
|
||||||
const type = hasChildren ? 'directory' : 'file';
|
const type = (isExplicitDir || hasChildren) ? 'directory' : 'file';
|
||||||
|
|
||||||
dragState = { vault, path, type, element: treeItem };
|
dragState = { vault, path, type, element: treeItem };
|
||||||
|
|
||||||
@@ -159,6 +160,9 @@ function onDragStart(e) {
|
|||||||
e.dataTransfer.effectAllowed = 'move';
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
// Prefix with 'tree:' so pane drop zones can distinguish from tab drags
|
// Prefix with 'tree:' so pane drop zones can distinguish from tab drags
|
||||||
e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path }));
|
e.dataTransfer.setData('text/plain', 'tree:' + JSON.stringify({ vault, path }));
|
||||||
|
e.dataTransfer.setData('tree:vault', vault);
|
||||||
|
e.dataTransfer.setData('tree:path', path);
|
||||||
|
e.dataTransfer.setData('tree:type', type);
|
||||||
|
|
||||||
// Visual: reduce opacity of source
|
// Visual: reduce opacity of source
|
||||||
treeItem.classList.add(DRAG_SOURCE_CLASS);
|
treeItem.classList.add(DRAG_SOURCE_CLASS);
|
||||||
@@ -170,10 +174,12 @@ function onDragStart(e) {
|
|||||||
if (item.classList.contains('vault-item')) {
|
if (item.classList.contains('vault-item')) {
|
||||||
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||||
} else {
|
} else {
|
||||||
const icon = item.querySelector('[data-lucide]');
|
const icon = item.querySelector('[data-lucide], i');
|
||||||
|
const iconName = icon?.getAttribute('data-lucide');
|
||||||
|
const isFolder = item.dataset.type === 'directory' || iconName === 'folder' || iconName === 'chevron-right' || iconName === 'chevron-down';
|
||||||
const hasChildContainer = item.nextElementSibling
|
const hasChildContainer = item.nextElementSibling
|
||||||
&& item.nextElementSibling.classList.contains('tree-children');
|
&& item.nextElementSibling.classList.contains('tree-children');
|
||||||
if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) {
|
if (isFolder || hasChildContainer) {
|
||||||
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,16 +442,25 @@ export function initGlobalDropOverlay() {
|
|||||||
if (zoneType === 'vault') {
|
if (zoneType === 'vault') {
|
||||||
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault);
|
const activeVault = window.state?.activeVault || (document.querySelector('.tree-item.vault-item')?.dataset?.vault);
|
||||||
if (activeVault) {
|
if (activeVault) {
|
||||||
await handleVaultDropUpload(activeVault, '', items);
|
// Pass null for targetDir so user is prompted to choose destination folder
|
||||||
|
await handleVaultDropUpload(activeVault, null, items);
|
||||||
} else {
|
} else {
|
||||||
showToast('Aucun vault sélectionné', 'error');
|
showToast('Aucun vault sélectionné', 'error');
|
||||||
}
|
}
|
||||||
} else if (zoneType === 'ai') {
|
} else if (zoneType === 'ai') {
|
||||||
// Dispatch or pass to BooksLM assistant
|
// Automatically open assistant if not already open and pass items
|
||||||
if (window.BooksLMInstance) {
|
if (booksLM) {
|
||||||
window.BooksLMInstance.handleExternalDroppedItems(items);
|
if (!booksLM._isOpen) {
|
||||||
|
await booksLM.openForCurrentContext();
|
||||||
|
}
|
||||||
|
await booksLM.handleExternalDroppedItems(items);
|
||||||
|
} else if (window.BooksLMInstance) {
|
||||||
|
if (!window.BooksLMInstance._isOpen) {
|
||||||
|
await window.BooksLMInstance.openForCurrentContext();
|
||||||
|
}
|
||||||
|
await window.BooksLMInstance.handleExternalDroppedItems(items);
|
||||||
} else {
|
} else {
|
||||||
showToast("Assistant IA non initialisé", 'warning');
|
showToast("Assistant IA non disponible", 'warning');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
} else {
|
} else {
|
||||||
// New item — create it
|
// New item — create it
|
||||||
if (item.type === "directory") {
|
if (item.type === "directory") {
|
||||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||||
fragment.appendChild(dirItem);
|
fragment.appendChild(dirItem);
|
||||||
@@ -365,7 +365,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
|||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
@@ -626,7 +626,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (item.type === "directory") {
|
if (item.type === "directory") {
|
||||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||||
fragment.appendChild(dirItem);
|
fragment.appendChild(dirItem);
|
||||||
@@ -660,7 +660,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
|||||||
} else {
|
} else {
|
||||||
const fileIconName = getFileIcon(item.name);
|
const fileIconName = getFileIcon(item.name);
|
||||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||||
fileItem.addEventListener("click", () => {
|
fileItem.addEventListener("click", () => {
|
||||||
|
|||||||
+100
-3
@@ -181,14 +181,22 @@ export async function uploadBatch(vault, targetDir, items, options = {}) {
|
|||||||
export async function handleVaultDropUpload(vault, targetDir, items) {
|
export async function handleVaultDropUpload(vault, targetDir, items) {
|
||||||
if (!items || !items.length) return;
|
if (!items || !items.length) return;
|
||||||
|
|
||||||
|
// If target directory is not explicitly passed or if dropping on vault root,
|
||||||
|
// allow user to choose or confirm the target folder
|
||||||
|
let chosenTargetDir = targetDir || '';
|
||||||
|
if (targetDir === undefined || targetDir === null) {
|
||||||
|
const picked = await promptTargetFolder(vault, chosenTargetDir, items);
|
||||||
|
if (picked === null) return; // User cancelled
|
||||||
|
chosenTargetDir = picked;
|
||||||
|
}
|
||||||
|
|
||||||
const fileCount = items.filter((x) => !x.isDir).length;
|
const fileCount = items.filter((x) => !x.isDir).length;
|
||||||
const dirCount = items.filter((x) => x.isDir).length;
|
const destName = chosenTargetDir ? `${vault}/${chosenTargetDir}` : vault;
|
||||||
const destName = targetDir ? `${vault}/${targetDir}` : vault;
|
|
||||||
|
|
||||||
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
|
showToast(`${t('upload.uploading', { count: fileCount }) || `Uploading ${fileCount} file(s) to ${destName}...`}`, 'info');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadBatch(vault, targetDir, items);
|
const res = await uploadBatch(vault, chosenTargetDir, items);
|
||||||
if (res && res.success) {
|
if (res && res.success) {
|
||||||
showToast(t('upload.success', { count: res.uploaded.length }) || `Imported ${res.uploaded.length} file(s) successfully`, 'success');
|
showToast(t('upload.success', { count: res.uploaded.length }) || `Imported ${res.uploaded.length} file(s) successfully`, 'success');
|
||||||
} else if (res && res.uploaded && res.uploaded.length > 0) {
|
} else if (res && res.uploaded && res.uploaded.length > 0) {
|
||||||
@@ -203,3 +211,92 @@ export async function handleVaultDropUpload(vault, targetDir, items) {
|
|||||||
showToast(`${t('upload.error') || 'Upload failed'}: ${err.message || err}`, 'error');
|
showToast(`${t('upload.error') || 'Upload failed'}: ${err.message || err}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display a folder picker modal for choosing the upload destination folder.
|
||||||
|
*
|
||||||
|
* @param {string} vault - Vault name
|
||||||
|
* @param {string} currentDir - Initial folder path
|
||||||
|
* @param {Array} items - Items to upload
|
||||||
|
* @returns {Promise<string|null>} Selected folder path (empty for root) or null if cancelled.
|
||||||
|
*/
|
||||||
|
export async function promptTargetFolder(vault, currentDir = '', items = []) {
|
||||||
|
let folders = [];
|
||||||
|
try {
|
||||||
|
const res = await api(`/api/vault/${encodeURIComponent(vault)}/paths?limit=5000`);
|
||||||
|
if (res && Array.isArray(res.paths)) {
|
||||||
|
folders = res.paths
|
||||||
|
.filter((p) => p.type === 'directory')
|
||||||
|
.map((p) => p.path)
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not fetch vault directory paths:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'obsigate-modal-overlay active';
|
||||||
|
overlay.style.zIndex = '100000';
|
||||||
|
|
||||||
|
const fileCount = items.filter((x) => !x.isDir).length;
|
||||||
|
const dirCount = items.filter((x) => x.isDir).length;
|
||||||
|
|
||||||
|
let optionsHtml = `<option value="">/ (${t('upload.vault_root') || 'Racine du vault'})</option>`;
|
||||||
|
for (const folder of folders) {
|
||||||
|
const selected = folder === currentDir ? 'selected' : '';
|
||||||
|
optionsHtml += `<option value="${escapeHtml(folder)}" ${selected}>📁 ${escapeHtml(folder)}</option>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="obsigate-modal" style="max-width: 480px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>📁 ${t('upload.choose_target_folder') || 'Choisir le dossier de destination'}</h3>
|
||||||
|
<button class="modal-close" type="button" aria-label="Fermer">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="padding: 16px;">
|
||||||
|
<p style="margin-bottom: 12px; color: var(--text-muted);">
|
||||||
|
${t('upload.import_summary', { files: fileCount, dirs: dirCount }) || `Éléments à importer : ${fileCount} fichier(s)${dirCount ? `, ${dirCount} dossier(s)` : ''}`}
|
||||||
|
</p>
|
||||||
|
<label style="display: block; font-weight: 500; margin-bottom: 6px;">
|
||||||
|
${t('upload.target_directory') || 'Dossier de destination dans'} <strong>${escapeHtml(vault)}</strong> :
|
||||||
|
</label>
|
||||||
|
<select id="obsigate-upload-folder-select" class="form-select" style="width: 100%; padding: 8px 12px; margin-bottom: 16px; border-radius: 6px; background: var(--bg-secondary); color: var(--text-normal); border: 1px solid var(--border-color);">
|
||||||
|
${optionsHtml}
|
||||||
|
</select>
|
||||||
|
<div style="display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;">
|
||||||
|
<button type="button" class="btn btn-secondary modal-cancel-btn">${t('button.cancel') || 'Annuler'}</button>
|
||||||
|
<button type="button" class="btn btn-primary modal-confirm-btn">${t('upload.import_button') || 'Importer'}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const close = (val) => {
|
||||||
|
overlay.remove();
|
||||||
|
resolve(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
overlay.querySelector('.modal-close').addEventListener('click', () => close(null));
|
||||||
|
overlay.querySelector('.modal-cancel-btn').addEventListener('click', () => close(null));
|
||||||
|
overlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === overlay) close(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
overlay.querySelector('.modal-confirm-btn').addEventListener('click', () => {
|
||||||
|
const select = overlay.querySelector('#obsigate-upload-folder-select');
|
||||||
|
close(select ? select.value : '');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return String(str || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|||||||
@@ -641,6 +641,11 @@
|
|||||||
"upload.success": "Successfully imported {count} file(s)",
|
"upload.success": "Successfully imported {count} file(s)",
|
||||||
"upload.partial": "Imported {count} file(s) ({errors} errors)",
|
"upload.partial": "Imported {count} file(s) ({errors} errors)",
|
||||||
"upload.error": "Upload failed",
|
"upload.error": "Upload failed",
|
||||||
|
"upload.choose_target_folder": "Choose destination folder",
|
||||||
|
"upload.vault_root": "Vault root",
|
||||||
|
"upload.target_directory": "Destination directory in",
|
||||||
|
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
|
||||||
|
"upload.import_button": "Import",
|
||||||
"editor.cancel": "Cancel",
|
"editor.cancel": "Cancel",
|
||||||
"editor.close": "Close",
|
"editor.close": "Close",
|
||||||
"editor.delete_error": "Delete error",
|
"editor.delete_error": "Delete error",
|
||||||
|
|||||||
@@ -641,6 +641,11 @@
|
|||||||
"upload.success": "{count} fichier(s) importé(s) avec succès",
|
"upload.success": "{count} fichier(s) importé(s) avec succès",
|
||||||
"upload.partial": "{count} fichier(s) importé(s) ({errors} erreurs)",
|
"upload.partial": "{count} fichier(s) importé(s) ({errors} erreurs)",
|
||||||
"upload.error": "Échec de l'importation",
|
"upload.error": "Échec de l'importation",
|
||||||
|
"upload.choose_target_folder": "Choisir le dossier de destination",
|
||||||
|
"upload.vault_root": "Racine du vault",
|
||||||
|
"upload.target_directory": "Dossier de destination dans",
|
||||||
|
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
|
||||||
|
"upload.import_button": "Importer",
|
||||||
"editor.cancel": "Annuler",
|
"editor.cancel": "Annuler",
|
||||||
"editor.close": "Fermer",
|
"editor.close": "Fermer",
|
||||||
"editor.delete_error": "Erreur de suppression",
|
"editor.delete_error": "Erreur de suppression",
|
||||||
|
|||||||
Reference in New Issue
Block a user