File
Blob: src/client/components/canvas/canvas-surface.tsx
| 1 | import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; |
| 2 | import "@excalidraw/excalidraw/index.css"; |
| 3 | import { Excalidraw, MainMenu } from "@excalidraw/excalidraw"; |
| 4 | import type { OrderedExcalidrawElement } from "@excalidraw/excalidraw/element/types"; |
| 5 | import type { |
| 6 | AppState, |
| 7 | BinaryFiles, |
| 8 | Collaborator, |
| 9 | ExcalidrawImperativeAPI, |
| 10 | SocketId, |
| 11 | } from "@excalidraw/excalidraw/types"; |
| 12 | import { Maximize2, Minimize2 } from "lucide-react"; |
| 13 | import { useCollabIdentity } from "@/client/lib/collab-identity"; |
| 14 | import { ExcalidrawBinding, type ExcalidrawBindingDeps } from "./excalidraw-binding"; |
| 15 | import type { CanvasSessionReadyState } from "./use-canvas-session"; |
| 16 | import "./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. |
| 21 | declare global { |
| 22 | interface Window { |
| 23 | EXCALIDRAW_ASSET_PATH?: string; |
| 24 | } |
| 25 | } |
| 26 | if (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. */ |
| 34 | async 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 | |
| 42 | export interface CanvasSurfaceProps { |
| 43 | session: CanvasSessionReadyState; |
| 44 | canEdit: boolean; |
| 45 | canInsertImages: boolean; |
| 46 | pageId: string; |
| 47 | workspaceId: string | undefined; |
| 48 | shareToken: string | undefined; |
| 49 | } |
| 50 | |
| 51 | export 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 | } |