Interface alignée sur Excalidraw, import de dossiers, icône de mise à jour

- Jetons de couleur, rayons, ombres, police (Assistant) et tailles de boutons
  repris d'Excalidraw 0.18, en clair et en sombre.
- Boutons de l'explorateur et de la barre de titre au style du bouton
  « Bibliothèque », icônes au trait (jeu Tabler) à la place des glyphes.
- Glisser-déposer de fichiers et dossiers du système dans l'explorateur ou
  sur un dossier : arborescence recréée, seuls les .excalidraw importés,
  schémas existants conservés, bilan affiché. Bouton « Importer un dossier ».
- Icône dans la barre du haut quand une version d'Excalidraw plus récente que
  celle embarquée (__EXCALIDRAW_VERSION__) est publiée ; un clic donne la
  commande de reconstruction.

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:46:25 +02:00
1 parent 49a816bb12
commit 6810472f09
9 files changed
+386 -34

No files matched your search

+22 -7
View File
@@ -17,8 +17,9 @@ import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/ty
import { api, ApiError, basename, stripExt, type Settings } from "./api";
import { CustomizeDialog, DEFAULT_TITLE } from "./CustomizeDialog";
import { Explorer } from "./Explorer";
import { MoonIcon, SettingsIcon, SunIcon } from "./icons";
import { CloseIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, SunIcon } from "./icons";
import { Splitter } from "./Splitter";
import { UpdateBadge } from "./UpdateBadge";
import { storeThumb } from "./thumbs";
type Theme = "light" | "dark";
@@ -338,11 +339,13 @@ export default function App() {
>
<header className="topbar">
<button
className="icon-btn"
className={`ex-btn square ${drawerOpen ? "active" : ""}`}
aria-label="Afficher/masquer l'explorateur"
aria-pressed={drawerOpen}
title="Explorateur"
onClick={() => setDrawerOpen((o) => !o)}
>
☰
<MenuIcon />
</button>
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
@@ -361,15 +364,27 @@ export default function App() {
{scene && (
<>
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
<button onClick={() => save()} disabled={saveState === "saved" || saveState === "saving"}>
Enregistrer
<button
className="ex-btn"
onClick={() => save()}
disabled={saveState === "saved" || saveState === "saving"}
title="Enregistrer (enregistrement automatique)"
>
<SaveIcon />
<span className="save-label">Enregistrer</span>
</button>
<button className="icon-btn" aria-label="Fermer le schéma" onClick={() => save().then(closeFile)}>
✕
<button
className="icon-btn"
aria-label="Fermer le schéma"
title="Fermer le schéma"
onClick={() => save().then(closeFile)}
>
<CloseIcon />
</button>
</>
)}
<div className="topbar-tools">
<UpdateBadge />
<button
className="icon-btn"
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
+1 -1
View File
@@ -65,7 +65,7 @@ export function CustomizeDialog({ settings, onChange, onClose }: Props) {
<label className="field">
<span>Titre affiché</span>
<input
className="search"
className="text-input"
value={title}
maxLength={MAX_TITLE}
placeholder={DEFAULT_TITLE}
+9 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from "react";
import { api, basename, dirname, join, type Entry } from "./api";
import { FolderIcon, FolderPlusIcon } from "./icons";
interface Props {
/** Éléments à déplacer. */
@@ -72,14 +73,20 @@ export function MoveDialog({ paths, startDir, onCancel, onConfirm }: Props) {
{folders.map((f) => (
<li key={f.path}>
<button disabled={blocked(f.path)} onClick={() => setDir(f.path)}>
<span aria-hidden>📁</span> {f.name}
<span className="folder">
<FolderIcon />
</span>
{f.name}
</button>
</li>
))}
{folders.length === 0 && <li className="muted empty">Aucun sous-dossier</li>}
</ul>
<div className="modal-actions">
<button onClick={newFolder}>+ Dossier</button>
<button onClick={newFolder}>
<FolderPlusIcon />
<span>Dossier</span>
</button>
<span className="spacer" />
<button onClick={onCancel}>Annuler</button>
<button
+99
View File
@@ -0,0 +1,99 @@
import { useEffect, useState } from "react";
import { api } from "./api";
import { CopyIcon, UpdateIcon } from "./icons";
const CURRENT = __EXCALIDRAW_VERSION__;
const POLL_MS = 60 * 60 * 1000; // le serveur, lui, interroge npm toutes les 12 h par défaut
/** Compare deux versions x.y.z (suffixe de pré-version ignoré) : > 0 si a est plus récente. */
export function compareVersions(a: string, b: string): number {
const parse = (v: string) => v.split("-")[0].split(".").map((n) => parseInt(n, 10) || 0);
const [pa, pb] = [parse(a), parse(b)];
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d) return d;
}
return 0;
}
/**
* Icône affichée dans la barre du haut quand une version d'Excalidraw plus récente
* que celle embarquée est publiée ; un clic donne la commande de reconstruction.
*/
export function UpdateBadge() {
const [latest, setLatest] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
useEffect(() => {
const check = () =>
api
.update()
.then((s) => setLatest(s.latest && compareVersions(s.latest, CURRENT) > 0 ? s.latest : null))
.catch(() => {}); // silencieux : vérification facultative
check();
const t = setInterval(check, POLL_MS);
return () => clearInterval(t);
}, []);
if (!latest) return null;
const cmd = `EXCALIDRAW_VERSION=${latest} docker compose up -d --build`;
const copy = () =>
navigator.clipboard?.writeText(cmd).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
return (
<>
<button
className="icon-btn update-btn"
title={`Excalidraw ${latest} disponible (installée : ${CURRENT}) — reconstruire l'image`}
aria-label={`Mise à jour d'Excalidraw disponible : ${latest}`}
onClick={() => setOpen(true)}
>
<UpdateIcon />
</button>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div
className="modal update"
role="dialog"
aria-modal="true"
aria-label="Mise à jour d'Excalidraw"
onClick={(e) => e.stopPropagation()}
>
<h3>Mise à jour d'Excalidraw disponible</h3>
<p>
Version installée : <b>{CURRENT}</b>
<br />
Dernière version publiée : <b>{latest}</b>
</p>
<p>Reconstruire et relancer le conteneur depuis le dossier du docker-compose :</p>
<div className="cmd">
<code>{cmd}</code>
<button className="icon-btn" onClick={copy} title="Copier" aria-label="Copier la commande">
<CopyIcon />
</button>
</div>
<p className="muted small">
{copied
? "Commande copiée."
: "Les versions 0.x d'Excalidraw peuvent changer d'API : tester l'image avant la mise en service. Pour figer la version dans le dépôt, voir « Mettre à jour Excalidraw » dans le README."}
</p>
<div className="modal-actions">
<span className="spacer" />
<a className="link" href={`https://github.com/excalidraw/excalidraw/releases`} target="_blank" rel="noreferrer">
Notes de version
</a>
<button className="primary" onClick={() => setOpen(false)}>
Fermer
</button>
</div>
</div>
</div>
)}
</>
);
}
+9
View File
@@ -20,6 +20,13 @@ export interface Settings {
logo: string;
}
/** Dernière version d'Excalidraw publiée sur npm (vide si inconnue ou désactivé). */
export interface UpdateStatus {
enabled: boolean;
latest?: string;
checkedAt?: string;
}
export class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
@@ -48,6 +55,8 @@ const json = (body: unknown): RequestInit => ({
});
export const api = {
update: async (): Promise<UpdateStatus> => (await request("/api/update")).json(),
list: async (dir: string): Promise<Entry[]> =>
(await request(`/api/list?path=${q(dir)}`)).json(),
+141 -23
View File
@@ -1,33 +1,151 @@
// Icônes de la barre de titre (traits, couleur héritée).
// Icônes au trait, dans le style d'Excalidraw (jeu Tabler, trait fin, couleur héritée).
import type { ReactNode } from "react";
const svg = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
const icon = (paths: ReactNode) =>
function Icon({ size = 16 }: { size?: number }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
{paths}
</svg>
);
};
export const SunIcon = () => (
<svg {...svg}>
export const SunIcon = icon(
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
</svg>
</>,
);
export const MoonIcon = () => (
<svg {...svg}>
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
export const MoonIcon = icon(<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9z" />);
export const SettingsIcon = icon(
<>
<path d="M4 20h4L18.5 9.5a2.83 2.83 0 0 0-4-4L4 16v4" />
<path d="M13.5 6.5l4 4" />
</>,
);
export const SettingsIcon = () => (
<svg {...svg}>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
</svg>
export const MenuIcon = icon(<path d="M4 6h16M4 12h16M4 18h16" />);
export const CloseIcon = icon(<path d="M18 6 6 18M6 6l12 12" />);
export const FilePlusIcon = icon(
<>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" />
<path d="M12 11v6M9 14h6" />
</>,
);
export const FolderPlusIcon = icon(
<>
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3.5" />
<path d="M16 19h6M19 16v6" />
</>,
);
export const UploadIcon = icon(
<>
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
<path d="M7 9l5-5 5 5M12 4v12" />
</>,
);
export const FolderUploadIcon = icon(
<>
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3" />
<path d="M19 22v-6M22 19l-3-3-3 3" />
</>,
);
export const FolderIcon = icon(
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />,
);
export const UpIcon = icon(<path d="M18 18h-6a3 3 0 0 1-3-3V5M13 10 9 6 5 10" />);
export const SearchIcon = icon(
<>
<circle cx="10" cy="10" r="7" />
<path d="m21 21-6-6" />
</>,
);
export const ListIcon = icon(<path d="M9 6h11M9 12h11M9 18h11M5 6v.01M5 12v.01M5 18v.01" />);
export const GridIcon = icon(
<>
<rect x="4" y="4" width="6" height="6" rx="1" />
<rect x="14" y="4" width="6" height="6" rx="1" />
<rect x="4" y="14" width="6" height="6" rx="1" />
<rect x="14" y="14" width="6" height="6" rx="1" />
</>,
);
export const RefreshIcon = icon(
<>
<path d="M20 11A8.1 8.1 0 0 0 4.5 9M4 5v4h4" />
<path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4" />
</>,
);
export const DotsIcon = icon(
<>
<circle cx="5" cy="12" r="1" />
<circle cx="12" cy="12" r="1" />
<circle cx="19" cy="12" r="1" />
</>,
);
export const TrashIcon = icon(
<path d="M4 7h16M10 11v6M14 11v6M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2l1-12M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />,
);
export const SelectAllIcon = icon(
<>
<rect x="4" y="4" width="16" height="16" rx="2" />
<path d="m9 12 2 2 4-4" />
</>,
);
export const DeselectIcon = icon(<rect x="4" y="4" width="16" height="16" rx="2" />);
export const MoveIcon = icon(
<>
<path d="M12 19H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h4l3 3h7a2 2 0 0 1 2 2v3" />
<path d="M16 19h6M19 16l3 3-3 3" />
</>,
);
export const SaveIcon = icon(
<>
<path d="M6 4h10l4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2" />
<circle cx="12" cy="14" r="2" />
<path d="M14 4v4H8V4" />
</>,
);
export const UpdateIcon = icon(
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 16V8M8.5 11.5 12 8l3.5 3.5" />
</>,
);
export const CopyIcon = icon(
<>
<rect x="8" y="8" width="12" height="12" rx="2" />
<path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2" />
</>,
);
+91
View File
@@ -0,0 +1,91 @@
// Import de fichiers et d'arborescences (glisser-déposer ou sélecteur de dossier).
export interface PickedFile {
/** Chemin relatif au point d'import, dossiers compris (« Archi/réseau/x.excalidraw »). */
rel: string;
file: File;
}
export interface Collected {
files: PickedFile[];
/** Dossiers à recréer (y compris vides), parents avant enfants. */
dirs: string[];
/** Fichiers ignorés : pas des schémas Excalidraw. */
ignored: string[];
}
export const isScene = (name: string) => /\.excalidraw$/i.test(name);
// Fichiers et dossiers cachés (.DS_Store, .git…) : ignorés sans être signalés.
const hidden = (name: string) => name.startsWith(".");
/** Le glisser contient-il des fichiers venant du système ? */
export const hasFiles = (dt: DataTransfer) => dt.types.includes("Files");
const readAll = (reader: FileSystemDirectoryReader) =>
new Promise<FileSystemEntry[]>((resolve, reject) => {
const out: FileSystemEntry[] = [];
// readEntries rend les entrées par lots (100 sous Chrome) : boucler jusqu'au lot vide.
const next = () =>
reader.readEntries((batch) => {
if (batch.length === 0) return resolve(out);
out.push(...batch);
next();
}, reject);
next();
});
const fileOf = (entry: FileSystemFileEntry) => new Promise<File>((resolve, reject) => entry.file(resolve, reject));
async function walk(entry: FileSystemEntry, prefix: string, acc: Collected) {
if (hidden(entry.name)) return;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory) {
acc.dirs.push(rel);
for (const child of await readAll((entry as FileSystemDirectoryEntry).createReader())) {
await walk(child, rel, acc);
}
} else if (isScene(entry.name)) {
acc.files.push({ rel, file: await fileOf(entry as FileSystemFileEntry) });
} else {
acc.ignored.push(rel);
}
}
/**
* Lit un dépôt (fichiers et dossiers). Les entrées doivent être capturées pendant
* l'événement drop : le DataTransfer est vidé dès que le gestionnaire rend la main.
*/
export function collectDrop(dt: DataTransfer): Promise<Collected> {
const entries = Array.from(dt.items)
.filter((i) => i.kind === "file")
.map((i) => i.webkitGetAsEntry?.())
.filter((e): e is FileSystemEntry => !!e);
const plain = entries.length ? [] : Array.from(dt.files); // navigateur sans webkitGetAsEntry
return (async () => {
const acc: Collected = { files: [], dirs: [], ignored: [] };
for (const e of entries) await walk(e, "", acc);
collectInto(plain, acc);
return acc;
})();
}
/** Fichiers d'un <input type="file" webkitdirectory> (chemins dans webkitRelativePath). */
export function collectFiles(files: FileList | File[]): Collected {
const acc: Collected = { files: [], dirs: [], ignored: [] };
collectInto(Array.from(files), acc);
return acc;
}
function collectInto(files: File[], acc: Collected) {
const dirs = new Set(acc.dirs);
for (const file of files) {
const rel = file.webkitRelativePath || file.name;
const parts = rel.split("/");
if (parts.some(hidden)) continue;
for (let i = 1; i < parts.length; i++) dirs.add(parts.slice(0, i).join("/"));
if (isScene(file.name)) acc.files.push({ rel, file });
else acc.ignored.push(rel);
}
acc.dirs = [...dirs].sort((a, b) => a.split("/").length - b.split("/").length);
}
+3
View File
@@ -3,3 +3,6 @@
interface Window {
EXCALIDRAW_ASSET_PATH?: string | string[];
}
/** Version de @excalidraw/excalidraw embarquée au build (voir vite.config.ts). */
declare const __EXCALIDRAW_VERSION__: string;
+11 -1
View File
@@ -1,5 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { readFileSync } from "node:fs";
// Version d'Excalidraw réellement installée (et non la plage de package.json),
// comparée par l'interface à la dernière version publiée sur npm.
const excalidrawVersion: string = JSON.parse(
readFileSync(new URL("./node_modules/@excalidraw/excalidraw/package.json", import.meta.url), "utf8"),
).version;
export default defineConfig({
plugins: [react()],
@@ -7,5 +14,8 @@ export default defineConfig({
build: { outDir: "../web/dist", emptyOutDir: true, chunkSizeWarningLimit: 4096 },
// `npm run dev` : l'API Go tourne sur :8080.
server: { proxy: { "/api": "http://localhost:8080" } },
define: { "process.env.IS_PREACT": JSON.stringify("false") },
define: {
"process.env.IS_PREACT": JSON.stringify("false"),
__EXCALIDRAW_VERSION__: JSON.stringify(excalidrawVersion),
},
});