From c7a43ff12baa70825593ce3c2bf8a01205c5e7ef Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 8 Oct 2026 09:06:46 +0200 Subject: [PATCH] =?UTF-8?q?Explorateur=20=C3=A9pinglable=20:=20=C3=A9tat,?= =?UTF-8?q?=20survol=20du=20bord=20gauche=20et=20compensation=20du=20d?= =?UTF-8?q?=C3=A9filement=20(App.tsx)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Suite du commit précédent. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XR9aSZwC3Ssajfy1R7SLPo --- frontend/src/App.tsx | 111 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 7 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b126166..32e712e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react"; +import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { Excalidraw, hashElementsVersion, @@ -16,7 +16,7 @@ import type { import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; import { api, ApiError, basename, stripExt, type Settings } from "./api"; import { Explorer } from "./Explorer"; -import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons"; +import { CloseIcon, HistoryIcon, MenuIcon, MoonIcon, PinIcon, SaveIcon, SettingsIcon, ShareIcon, SunIcon, UpdateIcon } from "./icons"; import { ShareDialog } from "./ShareDialog"; import { HistoryDialog } from "./HistoryDialog"; import { Splitter } from "./Splitter"; @@ -72,6 +72,11 @@ const drawerMax = () => Math.max(DRAWER_MIN, window.innerWidth - 360); // garde const clampDrawer = (w: number) => Math.round(Math.min(Math.max(w, DRAWER_MIN), drawerMax())); const initialDrawerWidth = () => clampDrawer(Number(load("drawerWidth")) || DRAWER_DEFAULT); +// Explorateur épinglé (colonne fixe) ou non (masqué, s'ouvre par-dessus le canevas). +const initialPinned = () => load("drawerPinned") !== "false"; +const PEEK_OPEN_MS = 150; // survol du bord gauche avant ouverture +const PEEK_CLOSE_MS = 400; // sortie du panneau avant fermeture (ouverture par survol) + /** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */ const sceneVersion = ( elements: readonly OrderedExcalidrawElement[], @@ -91,7 +96,10 @@ export default function App() { const [library, setLibrary] = useState(null); const [scene, setScene] = useState(null); const [saveState, setSaveState] = useState("saved"); - const [drawerOpen, setDrawerOpen] = useState(() => !isMobile()); + const [pinned, setPinned] = useState(initialPinned); + // Non épinglé : ouvert d'office tant qu'aucun schéma n'est demandé. + const [drawerOpen, setDrawerOpen] = useState(() => !isMobile() && (pinned || !pathFromHash())); + const [instant, setInstant] = useState(false); // bascule sans animation de la grille const [refreshKey, setRefreshKey] = useState(0); const [loadError, setLoadError] = useState(null); const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth); @@ -116,6 +124,11 @@ export default function App() { const fitPending = useRef(false); // cadrage à faire au premier rendu du schéma ouvert const exTheme = useRef(null); // dernier thème signalé par Excalidraw const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise>(async () => {}); + const pinnedRef = useRef(pinned); + pinnedRef.current = pinned; + const peek = useRef<"hover" | "click" | null>(null); // comment le panneau non épinglé a été ouvert + const peekTimer = useRef(undefined); + const scrollShift = useRef(0); // décalage du canevas à compenser après une bascule épinglé/non épinglé // --- Sauvegarde ----------------------------------------------------------- @@ -227,7 +240,7 @@ export default function App() { setSaveState("saved"); if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`); store("lastScene", path); - if (isMobile()) setDrawerOpen(false); + if (isMobile() || !pinnedRef.current) setDrawerOpen(false); } catch (e) { setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`); } @@ -241,6 +254,11 @@ export default function App() { excalidraw.current = null; setScene(null); history.replaceState(null, "", location.pathname); + // Non épinglé : l'explorateur revient pour choisir le schéma suivant. + if (!pinnedRef.current && !isMobile()) { + peek.current = "click"; + setDrawerOpen(true); + } }, []); openRef.current = openFile; @@ -384,6 +402,63 @@ export default function App() { return () => window.removeEventListener("resize", onResize); }, []); + // --- Explorateur épinglé ou flottant ------------------------------------------ + + useEffect(() => { + store("drawerPinned", String(pinned)); + }, [pinned]); + + const togglePin = () => { + const ex = excalidraw.current; + // Panneau visible : le bord gauche du canevas se déplace de sa largeur. Le + // défilement est compensé pour que le schéma reste en place à l'écran. + if (ex && drawerOpen && !isMobile()) scrollShift.current = pinned ? -drawerWidth : drawerWidth; + peek.current = "hover"; // désépinglé sous le pointeur : se referme quand il en sort + setInstant(true); + setPinned((p) => !p); + }; + + useLayoutEffect(() => { + const shift = scrollShift.current; + const ex = excalidraw.current; + scrollShift.current = 0; + if (shift && ex) { + const { scrollX, zoom } = ex.getAppState(); + ex.updateScene({ appState: { scrollX: scrollX - shift / zoom.value } }); + } + if (!instant) return; + const id = requestAnimationFrame(() => setInstant(false)); + return () => cancelAnimationFrame(id); + }, [pinned, instant]); + + const floating = !pinned && drawerOpen; + + const peekOpen = () => { + clearTimeout(peekTimer.current); + peekTimer.current = window.setTimeout(() => { + peek.current = "hover"; + setDrawerOpen(true); + }, PEEK_OPEN_MS); + }; + const peekCancel = () => clearTimeout(peekTimer.current); + const peekLeave = () => { + if (pinnedRef.current || peek.current !== "hover" || isMobile()) return; + clearTimeout(peekTimer.current); + peekTimer.current = window.setTimeout(() => setDrawerOpen(false), PEEK_CLOSE_MS); + }; + useEffect(() => () => clearTimeout(peekTimer.current), []); + + // Échap referme le panneau flottant (sauf si un dialogue ou la sélection l'utilise). + useEffect(() => { + if (!floating) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== "Escape" || e.defaultPrevented || document.querySelector(".modal-backdrop")) return; + setDrawerOpen(false); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [floating]); + // Titre et logo personnalisés (partagés, stockés côté serveur). useEffect(() => { api.settings().then(setSettings).catch(console.error); @@ -442,7 +517,7 @@ export default function App() { return (
@@ -451,7 +526,11 @@ export default function App() { aria-label="Afficher/masquer l'explorateur" aria-pressed={drawerOpen} title="Explorateur" - onClick={() => setDrawerOpen((o) => !o)} + onClick={() => { + clearTimeout(peekTimer.current); + peek.current = "click"; + setDrawerOpen((o) => !o); + }} > @@ -538,7 +617,7 @@ export default function App() {
-