Interface : explorateur redimensionnable, bouton clair/sombre, titre et logo
- poignée entre l'explorateur et le canevas (souris, tactile, clavier), largeur mémorisée - bouton clair/sombre à droite de la barre, synchronisé avec le menu d'Excalidraw - dialogue Personnaliser : titre et logo PNG affiché en rond - README Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013WSSWgbf7gUzTzEqhjqtzN
This commit was merged in pull request #3.
This commit is contained in:
1 parent
baa412d02e
commit
9ecaab550a
7 files changed
+489
-17
No files matched your search
+109
-15
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Excalidraw,
|
||||
hashElementsVersion,
|
||||
@@ -14,8 +14,11 @@ import type {
|
||||
LibraryItems,
|
||||
} from "@excalidraw/excalidraw/types";
|
||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||
import { api, ApiError, basename, stripExt } from "./api";
|
||||
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 { Splitter } from "./Splitter";
|
||||
import { storeThumb } from "./thumbs";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
@@ -31,16 +34,34 @@ const AUTOSAVE_MS = 1500;
|
||||
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
|
||||
const pathFromHash = () => decodeURIComponent(location.hash.replace(/^#\/?/, "")) || null;
|
||||
|
||||
const initialTheme = (): Theme => {
|
||||
const load = (key: string) => {
|
||||
try {
|
||||
const t = localStorage.getItem("theme");
|
||||
if (t === "light" || t === "dark") return t;
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null; // stockage indisponible
|
||||
}
|
||||
};
|
||||
const store = (key: string, value: string) => {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* stockage indisponible */
|
||||
}
|
||||
};
|
||||
|
||||
const initialTheme = (): Theme => {
|
||||
const t = load("theme");
|
||||
if (t === "light" || t === "dark") return t;
|
||||
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
// Largeur de l'explorateur (bureau) : réglable par la poignée, mémorisée par navigateur.
|
||||
const DRAWER_DEFAULT = 330;
|
||||
const DRAWER_MIN = 220;
|
||||
const drawerMax = () => Math.max(DRAWER_MIN, window.innerWidth - 360); // garde un canevas utilisable
|
||||
const clampDrawer = (w: number) => Math.round(Math.min(Math.max(w, DRAWER_MIN), drawerMax()));
|
||||
const initialDrawerWidth = () => clampDrawer(Number(load("drawerWidth")) || DRAWER_DEFAULT);
|
||||
|
||||
/** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */
|
||||
const sceneVersion = (
|
||||
elements: readonly OrderedExcalidrawElement[],
|
||||
@@ -63,6 +84,10 @@ export default function App() {
|
||||
const [drawerOpen, setDrawerOpen] = useState(() => !isMobile());
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
||||
const [resizing, setResizing] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings>({ title: "", logo: "" });
|
||||
const [customizing, setCustomizing] = useState(false);
|
||||
|
||||
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
|
||||
const pathRef = useRef<string | null>(null); // schéma ouvert (survit aux renommages)
|
||||
@@ -73,6 +98,7 @@ export default function App() {
|
||||
const libTimer = useRef<number>(undefined);
|
||||
const libJson = useRef(""); // dernier état connu côté serveur
|
||||
const sceneSeq = useRef(0);
|
||||
const exTheme = useRef<string | null>(null); // dernier thème signalé par Excalidraw
|
||||
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
|
||||
|
||||
// --- Sauvegarde -----------------------------------------------------------
|
||||
@@ -128,7 +154,13 @@ export default function App() {
|
||||
|
||||
const onChange = useCallback(
|
||||
(elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => {
|
||||
if (appState.theme !== theme) setTheme(appState.theme as Theme);
|
||||
// Bascule depuis le menu d'Excalidraw. On ne réagit qu'à un changement
|
||||
// signalé par Excalidraw : sinon, après un clic sur le bouton de la barre,
|
||||
// un onChange portant encore l'ancien thème le rétablirait (boucle).
|
||||
if (appState.theme !== exTheme.current) {
|
||||
exTheme.current = appState.theme;
|
||||
if (appState.theme !== theme) setTheme(appState.theme as Theme);
|
||||
}
|
||||
if (!pathRef.current || appState.isLoading) return;
|
||||
const version = sceneVersion(elements, appState, files);
|
||||
// Premier onChange après chargement : c'est la version de référence.
|
||||
@@ -251,13 +283,31 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
try {
|
||||
localStorage.setItem("theme", theme);
|
||||
} catch {
|
||||
/* stockage indisponible */
|
||||
}
|
||||
store("theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Largeur mémorisée en fin de glissement ; réajustée si la fenêtre rétrécit.
|
||||
useEffect(() => {
|
||||
if (!resizing) store("drawerWidth", String(drawerWidth));
|
||||
}, [drawerWidth, resizing]);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setDrawerWidth((w) => clampDrawer(w));
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
// Titre et logo personnalisés (partagés, stockés côté serveur).
|
||||
useEffect(() => {
|
||||
api.settings().then(setSettings).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const appTitle = settings.title || DEFAULT_TITLE;
|
||||
const sceneName = scene ? stripExt(basename(scene.path)) : null;
|
||||
useEffect(() => {
|
||||
document.title = sceneName ? `${sceneName} – ${appTitle}` : appTitle;
|
||||
}, [appTitle, sceneName]);
|
||||
|
||||
// --- Synchronisation avec l'explorateur -----------------------------------
|
||||
|
||||
const onMoved = useCallback((from: string, to: string) => {
|
||||
@@ -282,7 +332,10 @@ export default function App() {
|
||||
// --- Rendu -----------------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className={`app ${drawerOpen ? "drawer-open" : ""}`}>
|
||||
<div
|
||||
className={`app ${drawerOpen ? "drawer-open" : ""} ${resizing ? "resizing" : ""} ${scene ? "has-scene" : ""}`}
|
||||
style={{ "--drawer-w": `${drawerWidth}px` } as CSSProperties}
|
||||
>
|
||||
<header className="topbar">
|
||||
<button
|
||||
className="icon-btn"
|
||||
@@ -291,9 +344,20 @@ export default function App() {
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<span className="title" title={scene?.path}>
|
||||
{scene ? stripExt(basename(scene.path)) : "Excalidraw"}
|
||||
</span>
|
||||
<button className="brand" title="Personnaliser le titre et le logo" onClick={() => setCustomizing(true)}>
|
||||
{settings.logo && <img className="logo" src={api.logoUrl(settings.logo)} alt="" />}
|
||||
<span className="brand-title">{appTitle}</span>
|
||||
</button>
|
||||
{scene ? (
|
||||
<span className="title" title={scene.path}>
|
||||
<span className="sep muted" aria-hidden>
|
||||
/
|
||||
</span>
|
||||
{sceneName}
|
||||
</span>
|
||||
) : (
|
||||
<span className="spacer" />
|
||||
)}
|
||||
{scene && (
|
||||
<>
|
||||
<span className={`status ${saveState}`}>{SAVE_LABEL[saveState]}</span>
|
||||
@@ -305,6 +369,24 @@ export default function App() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<div className="topbar-tools">
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label={theme === "dark" ? "Passer en mode clair" : "Passer en mode sombre"}
|
||||
title={theme === "dark" ? "Mode clair" : "Mode sombre"}
|
||||
onClick={() => setTheme((t) => (t === "dark" ? "light" : "dark"))}
|
||||
>
|
||||
{theme === "dark" ? <SunIcon /> : <MoonIcon />}
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn"
|
||||
aria-label="Personnaliser le titre et le logo"
|
||||
title="Personnaliser"
|
||||
onClick={() => setCustomizing(true)}
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside className="drawer">
|
||||
@@ -316,6 +398,14 @@ export default function App() {
|
||||
onDeleted={onDeleted}
|
||||
/>
|
||||
</aside>
|
||||
<Splitter
|
||||
width={drawerWidth}
|
||||
min={DRAWER_MIN}
|
||||
max={drawerMax()}
|
||||
onResize={(w) => setDrawerWidth(clampDrawer(w))}
|
||||
onDragging={setResizing}
|
||||
onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))}
|
||||
/>
|
||||
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
|
||||
|
||||
<main className="canvas">
|
||||
@@ -341,6 +431,10 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{customizing && (
|
||||
<CustomizeDialog settings={settings} onChange={setSettings} onClose={() => setCustomizing(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user