Explorateur.tsx (sélection multiple, vignettes) et README

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZ78TqR7fgtRx3rvpNUbAL
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 12:47:59 +02:00
1 parent d04350df7a
commit 2da861b81d
2 files changed
+314 -91

No files matched your search

+292 -86
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { DragEvent } from "react";
import type { DragEvent, KeyboardEvent, MouseEvent } from "react";
import {
api,
ApiError,
@@ -11,6 +11,8 @@ import {
withExt,
type Entry,
} from "./api";
import { MoveDialog } from "./MoveDialog";
import { Thumb } from "./Thumb";
export const EMPTY_SCENE = JSON.stringify({
type: "excalidraw",
@@ -33,24 +35,46 @@ interface Props {
onDeleted: (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 DND_TYPE = "application/x-excalidraw-path";
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 }: Props) {
const [dir, setDir] = useState(() => (current ? dirname(current) : ""));
const [entries, setEntries] = useState<Entry[]>([]);
const [query, setQuery] = useState("");
const [results, setResults] = useState<Entry[] | null>(null);
const [view, setView] = useState<View>(initialView);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [anchor, setAnchor] = useState<string | null>(null); // origine d'une sélection Maj+clic
const [menuFor, setMenuFor] = useState<string | null>(null);
const [moving, setMoving] = useState<string[] | null>(null); // éléments du dialogue « Déplacer vers »
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const list = results ?? entries;
const guard = useCallback(async (fn: () => Promise<unknown>) => {
setError(null);
try {
@@ -63,8 +87,15 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
const refresh = useCallback(
() =>
guard(async () => {
setEntries(await api.list(dir));
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],
);
@@ -73,6 +104,12 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
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));
@@ -88,6 +125,65 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
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 = () =>
@@ -108,25 +204,36 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
await refresh();
});
const move = (e: Entry, to: string) =>
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();
});
const rename = (e: Entry) => {
const name = prompt("Nouveau nom :", e.isDir ? e.name : stripExt(e.name));
if (!name?.trim()) return;
move(e, join(dirname(e.path), e.isDir ? name.trim() : withExt(name.trim())));
};
const moveTo = (e: Entry) => {
const dest = prompt("Dossier de destination (vide = racine) :", dirname(e.path));
if (dest === null) return;
move(e, join(dest.trim().replace(/^\/+|\/+$/g, ""), e.name));
};
/** 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 () => {
@@ -143,13 +250,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
await refresh();
});
const remove = (e: Entry) =>
const removeMany = (paths: string[]) =>
guard(async () => {
const what = e.isDir ? `le dossier « ${e.name} » et tout son contenu` : `« ${e.name} »`;
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;
await api.remove(e.path, e.isDir);
onDeleted(e.path);
for (const p of paths) {
await api.remove(p, true);
onDeleted(p);
}
clearSelection();
await refresh();
if (results) setResults(await api.search(query));
});
const importFiles = (files: FileList | null) =>
@@ -160,94 +277,140 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
await refresh();
});
// --- Glisser-déposer (souris) ; sur mobile : menu « Déplacer… » ----------
// --- Glisser-déposer (souris) ; sur mobile : sélection + « Déplacer vers… » -
const onDragStart = (ev: DragEvent, e: Entry) => {
ev.dataTransfer.setData(DND_TYPE, e.path);
// 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());
}
};
const dropProps = (targetDir: string) => ({
onDragOver: (ev: DragEvent) => {
if (!ev.dataTransfer.types.includes(DND_TYPE)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
setDropTarget(targetDir);
},
onDragLeave: () => setDropTarget(null),
onDrop: (ev: DragEvent) => {
ev.preventDefault();
setDropTarget(null);
const from = ev.dataTransfer.getData(DND_TYPE);
if (!from || dirname(from) === targetDir) return;
move({ path: from, name: basename(from) } as Entry, join(targetDir, basename(from)));
let paths: string[] = [];
try {
paths = JSON.parse(ev.dataTransfer.getData(DND_TYPE));
} catch {
return;
}
moveMany(paths, targetDir);
},
});
// --- Rendu ---------------------------------------------------------------
const crumbs = dir ? dir.split("/") : [];
const list = results ?? entries;
const row = (e: Entry) => (
<li key={e.path} className={e.path === current ? "active" : ""}>
<div
className={`row ${dropTarget === e.path ? "drop" : ""}`}
draggable
onDragStart={(ev) => onDragStart(ev, e)}
{...(e.isDir ? dropProps(e.path) : {})}
>
<button
className="name"
onClick={() => (e.isDir ? (setQuery(""), setDir(e.path)) : onOpen(e.path))}
title={e.path}
>
<span className="icon" aria-hidden>
{e.isDir ? "📁" : "📄"}
</span>
<span className="label">
{e.isDir ? e.name : stripExt(e.name)}
{results && <small className="muted"> {dirname(e.path) || "/"}</small>}
</span>
</button>
{!e.isDir && <span className="meta muted">{fmtDate(e.modTime)}</span>}
<button
className="icon-btn"
aria-label="Actions"
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
>
⋯
</button>
</div>
{menuFor === e.path && (
<div className="actions">
<button onClick={() => rename(e)}>Renommer</button>
<button onClick={() => moveTo(e)}>Déplacer…</button>
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
{!e.isDir && (
<a href={api.downloadUrl(e.path)} download={e.name}>
Télécharger
</a>
)}
<button className="danger" onClick={() => remove(e)}>
Supprimer
</button>
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
</div>
const icon = (e: Entry) =>
e.isDir ? (
<span className="icon" aria-hidden>
📁
</span>
) : (
<Thumb path={e.path} version={e.modTime} />
);
const actions = (e: Entry) => (
<div className="actions">
<button onClick={() => rename(e)}>Renommer</button>
<button onClick={() => setMoving([e.path])}>Déplacer…</button>
{!e.isDir && <button onClick={() => duplicate(e)}>Dupliquer</button>}
{!e.isDir && (
<a href={api.downloadUrl(e.path)} download={e.name}>
Télécharger
</a>
)}
</li>
<button className="danger" onClick={() => removeMany([e.path])}>
Supprimer
</button>
{!e.isDir && <span className="muted">{fmtSize(e.size)}</span>}
</div>
);
const item = (e: Entry) => {
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 [
<li key={e.path} className={cls}>
<div
className="row"
draggable
onDragStart={(ev) => onDragStart(ev, e)}
{...(e.isDir ? dropProps(e.path) : {})}
>
<input
type="checkbox"
className="check"
checked={isSel}
aria-label={`Sélectionner ${e.name}`}
onClick={(ev) => {
ev.stopPropagation();
if (ev.shiftKey) {
ev.preventDefault();
selectRange(e.path);
}
}}
onChange={() => toggle(e.path)}
/>
<button className="name" onClick={(ev) => onItemClick(ev, e)} title={e.path}>
{icon(e)}
<span className="label">
{e.isDir ? e.name : stripExt(e.name)}
{results && <small className="muted where"> {dirname(e.path) || "/"}</small>}
</span>
</button>
{!e.isDir && view === "list" && <span className="meta muted">{fmtDate(e.modTime)}</span>}
<button
className="icon-btn more"
aria-label="Actions"
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
>
⋯
</button>
</div>
{view === "list" && menuFor === e.path && actions(e)}
</li>,
// En grille, le menu occupe toute la largeur sous la vignette.
view === "grid" && menuFor === e.path && (
<li key={`${e.path}#menu`} className="menu-row">
{actions(e)}
</li>
),
];
};
return (
<div className="explorer">
<div className="explorer" onKeyDown={onKeyDown}>
<div className="toolbar">
<button onClick={newDrawing} title="Nouveau schéma">+ Schéma</button>
<button onClick={newFolder} title="Nouveau dossier">+ Dossier</button>
<button onClick={() => fileInput.current?.click()} title="Importer des fichiers .excalidraw">
Importer
</button>
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
⟳
</button>
<input
ref={fileInput}
type="file"
@@ -261,13 +424,26 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
/>
</div>
<input
className="search"
type="search"
placeholder="Rechercher…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
<div className="searchbar">
<input
className="search"
type="search"
placeholder="Rechercher…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
<button
className="icon-btn"
onClick={() => setView(view === "grid" ? "list" : "grid")}
title={view === "grid" ? "Affichage en liste" : "Affichage en vignettes"}
aria-label={view === "grid" ? "Affichage en liste" : "Affichage en vignettes"}
>
{view === "grid" ? "☰" : "▦"}
</button>
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
⟳
</button>
</div>
{!results && (
<nav className="crumbs">
@@ -288,16 +464,37 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
</nav>
)}
{selected.size > 0 && (
<div className="selbar">
<button className="icon-btn" onClick={clearSelection} aria-label="Annuler la sélection" title="Annuler la sélection (Échap)">
✕
</button>
<span className="count">{plural(selected.size, "sélectionné")}</span>
<button
className="icon-btn"
onClick={selected.size === list.length ? clearSelection : selectAll}
title={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner (Ctrl+A)"}
aria-label={selected.size === list.length ? "Tout désélectionner" : "Tout sélectionner"}
>
{selected.size === list.length ? "☐" : "☑"}
</button>
<button onClick={() => setMoving([...selected])}>Déplacer…</button>
<button className="danger" onClick={() => removeMany([...selected])} title="Mettre à la corbeille (Suppr)" aria-label="Supprimer">
🗑
</button>
</div>
)}
{error && (
<div className="error" onClick={() => setError(null)}>
{error}
</div>
)}
<ul className="entries">
<ul className={`entries ${view} ${selected.size > 0 ? "selecting" : ""}`}>
{!results && dir && (
<li>
<div className={`row ${dropTarget === dirname(dir) ? "drop" : ""}`} {...dropProps(dirname(dir))}>
<li className={`item dir up ${dropTarget === dirname(dir) ? "drop" : ""}`}>
<div className="row" {...dropProps(dirname(dir))}>
<button className="name" onClick={() => setDir(dirname(dir))}>
<span className="icon" aria-hidden>↩</span>
<span className="label">..</span>
@@ -305,9 +502,18 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
</div>
</li>
)}
{list.map(row)}
{list.flatMap(item)}
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
</ul>
{moving && (
<MoveDialog
paths={moving}
startDir={results ? "" : dir}
onCancel={() => setMoving(null)}
onConfirm={(target) => moveMany(moving, target)}
/>
)}
</div>
);
}