import { useCallback, useEffect, useRef, useState } from "react"; import type { DragEvent, KeyboardEvent, MouseEvent, ReactNode } from "react"; import { api, ApiError, basename, dirname, displayPath, EXT, inFavoris, inPoubelle, join, label, POUBELLE, stripExt, withExt, type Entry, type Hit, type TagCount, } from "./api"; import { CloseIcon, DeselectIcon, CopyIcon, DotsIcon, DownloadIcon, EditIcon, FileIcon, FilePlusIcon, FolderIcon, FolderPlusIcon, FolderUploadIcon, GridIcon, HistoryIcon, ListIcon, MoveIcon, RefreshIcon, RestoreIcon, SearchIcon, SelectAllIcon, ShareIcon, StarFilledIcon, StarIcon, TagIcon, TrashFullIcon, TrashIcon, UpIcon, UploadIcon, UserIcon, } from "./icons"; import { ActionMenu } from "./ActionMenu"; import { FolderGlyph } from "./folderIcons"; import { IconDialog } from "./IconDialog"; import { collectDrop, collectFiles, hasFiles, type Collected } from "./importer"; import { MoveDialog } from "./MoveDialog"; import { TagsDialog } from "./TagsDialog"; import { Thumb } from "./Thumb"; export const EMPTY_SCENE = JSON.stringify({ type: "excalidraw", version: 2, source: "excalicloud", 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; /** Affiche les liens de partage d'un schéma. */ onShare: (path: string) => void; /** Contenu ajouté au bout de la barre de recherche (bouton d'épinglage du panneau). */ toolbarEnd?: ReactNode; } 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, onShare, toolbarEnd }: 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 [menu, setMenu] = useState<{ entry: Hit; anchor: HTMLElement } | null>(null); // menu « … » ouvert 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 [tags, setTags] = useState([]); const [tagFilter, setTagFilter] = useState(null); const [tagging, setTagging] = useState(null); // éléments du dialogue « Tags » const [iconing, setIconing] = useState(null); // dossiers du dialogue « Icône » const [trashDays, setTrashDays] = useState(null); // purge automatique (0 : jamais) const fileInput = useRef(null); const dirInput = useRef(null); const stay = useRef(null); // schéma ouvert depuis Favoris : on reste dans Favoris const list = results ?? entries; const filter = { tag: tagFilter }; const filtering = query.trim() !== "" || tagFilter !== null; // Dossier Favoris : des liens vers les originaux, ni création ni déplacement. const favView = !results && inFavoris(dir); // Poubelle : éléments à restaurer ou à supprimer définitivement, rien d'autre. const trashView = !results && inPoubelle(dir); const locked = favView || trashView; // ni création, ni import, ni déplacement const closeMenu = useCallback(() => setMenu(null), []); // La liste change de forme : le bouton « … » d'origine disparaît. useEffect(closeMenu, [view, dir, results, closeMenu]); 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, tagList] = await Promise.all([api.list(dir), api.tags()]); setEntries(next); setTags(tagList); // Tag filtré qui n'existe plus : filtre retiré. setTagFilter((t) => (t && tagList.some((x) => x.name === t) ? t : null)); setMenu(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 && current !== stay.current) setDir(dirname(current)); stay.current = null; }, [current]); // Recherche (texte, tag, favoris) avec anti-rebond. useEffect(() => { if (!filtering) { setResults(null); return; } const t = setTimeout(() => guard(async () => setResults(await api.search(query, filter))), 250); return () => clearTimeout(t); // eslint-disable-next-line react-hooks/exhaustive-deps }, [query, tagFilter, guard, refreshKey]); /** Recharge le dossier et, si une recherche est affichée, ses résultats. */ const reload = async () => { await refresh(); if (filtering) setResults(await api.search(query, filter)); }; // Délai de purge, affiché dans la poubelle (réglé dans les paramètres). useEffect(() => { if (!trashView) return; api .settings() .then((s) => setTrashDays(s.trashDays)) .catch(() => setTrashDays(null)); }, [trashView, 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 = selectable.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 selectable = list.filter((e) => !e.special); const selectAll = () => setSelected(new Set(selectable.map((e) => e.path))); const onItemClick = (ev: MouseEvent, e: Entry) => { if (!e.special) { if (ev.shiftKey) return selectRange(e.path); if (trashView) return toggle(e.path); // un élément de la poubelle ne s'ouvre pas // 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.broken) return; if (e.isDir) { setQuery(""); setDir(e.path); } else { if (favView && e.path !== current) stay.current = e.path; 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 reload(); if (failed.length) { setError(`${plural(failed.length, "élément")} non déplacé${failed.length > 1 ? "s" : ""} — ${failed.join(" ; ")}`); } }); /** Favoris : la sélection devient favorite, sauf si elle l'est déjà entièrement. */ const toggleFavorite = (items: Entry[]) => guard(async () => { const files = items.filter((e) => !e.isDir); if (files.length === 0) return; const fav = favView ? false : !files.every((e) => e.favorite); await api.setFavorite( files.map((e) => e.path), fav, ); setMenu(null); if (favView) clearSelection(); await reload(); }); /** Dépôt sur Favoris : les schémas déposés y sont ajoutés (lien, pas déplacement). */ const addFavorites = (paths: string[]) => toggleFavorite( paths.map((p) => list.find((e) => e.path === p) ?? { name: basename(p), path: p, isDir: false, size: 0, modTime: "" }), ); 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 () => { // Dans Favoris, « Supprimer » retire le lien ; l'original reste en place. if (favView) { await api.setFavorite(paths, false); clearSelection(); return reload(); } 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` : `« ${stripExt(one?.name ?? basename(paths[0]))} »`; if (trashView) { if (!confirm(`Supprimer définitivement ${what} ? Cette action est irréversible.`)) return; for (const p of paths) await api.remove(p, true); clearSelection(); return reload(); } if (!confirm(`Mettre ${what} à la poubelle ?`)) return; for (const p of paths) { await api.remove(p, true); onDeleted(p); } clearSelection(); await reload(); }); /** Remet des éléments de la poubelle à leur emplacement d'origine. */ const restoreMany = (paths: string[]) => guard(async () => { const res = await api.restoreTrash(paths); const failed = res.filter((r) => r.error); const done = res.filter((r) => r.to); clearSelection(); await reload(); if (done.length) { setNotice({ text: `${plural(done.length, "élément")} restauré${done.length > 1 ? "s" : ""}`, detail: done.map((r) => displayPath(r.to!)).join("\n"), }); } if (failed.length) { const name = (p: string) => stripExt(list.find((e) => e.path === p)?.name ?? basename(p)); setError( `${plural(failed.length, "élément")} non restauré${failed.length > 1 ? "s" : ""} — ${failed.map((r) => `${name(r.from)} : ${r.error}`).join(" ; ")}`, ); } }); const emptyTrash = () => guard(async () => { if (!confirm(`Vider la poubelle (${plural(list.length, "élément")}) ? Cette action est irréversible.`)) return; await api.remove(POUBELLE, true); clearSelection(); await reload(); }); /** * 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 = "linkMove"; 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; if (external && (inFavoris(targetDir) || inPoubelle(targetDir))) return; // pas d'import dans Favoris ni la poubelle ev.preventDefault(); ev.dataTransfer.dropEffect = external ? "copy" : inFavoris(targetDir) ? "link" : "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; } if (inFavoris(targetDir)) addFavorites(paths); else if (inPoubelle(targetDir)) removeMany(paths); else moveMany(paths, targetDir); }, }); // --- Rendu --------------------------------------------------------------- const crumbs = dir ? dir.split("/") : []; const importDir = results || locked ? "" : dir; // pendant une recherche, le dossier courant n'est pas affiché const where = (p: string) => displayPath(dirname(p)) || "Racine"; const deletedInfo = (e: Entry) => `Supprimé le ${fmtDate(e.modTime)}${e.by ? ` par ${e.by}` : ""}\nEmplacement d'origine : ${e.origin ? where(e.origin) : "inconnu"}`; // Dépôt n'importe où dans l'explorateur : import dans le dossier courant. const zoneProps = { onDragOver: (ev: DragEvent) => { if (locked || !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 (locked || !hasFiles(ev.dataTransfer)) return; ev.preventDefault(); setFileDrag(false); importTree(collectDrop(ev.dataTransfer), importDir); }, }; const big = view === "grid" ? 40 : 20; const icon = (e: Entry) => e.special ? ( {e.special === "favoris" ? ( ) : e.special === "poubelle" ? ( e.count ? ( ) : ( ) ) : ( )} ) : trashView ? ( {e.isDir ? : } ) : e.broken ? ( ) : e.isDir ? ( ) : ( ); // Dans Favoris : actions sur l'original, retrait du lien à la place de la suppression. const favActions = (e: Entry) => ( <> {!e.broken && ( <> Télécharger
)} ); // Dans la poubelle : restauration ou suppression définitive. const trashActions = (e: Entry) => ( <>
{deletedInfo(e)} ); const actions = (e: Entry) => ( <> {!e.isDir && ( )} {!e.isDir && ( )} {!e.isDir && ( )} {!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" : "", e.special ? "special" : "", e.broken ? "broken" : "", dropTarget === e.path ? "drop" : "", ].join(" "); return (
  • onDragStart(ev, e)} {...(e.isDir ? dropProps(e.path) : {})} > {!e.special && ( { ev.stopPropagation(); if (ev.shiftKey) { ev.preventDefault(); selectRange(e.path); } }} onChange={() => toggle(e.path)} /> )} {!!e.tags?.length && ( {e.tags.map((t) => ( ))} )} {(!e.isDir || trashView) && view === "list" && {fmtDate(e.modTime)}} {!e.special && ( )}
  • ); }; 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 = ""; }} />
    {toolbarEnd}
    {tags.map((t) => ( ))}
    {!results && ( )} {trashView && (
    {trashDays === null ? "" : trashDays === 0 ? "Conservés jusqu'à suppression définitive" : `Supprimés définitivement après ${plural(trashDays, "jour")}`}
    )} {selected.size > 0 && (
    {plural(selected.size, "sélectionné")} {trashView && ( )} {!trashView && ( )} {!trashView && ( )} {!locked && ( )}
    )} {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.map(item)} {list.length === 0 && (
    • {results ? "Aucun résultat" : favView ? "Aucun favori : étoile ou glisser-déposer un schéma ici" : trashView ? "La poubelle est vide" : "Dossier vide"}
    • )}
    {fileDrag && (
    Déposer pour importer dans « {importDir ? label(basename(importDir)) : "Racine"} » Les dossiers sont recréés · seuls les .excalidraw sont importés
    )} {tagging && ( list.find((e) => e.path === t.path) ?? t)} known={tags} onChanged={reload} onClose={() => setTagging(null)} /> )} {iconing && setIconing(null)} />} {menu && ( {favView ? favActions(menu.entry) : trashView ? trashActions(menu.entry) : actions(menu.entry)} )} {moving && ( setMoving(null)} onConfirm={(target) => moveMany(moving, target)} /> )}
    ); }