import { useCallback, useEffect, useRef, useState } from "react"; import type { DragEvent, KeyboardEvent, MouseEvent } from "react"; import { api, ApiError, basename, dirname, EXT, join, stripExt, withExt, type Entry, type Hit, } from "./api"; import { CloseIcon, DeselectIcon, DotsIcon, FilePlusIcon, FolderIcon, FolderPlusIcon, FolderUploadIcon, GridIcon, ListIcon, MoveIcon, RefreshIcon, SearchIcon, SelectAllIcon, TrashIcon, UpIcon, UploadIcon, } from "./icons"; import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; import { MoveDialog } from "./MoveDialog"; import { Thumb } from "./Thumb"; export const EMPTY_SCENE = JSON.stringify({ type: "excalidraw", version: 2, source: "excalidraw-go", elements: [], appState: {}, files: {}, }); interface Props { /** Schéma ouvert dans l'éditeur (surligné). */ current: string | null; /** Incrémenté par le parent pour forcer un rafraîchissement (ex. après sauvegarde). */ refreshKey: number; onOpen: (path: string) => void; /** Un fichier ou dossier a été déplacé/renommé : le parent met à jour le schéma ouvert. */ onMoved: (from: string, to: string) => void; /** Un fichier ou dossier a été supprimé. */ onDeleted: (path: string) => void; /** Affiche l'historique des versions d'un schéma. */ onHistory: (path: string) => void; } type View = "list" | "grid"; const fmtDate = (iso: string) => new Date(iso).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }); const fmtSize = (n: number) => n < 1024 ? `${n} o` : n < 1 << 20 ? `${(n / 1024).toFixed(1)} Ko` : `${(n / (1 << 20)).toFixed(1)} Mo`; const plural = (n: number, word: string) => `${n} ${word}${n > 1 ? "s" : ""}`; const DND_TYPE = "application/x-excalidraw-paths"; const initialView = (): View => { try { return localStorage.getItem("explorerView") === "list" ? "list" : "grid"; } catch { return "grid"; } }; /** Un élément peut-il être déplacé dans targetDir ? (pas sur place, pas dans lui-même) */ const canMoveInto = (path: string, targetDir: string) => dirname(path) !== targetDir && targetDir !== path && !targetDir.startsWith(path + "/"); export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted, onHistory }: Props) { const [dir, setDir] = useState(() => (current ? dirname(current) : "")); const [entries, setEntries] = useState([]); const [query, setQuery] = useState(""); const [results, setResults] = useState(null); const [view, setView] = useState(initialView); const [selected, setSelected] = useState>(new Set()); const [anchor, setAnchor] = useState(null); // origine d'une sélection Maj+clic const [menuFor, setMenuFor] = useState(null); const [moving, setMoving] = useState(null); // éléments du dialogue « Déplacer vers » const [dropTarget, setDropTarget] = useState(null); const [error, setError] = useState(null); const [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null); const [busy, setBusy] = useState(null); // progression d'un import const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur const fileInput = useRef(null); const dirInput = useRef(null); const list = results ?? entries; const guard = useCallback(async (fn: () => Promise) => { setError(null); try { await fn(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } }, []); const refresh = useCallback( () => guard(async () => { const next = await api.list(dir); setEntries(next); setMenuFor(null); // Oublie les éléments sélectionnés qui ont disparu. setSelected((s) => { if (s.size === 0) return s; const still = new Set(next.map((e) => e.path)); return new Set([...s].filter((p) => still.has(p))); }); }), [dir, guard], ); useEffect(() => { refresh(); }, [refresh, refreshKey]); // Changer de dossier ou de recherche réinitialise la sélection. useEffect(() => { setSelected(new Set()); setAnchor(null); }, [dir, results === null]); // Suit le dossier du schéma ouvert (lien direct, création, déplacement). useEffect(() => { if (current) setDir(dirname(current)); }, [current]); // Recherche avec anti-rebond. useEffect(() => { if (!query.trim()) { setResults(null); return; } const t = setTimeout(() => guard(async () => setResults(await api.search(query))), 250); return () => clearTimeout(t); }, [query, guard, refreshKey]); useEffect(() => { try { localStorage.setItem("explorerView", view); } catch { /* stockage indisponible */ } }, [view]); // --- Sélection ----------------------------------------------------------- const clearSelection = () => { setSelected(new Set()); setAnchor(null); }; const toggle = (path: string) => { setSelected((s) => { const n = new Set(s); if (!n.delete(path)) n.add(path); return n; }); setAnchor(path); }; const selectRange = (path: string) => { const paths = list.map((e) => e.path); const a = anchor ? paths.indexOf(anchor) : -1; const b = paths.indexOf(path); if (a < 0 || b < 0) return toggle(path); const [lo, hi] = a < b ? [a, b] : [b, a]; setSelected((s) => new Set([...s, ...paths.slice(lo, hi + 1)])); }; const selectAll = () => setSelected(new Set(list.map((e) => e.path))); const onItemClick = (ev: MouseEvent, e: Entry) => { if (ev.shiftKey) return selectRange(e.path); // Ctrl/Cmd+clic, ou clic quand une sélection est en cours (mode sélection tactile). if (ev.ctrlKey || ev.metaKey || selected.size > 0) return toggle(e.path); if (e.isDir) { setQuery(""); setDir(e.path); } else onOpen(e.path); }; const onKeyDown = (ev: KeyboardEvent) => { if ((ev.target as HTMLElement).tagName === "INPUT") return; if (ev.key === "Escape" && selected.size > 0) { ev.preventDefault(); clearSelection(); } else if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "a") { ev.preventDefault(); selectAll(); } else if (ev.key === "Delete" && selected.size > 0) { ev.preventDefault(); removeMany([...selected]); } }; // --- Actions ------------------------------------------------------------- const newDrawing = () => guard(async () => { const name = prompt("Nom du nouveau schéma :", "Sans titre"); if (!name?.trim()) return; const path = join(dir, withExt(name.trim())); await api.write(path, EMPTY_SCENE, "*"); await refresh(); onOpen(path); }); const newFolder = () => guard(async () => { const name = prompt("Nom du dossier :"); if (!name?.trim()) return; await api.mkdir(join(dir, name.trim())); await refresh(); }); const rename = (e: Entry) => guard(async () => { const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name)); if (!name?.trim()) return; const to = join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim())); if (to === e.path) return; await api.move(e.path, to); onMoved(e.path, to); await refresh(); }); /** Déplace plusieurs éléments dans targetDir ; les échecs partiels sont listés. */ const moveMany = (paths: string[], targetDir: string) => guard(async () => { const todo = paths.filter((p) => canMoveInto(p, targetDir)); if (todo.length === 0) return; const res = await api.moveMany(todo, targetDir); const failed: string[] = []; for (const r of res) { if (r.error) failed.push(`${basename(r.from)} : ${r.error}`); else if (r.to && r.to !== r.from) onMoved(r.from, r.to); } clearSelection(); setMoving(null); await refresh(); if (results) setResults(await api.search(query)); if (failed.length) { setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`); } }); const duplicate = (e: Entry) => guard(async () => { const base = join(dirname(e.path), stripExt(e.name)); for (let i = 1; i < 100; i++) { const to = `${base} (copie${i > 1 ? ` ${i}` : ""})${EXT}`; try { await api.copy(e.path, to); break; } catch (err) { if (!(err instanceof ApiError && err.status === 409)) throw err; } } await refresh(); }); const removeMany = (paths: string[]) => guard(async () => { const one = list.find((e) => e.path === paths[0]); const what = paths.length > 1 ? `${plural(paths.length, "élément")} (dossiers compris avec leur contenu)` : one?.isDir ? `le dossier « ${one.name} » et tout son contenu` : `« ${basename(paths[0])} »`; if (!confirm(`Mettre ${what} à la corbeille ?`)) return; for (const p of paths) { await api.remove(p, true); onDeleted(p); } clearSelection(); await refresh(); if (results) setResults(await api.search(query)); }); /** * Importe fichiers et arborescences dans targetDir : les dossiers sont recréés, * seuls les .excalidraw sont importés, les schémas déjà présents sont conservés. */ const importTree = (pending: Collected | Promise, targetDir: string) => guard(async () => { setNotice(null); setBusy("Lecture…"); try { const { files, dirs, ignored } = await pending; for (const d of dirs) { try { await api.mkdir(join(targetDir, d)); } catch (err) { if (!(err instanceof ApiError && err.status === 409)) throw err; // déjà présent } } let done = 0; const existing: string[] = []; const failed: string[] = []; for (const { rel, file } of files) { setBusy(`Import ${done + existing.length + failed.length + 1}/${files.length}…`); try { await api.write(join(targetDir, rel), await file.text(), "*"); done++; } catch (err) { if (err instanceof ApiError && err.status === 409) existing.push(rel); else failed.push(`${rel} : ${err instanceof Error ? err.message : err}`); } } await refresh(); const parts = [`${plural(done, "schéma")} importé${done > 1 ? "s" : ""}`]; if (dirs.length) parts.push(plural(dirs.length, "dossier")); if (existing.length) parts.push(`${existing.length} déjà présent${existing.length > 1 ? "s" : ""} (conservé${existing.length > 1 ? "s" : ""})`); if (ignored.length) parts.push(`${plural(ignored.length, "fichier")} ignoré${ignored.length > 1 ? "s" : ""} (pas .excalidraw)`); const detail = [ ...failed.map((f) => `Échec — ${f}`), ...existing.map((f) => `Déjà présent — ${f}`), ...ignored.map((f) => `Ignoré — ${f}`), ].join("\n"); setNotice({ text: parts.join(" · "), detail: detail || undefined }); if (failed.length) setError(`${plural(failed.length, "fichier")} en échec : ${failed.join(" ; ")}`); } finally { setBusy(null); } }); // --- Glisser-déposer (souris) ; sur mobile : sélection + « Déplacer vers… » - const onDragStart = (ev: DragEvent, e: Entry) => { // Glisser un élément sélectionné emporte toute la sélection. const paths = selected.has(e.path) ? [...selected] : [e.path]; ev.dataTransfer.setData(DND_TYPE, JSON.stringify(paths)); ev.dataTransfer.effectAllowed = "move"; if (paths.length > 1) { const ghost = document.createElement("div"); ghost.className = "drag-ghost"; ghost.textContent = plural(paths.length, "élément"); document.body.appendChild(ghost); ev.dataTransfer.setDragImage(ghost, 12, 12); setTimeout(() => ghost.remove()); } }; // Cibles de dépôt : dossiers, fil d'Ariane et « .. ». Accepte les éléments de // l'explorateur (déplacement) et les fichiers/dossiers du système (import). const dropProps = (targetDir: string) => ({ onDragOver: (ev: DragEvent) => { const external = hasFiles(ev.dataTransfer); if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = external ? "copy" : "move"; setDropTarget(targetDir); }, onDragLeave: () => setDropTarget(null), onDrop: (ev: DragEvent) => { ev.preventDefault(); ev.stopPropagation(); // l'explorateur n'importe pas une seconde fois setDropTarget(null); setFileDrag(false); if (hasFiles(ev.dataTransfer)) return importTree(collectDrop(ev.dataTransfer), targetDir); let paths: string[] = []; try { paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE)); } catch { return; } moveMany(paths, targetDir); }, }); // --- Rendu --------------------------------------------------------------- const crumbs = dir ? dir.split("/") : []; const importDir = results ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché // Dépôt n'importe où dans l'explorateur : import dans le dossier courant. const zoneProps = { onDragOver: (ev: DragEvent) => { if (!hasFiles(ev.dataTransfer)) return; ev.preventDefault(); ev.dataTransfer.dropEffect = "copy"; setFileDrag(true); }, onDragLeave: (ev: DragEvent) => { if (!ev.currentTarget.contains(ev.relatedTarget as Node | null)) setFileDrag(false); }, onDrop: (ev: DragEvent) => { if (!hasFiles(ev.dataTransfer)) return; ev.preventDefault(); setFileDrag(false); importTree(collectDrop(ev.dataTransfer), importDir); }, }; const icon = (e: Entry) => e.isDir ? ( ) : ( ); const actions = (e: Entry) => (
{!e.isDir && } {!e.isDir && } {!e.isDir && ( Télécharger )} {!e.isDir && {fmtSize(e.size)}}
); const item = (e: Hit) => { const isSel = selected.has(e.path); const cls = [ "item", e.isDir ? "dir" : "file", e.path === current ? "active" : "", isSel ? "selected" : "", dropTarget === e.path ? "drop" : "", ].join(" "); return [
  • onDragStart(ev, e)} {...(e.isDir ? dropProps(e.path) : {})} > { ev.stopPropagation(); if (ev.shiftKey) { ev.preventDefault(); selectRange(e.path); } }} onChange={() => toggle(e.path)} /> {!e.isDir && view === "list" && {fmtDate(e.modTime)}}
    {view === "list" && menuFor === e.path && actions(e)}
  • , // En grille, le menu occupe toute la largeur sous la vignette. view === "grid" && menuFor === e.path && (
  • {actions(e)}
  • ), ]; }; return (
    { if (ev.target.files) importTree(collectFiles(ev.target.files), importDir); ev.target.value = ""; }} /> { if (ev.target.files) importTree(collectFiles(ev.target.files), importDir); ev.target.value = ""; }} />
    {!results && ( )} {selected.size > 0 && (
    {plural(selected.size, "sélectionné")}
    )} {error && (
    setError(null)}> {error}
    )} {busy &&
    {busy}
    } {notice && !busy && (
    setNotice(null)} title={notice.detail ? `${notice.detail}\n\n(clic pour masquer)` : "Clic pour masquer"}> {notice.text}
    )}
      0 ? "selecting" : ""}`}> {!results && dir && (
    • )} {list.flatMap(item)} {list.length === 0 &&
    • {results ? "Aucun résultat" : "Dossier vide"}
    • }
    {fileDrag && (
    Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} » Les dossiers sont recréés · seuls les .excalidraw sont importés
    )} {moving && ( setMoving(null)} onConfirm={(target) => moveMany(moving, target)} /> )}
    ); }