Skip to content
File

Blob: src/client/components/canvas/canvas-surface.tsx

typescript293 lines
1import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
2import "@excalidraw/excalidraw/index.css";
3import { Excalidraw, MainMenu } from "@excalidraw/excalidraw";
4import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types";
5import type {
6 AppState,
7 BinaryFiles,
8 Collaborator,
9 ExcalidrawImperativeAPI,
10 SocketId,
11} from "@excalidraw/excalidraw/types";
12import { Maximize2, Minimize2 } from "lucide-react";
13import { useCollabIdentity } from "@/client/lib/collab-identity";
14import { ExcalidrawBinding, type ExcalidrawBindingDeps } from "./excalidraw-binding";
15import type { CanvasSessionReadyState } from "./use-canvas-session";
16import "./styles/canvas-theme.css";
17 
18// Point Excalidraw at same-origin `/fonts/*` (copied by the
19// `excalidraw-fonts-copy` Vite plugin into `public/fonts/`). Must be set
20// before the Excalidraw bundle resolves any @font-face URL.
21declare global {
22 interface Window {
23 EXCALIDRAW_ASSET_PATH?: string;
24 }
25}
26if (typeof window !== "undefined" && !window.EXCALIDRAW_ASSET_PATH) {
27 window.EXCALIDRAW_ASSET_PATH = "/";
28}
29 
30/** SHA-256 hex of a file's bytes. Passed to Excalidraw's `generateIdForFile`
31 * so the same image dropped twice deduplicates on fileId naturally (and our
32 * yFileRefs mapping gets a stable key). Excalidraw's default is a random
33 * nanoid, which is per-drop and wouldn't dedupe. */
34async function generateIdForFile(file: File): Promise<string> {
35 const buf = await file.arrayBuffer();
36 const digest = await crypto.subtle.digest("SHA-256", buf);
37 return Array.from(new Uint8Array(digest))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
40}
41 
42export interface CanvasSurfaceProps {
43 session: CanvasSessionReadyState;
44 canEdit: boolean;
45 canInsertImages: boolean;
46 pageId: string;
47 workspaceId: string | undefined;
48 shareToken: string | undefined;
49}
50 
51export function CanvasSurface({
52 session,
53 canEdit,
54 canInsertImages,
55 pageId,
56 workspaceId,
57 shareToken,
58}: CanvasSurfaceProps) {
59 const { userId, resolveIdentity } = useCollabIdentity();
60 const { ydoc, yElements, yAppState, yFileRefs, provider } = session;
61 const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
62 const [loadedApi, setLoadedApi] = useState<ExcalidrawImperativeAPI | null>(null);
63 const [collaborators, setCollaborators] = useState<Map<SocketId, Collaborator>>(() => new Map());
64 const [expanded, setExpanded] = useState(false);
65 const bindingRef = useRef<ExcalidrawBinding | null>(null);
66 
67 // Binding deps live in a ref so the binding reads current values on
68 // each event without being re-created when any of them change. A
69 // rebuild would cycle awareness (peers see us disconnect + rejoin) and
70 // re-hydrate every image from R2. `resolveIdentity` in particular
71 // churns on every member add/remove via its memo dependency on the
72 // workspace members array.
73 const depsRef = useRef<ExcalidrawBindingDeps>({
74 workspaceId,
75 shareToken,
76 pageId,
77 canInsertImages,
78 userId,
79 resolveIdentity,
80 });
81 useLayoutEffect(() => {
82 depsRef.current = { workspaceId, shareToken, pageId, canInsertImages, userId, resolveIdentity };
83 }, [canInsertImages, pageId, resolveIdentity, shareToken, userId, workspaceId]);
84 
85 // Escape collapses the expanded overlay. Listener is gated on `expanded`
86 // so we don't leak a global keydown handler in the default state.
87 useEffect(() => {
88 if (!expanded) return;
89 const handler = (e: KeyboardEvent) => {
90 if (e.key === "Escape") setExpanded(false);
91 };
92 window.addEventListener("keydown", handler);
93 return () => window.removeEventListener("keydown", handler);
94 }, [expanded]);
95 
96 useEffect(() => {
97 if (!api) return;
98 
99 // Excalidraw flips `isLoading` false after initial asset/font load.
100 // Poll lightly on a 50ms timeout — per-frame rAF is wasted work
101 // because isLoading doesn't change per frame.
102 let cancelled = false;
103 let timer: number | null = null;
104 const check = () => {
105 if (cancelled) return;
106 if (!api.getAppState().isLoading) {
107 setLoadedApi(api);
108 return;
109 }
110 setLoadedApi((current) => (current === api ? null : current));
111 timer = window.setTimeout(check, 50);
112 };
113 timer = window.setTimeout(check, 0);
114 return () => {
115 cancelled = true;
116 if (timer !== null) window.clearTimeout(timer);
117 };
118 }, [api]);
119 
120 const readyApi = loadedApi === api ? loadedApi : null;
121 
122 useEffect(() => {
123 if (!readyApi) return;
124 const binding = new ExcalidrawBinding(readyApi, ydoc, yElements, yAppState, yFileRefs, provider.awareness, {
125 onAppStateFromRemote: (partial) => {
126 const current = readyApi.getAppState();
127 readyApi.updateScene({ appState: { ...current, ...partial } });
128 },
129 onCollaboratorsChange: (next) => setCollaborators(next),
130 getDeps: () => depsRef.current,
131 });
132 bindingRef.current = binding;
133 // Test-only window hooks let Playwright assert on scene state without
134 // reading canvas pixels. Gated on DEV so they're stripped from prod
135 // bundles via Vite's dead-code elimination — the bland E2E harness
136 // runs its own dev server (tests/e2e/harness.ts) so DEV === true there.
137 type E2ECanvasWindow = Window & {
138 __E2E_CANVAS_SCENE_COUNT__?: () => number;
139 __E2E_CANVAS_COLLABORATORS__?: () => Array<{ id: string; pointer?: unknown }>;
140 };
141 if (import.meta.env.DEV && typeof window !== "undefined") {
142 const w = window as E2ECanvasWindow;
143 w.__E2E_CANVAS_SCENE_COUNT__ = () =>
144 readyApi.getSceneElementsIncludingDeleted().filter((el) => !el.isDeleted).length;
145 w.__E2E_CANVAS_COLLABORATORS__ = () => {
146 const map = readyApi.getAppState().collaborators;
147 const out: Array<{ id: string; pointer?: unknown }> = [];
148 map.forEach((c) => out.push({ id: c.id ?? "", pointer: c.pointer }));
149 return out;
150 };
151 }
152 return () => {
153 binding.destroy();
154 bindingRef.current = null;
155 if (import.meta.env.DEV && typeof window !== "undefined") {
156 const w = window as E2ECanvasWindow;
157 delete w.__E2E_CANVAS_SCENE_COUNT__;
158 delete w.__E2E_CANVAS_COLLABORATORS__;
159 }
160 };
161 }, [readyApi, ydoc, yElements, yAppState, yFileRefs, provider]);
162 
163 // Publish userId changes to awareness without tearing down the binding.
164 // In practice userId is stable for a session, but sign-in during an open
165 // canvas is possible, and this keeps peers in sync without the cost of
166 // rebuilding observers and re-hydrating images.
167 useEffect(() => {
168 bindingRef.current?.setUserId(userId);
169 }, [userId]);
170 
171 // Excalidraw reads collaborators from its internal scene state, so push
172 // the latest awareness-derived map into the scene whenever it changes.
173 useEffect(() => {
174 if (!readyApi) return;
175 readyApi.updateScene({ collaborators });
176 }, [readyApi, collaborators]);
177 
178 const handleChange = useCallback(
179 (elements: readonly OrderedExcalidrawElement[], appState: AppState, files: BinaryFiles) => {
180 bindingRef.current?.handleChange(elements, appState, files);
181 },
182 [],
183 );
184 
185 const handlePointerUpdate = useCallback(
186 (payload: { pointer: { x: number; y: number }; button: "up" | "down"; pointersMap: Map<number, unknown> }) => {
187 bindingRef.current?.handlePointerUpdate(payload);
188 },
189 [],
190 );
191 
192 const uiOptions = useMemo(
193 () => ({
194 canvasActions: {
195 changeViewBackgroundColor: canEdit,
196 clearCanvas: canEdit,
197 loadScene: false,
198 saveToActiveFile: false,
199 saveAsImage: true,
200 export: { saveFileToDisk: true },
201 toggleTheme: null,
202 },
203 tools: { image: canEdit && canInsertImages },
204 welcomeScreen: false,
205 }),
206 [canEdit, canInsertImages],
207 );
208 
209 // Seed the canvas background for fresh canvases only. Excalidraw's dark
210 // theme is implemented as a CSS filter (`invert(93%) hue-rotate(180deg)`),
211 // so the input color is not what the user sees on screen — it gets
212 // inverted. bland's `--color-accent-50` (warm amethyst-tinted near-white)
213 // inverts to a warm near-black, one step lifted from the zinc-950
214 // container so the canvas reads as a "paper" surface inside the chrome
215 // frame instead of a uniform void.
216 //
217 // For existing canvases, the ExcalidrawBinding applies the stored
218 // `viewBackgroundColor` from Yjs on mount and overrides this default.
219 const initialData = useMemo(() => ({ appState: { viewBackgroundColor: "#faf7ff" } }), []);
220 
221 // Toggling `expanded` only swaps the container's class + location.
222 // Excalidraw stays in the same React subtree so `api`, the binding,
223 // and Yjs wiring all survive the transition — no remount. A fixed
224 // position is viewport-relative, so ancestor `overflow: hidden` /
225 // `contain` on the shell or grid doesn't clip us.
226 //
227 // The expanded overlay leaves the bland header visible (60px tall,
228 // z-50) at the top and keeps a small gutter on the other three sides —
229 // full viewport fill would bury the top of the canvas under the
230 // header. A dimmed backdrop sits below the canvas (z-30) and collapses
231 // on click, same pattern as SummarizeSheet.
232 const containerClass = expanded
233 ? "bland-canvas fixed inset-x-4 bottom-4 top-[4.5rem] z-40 rounded-lg border border-zinc-800 bg-zinc-950 shadow-2xl"
234 : "bland-canvas relative mt-6 h-[70vh] rounded-lg border border-zinc-800 bg-zinc-950";
235 const ExpandIcon = expanded ? Minimize2 : Maximize2;
236 
237 const canvasTree = (
238 <div className={containerClass}>
239 <button
240 type="button"
241 onClick={() => setExpanded((v) => !v)}
242 aria-label={expanded ? "Collapse canvas" : "Expand canvas"}
243 aria-pressed={expanded}
244 title={expanded ? "Collapse canvas (Esc)" : "Expand canvas"}
245 className="absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-md border border-zinc-700/70 bg-zinc-800/70 text-zinc-300 backdrop-blur-sm transition-colors hover:border-zinc-600 hover:bg-zinc-700 hover:text-zinc-100"
246 >
247 <ExpandIcon className="h-3.5 w-3.5" />
248 </button>
249 <Excalidraw
250 excalidrawAPI={setApi}
251 viewModeEnabled={!canEdit}
252 onChange={handleChange}
253 onPointerUpdate={handlePointerUpdate}
254 generateIdForFile={generateIdForFile}
255 UIOptions={uiOptions}
256 initialData={initialData}
257 isCollaborating={collaborators.size > 0}
258 theme="dark"
259 >
260 {/* Always render a custom MainMenu. If we omit it, Excalidraw
261 * falls back to its DefaultMainMenu which exposes Help,
262 * SearchMenu, and an "Excalidraw links → Socials" group that
263 * viewers would see but editors (who get our curated menu)
264 * wouldn't — inconsistent, and off-brand for bland. Gating
265 * edit-only items inside keeps the menu shape stable across
266 * roles: viewers get just Save as image. */}
267 <MainMenu>
268 <MainMenu.DefaultItems.SaveAsImage />
269 {canEdit && <MainMenu.DefaultItems.ClearCanvas />}
270 {canEdit && <MainMenu.DefaultItems.ChangeCanvasBackground />}
271 </MainMenu>
272 </Excalidraw>
273 </div>
274 );
275 
276 // Keep canvasTree in a stable JSX position across expanded/collapsed so
277 // React doesn't unmount Excalidraw on toggle — a remount would throw
278 // away the imperative API, binding, and hydrated R2 images. Render the
279 // backdrop inline as a sibling so it shares the same parent stacking
280 // context as the canvas; z-40 > z-30 then resolves deterministically
281 // within that context. Portalling the backdrop to body would land it
282 // in a different stacking context than the inline canvas, which can
283 // flip the paint order (this matches SummarizeSheet's inline pattern).
284 return (
285 <>
286 {expanded && (
287 <div className="fixed inset-0 z-30 bg-zinc-950/55" onClick={() => setExpanded(false)} aria-hidden="true" />
288 )}
289 {canvasTree}
290 </>
291 );
292}