feat: drag & drop de fichiers dans l'arborescence (portion 5)
- Backend: nouvel endpoint POST /api/move/{vault_name} pour déplacer
fichiers et dossiers vers un autre répertoire parent
- Frontend: module dragdrop.js avec délégation d'événements sur le
vault-tree (dragstart, dragover, dragleave, drop)
- CSS: styles pour drag-source, drag-valid-target, drag-over
- Protections: pas de drop sur soi-même, pas dans un enfant,
pas de cross-vault
- Mise à jour de l'index et broadcast SSE après move
- ROADMAP: portion 5 marquée comme complétée
This commit is contained in:
+8
-2
@@ -116,9 +116,15 @@
|
|||||||
- [x] **Fermeture** : bouton ✕ par onglet dans le switcher
|
- [x] **Fermeture** : bouton ✕ par onglet dans le switcher
|
||||||
- [x] **Overlay** : tap à l'extérieur pour fermer le sheet
|
- [x] **Overlay** : tap à l'extérieur pour fermer le sheet
|
||||||
|
|
||||||
### 5. Drag & drop de fichiers
|
### ✅ 5. Drag & drop de fichiers — FAIT
|
||||||
- **Effort** : 1-2 jours | **Impact** : 🟡
|
- **Effort** : 1-2 jours | **Impact** : 🟡
|
||||||
- Déplacer/réorganiser dans l'arborescence
|
- Déplacer/réorganiser dans l'arborescence ✅
|
||||||
|
- **Fichiers** : `backend/main.py` (endpoint `POST /api/move/{vault_name}`), `frontend/js/dragdrop.js`, `frontend/js/app.js`, `frontend/style.css`
|
||||||
|
- [x] **Endpoint backend** : `POST /api/move/{vault_name}` — déplace fichiers et dossiers vers un autre répertoire parent, même vault
|
||||||
|
- [x] **Drag & drop frontend** : glisser-déposer les `.tree-item` (fichiers et dossiers) vers les dossiers ou la racine d'un vault
|
||||||
|
- [x] **Indicateurs visuels** : opacité réduite sur l'élément source, outline en pointillé sur les cibles valides, surbrillance sur la cible active
|
||||||
|
- [x] **Protection** : pas de drop sur soi-même, pas de drop dans un enfant, pas de cross-vault
|
||||||
|
- [x] **SSE + index** : mise à jour de l'index et broadcast SSE après move
|
||||||
|
|
||||||
### 6. Diff viewer backups
|
### 6. Diff viewer backups
|
||||||
- **Effort** : 1 jour | **Impact** : 🟢
|
- **Effort** : 1 jour | **Impact** : 🟢
|
||||||
|
|||||||
+127
@@ -319,9 +319,24 @@ class FileRenameResponse(BaseModel):
|
|||||||
new_path: str
|
new_path: str
|
||||||
|
|
||||||
|
|
||||||
|
class FileMoveRequest(BaseModel):
|
||||||
|
"""Request to move a file or directory to a different parent directory."""
|
||||||
|
source_path: str = Field(description="Current relative path of the file/directory")
|
||||||
|
destination_dir: str = Field(description="Target directory relative path (empty string for vault root)")
|
||||||
|
|
||||||
|
|
||||||
|
class FileMoveResponse(BaseModel):
|
||||||
|
"""Response after moving a file or directory."""
|
||||||
|
success: bool = Field(description="Whether move succeeded")
|
||||||
|
old_path: str = Field(description="Original path")
|
||||||
|
new_path: str = Field(description="New path after move")
|
||||||
|
item_type: str = Field(description="Type of item moved: 'file' or 'directory'")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# SSE Manager — Server-Sent Events for real-time notifications
|
# SSE Manager — Server-Sent Events for real-time notifications
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SSEManager:
|
class SSEManager:
|
||||||
"""Manages SSE client connections and broadcasts events."""
|
"""Manages SSE client connections and broadcasts events."""
|
||||||
@@ -1748,6 +1763,118 @@ async def api_file_rename(
|
|||||||
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Error renaming file: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/move/{vault_name}", response_model=FileMoveResponse)
|
||||||
|
async def api_file_move(
|
||||||
|
vault_name: str,
|
||||||
|
body: FileMoveRequest,
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Move a file or directory to a different parent directory within the same vault.
|
||||||
|
|
||||||
|
Supports both files and directories. The item keeps its original name;
|
||||||
|
only the parent directory changes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
body: Request body with source_path and destination_dir.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FileMoveResponse with old and new paths.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
vault_data = get_vault_data(vault_name)
|
||||||
|
if not vault_data:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||||
|
|
||||||
|
vault_root = Path(vault_data["path"])
|
||||||
|
|
||||||
|
# Check if vault is writable
|
||||||
|
if not _check_vault_writable(vault_root):
|
||||||
|
raise HTTPException(status_code=403, detail="Vault is read-only")
|
||||||
|
|
||||||
|
# Resolve source path
|
||||||
|
source = _resolve_safe_path(vault_root, body.source_path)
|
||||||
|
|
||||||
|
if not source.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Source not found: {body.source_path}")
|
||||||
|
|
||||||
|
is_directory = source.is_dir()
|
||||||
|
|
||||||
|
# Determine the item name (last segment)
|
||||||
|
item_name = source.name
|
||||||
|
|
||||||
|
# Construct destination path: destination_dir / item_name
|
||||||
|
dest_dir_clean = body.destination_dir.strip("/")
|
||||||
|
if dest_dir_clean:
|
||||||
|
dest_parent = _resolve_safe_path(vault_root, dest_dir_clean)
|
||||||
|
if not dest_parent.exists() or not dest_parent.is_dir():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Destination directory not found: {body.destination_dir}")
|
||||||
|
else:
|
||||||
|
dest_parent = vault_root
|
||||||
|
|
||||||
|
destination = dest_parent / item_name
|
||||||
|
|
||||||
|
# Validate destination is within vault
|
||||||
|
destination = _resolve_safe_path(vault_root, str(destination.relative_to(vault_root)))
|
||||||
|
|
||||||
|
# Don't move to the same location
|
||||||
|
if source.resolve() == destination.resolve():
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"old_path": body.source_path,
|
||||||
|
"new_path": body.source_path,
|
||||||
|
"item_type": "directory" if is_directory else "file",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if destination already exists
|
||||||
|
if destination.exists():
|
||||||
|
raise HTTPException(status_code=409, detail=f"A file or directory already exists at the destination: {destination.name}")
|
||||||
|
|
||||||
|
# For files, validate extension
|
||||||
|
if not is_directory:
|
||||||
|
ext = destination.suffix.lower()
|
||||||
|
if ext not in SUPPORTED_EXTENSIONS and destination.name.lower() not in ("dockerfile", "makefile"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unsupported file extension: {ext}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Move the file/directory
|
||||||
|
source.rename(destination)
|
||||||
|
|
||||||
|
old_path_str = str(source.relative_to(vault_root)).replace("\\", "/")
|
||||||
|
# After rename, source is now at destination — reconstruct the new relative path
|
||||||
|
new_path_str = body.destination_dir.strip("/")
|
||||||
|
new_path_str = (new_path_str + "/" if new_path_str else "") + item_name
|
||||||
|
|
||||||
|
item_type = "directory" if is_directory else "file"
|
||||||
|
|
||||||
|
logger.info(f"Item moved: {vault_name}/{old_path_str} -> {new_path_str}")
|
||||||
|
|
||||||
|
# Update index
|
||||||
|
if is_directory:
|
||||||
|
from backend.indexer import reload_single_vault
|
||||||
|
await reload_single_vault(vault_name)
|
||||||
|
else:
|
||||||
|
await handle_file_move(vault_name, old_path_str, new_path_str)
|
||||||
|
|
||||||
|
# Broadcast SSE event
|
||||||
|
await sse_manager.broadcast("item_moved", {
|
||||||
|
"vault": vault_name,
|
||||||
|
"old_path": old_path_str,
|
||||||
|
"new_path": new_path_str,
|
||||||
|
"item_type": item_type,
|
||||||
|
})
|
||||||
|
await dispatch_webhooks("item_moved", {"vault": vault_name, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type})
|
||||||
|
|
||||||
|
return {"success": True, "old_path": old_path_str, "new_path": new_path_str, "item_type": item_type}
|
||||||
|
except PermissionError:
|
||||||
|
raise HTTPException(status_code=403, detail="Permission denied: cannot move item")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving item {vault_name}/{body.source_path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error moving item: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/file/{vault_name}/backlinks")
|
@app.get("/api/file/{vault_name}/backlinks")
|
||||||
async def api_file_backlinks(
|
async def api_file_backlinks(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { initGraphView } from './graph.js';
|
|||||||
import * as Legacy from './legacy.js';
|
import * as Legacy from './legacy.js';
|
||||||
import { initCommandPalette } from './palette.js';
|
import { initCommandPalette } from './palette.js';
|
||||||
import { initMobileToolbar } from './mobile-toolbar.js';
|
import { initMobileToolbar } from './mobile-toolbar.js';
|
||||||
|
import { initDragDrop } from './dragdrop.js';
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Initialization — mirrors the original app.js init() ordering
|
// Initialization — mirrors the original app.js init() ordering
|
||||||
@@ -57,6 +58,8 @@ async function init() {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]);
|
await Promise.all([Legacy.loadVaultSettings(), Sidebar.loadVaults(), Sidebar.loadTags()]);
|
||||||
|
|
||||||
|
initDragDrop();
|
||||||
|
|
||||||
Legacy.DashboardRecentWidget.init();
|
Legacy.DashboardRecentWidget.init();
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|||||||
Vendored
+329
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Drag & Drop module for ObsiGate sidebar tree.
|
||||||
|
*
|
||||||
|
* Allows drag-and-drop of tree items (files and directories) onto
|
||||||
|
* target directories or vault roots to reorganize the vault.
|
||||||
|
*
|
||||||
|
* Usage: import { initDragDrop } from './dragdrop.js';
|
||||||
|
* initDragDrop() after the vault tree is ready.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { api } from './auth.js';
|
||||||
|
import { showToast } from './ui.js';
|
||||||
|
import { refreshSidebarTreePreservingState } from './sidebar.js';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// State
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Currently dragged item: { vault, path, type, element } */
|
||||||
|
let dragState = null;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CSS classes applied during drag operations
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DRAG_OVER_CLASS = 'drag-over';
|
||||||
|
const DRAG_SOURCE_CLASS = 'drag-source';
|
||||||
|
const DRAG_VALID_TARGET_CLASS = 'drag-valid-target';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk up the DOM to find the closest ancestor matching the selector.
|
||||||
|
*/
|
||||||
|
function closest(selector, el) {
|
||||||
|
while (el && el !== document) {
|
||||||
|
if (el.matches && el.matches(selector)) return el;
|
||||||
|
el = el.parentNode;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an element is a valid drop target (directory or vault root).
|
||||||
|
* Returns { vault, targetDir } or null.
|
||||||
|
*/
|
||||||
|
function getDropTarget(el) {
|
||||||
|
// Directory tree item
|
||||||
|
const dirItem = closest('.tree-item[data-path]', el);
|
||||||
|
if (dirItem) {
|
||||||
|
// Only directories are valid targets
|
||||||
|
const icon = dirItem.querySelector('[data-lucide]');
|
||||||
|
const isDir = icon && (icon.getAttribute('data-lucide') === 'folder'
|
||||||
|
|| icon.getAttribute('data-lucide') === 'chevron-right'
|
||||||
|
|| icon.getAttribute('data-lucide') === 'chevron-down');
|
||||||
|
// Heuristic: if the tree item has a child container sibling, it's a directory
|
||||||
|
const hasChildren = dirItem.nextElementSibling
|
||||||
|
&& dirItem.nextElementSibling.classList.contains('tree-children');
|
||||||
|
if (isDir || hasChildren) {
|
||||||
|
return {
|
||||||
|
vault: dirItem.dataset.vault,
|
||||||
|
targetDir: dirItem.dataset.path,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vault item (root of a vault)
|
||||||
|
const vaultItem = closest('.tree-item.vault-item[data-vault]', el);
|
||||||
|
if (vaultItem) {
|
||||||
|
return {
|
||||||
|
vault: vaultItem.dataset.vault,
|
||||||
|
targetDir: '', // empty = vault root
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highlight drop target and its parent chain.
|
||||||
|
*/
|
||||||
|
function highlightDropTarget(el) {
|
||||||
|
const target = getDropTarget(el);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
if (target.targetDir) {
|
||||||
|
// Highlight the directory
|
||||||
|
const dirEl = document.querySelector(
|
||||||
|
`.tree-item[data-vault="${CSS.escape(target.vault)}"][data-path="${CSS.escape(target.targetDir)}"]`
|
||||||
|
);
|
||||||
|
if (dirEl) dirEl.classList.add(DRAG_OVER_CLASS);
|
||||||
|
} else {
|
||||||
|
// Highlight the vault
|
||||||
|
const vaultEl = document.querySelector(`.tree-item.vault-item[data-vault="${CSS.escape(target.vault)}"]`);
|
||||||
|
if (vaultEl) vaultEl.classList.add(DRAG_OVER_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove all drop target highlights.
|
||||||
|
*/
|
||||||
|
function clearAllHighlights() {
|
||||||
|
document.querySelectorAll(`.${DRAG_OVER_CLASS}`).forEach(el => el.classList.remove(DRAG_OVER_CLASS));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the source item is being dropped on itself or inside itself.
|
||||||
|
*/
|
||||||
|
function isInvalidDrop(source, target) {
|
||||||
|
if (source.vault !== target.vault) return true; // cross-vault not supported
|
||||||
|
if (!target.targetDir) {
|
||||||
|
// Target is vault root — check if source is already at root
|
||||||
|
const sourceParent = source.path.includes('/')
|
||||||
|
? source.path.substring(0, source.path.lastIndexOf('/'))
|
||||||
|
: '';
|
||||||
|
return sourceParent === '';
|
||||||
|
}
|
||||||
|
// Can't drop a directory into itself or its children
|
||||||
|
if (source.type === 'directory') {
|
||||||
|
if (source.path === target.targetDir) return true; // dropping on itself
|
||||||
|
if (target.targetDir.startsWith(source.path + '/')) return true; // dropping into a child
|
||||||
|
}
|
||||||
|
// Check if source is already in this directory
|
||||||
|
const sourceParent = source.path.includes('/')
|
||||||
|
? source.path.substring(0, source.path.lastIndexOf('/'))
|
||||||
|
: '';
|
||||||
|
if (sourceParent === target.targetDir) return true; // same directory
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event handlers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function onDragStart(e) {
|
||||||
|
const treeItem = closest('.tree-item', e.target);
|
||||||
|
if (!treeItem) return;
|
||||||
|
|
||||||
|
// Don't allow dragging vault items themselves
|
||||||
|
if (treeItem.classList.contains('vault-item')) return;
|
||||||
|
|
||||||
|
const vault = treeItem.dataset.vault;
|
||||||
|
const path = treeItem.dataset.path;
|
||||||
|
if (!vault || path === undefined) return;
|
||||||
|
|
||||||
|
// Determine type: directory or file
|
||||||
|
const hasChildren = treeItem.nextElementSibling
|
||||||
|
&& treeItem.nextElementSibling.classList.contains('tree-children');
|
||||||
|
const type = hasChildren ? 'directory' : 'file';
|
||||||
|
|
||||||
|
dragState = { vault, path, type, element: treeItem };
|
||||||
|
|
||||||
|
// Set drag data and visual feedback
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', path);
|
||||||
|
|
||||||
|
// Visual: reduce opacity of source
|
||||||
|
treeItem.classList.add(DRAG_SOURCE_CLASS);
|
||||||
|
|
||||||
|
// Add valid-target indicator to all directories and vaults
|
||||||
|
setTimeout(() => {
|
||||||
|
document.querySelectorAll('.tree-item').forEach(item => {
|
||||||
|
if (item === treeItem) return;
|
||||||
|
if (item.classList.contains('vault-item')) {
|
||||||
|
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||||
|
} else {
|
||||||
|
const icon = item.querySelector('[data-lucide]');
|
||||||
|
const hasChildContainer = item.nextElementSibling
|
||||||
|
&& item.nextElementSibling.classList.contains('tree-children');
|
||||||
|
if (icon && (icon.getAttribute('data-lucide') === 'folder' || hasChildContainer)) {
|
||||||
|
item.classList.add(DRAG_VALID_TARGET_CLASS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e) {
|
||||||
|
if (!dragState) return;
|
||||||
|
e.preventDefault(); // Required to allow drop
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
|
||||||
|
clearAllHighlights();
|
||||||
|
|
||||||
|
const target = getDropTarget(e.target);
|
||||||
|
if (!target || isInvalidDrop(dragState, target)) {
|
||||||
|
e.dataTransfer.dropEffect = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
highlightDropTarget(e.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(e) {
|
||||||
|
// Only clear when actually leaving the container, not entering a child
|
||||||
|
const container = document.getElementById('vault-tree');
|
||||||
|
if (!container) return;
|
||||||
|
// Use a small delay to avoid flickering when moving between children
|
||||||
|
clearAllHighlights();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
clearAllHighlights();
|
||||||
|
|
||||||
|
// Remove drag source visual
|
||||||
|
if (dragState && dragState.element) {
|
||||||
|
dragState.element.classList.remove(DRAG_SOURCE_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove valid-target indicators
|
||||||
|
document.querySelectorAll(`.${DRAG_VALID_TARGET_CLASS}`).forEach(el => {
|
||||||
|
el.classList.remove(DRAG_VALID_TARGET_CLASS);
|
||||||
|
});
|
||||||
|
|
||||||
|
dragState = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!dragState) return;
|
||||||
|
|
||||||
|
const target = getDropTarget(e.target);
|
||||||
|
if (!target || isInvalidDrop(dragState, target)) {
|
||||||
|
onDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { vault, path, type } = dragState;
|
||||||
|
const { vault: targetVault, targetDir } = target;
|
||||||
|
|
||||||
|
// Don't move if source and target are the same vault
|
||||||
|
if (vault !== targetVault) {
|
||||||
|
showToast('Cross-vault moves are not supported', 'error');
|
||||||
|
onDragEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save drag state before cleanup
|
||||||
|
const savedState = { ...dragState };
|
||||||
|
|
||||||
|
// Clean up visual state immediately
|
||||||
|
onDragEnd();
|
||||||
|
|
||||||
|
// Call API
|
||||||
|
try {
|
||||||
|
const response = await api(`/api/move/${encodeURIComponent(vault)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
source_path: path,
|
||||||
|
destination_dir: targetDir,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response && response.success) {
|
||||||
|
showToast(`Moved ${type} to ${targetDir || 'vault root'}`, 'success');
|
||||||
|
// Refresh the tree preserving expanded state
|
||||||
|
await refreshSidebarTreePreservingState();
|
||||||
|
} else {
|
||||||
|
showToast('Move failed', 'error');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Drag & drop move failed:', err);
|
||||||
|
const detail = err?.detail || err?.message || 'Unknown error';
|
||||||
|
showToast(`Move failed: ${detail}`, 'error');
|
||||||
|
// Refresh anyway in case the filesystem state changed
|
||||||
|
try { await refreshSidebarTreePreservingState(); } catch (_) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Initialization
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach drag-and-drop event listeners to the vault tree container.
|
||||||
|
* Uses event delegation on the #vault-tree element.
|
||||||
|
*/
|
||||||
|
export function initDragDrop() {
|
||||||
|
const container = document.getElementById('vault-tree');
|
||||||
|
if (!container) {
|
||||||
|
console.warn('DragDrop: vault-tree container not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove previous listeners if any (idempotent)
|
||||||
|
container.removeEventListener('dragstart', onDragStart);
|
||||||
|
container.removeEventListener('dragover', onDragOver);
|
||||||
|
container.removeEventListener('dragleave', onDragLeave);
|
||||||
|
container.removeEventListener('dragend', onDragEnd);
|
||||||
|
container.removeEventListener('drop', onDrop);
|
||||||
|
|
||||||
|
// Attach listeners with delegation
|
||||||
|
container.addEventListener('dragstart', onDragStart);
|
||||||
|
container.addEventListener('dragover', onDragOver);
|
||||||
|
container.addEventListener('dragleave', onDragLeave);
|
||||||
|
container.addEventListener('dragend', onDragEnd);
|
||||||
|
container.addEventListener('drop', onDrop);
|
||||||
|
|
||||||
|
// Make all existing tree items draggable
|
||||||
|
makeAllDraggable();
|
||||||
|
|
||||||
|
// Re-apply draggable after any DOM mutation in the tree
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
makeAllDraggable();
|
||||||
|
});
|
||||||
|
observer.observe(container, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make all tree items (files and directories) draggable.
|
||||||
|
* Vault items themselves are NOT draggable.
|
||||||
|
*/
|
||||||
|
function makeAllDraggable() {
|
||||||
|
const container = document.getElementById('vault-tree');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.querySelectorAll('.tree-item').forEach(item => {
|
||||||
|
if (item.classList.contains('vault-item')) return;
|
||||||
|
if (!item.hasAttribute('draggable')) {
|
||||||
|
item.setAttribute('draggable', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -911,6 +911,37 @@ select {
|
|||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Drag & Drop — tree items
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Source item being dragged */
|
||||||
|
.tree-item.drag-source {
|
||||||
|
opacity: 0.35;
|
||||||
|
outline: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Valid drop targets during drag — subtle pulse indicator */
|
||||||
|
.tree-item.drag-valid-target {
|
||||||
|
outline: 1px dashed color-mix(in srgb, var(--accent) 30%, transparent);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active drop target (hovering over) */
|
||||||
|
.tree-item.drag-over {
|
||||||
|
background: color-mix(in srgb, var(--accent) 18%, transparent) !important;
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vault item as drop target */
|
||||||
|
.tree-item.vault-item.drag-over {
|
||||||
|
background: color-mix(in srgb, var(--accent) 15%, transparent) !important;
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.tree-children {
|
.tree-children {
|
||||||
padding-left: 16px;
|
padding-left: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user