diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/ToggleWhiteboardButton.tsx b/sample-apps/react/react-dogfood/components/Whiteboard/ToggleWhiteboardButton.tsx
new file mode 100644
index 0000000000..d3749826a7
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/ToggleWhiteboardButton.tsx
@@ -0,0 +1,55 @@
+/**
+ * Media-control toggle that opens/closes the shared whiteboard for everyone.
+ */
+import {
+ CompositeButton,
+ useI18n,
+ WithTooltip,
+} from '@stream-io/video-react-sdk';
+
+import type { WhiteboardApi } from './useWhiteboard';
+
+const WhiteboardIcon = () => (
+
+);
+
+export const ToggleWhiteboardButton = (props: { wb: WhiteboardApi }) => {
+ const { wb } = props;
+ const { t } = useI18n();
+ return (
+
+ (wb.isOpen ? wb.close() : wb.open())}
+ data-testid="whiteboard-toggle-button"
+ >
+
+
+
+ );
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/Whiteboard.tsx b/sample-apps/react/react-dogfood/components/Whiteboard/Whiteboard.tsx
new file mode 100644
index 0000000000..2f38ff8db1
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/Whiteboard.tsx
@@ -0,0 +1,145 @@
+/**
+ * Whiteboard stage: centered canvas + bottom toolbar + right-side participants
+ * strip. Laid out like CallParticipantsScreenView (screen-share takeover), and
+ * mounted only while the board is open. Keyboard shortcuts: P/L/R/T select
+ * tools; Ctrl/Cmd +/-/0 control zoom.
+ */
+import { useEffect, useRef, useState } from 'react';
+import {
+ DefaultParticipantViewUI,
+ IconButton,
+ ParticipantView,
+ useCall,
+ useCallStateHooks,
+ useVerticalScrollPosition,
+} from '@stream-io/video-react-sdk';
+
+import type { Tool } from './core/model';
+import {
+ WhiteboardCanvas,
+ type WhiteboardCanvasHandle,
+} from './WhiteboardCanvas';
+import { WhiteboardToolbar } from './WhiteboardToolbar';
+import type { WhiteboardApi } from './useWhiteboard';
+
+const SHORTCUT_TOOLS: Record
= {
+ p: 'pen',
+ l: 'line',
+ r: 'rect',
+ t: 'text',
+ e: 'eraser',
+ h: 'pan',
+};
+
+const isTypingTarget = (target: EventTarget | null): boolean => {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
+};
+
+const WhiteboardParticipants = () => {
+ const call = useCall();
+ const { useParticipants } = useCallStateHooks();
+ const participants = useParticipants();
+ const [scrollWrapper, setScrollWrapper] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (!scrollWrapper || !call) return;
+ const cleanup = call.setViewport(scrollWrapper);
+ return () => cleanup?.();
+ }, [scrollWrapper, call]);
+
+ const scrollPosition = useVerticalScrollPosition(scrollWrapper);
+
+ return (
+
+ {scrollPosition && scrollPosition !== 'top' && (
+
+ scrollWrapper?.scrollBy({ top: -150, behavior: 'smooth' })
+ }
+ />
+ )}
+
+ {participants.map((participant) => (
+
+ ))}
+
+ {scrollPosition && scrollPosition !== 'bottom' && (
+
+ scrollWrapper?.scrollBy({ top: 150, behavior: 'smooth' })
+ }
+ />
+ )}
+
+ );
+};
+
+export const Whiteboard = (props: { wb: WhiteboardApi }) => {
+ const { wb } = props;
+ const [zoom, setZoom] = useState(1);
+ const handleRef = useRef(null);
+
+ useEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (isTypingTarget(event.target)) return;
+ if (event.ctrlKey || event.metaKey) {
+ if (event.key === '=' || event.key === '+') {
+ event.preventDefault();
+ handleRef.current?.zoomIn();
+ } else if (event.key === '-') {
+ event.preventDefault();
+ handleRef.current?.zoomOut();
+ } else if (event.key === '0') {
+ event.preventDefault();
+ handleRef.current?.reset();
+ }
+ return;
+ }
+ const tool = SHORTCUT_TOOLS[event.key.toLowerCase()];
+ if (tool) {
+ event.preventDefault();
+ wb.setTool(tool);
+ }
+ };
+ window.addEventListener('keydown', onKeyDown);
+ return () => window.removeEventListener('keydown', onKeyDown);
+ }, [wb]);
+
+ return (
+
+
+
+ handleRef.current?.zoomIn()}
+ onZoomOut={() => handleRef.current?.zoomOut()}
+ onReset={() => handleRef.current?.reset()}
+ onFit={() => handleRef.current?.fit()}
+ zoom={zoom}
+ />
+
+
+
+ );
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardCanvas.tsx b/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardCanvas.tsx
new file mode 100644
index 0000000000..32c3b2da82
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardCanvas.tsx
@@ -0,0 +1,419 @@
+/**
+ * The drawing surface. Renders the document imperatively (Renderer + rAF) and
+ * routes Pointer Events (mouse, touch, stylus) through the ToolController. Zoom
+ * and pan are local to this client and never sync. A ResizeObserver keeps the
+ * backing store at devicePixelRatio and re-centers the visible world on resize.
+ */
+import { type MutableRefObject, useEffect, useRef, useState } from 'react';
+
+import {
+ documentBounds,
+ hitTestElement,
+ type Point,
+ type Tool,
+} from './core/model';
+import { Renderer } from './core/renderer';
+import { ToolController } from './core/tools';
+import {
+ centerOnBounds,
+ createViewport,
+ fitToBounds,
+ panBy,
+ recenterAfterResize,
+ screenToWorld,
+ type Viewport,
+ worldToScreen,
+ zoomAt,
+} from './core/viewport';
+import type { WhiteboardApi } from './useWhiteboard';
+
+const ZOOM_BUTTON_FACTOR = 1.25;
+const WHEEL_ZOOM_SENSITIVITY = 0.002;
+// Eraser hit radius in CSS pixels; converted to world units via the zoom.
+const ERASER_RADIUS = 12;
+
+export interface WhiteboardCanvasHandle {
+ zoomIn: () => void;
+ zoomOut: () => void;
+ reset: () => void;
+ fit: () => void;
+}
+
+interface WhiteboardCanvasProps {
+ wb: WhiteboardApi;
+ handleRef: MutableRefObject;
+ onZoomChange?: (zoom: number) => void;
+}
+
+interface TextOverlay {
+ screenX: number;
+ screenY: number;
+ world: Point;
+}
+
+// Space is the pan modifier, but it must keep working as a space character in
+// the text overlay and as the activator on focused buttons, so we ignore it
+// when an interactive element has focus.
+const isInteractiveTarget = (target: EventTarget | null): boolean => {
+ if (!(target instanceof HTMLElement)) return false;
+ const tag = target.tagName;
+ return (
+ tag === 'INPUT' ||
+ tag === 'TEXTAREA' ||
+ tag === 'BUTTON' ||
+ target.isContentEditable
+ );
+};
+
+const isSpaceKey = (event: KeyboardEvent): boolean =>
+ event.code === 'Space' || event.key === ' ';
+
+const cursorForTool = (tool: Tool): string =>
+ tool === 'pan' ? 'grab' : tool === 'eraser' ? 'cell' : 'crosshair';
+
+export const WhiteboardCanvas = (props: WhiteboardCanvasProps) => {
+ const { wb, handleRef, onZoomChange } = props;
+ const { store, sync, sequencer, getStyle, activeTool } = wb;
+
+ const containerRef = useRef(null);
+ const canvasRef = useRef(null);
+ const viewportRef = useRef(createViewport());
+ const rendererRef = useRef(null);
+ const controllerRef = useRef(null);
+ const spaceHeldRef = useRef(false);
+ const interactedRef = useRef(false);
+ const autoFittedRef = useRef(false);
+ const lastSizeRef = useRef<{ w: number; h: number }>({ w: 0, h: 0 });
+
+ const [textOverlay, setTextOverlay] = useState(null);
+ const textOverlayRef = useRef(null);
+ textOverlayRef.current = textOverlay;
+ const textValueRef = useRef('');
+ const textareaRef = useRef(null);
+
+ // Focus the text overlay on the next frame, after the click that opened it
+ // has fully settled. Focusing during mount (autoFocus) races the click's
+ // mousedown focus fix, which moves focus to (the canvas is not
+ // focusable) and blurs the box away before it ever paints.
+ useEffect(() => {
+ if (!textOverlay) return;
+ const frame = requestAnimationFrame(() => textareaRef.current?.focus());
+ return () => cancelAnimationFrame(frame);
+ }, [textOverlay]);
+
+ // Capture render-changing values in refs so the setup effect rebuilds the
+ // renderer/controller only when the call-scoped instances change.
+ const onZoomChangeRef = useRef(onZoomChange);
+ onZoomChangeRef.current = onZoomChange;
+ const activeToolRef = useRef(activeTool);
+ activeToolRef.current = activeTool;
+
+ // keep the controller's active tool in sync with the toolbar
+ useEffect(() => {
+ controllerRef.current?.setTool(activeTool);
+ if (canvasRef.current) {
+ canvasRef.current.style.cursor = cursorForTool(activeTool);
+ }
+ }, [activeTool]);
+
+ useEffect(() => {
+ const canvas = canvasRef.current;
+ const container = containerRef.current;
+ if (!canvas || !container || !store || !sync || !sequencer) return;
+
+ const reportZoom = () =>
+ onZoomChangeRef.current?.(viewportRef.current.zoom);
+
+ const renderer = new Renderer(
+ canvas,
+ store.getDocument,
+ () => viewportRef.current,
+ sync.getActiveDrawers,
+ );
+ rendererRef.current = renderer;
+
+ // Open / late-join start position: always 100% zoom, panned so existing
+ // content is centered (or world origin when the board is empty).
+ const startViewport = (force: boolean) => {
+ const bounds = documentBounds(store.getDocument());
+ if (bounds) {
+ viewportRef.current = centerOnBounds(
+ bounds,
+ canvas.clientWidth,
+ canvas.clientHeight,
+ );
+ autoFittedRef.current = true;
+ } else if (force) {
+ viewportRef.current = createViewport();
+ }
+ renderer.requestPaint();
+ reportZoom();
+ };
+
+ // Fit-to-content (variable zoom) for the toolbar Fit button only.
+ const fitToContent = () => {
+ const bounds = documentBounds(store.getDocument());
+ viewportRef.current = bounds
+ ? fitToBounds(bounds, canvas.clientWidth, canvas.clientHeight)
+ : createViewport();
+ interactedRef.current = true;
+ renderer.requestPaint();
+ reportZoom();
+ };
+
+ const controller = new ToolController({
+ sequencer,
+ getEpoch: store.getEpoch,
+ getStyle,
+ emit: sync.applyLocalOp,
+ onTextRequested: (at) => {
+ const screen = worldToScreen(viewportRef.current, at);
+ setTextOverlay({ screenX: screen.x, screenY: screen.y, world: at });
+ textValueRef.current = '';
+ },
+ findElementsAt: (at) => {
+ const tolerance = ERASER_RADIUS / viewportRef.current.zoom;
+ const { elements } = store.getDocument();
+ const ids: string[] = [];
+ for (const id in elements) {
+ if (hitTestElement(elements[id], at, tolerance)) ids.push(id);
+ }
+ return ids;
+ },
+ });
+ controller.setTool(activeToolRef.current);
+ controllerRef.current = controller;
+
+ lastSizeRef.current = { w: canvas.clientWidth, h: canvas.clientHeight };
+ renderer.resize();
+ // land late joiners / re-openers on existing content, at 100%
+ startViewport(true);
+
+ const unsubscribe = store.subscribe(() => {
+ if (
+ !interactedRef.current &&
+ !autoFittedRef.current &&
+ !store.isEmpty()
+ ) {
+ startViewport(false);
+ } else {
+ renderer.requestPaint();
+ }
+ });
+
+ const unsubscribePresence = sync.subscribePresence(renderer.requestPaint);
+
+ const resizeObserver = new ResizeObserver(() => {
+ const { w: prevW, h: prevH } = lastSizeRef.current;
+ const nextW = canvas.clientWidth;
+ const nextH = canvas.clientHeight;
+ if (prevW > 0 && prevH > 0 && (prevW !== nextW || prevH !== nextH)) {
+ viewportRef.current = recenterAfterResize(
+ viewportRef.current,
+ prevW,
+ prevH,
+ nextW,
+ nextH,
+ );
+ }
+ lastSizeRef.current = { w: nextW, h: nextH };
+ renderer.resize();
+ });
+ resizeObserver.observe(container);
+
+ const toWorld = (event: PointerEvent): Point => {
+ const rect = canvas.getBoundingClientRect();
+ return screenToWorld(viewportRef.current, {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ });
+ };
+
+ let pan: { x: number; y: number } | null = null;
+
+ const toolCursor = () => cursorForTool(activeToolRef.current);
+
+ const onPointerDown = (event: PointerEvent) => {
+ if (textOverlayRef.current) return;
+ const wantsPan =
+ event.button === 1 ||
+ spaceHeldRef.current ||
+ activeToolRef.current === 'pan';
+ if (wantsPan) {
+ pan = { x: event.clientX, y: event.clientY };
+ canvas.setPointerCapture(event.pointerId);
+ canvas.style.cursor = 'grabbing';
+ return;
+ }
+ if (event.button !== 0) return;
+ interactedRef.current = true;
+ canvas.setPointerCapture(event.pointerId);
+ controller.onPointerDown(toWorld(event));
+ };
+
+ const onPointerMove = (event: PointerEvent) => {
+ if (pan) {
+ const dx = event.clientX - pan.x;
+ const dy = event.clientY - pan.y;
+ pan = { x: event.clientX, y: event.clientY };
+ viewportRef.current = panBy(viewportRef.current, -dx, -dy);
+ renderer.requestPaint();
+ return;
+ }
+ if (controller.isDrawing()) controller.onPointerMove(toWorld(event));
+ };
+
+ const onPointerUp = (event: PointerEvent) => {
+ if (pan) {
+ pan = null;
+ canvas.releasePointerCapture(event.pointerId);
+ canvas.style.cursor = spaceHeldRef.current ? 'grab' : toolCursor();
+ return;
+ }
+ if (controller.isDrawing()) {
+ controller.onPointerUp(toWorld(event));
+ canvas.releasePointerCapture(event.pointerId);
+ }
+ };
+
+ const onWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ interactedRef.current = true;
+ if (event.ctrlKey || event.metaKey) {
+ const rect = canvas.getBoundingClientRect();
+ const anchor = {
+ x: event.clientX - rect.left,
+ y: event.clientY - rect.top,
+ };
+ const factor = Math.exp(-event.deltaY * WHEEL_ZOOM_SENSITIVITY);
+ viewportRef.current = zoomAt(viewportRef.current, anchor, factor);
+ reportZoom();
+ } else {
+ viewportRef.current = panBy(
+ viewportRef.current,
+ event.deltaX,
+ event.deltaY,
+ );
+ }
+ renderer.requestPaint();
+ };
+
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!isSpaceKey(event) || isInteractiveTarget(event.target)) return;
+ event.preventDefault(); // hold-space pans; do not scroll the page
+ if (!spaceHeldRef.current) {
+ spaceHeldRef.current = true;
+ if (!pan) canvas.style.cursor = 'grab';
+ }
+ };
+
+ const onKeyUp = (event: KeyboardEvent) => {
+ if (!isSpaceKey(event) || !spaceHeldRef.current) return;
+ spaceHeldRef.current = false;
+ if (!pan) canvas.style.cursor = toolCursor();
+ };
+
+ // Releasing focus (e.g. alt-tab) can swallow the keyup; reset so the pan
+ // modifier does not stay stuck on.
+ const onBlur = () => {
+ if (!spaceHeldRef.current) return;
+ spaceHeldRef.current = false;
+ if (!pan) canvas.style.cursor = toolCursor();
+ };
+
+ canvas.addEventListener('pointerdown', onPointerDown);
+ canvas.addEventListener('pointermove', onPointerMove);
+ canvas.addEventListener('pointerup', onPointerUp);
+ canvas.addEventListener('pointercancel', onPointerUp);
+ canvas.addEventListener('wheel', onWheel, { passive: false });
+ window.addEventListener('keydown', onKeyDown);
+ window.addEventListener('keyup', onKeyUp);
+ window.addEventListener('blur', onBlur);
+
+ const zoomAtCenter = (factor: number) => {
+ const center = {
+ x: canvas.clientWidth / 2,
+ y: canvas.clientHeight / 2,
+ };
+ interactedRef.current = true;
+ viewportRef.current = zoomAt(viewportRef.current, center, factor);
+ renderer.requestPaint();
+ reportZoom();
+ };
+
+ handleRef.current = {
+ zoomIn: () => zoomAtCenter(ZOOM_BUTTON_FACTOR),
+ zoomOut: () => zoomAtCenter(1 / ZOOM_BUTTON_FACTOR),
+ reset: () => {
+ interactedRef.current = true;
+ viewportRef.current = createViewport();
+ renderer.requestPaint();
+ reportZoom();
+ },
+ fit: fitToContent,
+ };
+
+ return () => {
+ unsubscribe();
+ unsubscribePresence();
+ resizeObserver.disconnect();
+ renderer.dispose();
+ canvas.removeEventListener('pointerdown', onPointerDown);
+ canvas.removeEventListener('pointermove', onPointerMove);
+ canvas.removeEventListener('pointerup', onPointerUp);
+ canvas.removeEventListener('pointercancel', onPointerUp);
+ canvas.removeEventListener('wheel', onWheel);
+ window.removeEventListener('keydown', onKeyDown);
+ window.removeEventListener('keyup', onKeyUp);
+ window.removeEventListener('blur', onBlur);
+ rendererRef.current = null;
+ controllerRef.current = null;
+ handleRef.current = null;
+ };
+ }, [store, sync, sequencer, getStyle, handleRef]);
+
+ const commitText = () => {
+ const overlay = textOverlayRef.current;
+ if (overlay)
+ controllerRef.current?.commitText(overlay.world, textValueRef.current);
+ setTextOverlay(null);
+ textValueRef.current = '';
+ };
+
+ const cancelText = () => {
+ setTextOverlay(null);
+ textValueRef.current = '';
+ };
+
+ return (
+
+
+ {textOverlay && (
+
+ );
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardToolbar.tsx b/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardToolbar.tsx
new file mode 100644
index 0000000000..c2cd5307aa
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/WhiteboardToolbar.tsx
@@ -0,0 +1,291 @@
+/**
+ * Bottom toolbar: tool selection, color palette, clear (confirmed), and zoom
+ * controls. Fully keyboard-operable; the active tool and color use aria-pressed
+ * plus a selected outline so state is never conveyed by color alone.
+ */
+import { type ReactNode, useState } from 'react';
+import { useI18n } from '@stream-io/video-react-sdk';
+
+import type { Tool } from './core/model';
+import { WHITEBOARD_PALETTE } from './useWhiteboard';
+
+interface WhiteboardToolbarProps {
+ activeTool: Tool;
+ setTool: (tool: Tool) => void;
+ color: string;
+ setColor: (color: string) => void;
+ onClear: () => void;
+ onZoomIn: () => void;
+ onZoomOut: () => void;
+ onReset: () => void;
+ onFit: () => void;
+ zoom: number;
+}
+
+const PenIcon = () => (
+
+);
+
+const LineIcon = () => (
+
+);
+
+const RectIcon = () => (
+
+);
+
+const TextIcon = () => (
+
+);
+
+const HandIcon = () => (
+
+);
+
+const EraserIcon = () => (
+
+);
+
+const ZoomInIcon = () => (
+
+);
+
+const ZoomOutIcon = () => (
+
+);
+
+const FitIcon = () => (
+
+);
+
+const ClearIcon = () => (
+
+);
+
+export const WhiteboardToolbar = (props: WhiteboardToolbarProps) => {
+ const {
+ activeTool,
+ setTool,
+ color,
+ setColor,
+ onClear,
+ onZoomIn,
+ onZoomOut,
+ onReset,
+ onFit,
+ zoom,
+ } = props;
+ const { t } = useI18n();
+ const [confirmingClear, setConfirmingClear] = useState(false);
+
+ const tools: { tool: Tool; label: string; icon: ReactNode }[] = [
+ { tool: 'pen', label: t('Pen'), icon: },
+ { tool: 'line', label: t('Line'), icon: },
+ { tool: 'rect', label: t('Rectangle'), icon: },
+ { tool: 'text', label: t('Text'), icon: },
+ { tool: 'eraser', label: t('Eraser'), icon: },
+ { tool: 'pan', label: t('Pan'), icon: },
+ ];
+
+ return (
+
+
+ {tools.map(({ tool, label, icon }) => (
+
+ ))}
+
+
+
+ {WHITEBOARD_PALETTE.map(({ name, value }) => (
+
+
+
+
+
+
+
+
+
+
+ {confirmingClear ? (
+
+
+ {t('Clear for everyone?')}
+
+
+
+
+ ) : (
+
+ )}
+
+
+ );
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/SceneStore.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/SceneStore.ts
new file mode 100644
index 0000000000..da6bcd73bf
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/SceneStore.ts
@@ -0,0 +1,135 @@
+/**
+ * Owns the whiteboard document and applies ops with last-write-wins
+ * reconciliation. Pure logic: no React, no SDK. The same `apply` path serves
+ * both local optimistic ops and inbound remote ops, so convergence rules live
+ * in exactly one place.
+ *
+ * Element ids are owner-scoped (`${sessionId}-${counter}`), so a given element
+ * has a single writer and `version` is a per-element total order: no
+ * cross-writer ties can occur, which keeps LWW trivial (strict-greater wins,
+ * equal is a duplicate and ignored).
+ */
+import {
+ createEmptyDocument,
+ type Op,
+ type Point,
+ type WhiteboardDocument,
+ type WhiteboardElement,
+} from './model';
+
+type Listener = () => void;
+
+export class SceneStore {
+ private doc: WhiteboardDocument = createEmptyDocument();
+ private listeners: Set = new Set();
+ /** Bumped on every effective change so consumers can detect staleness. */
+ private revision = 0;
+
+ /** The live document. Read imperatively by the renderer; do not mutate. */
+ getDocument = (): WhiteboardDocument => this.doc;
+
+ getEpoch = (): number => this.doc.epoch;
+
+ getRevision = (): number => this.revision;
+
+ isEmpty = (): boolean => Object.keys(this.doc.elements).length === 0;
+
+ /** Subscribe to change notifications; returns an unsubscribe function. */
+ subscribe = (listener: Listener): (() => void) => {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ };
+
+ /**
+ * Set a pen element's revealed points directly, for receive-side smooth
+ * playback (display-only catch-up). Bypasses LWW because it never decreases
+ * information: the caller owns the full target path and reveals a growing
+ * prefix of it. No-op if the element is missing or not a pen.
+ */
+ setPenPoints = (id: string, points: Point[], version: number): void => {
+ const element = this.doc.elements[id];
+ if (!element || element.type !== 'pen') return;
+ // Never regress: a snapshot/merge may have already revealed more points.
+ if (points.length < element.points.length) return;
+ element.points = points;
+ element.version = version;
+ this.changed();
+ };
+
+ /**
+ * Apply an op with LWW + clear-epoch semantics. Returns true when the
+ * document actually changed (callers can skip a repaint otherwise).
+ */
+ apply = (op: Op): boolean => {
+ if (op.op === 'clear') {
+ // Only a strictly newer epoch wipes, so concurrent clears converge
+ // without oscillation and a stale duplicate clear is a no-op.
+ if (op.epoch <= this.doc.epoch) return false;
+ this.doc = { epoch: op.epoch, elements: {} };
+ this.changed();
+ return true;
+ }
+
+ // An op stamped with an older epoch predates a clear we have already seen.
+ if (op.epoch < this.doc.epoch) return false;
+ // A newer epoch means a clear we missed; adopt it and wipe before applying.
+ if (op.epoch > this.doc.epoch) {
+ this.doc = { epoch: op.epoch, elements: {} };
+ }
+
+ switch (op.op) {
+ case 'upsert':
+ return this.applyUpsert(op.element);
+ case 'append':
+ return this.applyAppend(op.id, op.points, op.version);
+ case 'remove':
+ return this.applyRemove(op.id, op.version);
+ }
+ };
+
+ /** Replace the whole document (snapshot load / late-joiner catch-up). */
+ replaceDocument = (doc: WhiteboardDocument): void => {
+ this.doc = { epoch: doc.epoch, elements: { ...doc.elements } };
+ this.changed();
+ };
+
+ private applyUpsert = (element: WhiteboardElement): boolean => {
+ const existing = this.doc.elements[element.id];
+ if (existing && existing.version >= element.version) return false;
+ this.doc.elements[element.id] = element;
+ this.changed();
+ return true;
+ };
+
+ private applyAppend = (
+ id: string,
+ points: Point[],
+ version: number,
+ ): boolean => {
+ const existing = this.doc.elements[id];
+ // Live-preview only: if the creating upsert was dropped we ignore the
+ // append; the final upsert (full path) heals it.
+ if (!existing || existing.type !== 'pen') return false;
+ if (version <= existing.version) return false;
+ existing.points = existing.points.concat(points);
+ existing.version = version;
+ this.changed();
+ return true;
+ };
+
+ private applyRemove = (id: string, version: number): boolean => {
+ const existing = this.doc.elements[id];
+ if (!existing) return false;
+ if (version < existing.version) return false;
+ delete this.doc.elements[id];
+ this.changed();
+ return true;
+ };
+
+ private changed = (): void => {
+ this.revision++;
+ for (const listener of this.listeners) listener();
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/model.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/model.ts
new file mode 100644
index 0000000000..ae76871a6d
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/model.ts
@@ -0,0 +1,277 @@
+/**
+ * Pure data model for the collaborative whiteboard.
+ *
+ * Geometry is stored in an infinite "world coordinate" space. Each client
+ * renders the world through its own local viewport transform, so a drawing
+ * looks geometrically identical on every device regardless of screen size.
+ * See viewport.ts for the transform. This module has no React and no SDK
+ * dependency on purpose: it is the lowest layer everything else builds on.
+ */
+
+/** A point in world coordinates. */
+export interface Point {
+ x: number;
+ y: number;
+}
+
+/** Axis-aligned bounding box in world coordinates. */
+export interface Bounds {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+}
+
+/**
+ * The tools available in v1. `eraser` removes whole elements it touches; `pan`
+ * drags the viewport instead of drawing (handled by the canvas, not the
+ * controller).
+ */
+export type Tool = 'pen' | 'line' | 'rect' | 'text' | 'eraser' | 'pan';
+
+/** Fields shared by every element. */
+export interface BaseElement {
+ id: string;
+ /** Monotonic per-sender counter used for last-write-wins reconciliation. */
+ version: number;
+ strokeColor: string;
+ strokeWidth: number;
+}
+
+export interface PenElement extends BaseElement {
+ type: 'pen';
+ /** Freehand path in world coords, appended while the stroke is in progress. */
+ points: Point[];
+}
+
+export interface LineElement extends BaseElement {
+ type: 'line';
+ a: Point;
+ b: Point;
+}
+
+export interface RectElement extends BaseElement {
+ type: 'rect';
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+}
+
+export interface TextElement extends BaseElement {
+ type: 'text';
+ x: number;
+ y: number;
+ text: string;
+ fontSize: number;
+}
+
+export type WhiteboardElement =
+ | PenElement
+ | LineElement
+ | RectElement
+ | TextElement;
+
+export interface WhiteboardDocument {
+ /**
+ * Monotonically increasing clear epoch. A `clear` bumps it; ops stamped with
+ * an older epoch are dropped on receipt so a stroke in flight during a clear
+ * cannot resurrect a wiped board.
+ */
+ epoch: number;
+ elements: Record;
+}
+
+export const createEmptyDocument = (): WhiteboardDocument => ({
+ epoch: 0,
+ elements: {},
+});
+
+/**
+ * The mutation vocabulary applied to a document, locally (optimistic) and from
+ * remote peers. Every op carries the `epoch` it was created under so a stroke
+ * in flight during a `clear` is dropped on receipt instead of resurrecting a
+ * wiped board.
+ *
+ * - `upsert` creates or replaces an element (pen create + final, line/rect
+ * draft + final, text commit); `element.version` is the LWW key.
+ * - `append` adds points to an existing pen element during a live stroke; the
+ * final `upsert` carries the complete path and heals dropped
+ * appends, so appends are best-effort live preview only.
+ * - `remove` deletes one element (reserved: not surfaced in the v1 UI, which
+ * only offers clear-all).
+ * - `clear` wipes the board and bumps the epoch.
+ */
+export type Op =
+ | { op: 'upsert'; epoch: number; element: WhiteboardElement }
+ | {
+ op: 'append';
+ epoch: number;
+ id: string;
+ points: Point[];
+ version: number;
+ }
+ | { op: 'remove'; epoch: number; id: string; version: number }
+ | { op: 'clear'; epoch: number };
+
+/**
+ * Rough per-character width factor used to estimate text bounds without a
+ * canvas context. Generous on purpose: over-estimating only widens culling and
+ * fit-to-content framing, which is harmless.
+ */
+const TEXT_WIDTH_FACTOR = 0.6;
+const TEXT_HEIGHT_FACTOR = 1.2;
+
+/** World-space bounding box of a single element, padded by half the stroke. */
+export const elementBounds = (element: WhiteboardElement): Bounds => {
+ const pad = element.strokeWidth / 2;
+ let minX: number;
+ let minY: number;
+ let maxX: number;
+ let maxY: number;
+
+ switch (element.type) {
+ case 'pen': {
+ const { points } = element;
+ if (points.length === 0) {
+ return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
+ }
+ minX = maxX = points[0].x;
+ minY = maxY = points[0].y;
+ for (const p of points) {
+ if (p.x < minX) minX = p.x;
+ if (p.x > maxX) maxX = p.x;
+ if (p.y < minY) minY = p.y;
+ if (p.y > maxY) maxY = p.y;
+ }
+ break;
+ }
+ case 'line': {
+ minX = Math.min(element.a.x, element.b.x);
+ maxX = Math.max(element.a.x, element.b.x);
+ minY = Math.min(element.a.y, element.b.y);
+ maxY = Math.max(element.a.y, element.b.y);
+ break;
+ }
+ case 'rect': {
+ minX = Math.min(element.x, element.x + element.w);
+ maxX = Math.max(element.x, element.x + element.w);
+ minY = Math.min(element.y, element.y + element.h);
+ maxY = Math.max(element.y, element.y + element.h);
+ break;
+ }
+ case 'text': {
+ const width = element.text.length * element.fontSize * TEXT_WIDTH_FACTOR;
+ const height = element.fontSize * TEXT_HEIGHT_FACTOR;
+ minX = element.x;
+ minY = element.y;
+ maxX = element.x + width;
+ maxY = element.y + height;
+ break;
+ }
+ }
+
+ return {
+ minX: minX - pad,
+ minY: minY - pad,
+ maxX: maxX + pad,
+ maxY: maxY + pad,
+ };
+};
+
+/**
+ * The "leading" world point of an element - where a drawer's name tag should
+ * sit. For a pen it is the last (most recently revealed) point, so the tag
+ * follows the pen tip as a stroke animates in; for other shapes it is the
+ * end/anchor being dragged.
+ */
+export const elementLeadingPoint = (element: WhiteboardElement): Point => {
+ switch (element.type) {
+ case 'pen':
+ return element.points.length > 0
+ ? element.points[element.points.length - 1]
+ : { x: 0, y: 0 };
+ case 'line':
+ return element.b;
+ case 'rect':
+ return { x: element.x + element.w, y: element.y + element.h };
+ case 'text':
+ return { x: element.x, y: element.y };
+ }
+};
+
+/** World-space bounding box of the whole document, or null when empty. */
+export const documentBounds = (doc: WhiteboardDocument): Bounds | null => {
+ let result: Bounds | null = null;
+ for (const id in doc.elements) {
+ const b = elementBounds(doc.elements[id]);
+ if (!result) {
+ result = { ...b };
+ } else {
+ if (b.minX < result.minX) result.minX = b.minX;
+ if (b.minY < result.minY) result.minY = b.minY;
+ if (b.maxX > result.maxX) result.maxX = b.maxX;
+ if (b.maxY > result.maxY) result.maxY = b.maxY;
+ }
+ }
+ return result;
+};
+
+/** True when the two world-space boxes overlap (used for viewport culling). */
+export const boundsIntersect = (a: Bounds, b: Bounds): boolean =>
+ a.minX <= b.maxX && a.maxX >= b.minX && a.minY <= b.maxY && a.maxY >= b.minY;
+
+/** Shortest distance from point p to the segment a-b, in world units. */
+const distanceToSegment = (p: Point, a: Point, b: Point): number => {
+ const dx = b.x - a.x;
+ const dy = b.y - a.y;
+ const lengthSq = dx * dx + dy * dy;
+ if (lengthSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
+ let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lengthSq;
+ t = Math.max(0, Math.min(1, t));
+ return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
+};
+
+/**
+ * True when world point p is within `tolerance` of the element. Used by the
+ * eraser: strokes/lines hit-test against the actual path; rectangles and text
+ * hit-test against their (padded) bounding box so dragging anywhere over them
+ * erases.
+ */
+export const hitTestElement = (
+ element: WhiteboardElement,
+ p: Point,
+ tolerance: number,
+): boolean => {
+ switch (element.type) {
+ case 'pen': {
+ const reach = tolerance + element.strokeWidth / 2;
+ const { points } = element;
+ if (points.length === 0) return false;
+ if (points.length === 1) {
+ return Math.hypot(p.x - points[0].x, p.y - points[0].y) <= reach;
+ }
+ for (let i = 1; i < points.length; i++) {
+ if (distanceToSegment(p, points[i - 1], points[i]) <= reach) {
+ return true;
+ }
+ }
+ return false;
+ }
+ case 'line':
+ return (
+ distanceToSegment(p, element.a, element.b) <=
+ tolerance + element.strokeWidth / 2
+ );
+ case 'rect':
+ case 'text': {
+ const b = elementBounds(element);
+ return (
+ p.x >= b.minX - tolerance &&
+ p.x <= b.maxX + tolerance &&
+ p.y >= b.minY - tolerance &&
+ p.y <= b.maxY + tolerance
+ );
+ }
+ }
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/renderer.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/renderer.ts
new file mode 100644
index 0000000000..63a4485b52
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/renderer.ts
@@ -0,0 +1,194 @@
+/**
+ * Draws a whiteboard document onto a 2D canvas through a viewport transform.
+ * Side-effecting but deterministic given (document, viewport, canvas size).
+ *
+ * Repaints are dirty-driven: a frame is scheduled via requestAnimationFrame
+ * only when the store or viewport changes, never as a perpetual loop. Drawing
+ * is viewport-culled so only elements whose bounds intersect the visible world
+ * are painted, and the backing store is sized to devicePixelRatio for crisp
+ * hi-DPI output. The board surface is a fixed light color regardless of the
+ * app theme so strokes read well.
+ */
+import {
+ boundsIntersect,
+ type Bounds,
+ elementBounds,
+ elementLeadingPoint,
+ type WhiteboardDocument,
+ type WhiteboardElement,
+} from './model';
+import { visibleWorldBounds, type Viewport, worldToScreen } from './viewport';
+
+const SURFACE_COLOR = '#f7f7f4';
+const TEXT_FONT_FAMILY =
+ "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
+
+export class Renderer {
+ private canvas: HTMLCanvasElement;
+ private ctx: CanvasRenderingContext2D;
+ private getDocument: () => WhiteboardDocument;
+ private getViewport: () => Viewport;
+ private getLabels?: () => Map;
+ private frame = 0;
+ private dpr = 1;
+
+ constructor(
+ canvas: HTMLCanvasElement,
+ getDocument: () => WhiteboardDocument,
+ getViewport: () => Viewport,
+ getLabels?: () => Map,
+ ) {
+ this.canvas = canvas;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) throw new Error('Whiteboard: 2D canvas context unavailable');
+ this.ctx = ctx;
+ this.getDocument = getDocument;
+ this.getViewport = getViewport;
+ this.getLabels = getLabels;
+ }
+
+ /**
+ * Size the backing store to the element's CSS box times devicePixelRatio,
+ * then repaint. Call from a ResizeObserver and on DPI changes.
+ */
+ resize = (): void => {
+ const dpr = window.devicePixelRatio || 1;
+ const width = Math.max(1, Math.round(this.canvas.clientWidth * dpr));
+ const height = Math.max(1, Math.round(this.canvas.clientHeight * dpr));
+ if (this.canvas.width !== width || this.canvas.height !== height) {
+ this.canvas.width = width;
+ this.canvas.height = height;
+ }
+ this.dpr = dpr;
+ this.paint();
+ };
+
+ /** Schedule exactly one repaint on the next animation frame. */
+ requestPaint = (): void => {
+ if (this.frame) return;
+ this.frame = requestAnimationFrame(this.onFrame);
+ };
+
+ dispose = (): void => {
+ if (this.frame) cancelAnimationFrame(this.frame);
+ this.frame = 0;
+ };
+
+ private onFrame = (): void => {
+ this.frame = 0;
+ this.paint();
+ };
+
+ private paint = (): void => {
+ const { ctx, dpr } = this;
+ const deviceW = this.canvas.width;
+ const deviceH = this.canvas.height;
+ const cssW = deviceW / dpr;
+ const cssH = deviceH / dpr;
+ const vp = this.getViewport();
+ const doc = this.getDocument();
+
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.fillStyle = SURFACE_COLOR;
+ ctx.fillRect(0, 0, deviceW, deviceH);
+
+ // world -> device: device = dpr * zoom * (world - pan)
+ const scale = dpr * vp.zoom;
+ ctx.setTransform(scale, 0, 0, scale, -vp.panX * scale, -vp.panY * scale);
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.textBaseline = 'top';
+
+ const visible = visibleWorldBounds(vp, cssW, cssH);
+ const { elements } = doc;
+ for (const id in elements) {
+ const element = elements[id];
+ if (!boundsIntersect(elementBounds(element), visible)) continue;
+ this.drawElement(element);
+ }
+
+ this.drawLabels(vp, doc, visible, cssW, cssH);
+ };
+
+ // Name tags for elements a remote peer is actively drawing. Drawn in CSS
+ // pixel space so they stay a constant size, and anchored at each element's
+ // leading point so the tag follows the pen tip as the stroke animates in.
+ private drawLabels = (
+ vp: Viewport,
+ doc: WhiteboardDocument,
+ visible: Bounds,
+ cssW: number,
+ cssH: number,
+ ): void => {
+ if (!this.getLabels) return;
+ const labels = this.getLabels();
+ if (labels.size === 0) return;
+
+ const { ctx, dpr } = this;
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
+ ctx.font = `600 12px ${TEXT_FONT_FAMILY}`;
+ ctx.textBaseline = 'alphabetic';
+ const padX = 6;
+ const height = 18;
+ const offset = 10;
+
+ for (const [id, name] of labels) {
+ const element = doc.elements[id];
+ if (!element) continue;
+ if (!boundsIntersect(elementBounds(element), visible)) continue;
+ const tip = worldToScreen(vp, elementLeadingPoint(element));
+ const width = ctx.measureText(name).width + padX * 2;
+ // float up-and-right of the tip, clamped to stay on screen
+ let x = tip.x + offset;
+ let top = tip.y - offset - height;
+ x = Math.max(2, Math.min(x, cssW - width - 2));
+ if (top < 2) top = Math.min(tip.y + offset, cssH - height - 2);
+ ctx.fillStyle = 'rgba(40, 40, 40, 0.92)';
+ ctx.fillRect(x, top, width, height);
+ ctx.fillStyle = '#ffffff';
+ ctx.fillText(name, x + padX, top + height - 5);
+ }
+ };
+
+ private drawElement = (element: WhiteboardElement): void => {
+ const { ctx } = this;
+ ctx.strokeStyle = element.strokeColor;
+ ctx.fillStyle = element.strokeColor;
+ ctx.lineWidth = element.strokeWidth;
+
+ switch (element.type) {
+ case 'pen': {
+ const { points } = element;
+ if (points.length === 0) return;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ if (points.length === 1) {
+ // a single tap: draw a dot so it is still visible
+ ctx.lineTo(points[0].x + 0.01, points[0].y);
+ } else {
+ for (let i = 1; i < points.length; i++) {
+ ctx.lineTo(points[i].x, points[i].y);
+ }
+ }
+ ctx.stroke();
+ return;
+ }
+ case 'line': {
+ ctx.beginPath();
+ ctx.moveTo(element.a.x, element.a.y);
+ ctx.lineTo(element.b.x, element.b.y);
+ ctx.stroke();
+ return;
+ }
+ case 'rect': {
+ ctx.strokeRect(element.x, element.y, element.w, element.h);
+ return;
+ }
+ case 'text': {
+ ctx.font = `${element.fontSize}px ${TEXT_FONT_FAMILY}`;
+ ctx.fillText(element.text, element.x, element.y);
+ return;
+ }
+ }
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/serialization.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/serialization.ts
new file mode 100644
index 0000000000..c55765542d
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/serialization.ts
@@ -0,0 +1,42 @@
+/**
+ * Document (de)serialization and the snapshot chunker. A full document can
+ * exceed the ~5KB custom-event cap, so snapshots are split into chunks that are
+ * paced through the outbound funnel and reassembled by the receiver. Chunks are
+ * never parsed individually: only the reassembled whole is valid JSON, so a
+ * multibyte character split across a boundary rejoins correctly.
+ */
+import type { WhiteboardDocument } from './model';
+
+export const serialize = (doc: WhiteboardDocument): string =>
+ JSON.stringify(doc);
+
+export const deserialize = (data: string): WhiteboardDocument | null => {
+ try {
+ const parsed = JSON.parse(data);
+ if (!parsed || typeof parsed !== 'object') return null;
+ if (typeof parsed.epoch !== 'number') return null;
+ if (typeof parsed.elements !== 'object' || parsed.elements === null) {
+ return null;
+ }
+ return parsed as WhiteboardDocument;
+ } catch {
+ return null;
+ }
+};
+
+/**
+ * Chunk size in characters. Kept comfortably under the ~5KB cap so the chunk,
+ * once wrapped in the snapshot envelope and JSON-escaped, still fits.
+ */
+export const DEFAULT_CHUNK_SIZE = 4000;
+
+export const chunk = (data: string, size = DEFAULT_CHUNK_SIZE): string[] => {
+ if (data.length <= size) return [data];
+ const chunks: string[] = [];
+ for (let i = 0; i < data.length; i += size) {
+ chunks.push(data.slice(i, i + size));
+ }
+ return chunks;
+};
+
+export const reassemble = (chunks: string[]): string => chunks.join('');
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/tools.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/tools.ts
new file mode 100644
index 0000000000..cf0b746e92
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/tools.ts
@@ -0,0 +1,289 @@
+/**
+ * Input controller: turns pointer down/move/up (in world coordinates) into
+ * ops for the active tool and owns the in-progress element. Pure logic with no
+ * React or canvas dependency; the canvas converts screen pointer coordinates to
+ * world coordinates before calling in, and wires `emit` to both the local store
+ * (optimistic apply) and the outbound sync funnel.
+ *
+ * Text is click-to-place: the controller asks the host to open a DOM overlay at
+ * the click point and the committed `TextElement` is emitted separately via
+ * `commitText`, since a canvas cannot capture text natively.
+ */
+import type {
+ LineElement,
+ Op,
+ PenElement,
+ Point,
+ RectElement,
+ TextElement,
+ Tool,
+ WhiteboardElement,
+} from './model';
+
+/** Mints owner-scoped element ids and monotonic op versions for one session. */
+export class Sequencer {
+ private sessionId: string;
+ private idCounter = 0;
+ private versionCounter = 0;
+
+ constructor(sessionId: string) {
+ this.sessionId = sessionId;
+ }
+
+ nextId = (): string => `${this.sessionId}-${this.idCounter++}`;
+
+ nextVersion = (): number => ++this.versionCounter;
+}
+
+export interface ToolStyle {
+ color: string;
+ width: number;
+}
+
+export interface ToolHost {
+ sequencer: Sequencer;
+ /** Current clear epoch from the store, stamped onto every op. */
+ getEpoch: () => number;
+ /** Active stroke style, captured at pointer-down so mid-stroke changes do not apply. */
+ getStyle: () => ToolStyle;
+ /** Apply locally (optimistic) and enqueue for sending. */
+ emit: (op: Op) => void;
+ /** Open the text-entry overlay at a world point (Text tool). */
+ onTextRequested: (at: Point) => void;
+ /** Ids of elements under a world point, used by the eraser (Eraser tool). */
+ findElementsAt: (at: Point) => string[];
+}
+
+/** Default text size in world units. */
+export const DEFAULT_FONT_SIZE = 20;
+
+/** Pen points closer than this (world units) than the previous one are dropped. */
+const MIN_PEN_POINT_DISTANCE = 1.5;
+
+const clonePoint = (p: Point): Point => ({ x: p.x, y: p.y });
+
+export class ToolController {
+ private host: ToolHost;
+ activeTool: Tool = 'pen';
+
+ private drawing = false;
+ private current: WhiteboardElement | null = null;
+ private penPoints: Point[] = [];
+ private rectAnchor: Point | null = null;
+ private erasedThisStroke: Set = new Set();
+
+ constructor(host: ToolHost) {
+ this.host = host;
+ }
+
+ setTool = (tool: Tool): void => {
+ this.cancel();
+ this.activeTool = tool;
+ };
+
+ /** True while a drag-based stroke/shape is in progress. */
+ isDrawing = (): boolean => this.drawing;
+
+ onPointerDown = (at: Point): void => {
+ if (this.activeTool === 'pan') return; // canvas handles panning
+ if (this.activeTool === 'eraser') {
+ this.drawing = true;
+ this.erasedThisStroke = new Set();
+ this.eraseAt(at);
+ return;
+ }
+ if (this.activeTool === 'text') {
+ this.host.onTextRequested(at);
+ return;
+ }
+ const style = this.host.getStyle();
+ const id = this.host.sequencer.nextId();
+ const version = this.host.sequencer.nextVersion();
+ const base = {
+ id,
+ version,
+ strokeColor: style.color,
+ strokeWidth: style.width,
+ };
+
+ switch (this.activeTool) {
+ case 'pen': {
+ this.penPoints = [clonePoint(at)];
+ const element: PenElement = {
+ ...base,
+ type: 'pen',
+ points: [clonePoint(at)],
+ };
+ this.current = element;
+ break;
+ }
+ case 'line': {
+ const element: LineElement = {
+ ...base,
+ type: 'line',
+ a: clonePoint(at),
+ b: clonePoint(at),
+ };
+ this.current = element;
+ break;
+ }
+ case 'rect': {
+ this.rectAnchor = clonePoint(at);
+ const element: RectElement = {
+ ...base,
+ type: 'rect',
+ x: at.x,
+ y: at.y,
+ w: 0,
+ h: 0,
+ };
+ this.current = element;
+ break;
+ }
+ }
+
+ this.drawing = true;
+ if (this.current) {
+ this.host.emit({
+ op: 'upsert',
+ epoch: this.host.getEpoch(),
+ element: this.current,
+ });
+ }
+ };
+
+ onPointerMove = (at: Point): void => {
+ if (this.activeTool === 'eraser') {
+ if (this.drawing) this.eraseAt(at);
+ return;
+ }
+ const current = this.current;
+ if (!this.drawing || !current) return;
+
+ switch (current.type) {
+ case 'pen': {
+ const last = this.penPoints[this.penPoints.length - 1];
+ const dx = at.x - last.x;
+ const dy = at.y - last.y;
+ const minSq = MIN_PEN_POINT_DISTANCE * MIN_PEN_POINT_DISTANCE;
+ if (dx * dx + dy * dy < minSq) return;
+ const point = clonePoint(at);
+ this.penPoints.push(point);
+ this.host.emit({
+ op: 'append',
+ epoch: this.host.getEpoch(),
+ id: current.id,
+ points: [point],
+ version: this.host.sequencer.nextVersion(),
+ });
+ return;
+ }
+ case 'line': {
+ const next: LineElement = {
+ ...current,
+ b: clonePoint(at),
+ version: this.host.sequencer.nextVersion(),
+ };
+ this.current = next;
+ this.host.emit({
+ op: 'upsert',
+ epoch: this.host.getEpoch(),
+ element: next,
+ });
+ return;
+ }
+ case 'rect': {
+ const anchor = this.rectAnchor!;
+ const next: RectElement = {
+ ...current,
+ x: anchor.x,
+ y: anchor.y,
+ w: at.x - anchor.x,
+ h: at.y - anchor.y,
+ version: this.host.sequencer.nextVersion(),
+ };
+ this.current = next;
+ this.host.emit({
+ op: 'upsert',
+ epoch: this.host.getEpoch(),
+ element: next,
+ });
+ return;
+ }
+ }
+ };
+
+ onPointerUp = (at: Point): void => {
+ if (this.activeTool === 'eraser') {
+ this.cancel();
+ return;
+ }
+ if (!this.drawing || !this.current) {
+ this.cancel();
+ return;
+ }
+ // Fold the final position in, then emit an authoritative full upsert that
+ // heals any dropped intermediate ops via last-write-wins.
+ this.onPointerMove(at);
+ const current = this.current;
+ let final: WhiteboardElement;
+ if (current.type === 'pen') {
+ final = {
+ ...current,
+ points: this.penPoints.map(clonePoint),
+ version: this.host.sequencer.nextVersion(),
+ };
+ } else {
+ final = { ...current, version: this.host.sequencer.nextVersion() };
+ }
+ this.host.emit({
+ op: 'upsert',
+ epoch: this.host.getEpoch(),
+ element: final,
+ });
+ this.cancel();
+ };
+
+ /** Remove every element under the point that has not yet been erased this stroke. */
+ private eraseAt = (at: Point): void => {
+ const ids = this.host.findElementsAt(at);
+ for (const id of ids) {
+ if (this.erasedThisStroke.has(id)) continue;
+ this.erasedThisStroke.add(id);
+ this.host.emit({
+ op: 'remove',
+ epoch: this.host.getEpoch(),
+ id,
+ version: this.host.sequencer.nextVersion(),
+ });
+ }
+ };
+
+ /** Commit a text element from the overlay (blur / Enter). */
+ commitText = (at: Point, text: string): void => {
+ const trimmed = text.trim();
+ if (!trimmed) return;
+ const style = this.host.getStyle();
+ const element: TextElement = {
+ id: this.host.sequencer.nextId(),
+ version: this.host.sequencer.nextVersion(),
+ strokeColor: style.color,
+ strokeWidth: style.width,
+ type: 'text',
+ x: at.x,
+ y: at.y,
+ text: trimmed,
+ fontSize: DEFAULT_FONT_SIZE,
+ };
+ this.host.emit({ op: 'upsert', epoch: this.host.getEpoch(), element });
+ };
+
+ /** Abandon any in-progress drag without emitting a final op. */
+ cancel = (): void => {
+ this.drawing = false;
+ this.current = null;
+ this.penPoints = [];
+ this.rectAnchor = null;
+ this.erasedThisStroke.clear();
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/core/viewport.ts b/sample-apps/react/react-dogfood/components/Whiteboard/core/viewport.ts
new file mode 100644
index 0000000000..af89883f46
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/core/viewport.ts
@@ -0,0 +1,151 @@
+/**
+ * Per-client viewport transform. Zoom and pan are purely local: only world
+ * coordinates ever sync, so a phone and a 4K monitor see the same world
+ * through different windows with no stretching. Pure math, no side effects.
+ *
+ * screen = (world - pan) * zoom
+ * world = screen / zoom + pan
+ *
+ * (panX, panY) is the world coordinate shown at the canvas top-left; zoom is
+ * world-units per screen pixel scale.
+ */
+import type { Bounds, Point } from './model';
+
+export interface Viewport {
+ zoom: number;
+ panX: number;
+ panY: number;
+}
+
+export const MIN_ZOOM = 0.1;
+export const MAX_ZOOM = 8;
+
+export const createViewport = (): Viewport => ({ zoom: 1, panX: 0, panY: 0 });
+
+export const clampZoom = (zoom: number): number =>
+ Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom));
+
+export const screenToWorld = (vp: Viewport, screen: Point): Point => ({
+ x: screen.x / vp.zoom + vp.panX,
+ y: screen.y / vp.zoom + vp.panY,
+});
+
+export const worldToScreen = (vp: Viewport, world: Point): Point => ({
+ x: (world.x - vp.panX) * vp.zoom,
+ y: (world.y - vp.panY) * vp.zoom,
+});
+
+/**
+ * Zoom by a multiplicative `factor` while keeping the world point under
+ * `anchor` (screen pixels) pinned in place. Returns the same instance when the
+ * zoom is already clamped at a limit so callers can skip no-op repaints.
+ */
+export const zoomAt = (
+ vp: Viewport,
+ anchor: Point,
+ factor: number,
+): Viewport => {
+ const zoom = clampZoom(vp.zoom * factor);
+ if (zoom === vp.zoom) return vp;
+ const worldUnderAnchor = screenToWorld(vp, anchor);
+ return {
+ zoom,
+ panX: worldUnderAnchor.x - anchor.x / zoom,
+ panY: worldUnderAnchor.y - anchor.y / zoom,
+ };
+};
+
+/** Translate the viewport by a screen-space delta (wheel scroll / drag pan). */
+export const panBy = (
+ vp: Viewport,
+ dxScreen: number,
+ dyScreen: number,
+): Viewport => ({
+ zoom: vp.zoom,
+ panX: vp.panX + dxScreen / vp.zoom,
+ panY: vp.panY + dyScreen / vp.zoom,
+});
+
+/** Reset to world origin at 100%. */
+export const resetViewport = (): Viewport => createViewport();
+
+/**
+ * Viewport that frames `bounds` inside a canvas of the given CSS size, with a
+ * fractional `padding` margin. Drives fit-to-content on open / late-join and
+ * the toolbar Fit action.
+ */
+export const fitToBounds = (
+ bounds: Bounds,
+ viewW: number,
+ viewH: number,
+ padding = 0.1,
+): Viewport => {
+ const bw = Math.max(1, bounds.maxX - bounds.minX);
+ const bh = Math.max(1, bounds.maxY - bounds.minY);
+ const scale = Math.min(viewW / bw, viewH / bh) * (1 - padding);
+ const zoom = clampZoom(scale);
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+ return {
+ zoom,
+ panX: centerX - viewW / 2 / zoom,
+ panY: centerY - viewH / 2 / zoom,
+ };
+};
+
+/**
+ * Viewport that centers `bounds` in a canvas of the given CSS size at a fixed
+ * zoom (default 100%), without scaling to fit. Used for the open / late-join
+ * start position so the board always opens at 100% but still lands on content.
+ */
+export const centerOnBounds = (
+ bounds: Bounds,
+ viewW: number,
+ viewH: number,
+ zoom = 1,
+): Viewport => {
+ const centerX = (bounds.minX + bounds.maxX) / 2;
+ const centerY = (bounds.minY + bounds.maxY) / 2;
+ return {
+ zoom,
+ panX: centerX - viewW / 2 / zoom,
+ panY: centerY - viewH / 2 / zoom,
+ };
+};
+
+/** World-space rectangle currently visible; used for renderer culling. */
+export const visibleWorldBounds = (
+ vp: Viewport,
+ viewW: number,
+ viewH: number,
+): Bounds => {
+ const topLeft = screenToWorld(vp, { x: 0, y: 0 });
+ const bottomRight = screenToWorld(vp, { x: viewW, y: viewH });
+ return {
+ minX: topLeft.x,
+ minY: topLeft.y,
+ maxX: bottomRight.x,
+ maxY: bottomRight.y,
+ };
+};
+
+/**
+ * Recompute pan so the world point at the old canvas center stays centered
+ * after a resize / DPI change, leaving zoom untouched. Because only the
+ * transform changes, drawings never distort: resize changes how much of the
+ * world is visible, not its proportions.
+ */
+export const recenterAfterResize = (
+ vp: Viewport,
+ oldW: number,
+ oldH: number,
+ newW: number,
+ newH: number,
+): Viewport => {
+ const center = screenToWorld(vp, { x: oldW / 2, y: oldH / 2 });
+ return {
+ zoom: vp.zoom,
+ panX: center.x - newW / 2 / vp.zoom,
+ panY: center.y - newH / 2 / vp.zoom,
+ };
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/index.ts b/sample-apps/react/react-dogfood/components/Whiteboard/index.ts
new file mode 100644
index 0000000000..9b425e6556
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/index.ts
@@ -0,0 +1,3 @@
+export { Whiteboard } from './Whiteboard';
+export { ToggleWhiteboardButton } from './ToggleWhiteboardButton';
+export { useWhiteboard, type WhiteboardApi } from './useWhiteboard';
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/sync/PenPlayback.ts b/sample-apps/react/react-dogfood/components/Whiteboard/sync/PenPlayback.ts
new file mode 100644
index 0000000000..5d78319d54
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/sync/PenPlayback.ts
@@ -0,0 +1,140 @@
+/**
+ * Receive-side smoothing for remote pen strokes. Because the outbound funnel is
+ * throttled to ~1 send/sec, a remote pen batch carries ~1s of points at once.
+ * Applying them instantly makes the stroke lurch forward each second; instead
+ * we hold the full received path as a per-element "target" and reveal a growing
+ * prefix of it into the store over ~0.9s via requestAnimationFrame, so the
+ * stroke grows continuously. This hides the chunkiness but not the inherent
+ * ~1s latency of the channel.
+ *
+ * Only pen ops are routed here. The caller applies everything else (line, rect,
+ * text, remove, clear) directly, and calls cancel/cancelAll so a removed or
+ * cleared element stops playing back.
+ */
+import type { SceneStore } from '../core/SceneStore';
+import type { Op, Point } from '../core/model';
+import { MIN_SEND_INTERVAL_MS } from './WhiteboardSender';
+
+// Drain a backlog over ~85% of one send interval: fast enough to stay caught up
+// between batches, leaving a small gap rather than running dry on a late batch.
+const DRAIN_FRAMES = Math.max(
+ 6,
+ Math.round((MIN_SEND_INTERVAL_MS * 0.85) / (1000 / 60)),
+);
+// Drop a caught-up entry after this much idle so target paths are not retained.
+const IDLE_PRUNE_MS = 2500;
+
+interface Entry {
+ target: Point[];
+ revealed: number;
+ version: number;
+ caughtUpSince: number | null;
+}
+
+export class PenPlayback {
+ private store: SceneStore;
+ private entries: Map = new Map();
+ private frame = 0;
+
+ constructor(store: SceneStore) {
+ this.store = store;
+ }
+
+ /** Route a remote pen op (caller guarantees its epoch matches the store). */
+ ingest = (op: Op): void => {
+ if (op.op === 'upsert') {
+ const element = op.element;
+ if (element.type !== 'pen') return;
+ const existing = this.entries.get(element.id);
+ if (existing) {
+ existing.target = element.points.slice();
+ existing.version = Math.max(existing.version, element.version);
+ existing.caughtUpSince = null;
+ } else {
+ // Materialize the element (keeping any points it already has, e.g. from
+ // a snapshot), then reveal the remaining points into it.
+ const current = this.store.getDocument().elements[element.id];
+ const startPoints =
+ current && current.type === 'pen' ? current.points : [];
+ this.store.apply({
+ op: 'upsert',
+ epoch: op.epoch,
+ element: { ...element, points: startPoints.slice() },
+ });
+ this.entries.set(element.id, {
+ target: element.points.slice(),
+ revealed: startPoints.length,
+ version: element.version,
+ caughtUpSince: null,
+ });
+ }
+ } else if (op.op === 'append') {
+ let entry = this.entries.get(op.id);
+ if (!entry) {
+ // Entry was pruned (or we joined mid-stroke); rebuild from the store.
+ const element = this.store.getDocument().elements[op.id];
+ if (!element || element.type !== 'pen') return;
+ entry = {
+ target: element.points.slice(),
+ revealed: element.points.length,
+ version: op.version,
+ caughtUpSince: null,
+ };
+ this.entries.set(op.id, entry);
+ }
+ entry.target = entry.target.concat(op.points);
+ entry.version = Math.max(entry.version, op.version);
+ entry.caughtUpSince = null;
+ } else {
+ return;
+ }
+ this.ensureRunning();
+ };
+
+ cancel = (id: string): void => {
+ this.entries.delete(id);
+ };
+
+ cancelAll = (): void => {
+ this.entries.clear();
+ };
+
+ dispose = (): void => {
+ if (this.frame) cancelAnimationFrame(this.frame);
+ this.frame = 0;
+ this.entries.clear();
+ };
+
+ private ensureRunning = (): void => {
+ if (this.frame) return;
+ this.frame = requestAnimationFrame(this.tick);
+ };
+
+ private tick = (): void => {
+ this.frame = 0;
+ const now = Date.now();
+ let active = false;
+ for (const [id, entry] of this.entries) {
+ const backlog = entry.target.length - entry.revealed;
+ if (backlog > 0) {
+ const step = Math.max(2, Math.ceil(backlog / DRAIN_FRAMES));
+ entry.revealed = Math.min(entry.target.length, entry.revealed + step);
+ this.store.setPenPoints(
+ id,
+ entry.target.slice(0, entry.revealed),
+ entry.version,
+ );
+ entry.caughtUpSince = null;
+ active = true;
+ } else if (entry.caughtUpSince === null) {
+ entry.caughtUpSince = now;
+ active = true;
+ } else if (now - entry.caughtUpSince > IDLE_PRUNE_MS) {
+ this.entries.delete(id);
+ } else {
+ active = true;
+ }
+ }
+ if (active) this.frame = requestAnimationFrame(this.tick);
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSender.ts b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSender.ts
new file mode 100644
index 0000000000..0d1ca433b2
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSender.ts
@@ -0,0 +1,183 @@
+/**
+ * The single rate-limited, coalescing funnel for all outbound whiteboard
+ * events. `sendCustomEvent` is rate-limited per user, so no other code path
+ * calls it directly: everything routes through here.
+ *
+ * - Element ops are buffered and coalesced (pen appends accumulate; repeated
+ * line/rect drafts collapse to the latest per id; a clear drops everything
+ * queued before it) into one `whiteboard.ops` batch flushed at most once per
+ * FLUSH_INTERVAL_MS.
+ * - Discrete events (session toggle, state request, snapshot chunks) are paced
+ * through the same funnel so a snapshot is never bursted.
+ * - On a send error (e.g. rate limit) it backs off exponentially and lets the
+ * next reconciliation snapshot heal any dropped ops.
+ */
+import type { Call, Logger } from '@stream-io/video-react-sdk';
+import type { Op, Point } from '../core/model';
+import { type OutboundEvent, WHITEBOARD_PROTOCOL_VERSION } from './events';
+
+// sendCustomEvent is rate-limited per user (~60/min on the default tier). We
+// send at most one event per MIN_SEND_INTERVAL_MS, coalescing everything drawn
+// in between into a single batch. NOTE: 500ms == ~120/min, which exceeds the
+// 60/min default - chosen for lower latency; if the backend limit is hit, the
+// exponential backoff below absorbs it and the next snapshot heals any gap.
+// Raise this toward ~1100ms to stay safely under 60/min.
+export const MIN_SEND_INTERVAL_MS = 500;
+const LEADING_DELAY_MS = 40;
+const BASE_BACKOFF_MS = 2000;
+const MAX_BACKOFF_MS = 15000;
+
+type Dispatchable = OutboundEvent | { type: 'whiteboard.ops'; ops: Op[] };
+
+export class WhiteboardSender {
+ private call: Call;
+ private sessionId: string;
+ private logger: Logger;
+
+ private pendingClear: Extract | null = null;
+ private pendingById: Map = new Map();
+ private urgent: OutboundEvent[] = [];
+ private bulk: OutboundEvent[] = [];
+ private preferBulk = false;
+
+ private timer: ReturnType | null = null;
+ private nextAllowedAt = 0;
+ private backoffMs = 0;
+ private disposed = false;
+
+ constructor(call: Call, sessionId: string, logger: Logger) {
+ this.call = call;
+ this.sessionId = sessionId;
+ this.logger = logger;
+ }
+
+ /** Buffer an element op for coalescing into the next `whiteboard.ops` batch. */
+ enqueueOp = (op: Op): void => {
+ if (this.disposed) return;
+ if (op.op === 'clear') {
+ this.pendingClear = op;
+ this.pendingById.clear();
+ } else {
+ const id = op.op === 'upsert' ? op.element.id : op.id;
+ this.pendingById.set(id, this.merge(this.pendingById.get(id), op));
+ }
+ this.scheduleFlush();
+ };
+
+ /** Queue a discrete event; `urgent` (session) jumps ahead of bulk/snapshot. */
+ enqueue = (event: OutboundEvent, priority: 'urgent' | 'bulk'): void => {
+ if (this.disposed) return;
+ if (priority === 'urgent') this.urgent.push(event);
+ else this.bulk.push(event);
+ this.scheduleFlush();
+ };
+
+ dispose = (): void => {
+ this.disposed = true;
+ if (this.timer) clearTimeout(this.timer);
+ this.timer = null;
+ this.pendingById.clear();
+ this.pendingClear = null;
+ this.urgent = [];
+ this.bulk = [];
+ };
+
+ private merge = (existing: Op | undefined, incoming: Op): Op => {
+ if (incoming.op !== 'append') return incoming;
+ if (existing?.op === 'upsert' && existing.element.type === 'pen') {
+ const points = existing.element.points.concat(incoming.points);
+ return {
+ op: 'upsert',
+ epoch: incoming.epoch,
+ element: { ...existing.element, points, version: incoming.version },
+ };
+ }
+ if (existing?.op === 'append') {
+ const points: Point[] = existing.points.concat(incoming.points);
+ return { ...existing, points, version: incoming.version };
+ }
+ return incoming;
+ };
+
+ private hasPendingWork = (): boolean =>
+ this.urgent.length > 0 ||
+ this.bulk.length > 0 ||
+ this.pendingClear !== null ||
+ this.pendingById.size > 0;
+
+ private scheduleFlush = (): void => {
+ if (this.disposed || this.timer) return;
+ const delay = Math.max(LEADING_DELAY_MS, this.nextAllowedAt - Date.now());
+ this.timer = setTimeout(this.flush, delay);
+ };
+
+ private flush = (): void => {
+ this.timer = null;
+ if (this.disposed) return;
+ if (Date.now() < this.nextAllowedAt) {
+ this.scheduleFlush();
+ return;
+ }
+ const event = this.pickNext();
+ if (event) this.dispatch(event);
+ if (this.hasPendingWork()) this.scheduleFlush();
+ };
+
+ private pickNext = (): Dispatchable | null => {
+ if (this.urgent.length) return this.urgent.shift()!;
+ const hasOps = this.pendingClear !== null || this.pendingById.size > 0;
+ const hasBulk = this.bulk.length > 0;
+ if (hasOps && hasBulk) {
+ this.preferBulk = !this.preferBulk;
+ return this.preferBulk ? this.bulk.shift()! : this.buildOpsEvent();
+ }
+ if (hasOps) return this.buildOpsEvent();
+ if (hasBulk) return this.bulk.shift()!;
+ return null;
+ };
+
+ private buildOpsEvent = (): { type: 'whiteboard.ops'; ops: Op[] } => {
+ const ops: Op[] = [];
+ if (this.pendingClear) {
+ ops.push(this.pendingClear);
+ this.pendingClear = null;
+ }
+ for (const op of this.pendingById.values()) ops.push(op);
+ this.pendingById.clear();
+ return { type: 'whiteboard.ops', ops };
+ };
+
+ private dispatch = (event: Dispatchable): void => {
+ // Reserve the next slot up front (the request goes out now), so spacing
+ // holds regardless of how long the response takes.
+ this.nextAllowedAt = Date.now() + MIN_SEND_INTERVAL_MS;
+ const payload = {
+ ...event,
+ v: WHITEBOARD_PROTOCOL_VERSION,
+ sid: this.sessionId,
+ };
+ this.call
+ .sendCustomEvent(payload)
+ .then(() => {
+ this.backoffMs = 0;
+ })
+ .catch((err) => {
+ this.logger('warn', 'whiteboard: custom event send failed', err);
+ this.requeue(event);
+ this.backoffMs = this.backoffMs
+ ? Math.min(this.backoffMs * 2, MAX_BACKOFF_MS)
+ : BASE_BACKOFF_MS;
+ this.nextAllowedAt = Date.now() + this.backoffMs;
+ this.scheduleFlush();
+ });
+ };
+
+ // Element-op batches are not requeued: the periodic reconciliation snapshot
+ // heals dropped ops. Discrete events are requeued so an open/close toggle or
+ // a snapshot chunk is not silently lost on a transient failure.
+ private requeue = (event: Dispatchable): void => {
+ if (this.disposed) return;
+ if (event.type === 'whiteboard.session') this.urgent.unshift(event);
+ else if (event.type !== 'whiteboard.ops') this.bulk.unshift(event);
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardStore.ts b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardStore.ts
new file mode 100644
index 0000000000..532896d0e7
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardStore.ts
@@ -0,0 +1,13 @@
+/**
+ * Persistence seam. v1 ships ephemeral (no store): the board lives only in
+ * peer memory and is reconstructed for late joiners from a peer snapshot, so it
+ * is lost when the last participant leaves. Providing a WhiteboardStore (Phase
+ * 2, e.g. RestWhiteboardStore backed by pages/api/whiteboard/[callId].ts) adds
+ * durable persistence without touching the sync layer.
+ */
+import type { WhiteboardDocument } from '../core/model';
+
+export interface WhiteboardStore {
+ load(callCid: string): Promise;
+ save(callCid: string, doc: WhiteboardDocument): Promise;
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSync.ts b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSync.ts
new file mode 100644
index 0000000000..80cc8b7609
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/sync/WhiteboardSync.ts
@@ -0,0 +1,440 @@
+/**
+ * Bridges the SceneStore to the Stream custom-event channel. It applies inbound
+ * ops, dedupes echoes, owns the synced open flag, answers late joiners, and
+ * periodically reconciles. All outbound traffic goes through WhiteboardSender;
+ * this class never calls call.sendCustomEvent directly.
+ *
+ * Convergence rests on last-write-wins by version plus the clear epoch
+ * (SceneStore.apply). Snapshots are therefore always *merged*, not replaced:
+ * each element is applied as an upsert, so a snapshot can heal an established
+ * client (fills gaps, never drops its newer strokes) and bootstrap a late
+ * joiner (who starts empty) with the same code path. Because order does not
+ * matter under LWW, live ops applied while a snapshot is in transit converge
+ * with it regardless of arrival order, so no separate op buffering is needed.
+ */
+import type {
+ Call,
+ CustomVideoEvent,
+ Logger,
+} from '@stream-io/video-react-sdk';
+import type { SceneStore } from '../core/SceneStore';
+import type { Op, WhiteboardDocument } from '../core/model';
+import {
+ chunk,
+ deserialize,
+ reassemble,
+ serialize,
+} from '../core/serialization';
+import { parseWhiteboardEvent, type SnapshotEvent } from './events';
+import { PenPlayback } from './PenPlayback';
+import { WhiteboardSender } from './WhiteboardSender';
+
+const RECONCILE_INTERVAL_MS = 10_000;
+const SNAPSHOT_TIMEOUT_MS = 3_000;
+const RESPONSE_SLOT_MS = 150;
+const MAX_SNAPSHOT_RETRIES = 2;
+// How long a remote drawer's name stays attached to an element after their
+// last op for it. Refreshes on every op, so it shows while drawing and fades
+// shortly after they stop.
+const PRESENCE_TTL_MS = 2_000;
+
+export interface SessionActor {
+ name: string;
+}
+
+export type OpenListener = (open: boolean, actor?: SessionActor) => void;
+
+interface SnapshotAssembly {
+ snapshotId: string;
+ n: number;
+ open: boolean;
+ parts: Map;
+ timer: ReturnType;
+}
+
+export class WhiteboardSync {
+ private call: Call;
+ private sessionId: string;
+ private store: SceneStore;
+ private logger: Logger;
+ private sender: WhiteboardSender;
+ private playback: PenPlayback;
+
+ private open = false;
+ private openListeners: Set = new Set();
+ private unsubscribeCustom: (() => void) | null = null;
+
+ private reconcileTimer: ReturnType | null = null;
+ private lastSnapshotRevision = -1;
+
+ private assembly: SnapshotAssembly | null = null;
+ private awaitingSnapshot = false;
+ private awaitTimer: ReturnType | null = null;
+ private snapshotRetries = 0;
+
+ private pendingResponse: {
+ timer: ReturnType;
+ snapshotId: string;
+ } | null = null;
+ private snapshotCounter = 0;
+
+ // elementId -> { drawer name, last-activity timestamp } for remote drawings.
+ private presence: Map = new Map();
+ private presenceTimer: ReturnType | null = null;
+ private presenceListeners: Set<() => void> = new Set();
+
+ constructor(
+ call: Call,
+ sessionId: string,
+ store: SceneStore,
+ logger: Logger,
+ ) {
+ this.call = call;
+ this.sessionId = sessionId;
+ this.store = store;
+ this.logger = logger;
+ this.sender = new WhiteboardSender(call, sessionId, logger);
+ this.playback = new PenPlayback(store);
+ }
+
+ start = (): void => {
+ if (this.unsubscribeCustom) return;
+ this.unsubscribeCustom = this.call.on('custom', this.onCustomEvent);
+ this.requestState();
+ };
+
+ dispose = (): void => {
+ this.unsubscribeCustom?.();
+ this.unsubscribeCustom = null;
+ this.stopReconcile();
+ this.clearAwaitTimer();
+ if (this.assembly) clearTimeout(this.assembly.timer);
+ this.assembly = null;
+ if (this.pendingResponse) clearTimeout(this.pendingResponse.timer);
+ this.pendingResponse = null;
+ if (this.presenceTimer) clearTimeout(this.presenceTimer);
+ this.presenceTimer = null;
+ this.presence.clear();
+ this.presenceListeners.clear();
+ this.playback.dispose();
+ this.sender.dispose();
+ this.openListeners.clear();
+ };
+
+ isOpen = (): boolean => this.open;
+
+ /** Open/close the shared board for everyone (local action). */
+ setOpen = (next: boolean): void => this.setOpenLocal(next);
+
+ subscribeOpen = (listener: OpenListener): (() => void) => {
+ this.openListeners.add(listener);
+ return () => {
+ this.openListeners.delete(listener);
+ };
+ };
+
+ /** Notified when remote drawing presence changes (for live name labels). */
+ subscribePresence = (listener: () => void): (() => void) => {
+ this.presenceListeners.add(listener);
+ return () => {
+ this.presenceListeners.delete(listener);
+ };
+ };
+
+ /** elementId -> drawer name for elements a remote peer is actively drawing. */
+ getActiveDrawers = (): Map => {
+ const cutoff = Date.now() - PRESENCE_TTL_MS;
+ const active = new Map();
+ for (const [id, entry] of this.presence) {
+ if (entry.ts < cutoff) this.presence.delete(id);
+ else active.set(id, entry.name);
+ }
+ return active;
+ };
+
+ /** Apply a local op optimistically and enqueue it for sending. */
+ applyLocalOp = (op: Op): void => {
+ this.store.apply(op);
+ this.sender.enqueueOp(op);
+ };
+
+ /** Re-request state after a reconnect to catch up events missed offline. */
+ onReconnected = (): void => {
+ this.requestState();
+ };
+
+ private setOpenLocal = (next: boolean): void => {
+ if (this.open === next) return;
+ this.applyOpen(next);
+ this.sender.enqueue({ type: 'whiteboard.session', open: next }, 'urgent');
+ };
+
+ private applyOpen = (next: boolean, actor?: SessionActor): void => {
+ if (this.open === next) return;
+ this.open = next;
+ if (next) this.startReconcile();
+ else this.stopReconcile();
+ for (const listener of this.openListeners) listener(next, actor);
+ };
+
+ private notifyPresence = (): void => {
+ for (const listener of this.presenceListeners) listener();
+ };
+
+ // One repaint shortly after the last activity so an expired label is cleared
+ // (live updates while drawing already ride the store-change repaint).
+ private schedulePresenceExpiry = (): void => {
+ if (this.presenceTimer) clearTimeout(this.presenceTimer);
+ this.presenceTimer = setTimeout(() => {
+ this.presenceTimer = null;
+ this.notifyPresence();
+ }, PRESENCE_TTL_MS + 100);
+ };
+
+ private onCustomEvent = (event: CustomVideoEvent): void => {
+ if (event.type !== 'custom') return;
+ const parsed = parseWhiteboardEvent(
+ event.custom as Record,
+ );
+ if (!parsed) return;
+ if (parsed.sid === this.sessionId) return; // echo dedupe (echo-agnostic)
+
+ switch (parsed.type) {
+ case 'whiteboard.session': {
+ const name = event.user?.name || event.user?.id || 'Someone';
+ this.applyOpen(parsed.open, { name });
+ return;
+ }
+ case 'whiteboard.ops': {
+ const name = event.user?.name || event.user?.id || 'Someone';
+ for (const op of parsed.ops) {
+ if (op.op === 'upsert' || op.op === 'append') {
+ const id = op.op === 'upsert' ? op.element.id : op.id;
+ this.presence.set(id, { name, ts: Date.now() });
+ }
+ this.applyInbound(op);
+ }
+ this.schedulePresenceExpiry();
+ this.notifyPresence();
+ return;
+ }
+ case 'whiteboard.request-state':
+ this.scheduleSnapshotResponse();
+ return;
+ case 'whiteboard.snapshot':
+ this.handleSnapshot(parsed);
+ return;
+ }
+ };
+
+ // Pen ops are smoothed via PenPlayback; everything else applies immediately.
+ // remove/clear cancel any in-flight playback for the affected element(s).
+ private applyInbound = (op: Op): void => {
+ if (op.op === 'clear') {
+ this.playback.cancelAll();
+ this.store.apply(op);
+ return;
+ }
+ if (op.op === 'remove') {
+ this.playback.cancel(op.id);
+ this.store.apply(op);
+ return;
+ }
+ const isPen = op.op === 'append' || op.element.type === 'pen';
+ if (!isPen) {
+ this.store.apply(op);
+ return;
+ }
+ // Smooth only when the epoch matches; otherwise let the store resolve the
+ // clear epoch (drop a stale op / wipe on a missed clear) and reset playback.
+ if (op.epoch === this.store.getEpoch()) {
+ this.playback.ingest(op);
+ } else {
+ this.playback.cancelAll();
+ this.store.apply(op);
+ }
+ };
+
+ // ---- late joiner: requesting and receiving snapshots ---------------------
+
+ private requestState = (): void => {
+ if (this.peerCount() === 0) return;
+ this.awaitingSnapshot = true;
+ this.sender.enqueue({ type: 'whiteboard.request-state' }, 'bulk');
+ this.resetAwaitTimer();
+ };
+
+ private resetAwaitTimer = (): void => {
+ this.clearAwaitTimer();
+ this.awaitTimer = setTimeout(this.onAwaitTimeout, SNAPSHOT_TIMEOUT_MS);
+ };
+
+ private clearAwaitTimer = (): void => {
+ if (this.awaitTimer) clearTimeout(this.awaitTimer);
+ this.awaitTimer = null;
+ };
+
+ private onAwaitTimeout = (): void => {
+ this.awaitTimer = null;
+ if (this.assembly) {
+ clearTimeout(this.assembly.timer);
+ this.assembly = null;
+ }
+ if (this.snapshotRetries < MAX_SNAPSHOT_RETRIES && this.peerCount() > 0) {
+ this.snapshotRetries++;
+ this.requestState();
+ } else {
+ this.awaitingSnapshot = false;
+ this.snapshotRetries = 0;
+ }
+ };
+
+ private handleSnapshot = (event: SnapshotEvent): void => {
+ // Another peer is responding; drop our own pending response if any.
+ if (this.pendingResponse) {
+ clearTimeout(this.pendingResponse.timer);
+ this.pendingResponse = null;
+ }
+
+ let assembly = this.assembly;
+ if (!assembly || assembly.snapshotId !== event.snapshotId) {
+ if (assembly) clearTimeout(assembly.timer);
+ assembly = {
+ snapshotId: event.snapshotId,
+ n: event.n,
+ open: event.open,
+ parts: new Map(),
+ timer: setTimeout(this.onAssemblyTimeout, SNAPSHOT_TIMEOUT_MS),
+ };
+ this.assembly = assembly;
+ }
+ assembly.parts.set(event.i, event.data);
+ assembly.open = event.open;
+
+ if (assembly.parts.size < assembly.n) return;
+ clearTimeout(assembly.timer);
+ this.assembly = null;
+ this.completeSnapshot(assembly);
+ };
+
+ private onAssemblyTimeout = (): void => {
+ this.assembly = null;
+ };
+
+ private completeSnapshot = (assembly: SnapshotAssembly): void => {
+ const ordered: string[] = [];
+ for (let i = 0; i < assembly.n; i++) {
+ const part = assembly.parts.get(i);
+ if (part === undefined) return; // incomplete; await timer will re-request
+ ordered.push(part);
+ }
+ const doc = deserialize(reassemble(ordered));
+ if (!doc) return;
+ this.mergeSnapshot(doc);
+ if (this.awaitingSnapshot) {
+ this.applyOpen(assembly.open);
+ this.awaitingSnapshot = false;
+ this.snapshotRetries = 0;
+ this.clearAwaitTimer();
+ }
+ };
+
+ private mergeSnapshot = (doc: WhiteboardDocument): void => {
+ // Adopt a newer clear epoch even when the snapshot is empty.
+ if (doc.epoch > this.store.getEpoch()) {
+ this.store.apply({ op: 'clear', epoch: doc.epoch });
+ }
+ for (const id in doc.elements) {
+ this.store.apply({
+ op: 'upsert',
+ epoch: doc.epoch,
+ element: doc.elements[id],
+ });
+ }
+ };
+
+ // ---- snapshot responder election -----------------------------------------
+
+ private scheduleSnapshotResponse = (): void => {
+ if (!this.holdsState() || this.pendingResponse) return;
+ const snapshotId = `${this.sessionId}-snap-${this.snapshotCounter++}`;
+ const timer = setTimeout(() => {
+ this.pendingResponse = null;
+ this.broadcastSnapshot(snapshotId);
+ }, this.responseSlotDelay());
+ this.pendingResponse = { timer, snapshotId };
+ };
+
+ private broadcastSnapshot = (snapshotId: string): void => {
+ const data = serialize(this.store.getDocument());
+ const chunks = chunk(data);
+ const n = chunks.length;
+ for (let i = 0; i < n; i++) {
+ this.sender.enqueue(
+ {
+ type: 'whiteboard.snapshot',
+ snapshotId,
+ i,
+ n,
+ data: chunks[i],
+ open: this.open,
+ },
+ 'bulk',
+ );
+ }
+ this.lastSnapshotRevision = this.store.getRevision();
+ };
+
+ private holdsState = (): boolean => this.open || !this.store.isEmpty();
+
+ // ---- periodic reconciliation ---------------------------------------------
+
+ private startReconcile = (): void => {
+ if (this.reconcileTimer) return;
+ this.reconcileTimer = setInterval(
+ this.tickReconcile,
+ RECONCILE_INTERVAL_MS,
+ );
+ };
+
+ private stopReconcile = (): void => {
+ if (this.reconcileTimer) clearInterval(this.reconcileTimer);
+ this.reconcileTimer = null;
+ };
+
+ private tickReconcile = (): void => {
+ if (!this.open || !this.isReconcileOwner()) return;
+ if (this.store.getRevision() === this.lastSnapshotRevision) return;
+ this.broadcastSnapshot(`${this.sessionId}-recon-${this.snapshotCounter++}`);
+ };
+
+ // ---- participant helpers --------------------------------------------------
+
+ private participantSessionIds = (): string[] => {
+ try {
+ return this.call.state.participants
+ .map((p) => p.sessionId)
+ .filter((id): id is string => !!id);
+ } catch (err) {
+ this.logger('warn', 'whiteboard: unable to read participants', err);
+ return [this.sessionId];
+ }
+ };
+
+ private peerCount = (): number =>
+ this.participantSessionIds().filter((id) => id !== this.sessionId).length;
+
+ private isReconcileOwner = (): boolean => {
+ const ids = this.participantSessionIds();
+ if (ids.length === 0) return true;
+ let smallest = ids[0];
+ for (const id of ids) if (id < smallest) smallest = id;
+ return smallest === this.sessionId;
+ };
+
+ private responseSlotDelay = (): number => {
+ const ids = this.participantSessionIds().slice().sort();
+ const index = ids.indexOf(this.sessionId);
+ return (index < 0 ? ids.length : index) * RESPONSE_SLOT_MS;
+ };
+}
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/sync/events.ts b/sample-apps/react/react-dogfood/components/Whiteboard/sync/events.ts
new file mode 100644
index 0000000000..f341e7053e
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/sync/events.ts
@@ -0,0 +1,172 @@
+/**
+ * Wire protocol for the whiteboard custom-event channel. Every event lives
+ * under `event.custom` with the envelope { type: 'whiteboard.', v, sid }.
+ *
+ * Inbound events come from other clients, so everything is shape-validated and
+ * anything malformed or unknown is silently ignored (no warn-spam): the host
+ * owns diagnostics, not us.
+ */
+import type { Op, Point, WhiteboardElement } from '../core/model';
+
+export const WHITEBOARD_PROTOCOL_VERSION = 1;
+
+export const WHITEBOARD_EVENT_PREFIX = 'whiteboard.';
+
+interface BaseEnvelope {
+ v: number;
+ sid: string;
+}
+
+export interface SessionEvent extends BaseEnvelope {
+ type: 'whiteboard.session';
+ open: boolean;
+}
+
+export interface OpsEvent extends BaseEnvelope {
+ type: 'whiteboard.ops';
+ ops: Op[];
+}
+
+export interface RequestStateEvent extends BaseEnvelope {
+ type: 'whiteboard.request-state';
+}
+
+export interface SnapshotEvent extends BaseEnvelope {
+ type: 'whiteboard.snapshot';
+ snapshotId: string;
+ /** Chunk index (0-based). */
+ i: number;
+ /** Total chunk count. */
+ n: number;
+ /** One chunk of JSON.stringify(document). */
+ data: string;
+ open: boolean;
+}
+
+export type WhiteboardEvent =
+ | SessionEvent
+ | OpsEvent
+ | RequestStateEvent
+ | SnapshotEvent;
+
+/** Outbound event minus the envelope fields the sender stamps (v, sid). */
+export type OutboundEvent =
+ | { type: 'whiteboard.session'; open: boolean }
+ | { type: 'whiteboard.request-state' }
+ | {
+ type: 'whiteboard.snapshot';
+ snapshotId: string;
+ i: number;
+ n: number;
+ data: string;
+ open: boolean;
+ };
+
+const isFiniteNumber = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value);
+
+const isString = (value: unknown): value is string => typeof value === 'string';
+
+const isPoint = (value: unknown): value is Point => {
+ if (!value || typeof value !== 'object') return false;
+ const p = value as Record;
+ return isFiniteNumber(p.x) && isFiniteNumber(p.y);
+};
+
+const isPointArray = (value: unknown): value is Point[] =>
+ Array.isArray(value) && value.every(isPoint);
+
+const isElement = (value: unknown): value is WhiteboardElement => {
+ if (!value || typeof value !== 'object') return false;
+ const e = value as Record;
+ if (!isString(e.id) || !isFiniteNumber(e.version)) return false;
+ if (!isString(e.strokeColor) || !isFiniteNumber(e.strokeWidth)) return false;
+ switch (e.type) {
+ case 'pen':
+ return isPointArray(e.points);
+ case 'line':
+ return isPoint(e.a) && isPoint(e.b);
+ case 'rect':
+ return (
+ isFiniteNumber(e.x) &&
+ isFiniteNumber(e.y) &&
+ isFiniteNumber(e.w) &&
+ isFiniteNumber(e.h)
+ );
+ case 'text':
+ return (
+ isFiniteNumber(e.x) &&
+ isFiniteNumber(e.y) &&
+ isString(e.text) &&
+ isFiniteNumber(e.fontSize)
+ );
+ default:
+ return false;
+ }
+};
+
+const isOp = (value: unknown): value is Op => {
+ if (!value || typeof value !== 'object') return false;
+ const o = value as Record;
+ if (!isFiniteNumber(o.epoch)) return false;
+ switch (o.op) {
+ case 'upsert':
+ return isElement(o.element);
+ case 'append':
+ return (
+ isString(o.id) && isPointArray(o.points) && isFiniteNumber(o.version)
+ );
+ case 'remove':
+ return isString(o.id) && isFiniteNumber(o.version);
+ case 'clear':
+ return true;
+ default:
+ return false;
+ }
+};
+
+/**
+ * Validate and narrow a raw `event.custom` payload to a WhiteboardEvent, or
+ * return null when it is not a (well-formed) whiteboard event.
+ */
+export const parseWhiteboardEvent = (
+ custom: Record | null | undefined,
+): WhiteboardEvent | null => {
+ if (!custom || typeof custom !== 'object') return null;
+ const { type, v, sid } = custom;
+ if (!isString(type) || !type.startsWith(WHITEBOARD_EVENT_PREFIX)) return null;
+ if (!isFiniteNumber(v) || v !== WHITEBOARD_PROTOCOL_VERSION) return null;
+ if (!isString(sid)) return null;
+
+ switch (type) {
+ case 'whiteboard.session':
+ return typeof custom.open === 'boolean'
+ ? { type, v, sid, open: custom.open }
+ : null;
+ case 'whiteboard.ops':
+ return Array.isArray(custom.ops) && custom.ops.every(isOp)
+ ? { type, v, sid, ops: custom.ops as Op[] }
+ : null;
+ case 'whiteboard.request-state':
+ return { type, v, sid };
+ case 'whiteboard.snapshot':
+ return isString(custom.snapshotId) &&
+ isFiniteNumber(custom.i) &&
+ isFiniteNumber(custom.n) &&
+ isString(custom.data) &&
+ typeof custom.open === 'boolean'
+ ? {
+ type,
+ v,
+ sid,
+ snapshotId: custom.snapshotId,
+ i: custom.i,
+ n: custom.n,
+ data: custom.data,
+ open: custom.open,
+ }
+ : null;
+ default:
+ return null;
+ }
+};
diff --git a/sample-apps/react/react-dogfood/components/Whiteboard/useWhiteboard.ts b/sample-apps/react/react-dogfood/components/Whiteboard/useWhiteboard.ts
new file mode 100644
index 0000000000..097406a398
--- /dev/null
+++ b/sample-apps/react/react-dogfood/components/Whiteboard/useWhiteboard.ts
@@ -0,0 +1,168 @@
+/**
+ * Call-scoped whiteboard hook. Mounted once at the ActiveCall level so the
+ * document and sync run for the whole call, not just while the board is open:
+ * ops keep applying, late joiners can be answered, and the snapshot-responder
+ * role survives a closed board. Instances are created fresh per effect run so
+ * React Strict Mode's mount/unmount/remount cycle stays correct.
+ *
+ * The document is read imperatively by the canvas, never through React state;
+ * the hook only holds coarse UI state (open flag, active tool, color, notice).
+ */
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import {
+ CallingState,
+ type Logger,
+ logToConsole,
+ useCall,
+ useCallStateHooks,
+} from '@stream-io/video-react-sdk';
+
+import { SceneStore } from './core/SceneStore';
+import type { Tool } from './core/model';
+import { Sequencer, type ToolStyle } from './core/tools';
+import { WhiteboardSync } from './sync/WhiteboardSync';
+
+// Chosen for legibility on the fixed light board surface (#f7f7f4); no near-white
+// values. Black stays first so it remains the default stroke color.
+export const WHITEBOARD_PALETTE = [
+ { name: 'Black', value: '#2e2e2e' },
+ { name: 'Grey', value: '#868e96' },
+ { name: 'Red', value: '#e03131' },
+ { name: 'Orange', value: '#e8590c' },
+ { name: 'Gold', value: '#c9920a' },
+ { name: 'Green', value: '#2f9e44' },
+ { name: 'Teal', value: '#0c8599' },
+ { name: 'Blue', value: '#1971c2' },
+ { name: 'Indigo', value: '#3b5bdb' },
+ { name: 'Violet', value: '#7048e8' },
+ { name: 'Pink', value: '#e64980' },
+ { name: 'Brown', value: '#846358' },
+] as const;
+
+export const DEFAULT_WHITEBOARD_COLOR = WHITEBOARD_PALETTE[0].value;
+export const DEFAULT_STROKE_WIDTH = 3;
+const NOTICE_TIMEOUT_MS = 4000;
+
+interface Instances {
+ store: SceneStore;
+ sequencer: Sequencer;
+ sync: WhiteboardSync;
+}
+
+export interface WhiteboardApi {
+ ready: boolean;
+ isOpen: boolean;
+ open: () => void;
+ close: () => void;
+ clear: () => void;
+ activeTool: Tool;
+ setTool: (tool: Tool) => void;
+ color: string;
+ setColor: (color: string) => void;
+ /** Transient message naming a remote actor who opened/closed the board. */
+ notice: string | null;
+ store: SceneStore | null;
+ sync: WhiteboardSync | null;
+ sequencer: Sequencer | null;
+ getStyle: () => ToolStyle;
+}
+
+export const useWhiteboard = (): WhiteboardApi => {
+ const call = useCall();
+ const { useLocalParticipant, useCallCallingState } = useCallStateHooks();
+ const localParticipant = useLocalParticipant();
+ const sessionId = localParticipant?.sessionId;
+ const callingState = useCallCallingState();
+
+ const logger = useMemo(
+ () =>
+ (level, message, ...args) =>
+ logToConsole(level, `[whiteboard] ${message}`, ...args),
+ [],
+ );
+
+ const [instances, setInstances] = useState(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [activeTool, setActiveTool] = useState('pen');
+ const [color, setColor] = useState(DEFAULT_WHITEBOARD_COLOR);
+ const [notice, setNotice] = useState(null);
+
+ const styleRef = useRef({
+ color: DEFAULT_WHITEBOARD_COLOR,
+ width: DEFAULT_STROKE_WIDTH,
+ });
+ useEffect(() => {
+ styleRef.current = { color, width: DEFAULT_STROKE_WIDTH };
+ }, [color]);
+ const getStyle = useCallback(() => styleRef.current, []);
+
+ useEffect(() => {
+ if (!call || !sessionId) return;
+ const store = new SceneStore();
+ const sequencer = new Sequencer(sessionId);
+ const sync = new WhiteboardSync(call, sessionId, store, logger);
+ sync.start();
+ setInstances({ store, sequencer, sync });
+ return () => {
+ sync.dispose();
+ setInstances(null);
+ };
+ }, [call, sessionId, logger]);
+
+ useEffect(() => {
+ if (!instances) return;
+ setIsOpen(instances.sync.isOpen());
+ return instances.sync.subscribeOpen((open, actor) => {
+ setIsOpen(open);
+ if (actor) {
+ setNotice(`${actor.name} ${open ? 'opened' : 'closed'} the whiteboard`);
+ }
+ });
+ }, [instances]);
+
+ useEffect(() => {
+ if (!notice) return;
+ const handle = setTimeout(() => setNotice(null), NOTICE_TIMEOUT_MS);
+ return () => clearTimeout(handle);
+ }, [notice]);
+
+ const prevCallingState = useRef(callingState);
+ useEffect(() => {
+ const prev = prevCallingState.current;
+ prevCallingState.current = callingState;
+ if (
+ instances &&
+ prev === CallingState.RECONNECTING &&
+ callingState === CallingState.JOINED
+ ) {
+ instances.sync.onReconnected();
+ }
+ }, [callingState, instances]);
+
+ const open = useCallback(() => instances?.sync.setOpen(true), [instances]);
+ const close = useCallback(() => instances?.sync.setOpen(false), [instances]);
+ const clear = useCallback(() => {
+ if (!instances) return;
+ instances.sync.applyLocalOp({
+ op: 'clear',
+ epoch: instances.store.getEpoch() + 1,
+ });
+ }, [instances]);
+
+ return {
+ ready: !!instances,
+ isOpen,
+ open,
+ close,
+ clear,
+ activeTool,
+ setTool: setActiveTool,
+ color,
+ setColor,
+ notice,
+ store: instances?.store ?? null,
+ sync: instances?.sync ?? null,
+ sequencer: instances?.sequencer ?? null,
+ getStyle,
+ };
+};
diff --git a/sample-apps/react/react-dogfood/style/Whiteboard/Whiteboard.scss b/sample-apps/react/react-dogfood/style/Whiteboard/Whiteboard.scss
new file mode 100644
index 0000000000..ad692607f5
--- /dev/null
+++ b/sample-apps/react/react-dogfood/style/Whiteboard/Whiteboard.scss
@@ -0,0 +1,223 @@
+@use '../breakpoints' as bp;
+
+// Fixed light board surface, independent of the app theme, so strokes read
+// well. Kept in sync with SURFACE_COLOR in core/renderer.ts.
+$wb-surface: #f7f7f4;
+$wb-accent: #5c84c4;
+$wb-danger: #d96b6b;
+
+.rd__whiteboard {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ width: 100%;
+ min-height: 0;
+ gap: 0.625rem;
+
+ @include bp.respond-above('md') {
+ flex-direction: row;
+ }
+}
+
+.rd__whiteboard__stage {
+ position: relative;
+ flex: 1 1 auto;
+ min-width: 0;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.rd__whiteboard__canvas-wrapper {
+ position: relative;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
+ border-radius: var(--str-video__border-radius-md);
+}
+
+.rd__whiteboard__canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+ background-color: $wb-surface;
+ // let pointer events drive drawing instead of browser scroll/zoom gestures
+ touch-action: none;
+ cursor: crosshair;
+}
+
+.rd__whiteboard__text-input {
+ position: absolute;
+ z-index: 3;
+ min-width: 8rem;
+ min-height: 1.6rem;
+ margin: 0;
+ padding: 2px 4px;
+ border: 1px solid $wb-accent;
+ border-radius: var(--str-video__border-radius-xs);
+ background: rgba(255, 255, 255, 0.96);
+ color: #2e2e2e;
+ font:
+ 20px system-ui,
+ -apple-system,
+ 'Segoe UI',
+ Roboto,
+ sans-serif;
+ line-height: 1.2;
+ resize: none;
+ outline: none;
+}
+
+.rd__whiteboard__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem 0.75rem;
+ margin-top: 0.5rem;
+ padding: 0.5rem;
+ background-color: var(--str-video__background-color1);
+ border-radius: var(--str-video__border-radius-md);
+}
+
+.rd__whiteboard__tool-group {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.375rem;
+ padding-right: 0.75rem;
+ border-right: 1px solid rgba(255, 255, 255, 0.12);
+
+ &:last-child {
+ padding-right: 0;
+ border-right: none;
+ }
+}
+
+.rd__whiteboard__tool-group--colors {
+ max-width: 13rem;
+}
+
+.rd__whiteboard__tool {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: 2px solid transparent;
+ border-radius: var(--str-video__border-radius-xs);
+ background-color: rgba(255, 255, 255, 0.08);
+ color: var(--str-video__text-color1, #fff);
+ cursor: pointer;
+ transition:
+ background-color 0.15s ease,
+ border-color 0.15s ease;
+
+ &:hover {
+ background-color: rgba(255, 255, 255, 0.16);
+ }
+
+ &:focus-visible {
+ outline: 2px solid $wb-accent;
+ outline-offset: 2px;
+ }
+
+ &[data-active='true'] {
+ border-color: $wb-accent;
+ background-color: rgba(92, 132, 196, 0.25);
+ }
+}
+
+.rd__whiteboard__tool--danger {
+ color: $wb-danger;
+}
+
+.rd__whiteboard__swatch {
+ width: 1.5rem;
+ height: 1.5rem;
+ padding: 0;
+ border: 2px solid transparent;
+ border-radius: 50%;
+ box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
+ cursor: pointer;
+ transition: transform 0.15s ease;
+
+ &:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+ }
+
+ &[data-active='true'] {
+ border-color: #fff;
+ transform: scale(1.12);
+ }
+}
+
+.rd__whiteboard__zoom-label {
+ min-width: 3.25rem;
+ height: 2.25rem;
+ border: none;
+ background: transparent;
+ color: var(--str-video__text-color1, #fff);
+ font-variant-numeric: tabular-nums;
+ cursor: pointer;
+
+ &:focus-visible {
+ outline: 2px solid $wb-accent;
+ outline-offset: 2px;
+ }
+}
+
+.rd__whiteboard__confirm {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.rd__whiteboard__confirm-label {
+ color: var(--str-video__text-color1, #fff);
+ font-size: 0.875rem;
+}
+
+.rd__whiteboard__confirm-clear {
+ background-color: $wb-danger;
+ color: #fff;
+}
+
+.rd__whiteboard__participants {
+ display: none;
+
+ @include bp.respond-above('md') {
+ position: relative;
+ display: flex;
+ flex: 0 0 12rem;
+ flex-direction: column;
+ align-items: center;
+ width: 12rem;
+ min-height: 0;
+ gap: 0.5rem;
+ }
+}
+
+.rd__whiteboard__participants-scroll {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 0.75rem;
+ width: 100%;
+ overflow-y: auto;
+ scrollbar-width: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+}
+
+.rd__whiteboard__notice {
+ padding: 0.5rem 1rem;
+ background-color: var(--str-video__background-color1);
+ border-radius: var(--str-video__border-radius-md);
+ color: var(--str-video__text-color1, #fff);
+ font-size: 0.875rem;
+}
diff --git a/sample-apps/react/react-dogfood/style/Whiteboard/index.scss b/sample-apps/react/react-dogfood/style/Whiteboard/index.scss
new file mode 100644
index 0000000000..eef1419dda
--- /dev/null
+++ b/sample-apps/react/react-dogfood/style/Whiteboard/index.scss
@@ -0,0 +1 @@
+@use 'Whiteboard';
diff --git a/sample-apps/react/react-dogfood/style/index.scss b/sample-apps/react/react-dogfood/style/index.scss
index 5654db1d82..378e1eb41d 100644
--- a/sample-apps/react/react-dogfood/style/index.scss
+++ b/sample-apps/react/react-dogfood/style/index.scss
@@ -26,6 +26,7 @@
@use 'CallParticipantsView';
@use 'CallParticipantsScreenView';
@use 'CallLayout';
+@use 'Whiteboard';
@use 'SettingsTabModal';
@use 'ToggleMoreOptionsListButton';