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:
1 parent
d04350df7a
commit
2da861b81d
2 files changed
+314
-91
No files matched your search
+292
-86
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user