/** * @file cursors-overlay.tsx — foreign peer cursors rendered on the canvas * @scope apps/studio/cursors-overlay.tsx * @purpose Renders one colored SVG arrow + name label per foreign peer at * their published world coords, transformed through the local * viewport so the cursor anchors to canvas content (not to my * screen) — the standard Excalidraw / tldraw multiplayer model. * * Render shape: *
* *
* * Each cursor is positioned via `transform: translate(screenX px, screenY px)` * where (screenX, screenY) = world * viewport.zoom + viewport.{x,y}. No * react-spring / lerp in this ship (just last-known-position render); the * 30 Hz publish cadence keeps it smooth enough for the multiplayer demo. */ import type { JSX } from 'react'; import { memo, useEffect, useState } from 'react'; import { useLiveViewport } from './canvas-lib.tsx'; import { type ForeignAwareness, useCollab, useForeignAwareness } from './use-collab.tsx'; const CURSOR_CSS = ` .dc-cursor-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10000; } .dc-cursor { position: absolute; top: 0; left: 0; pointer-events: none; will-change: transform; /* The arrow tip is the (0,0) anchor of the SVG; the label hangs to the bottom-right so it never occludes the tip itself. */ } .dc-cursor-arrow { display: block; filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.4)); } /* DS colors-presence .cur-label recipe — hue pill, mono 10px, dark * accent-fg text (never white-on-hue, which washed out on light hues). * NOTE: keep this comment backtick-free — it lives inside the CURSOR_CSS * template literal and a stray backtick closes it. */ .dc-cursor-label { position: absolute; /* specimen geometry: the pill hangs below-right of the 15px triangle */ top: 14px; left: 10px; padding: 1px 8px; font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-weight: 500; font-size: 10px; line-height: 1.4; color: var(--maude-hud-accent-fg, oklch(0.180 0.030 268)); border-radius: 999px; white-space: nowrap; max-width: 180px; overflow: hidden; text-overflow: ellipsis; } .dc-peer-selection { position: absolute; top: 0; left: 0; pointer-events: none; border: 2px solid; border-radius: 2px; box-sizing: border-box; will-change: transform, width, height; } .dc-peer-selection__label { position: absolute; top: -18px; left: -2px; padding: 1px 5px; font-family: system-ui, -apple-system, sans-serif; font-weight: 600; font-size: 10px; line-height: 1.3; color: var(--maude-hud-accent-fg, #fff); border-radius: 2px 2px 0 0; white-space: nowrap; } /* Phase 30 / DDR-120 — soft editing-presence. When a peer (human or a bridged * agent) is actively editing this canvas, their cursor pulses gently and the * label carries a ✎ mark. A heads-up, never a lock. */ .dc-cursor--editing .dc-cursor-arrow { animation: dc-cursor-edit-pulse 1.6s ease-in-out infinite; } .dc-cursor-edit-mark { margin-left: 3px; opacity: 0.85; } @keyframes dc-cursor-edit-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } @media (prefers-reduced-motion: reduce) { .dc-cursor { transition: none !important; } .dc-cursor--editing .dc-cursor-arrow { animation: none; } } `.trim(); function ensureCursorStyles(): void { if (typeof document === 'undefined') return; if (document.getElementById('dc-cursor-overlay-css')) return; const s = document.createElement('style'); s.id = 'dc-cursor-overlay-css'; s.textContent = CURSOR_CSS; document.head.appendChild(s); } interface ViewportSnapshot { x: number; y: number; zoom: number; } interface CursorProps { peer: ForeignAwareness; viewport: ViewportSnapshot; } const Cursor = memo(function Cursor({ peer, viewport }: CursorProps): JSX.Element | null { if (!peer.cursor) return null; // world → screen: screen = world * zoom + viewport.{x,y} const screenX = peer.cursor.x * viewport.zoom + viewport.x; const screenY = peer.cursor.y * viewport.zoom + viewport.y; const editing = !!peer.editing; return (
{/* DS colors-presence Pointer — one plain triangle glyph, tinted by its owner (the specimen's exact 24-grid path, no tail/notch). */}
{peer.name} {editing && ( )}
); }); /** * Foreign-selection halos for stamped annotation strokes. Each peer publishes * `annotationSelection: string[]` — stroke `data-id` values from Phase 5. * We resolve each via `[data-id=""]` and render an outlined rect at the * element's current screen bounds. Re-runs every render so geometry changes * (resize, move) stay in sync without manual re-publish. */ interface PeerAnnotationSelectionProps { peer: ForeignAwareness; /** * Unused in the math below — the halo is measured straight off the DOM — but * load-bearing as a PROP: `memo` compares props, and through a pan/zoom the * peer never changes, so without a value that ticks with the camera this * component would bail out of every re-render and leave the halo frozen in * screen space while the stroke it outlines moves away underneath it. */ viewport: ViewportSnapshot; } export const PeerAnnotationSelection = memo(function PeerAnnotationSelection({ peer, }: PeerAnnotationSelectionProps): JSX.Element | null { if (!peer.annotationSelection || peer.annotationSelection.length === 0) return null; if (typeof document === 'undefined') return null; const rects: { id: string; x: number; y: number; w: number; h: number }[] = []; for (const id of peer.annotationSelection) { try { const el = document.querySelector(`[data-id="${CSS.escape(id)}"]`); if (!el) continue; const r = (el as Element).getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) continue; // Pad by 3px so the halo sits OUTSIDE the stroke instead of clipping it. rects.push({ id, x: r.left - 3, y: r.top - 3, w: r.width + 6, h: r.height + 6 }); } catch { /* invalid id token — skip */ } } if (rects.length === 0) return null; return ( <> {rects.map((r, i) => (
{i === 0 && (
{peer.name}
)}
))} ); }); /** * Foreign-selection halo for canvas-shell elements (cdId-based selSet). * The peer publishes selection.cssPath; we re-resolve in the local DOM * each render so pan / zoom / hydration changes don't desync. Falls back * to the published `bounds` (screen-px at publish time) when cssPath * can't be resolved locally. */ interface PeerSelectionProps { peer: ForeignAwareness; /** See PeerAnnotationSelectionProps.viewport — the memo-invalidation tick. */ viewport: ViewportSnapshot; } export const PeerSelection = memo(function PeerSelection({ peer, }: PeerSelectionProps): JSX.Element | null { if (!peer.selection) return null; const { cssPath, bounds } = peer.selection; let rect: { x: number; y: number; w: number; h: number } | null = bounds ?? null; if (cssPath && typeof document !== 'undefined') { try { const el = document.querySelector(cssPath); if (el && el instanceof Element) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.height > 0) { rect = { x: r.left, y: r.top, w: r.width, h: r.height }; } } } catch { /* invalid selector — fall through to published bounds */ } } if (!rect) return null; return (
{peer.name}
); }); /** * Subscribes to foreign awareness + the local viewport. Renders one Cursor per * peer. Reads viewport from `useViewportControllerContext` — if the canvas * doesn't expose a controller (rare edge case), falls back to identity. */ export function CursorsOverlay(): JSX.Element { ensureCursorStyles(); const peers = useForeignAwareness(); // LIVE, not the published viewport. `controller.viewport` republishes only on // the settle (SETTLE_MS), so through a pan/zoom it is one frozen value: every // peer cursor and every peer selection frame sat at the screen position the // camera had when the gesture STARTED and only snapped onto the content half // a second after it ended. Same root cause, same fix as the annotation resize // handles — read the camera that is written on every applyViewport. Settled, // `useLiveViewport()` IS the published value, and it only subscribes to the // shared rAF ticker while a gesture is in flight. const vp = useLiveViewport(); // Bump a render tick whenever annotations change so PeerAnnotationSelection // re-resolves [data-id] bounds. Without this, a peer resizing a stroke // would propagate via Y.Map → local annotations-layer re-render — but the // sibling CursorsOverlay wouldn't re-render, so the annotation halo would // sit on the OLD bounds until awareness independently changed. const collab = useCollab(); const [, bumpAnnotTick] = useState(0); useEffect(() => { if (!collab) return; const map = collab.doc.getMap('annotations'); const onChange = () => bumpAnnotTick((n) => n + 1); map.observe(onChange); return () => { try { map.unobserve(onChange); } catch { /* doc destroyed */ } }; }, [collab]); return ( ); }