Explorateur épinglable : état, survol du bord gauche et compensation du défilement (App.tsx)
Suite du commit précédent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XR9aSZwC3Ssajfy1R7SLPo
This commit is contained in:
1 parent
a5fddba9a0
commit
c7a43ff12b
1 file changed
+104
-7
+104
-7
@@ -1,4 +1,4 @@
|
|||||||
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
|
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Excalidraw,
|
Excalidraw,
|
||||||
hashElementsVersion,
|
hashElementsVersion,
|
||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
|
||||||
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
import { api, ApiError, basename, stripExt, type Settings } from "./api";
|
||||||
import { Explorer } from "./Explorer";
|
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 { ShareDialog } from "./ShareDialog";
|
||||||
import { HistoryDialog } from "./HistoryDialog";
|
import { HistoryDialog } from "./HistoryDialog";
|
||||||
import { Splitter } from "./Splitter";
|
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 clampDrawer = (w: number) => Math.round(Math.min(Math.max(w, DRAWER_MIN), drawerMax()));
|
||||||
const initialDrawerWidth = () => clampDrawer(Number(load("drawerWidth")) || DRAWER_DEFAULT);
|
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...). */
|
/** Empreinte du contenu « persistant » (hors scroll, sélection, zoom...). */
|
||||||
const sceneVersion = (
|
const sceneVersion = (
|
||||||
elements: readonly OrderedExcalidrawElement[],
|
elements: readonly OrderedExcalidrawElement[],
|
||||||
@@ -91,7 +96,10 @@ export default function App() {
|
|||||||
const [library, setLibrary] = useState<LibraryItems | null>(null);
|
const [library, setLibrary] = useState<LibraryItems | null>(null);
|
||||||
const [scene, setScene] = useState<Scene | null>(null);
|
const [scene, setScene] = useState<Scene | null>(null);
|
||||||
const [saveState, setSaveState] = useState<SaveState>("saved");
|
const [saveState, setSaveState] = useState<SaveState>("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 [refreshKey, setRefreshKey] = useState(0);
|
||||||
const [loadError, setLoadError] = useState<string | null>(null);
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [drawerWidth, setDrawerWidth] = useState(initialDrawerWidth);
|
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 fitPending = useRef(false); // cadrage à faire au premier rendu du schéma ouvert
|
||||||
const exTheme = useRef<string | null>(null); // dernier thème signalé par Excalidraw
|
const exTheme = useRef<string | null>(null); // dernier thème signalé par Excalidraw
|
||||||
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(async () => {});
|
const openRef = useRef<(path: string, opts?: { skipFlush?: boolean }) => Promise<void>>(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<number>(undefined);
|
||||||
|
const scrollShift = useRef(0); // décalage du canevas à compenser après une bascule épinglé/non épinglé
|
||||||
|
|
||||||
// --- Sauvegarde -----------------------------------------------------------
|
// --- Sauvegarde -----------------------------------------------------------
|
||||||
|
|
||||||
@@ -227,7 +240,7 @@ export default function App() {
|
|||||||
setSaveState("saved");
|
setSaveState("saved");
|
||||||
if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`);
|
if (location.hash !== `#/${path}`) history.replaceState(null, "", `#/${encodeURI(path)}`);
|
||||||
store("lastScene", path);
|
store("lastScene", path);
|
||||||
if (isMobile()) setDrawerOpen(false);
|
if (isMobile() || !pinnedRef.current) setDrawerOpen(false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`);
|
setLoadError(`Impossible d'ouvrir « ${path} » : ${e instanceof Error ? e.message : e}`);
|
||||||
}
|
}
|
||||||
@@ -241,6 +254,11 @@ export default function App() {
|
|||||||
excalidraw.current = null;
|
excalidraw.current = null;
|
||||||
setScene(null);
|
setScene(null);
|
||||||
history.replaceState(null, "", location.pathname);
|
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;
|
openRef.current = openFile;
|
||||||
@@ -384,6 +402,63 @@ export default function App() {
|
|||||||
return () => window.removeEventListener("resize", onResize);
|
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).
|
// Titre et logo personnalisés (partagés, stockés côté serveur).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.settings().then(setSettings).catch(console.error);
|
api.settings().then(setSettings).catch(console.error);
|
||||||
@@ -442,7 +517,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`app ${drawerOpen ? "drawer-open" : ""} ${resizing ? "resizing" : ""} ${scene ? "has-scene" : ""}`}
|
className={`app ${drawerOpen ? "drawer-open" : ""} ${pinned ? "pinned" : "unpinned"} ${resizing ? "resizing" : ""} ${instant ? "instant" : ""} ${scene ? "has-scene" : ""}`}
|
||||||
style={{ "--drawer-w": `${drawerWidth}px` } as CSSProperties}
|
style={{ "--drawer-w": `${drawerWidth}px` } as CSSProperties}
|
||||||
>
|
>
|
||||||
<header className="topbar">
|
<header className="topbar">
|
||||||
@@ -451,7 +526,11 @@ export default function App() {
|
|||||||
aria-label="Afficher/masquer l'explorateur"
|
aria-label="Afficher/masquer l'explorateur"
|
||||||
aria-pressed={drawerOpen}
|
aria-pressed={drawerOpen}
|
||||||
title="Explorateur"
|
title="Explorateur"
|
||||||
onClick={() => setDrawerOpen((o) => !o)}
|
onClick={() => {
|
||||||
|
clearTimeout(peekTimer.current);
|
||||||
|
peek.current = "click";
|
||||||
|
setDrawerOpen((o) => !o);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuIcon />
|
<MenuIcon />
|
||||||
</button>
|
</button>
|
||||||
@@ -538,7 +617,7 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<aside className="drawer">
|
<aside className="drawer" onMouseEnter={peekCancel} onMouseLeave={peekLeave}>
|
||||||
<Explorer
|
<Explorer
|
||||||
current={scene?.path ?? null}
|
current={scene?.path ?? null}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
@@ -547,6 +626,21 @@ export default function App() {
|
|||||||
onDeleted={onDeleted}
|
onDeleted={onDeleted}
|
||||||
onHistory={showHistory}
|
onHistory={showHistory}
|
||||||
onShare={setShareFor}
|
onShare={setShareFor}
|
||||||
|
toolbarEnd={
|
||||||
|
<button
|
||||||
|
className={`icon-btn pin-btn ${pinned ? "on" : ""}`}
|
||||||
|
aria-pressed={pinned}
|
||||||
|
aria-label={pinned ? "Désépingler l'explorateur" : "Épingler l'explorateur"}
|
||||||
|
title={
|
||||||
|
pinned
|
||||||
|
? "Désépingler : l'explorateur se masque et s'ouvre par-dessus le schéma (bouton ☰ ou survol du bord gauche)"
|
||||||
|
: "Épingler l'explorateur à côté du schéma"
|
||||||
|
}
|
||||||
|
onClick={togglePin}
|
||||||
|
>
|
||||||
|
<PinIcon />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
<Splitter
|
<Splitter
|
||||||
@@ -558,6 +652,9 @@ export default function App() {
|
|||||||
onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))}
|
onReset={() => setDrawerWidth(clampDrawer(DRAWER_DEFAULT))}
|
||||||
/>
|
/>
|
||||||
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
|
<div className="backdrop" onClick={() => setDrawerOpen(false)} />
|
||||||
|
{!pinned && !drawerOpen && (
|
||||||
|
<div className="peek-edge" aria-hidden onMouseEnter={peekOpen} onMouseLeave={peekCancel} />
|
||||||
|
)}
|
||||||
|
|
||||||
<main className="canvas">
|
<main className="canvas">
|
||||||
{loadError && <div className="error">{loadError}</div>}
|
{loadError && <div className="error">{loadError}</div>}
|
||||||
|
|||||||
Reference in new issue
Block a user