Ouverture d'un schéma : cadrage sur l'ensemble du contenu

initialData.scrollToContent centre un seul élément quand le schéma déborde
de la fenêtre (calculateScrollCenter → getClosestElementBounds) : le contenu
apparaissait à partir du milieu de l'écran. On cadre désormais tout le
schéma au premier rendu via scrollToContent(fitToContent), zoom plafonné à
100 %, dans l'éditeur comme dans la vue partagée.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ATA96SLvdmiapoLXgenYrR
This commit is contained in:
claude BotandClaude Opus 5.5 committed 2026-10-08 07:59:57 +02:00
1 parent 85946ab3a2
commit af53e30e33
3 files changed
+49 -4

No files matched your search

+7 -1
View File
@@ -23,6 +23,7 @@ import { Splitter } from "./Splitter";
import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog"; import { SettingsDialog, DEFAULT_TITLE } from "./SettingsDialog";
import { pendingVersion, useUpdateStatus } from "./update"; import { pendingVersion, useUpdateStatus } from "./update";
import { storeThumb } from "./thumbs"; import { storeThumb } from "./thumbs";
import { fitScene } from "./fit";
type Theme = "light" | "dark"; type Theme = "light" | "dark";
type SaveState = "saved" | "dirty" | "saving" | "error"; type SaveState = "saved" | "dirty" | "saving" | "error";
@@ -105,6 +106,7 @@ export default function App() {
const libTimer = useRef<number>(undefined); const libTimer = useRef<number>(undefined);
const libJson = useRef(""); // dernier état connu côté serveur const libJson = useRef(""); // dernier état connu côté serveur
const sceneSeq = useRef(0); const sceneSeq = useRef(0);
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 () => {});
@@ -169,6 +171,10 @@ export default function App() {
if (appState.theme !== theme) setTheme(appState.theme as Theme); if (appState.theme !== theme) setTheme(appState.theme as Theme);
} }
if (!pathRef.current || appState.isLoading) return; if (!pathRef.current || appState.isLoading) return;
if (fitPending.current && excalidraw.current) {
fitPending.current = false;
fitScene(excalidraw.current);
}
const version = sceneVersion(elements, appState, files); const version = sceneVersion(elements, appState, files);
// Premier onChange après chargement : c'est la version de référence. // Premier onChange après chargement : c'est la version de référence.
if (savedVersion.current === "") { if (savedVersion.current === "") {
@@ -195,6 +201,7 @@ export default function App() {
etagRef.current = etag; etagRef.current = etag;
savedVersion.current = ""; // fixée au premier onChange (voir onChange) savedVersion.current = ""; // fixée au premier onChange (voir onChange)
pathRef.current = path; pathRef.current = path;
fitPending.current = true;
setScene({ setScene({
id: ++sceneSeq.current, id: ++sceneSeq.current,
path, path,
@@ -202,7 +209,6 @@ export default function App() {
elements: data.elements ?? [], elements: data.elements ?? [],
appState: { ...(data.appState ?? {}), theme }, appState: { ...(data.appState ?? {}), theme },
files: data.files ?? {}, files: data.files ?? {},
scrollToContent: true,
}, },
}); });
setSaveState("saved"); setSaveState("saved");
+20 -3
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Excalidraw } from "@excalidraw/excalidraw"; import { Excalidraw } from "@excalidraw/excalidraw";
import type { ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types"; import type { AppState, ExcalidrawImperativeAPI, ExcalidrawInitialDataState } from "@excalidraw/excalidraw/types";
import { api, type PublicScene } from "./api"; import { api, type PublicScene } from "./api";
import { DEFAULT_TITLE } from "./SettingsDialog"; import { DEFAULT_TITLE } from "./SettingsDialog";
import { fitScene } from "./fit";
import { DownloadIcon, EyeIcon, MoonIcon, SunIcon } from "./icons"; import { DownloadIcon, EyeIcon, MoonIcon, SunIcon } from "./icons";
type Theme = "light" | "dark"; type Theme = "light" | "dark";
@@ -22,6 +23,21 @@ export function ShareView({ token }: { token: string }) {
const [data, setData] = useState<PublicScene | null>(null); const [data, setData] = useState<PublicScene | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [theme, setTheme] = useState<Theme>(initialTheme); const [theme, setTheme] = useState<Theme>(initialTheme);
const excalidraw = useRef<ExcalidrawImperativeAPI | null>(null);
const fitted = useRef(false);
const onApi = useCallback((ex: ExcalidrawImperativeAPI | null) => {
excalidraw.current = ex;
}, []);
// excalidrawAPI jusqu'à la 0.18, onExcalidrawAPI sur le canal next.
const apiProps = { excalidrawAPI: onApi, onExcalidrawAPI: onApi } as object;
// Premier rendu : cadrage sur l'ensemble du schéma.
const onChange = useCallback((_elements: unknown, appState: AppState) => {
if (appState.isLoading || fitted.current || !excalidraw.current) return;
fitted.current = true;
fitScene(excalidraw.current);
}, []);
useEffect(() => { useEffect(() => {
api api
@@ -51,7 +67,6 @@ export function ShareView({ token }: { token: string }) {
elements: (data.scene.elements ?? []) as ExcalidrawInitialDataState["elements"], elements: (data.scene.elements ?? []) as ExcalidrawInitialDataState["elements"],
appState: { ...(data.scene.appState ?? {}), theme } as ExcalidrawInitialDataState["appState"], appState: { ...(data.scene.appState ?? {}), theme } as ExcalidrawInitialDataState["appState"],
files: (data.scene.files ?? {}) as ExcalidrawInitialDataState["files"], files: (data.scene.files ?? {}) as ExcalidrawInitialDataState["files"],
scrollToContent: true,
}; };
return ( return (
@@ -96,6 +111,8 @@ export function ShareView({ token }: { token: string }) {
<Excalidraw <Excalidraw
initialData={initialData} initialData={initialData}
viewModeEnabled viewModeEnabled
{...apiProps}
onChange={onChange}
theme={theme} theme={theme}
langCode="fr-FR" langCode="fr-FR"
name={data!.name} name={data!.name}
+22
View File
@@ -0,0 +1,22 @@
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
const isMobile = () => window.matchMedia("(max-width: 768px)").matches;
/**
* Cadre tout le schéma dans la zone visible, sans zoomer au-delà de 100 %.
*
* Remplace `initialData.scrollToContent` : quand le contenu déborde de la
* fenêtre, Excalidraw centre alors un seul élément (le plus proche de l'origine)
* au lieu de l'ensemble, et le schéma s'affiche à partir du milieu de l'écran.
*/
export function fitScene(ex: ExcalidrawImperativeAPI) {
const elements = ex.getSceneElements();
if (elements.length === 0) return;
ex.scrollToContent(elements, {
fitToContent: true,
viewportZoomFactor: 0.9,
animate: false,
// Barres d'outils d'Excalidraw posées sur le canevas (en haut, et en bas sur mobile).
canvasOffsets: { top: 64, bottom: isMobile() ? 64 : 0 },
});
}