Explorateur : boutons et icônes Excalidraw, import de dossiers ; feuille de style

Explorateur : barre d'outils et sélecteur vignettes/liste au style
d'Excalidraw, dépôt de fichiers et dossiers du système (arborescence
recréée, .excalidraw seuls, bilan d'import). Feuille de style : jetons
--ex-* repris d'Excalidraw 0.18, clair et sombre.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019EuvvuTv7AUc8ksDHv8VKx
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-07 15:50:04 +02:00
1 parent 1d4b635b19
commit 6c8741731e
1 file changed
+172 -23
+172 -23
View File
@@ -11,6 +11,25 @@ import {
withExt,
type Entry,
} 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";
@@ -71,7 +90,11 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
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 [notice, setNotice] = useState<{ text: string; detail?: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null); // progression d'un import
const [fileDrag, setFileDrag] = useState(false); // fichiers du système survolant l'explorateur
const fileInput = useRef<HTMLInputElement>(null);
const dirInput = useRef<HTMLInputElement>(null);
const list = results ?? entries;
@@ -269,12 +292,51 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
if (results) setResults(await api.search(query));
});
const importFiles = (files: FileList | null) =>
/**
* 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<Collected>, targetDir: string) =>
guard(async () => {
for (const f of Array.from(files ?? [])) {
await api.write(join(dir, withExt(stripExt(f.name.replace(/\.json$/i, "")))), await f.text(), "*");
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… » -
@@ -294,17 +356,23 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
}
};
// 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) => {
if (!ev.dataTransfer.types.includes(DND_TYPE)) return;
const external = hasFiles(ev.dataTransfer);
if (!external && !ev.dataTransfer.types.includes(DND_TYPE)) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
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));
@@ -318,11 +386,31 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
// --- 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 ? (
<span className="icon" aria-hidden>
📁
<span className="icon folder" aria-hidden>
<FolderIcon size={view === "grid" ? 40 : 20} />
</span>
) : (
<Thumb path={e.path} version={e.modTime} />
@@ -387,9 +475,10 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
<button
className="icon-btn more"
aria-label="Actions"
title="Actions"
onClick={() => setMenuFor(menuFor === e.path ? null : e.path)}
>
⋯
<DotsIcon />
</button>
</div>
{view === "list" && menuFor === e.path && actions(e)}
@@ -404,44 +493,88 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
};
return (
<div className="explorer" onKeyDown={onKeyDown}>
<div className={`explorer ${fileDrag ? "file-drag" : ""}`} onKeyDown={onKeyDown} {...zoneProps}>
<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 className="ex-btn" onClick={newDrawing} title="Nouveau schéma">
<FilePlusIcon />
<span>Schéma</span>
</button>
<button className="ex-btn" onClick={newFolder} title="Nouveau dossier">
<FolderPlusIcon />
<span>Dossier</span>
</button>
<button
className="ex-btn"
onClick={() => fileInput.current?.click()}
title="Importer des fichiers .excalidraw (ou glisser-déposer fichiers et dossiers ici)"
>
<UploadIcon />
<span>Importer</span>
</button>
<button
className="ex-btn square"
onClick={() => dirInput.current?.click()}
title="Importer un dossier complet (arborescence recréée, seuls les .excalidraw sont importés)"
aria-label="Importer un dossier"
>
<FolderUploadIcon />
</button>
<input
ref={fileInput}
type="file"
accept=".excalidraw,application/json"
accept=".excalidraw"
multiple
hidden
onChange={(ev) => {
importFiles(ev.target.files);
if (ev.target.files) importTree(collectFiles(ev.target.files), importDir);
ev.target.value = "";
}}
/>
<input
ref={dirInput}
type="file"
multiple
hidden
{...{ webkitdirectory: "" }}
onChange={(ev) => {
if (ev.target.files) importTree(collectFiles(ev.target.files), importDir);
ev.target.value = "";
}}
/>
</div>
<div className="searchbar">
<label className="search">
<SearchIcon />
<input
className="search"
type="search"
placeholder="Rechercher…"
value={query}
onChange={(ev) => setQuery(ev.target.value)}
/>
</label>
<div className="segmented" role="group" aria-label="Affichage">
<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"}
className={`icon-btn ${view === "grid" ? "on" : ""}`}
onClick={() => setView("grid")}
title="Affichage en vignettes"
aria-label="Affichage en vignettes"
aria-pressed={view === "grid"}
>
{view === "grid" ? "☰" : "▦"}
<GridIcon />
</button>
<button
className={`icon-btn ${view === "list" ? "on" : ""}`}
onClick={() => setView("list")}
title="Affichage en liste"
aria-label="Affichage en liste"
aria-pressed={view === "list"}
>
<ListIcon />
</button>
</div>
<button className="icon-btn" onClick={refresh} title="Rafraîchir" aria-label="Rafraîchir">
⟳
<RefreshIcon />
</button>
</div>
@@ -493,13 +626,21 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
{error}
</div>
)}
{busy && <div className="notice busy">{busy}</div>}
{notice && !busy && (
<div className="notice" onClick={() => setNotice(null)} title={notice.detail ? `${notice.detail}\n\n(clic pour masquer)` : "Clic pour masquer"}>
{notice.text}
</div>
)}
<ul className={`entries ${view} ${selected.size > 0 ? "selecting" : ""}`}>
{!results && 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="icon" aria-hidden>
<UpIcon size={view === "grid" ? 32 : 20} />
</span>
<span className="label">..</span>
</button>
</div>
@@ -509,6 +650,14 @@ export function Explorer({ current, refreshKey, onOpen, onMoved, onDeleted }: Pr
{list.length === 0 && <li className="muted empty">{results ? "Aucun résultat" : "Dossier vide"}</li>}
</ul>
{fileDrag && (
<div className="drop-hint" aria-hidden>
<FolderUploadIcon size={28} />
<span>Déposer pour importer dans « {importDir ? basename(importDir) : "Racine"} »</span>
<small>Les dossiers sont recréés · seuls les .excalidraw sont importés</small>
</div>
)}
{moving && (
<MoveDialog
paths={moving}