diff --git a/package-lock.json b/package-lock.json index 5afd186..e5d661e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4219,7 +4219,7 @@ }, "packages/core": { "name": "system-canvas", - "version": "0.2.19", + "version": "0.2.20", "license": "MIT", "devDependencies": { "typescript": "^5.4.0" @@ -4227,13 +4227,13 @@ }, "packages/react": { "name": "system-canvas-react", - "version": "0.2.19", + "version": "0.2.20", "license": "MIT", "dependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.0", "d3-zoom": "^3.0.0", - "system-canvas": "^0.2.19" + "system-canvas": "^0.2.20" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -4257,13 +4257,13 @@ }, "packages/standalone": { "name": "system-canvas-standalone", - "version": "0.2.19", + "version": "0.2.20", "license": "MIT", "dependencies": { "react": "^19.0.0", "react-dom": "^19.0.0", - "system-canvas": "^0.2.19", - "system-canvas-react": "^0.2.19" + "system-canvas": "^0.2.20", + "system-canvas-react": "^0.2.20" }, "devDependencies": { "@types/react": "^19.0.0", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5c9fae8..10296d1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -40,6 +40,7 @@ export type { NodeUpdate, EdgeUpdate, NodeMenuOption, + CollaboratorInfo, NodeAction, NodeActionGroup, // Category slots diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6e46202..c8f029c 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1386,3 +1386,22 @@ export interface NodeMenuOption { */ nodeType: NodeType } + +// --------------------------------------------------------------------------- +// Collaboration / presence types +// --------------------------------------------------------------------------- + +/** + * Describes a single remote collaborator currently active on the canvas. + * Consumed by `CollaboratorsOverlay` for cursor, halo, and conflict-flash + * rendering. The library owns zero transport — callers supply this array. + */ +export interface CollaboratorInfo { + id: string + name: string + color: string + /** Canvas-space cursor position, or null when the collaborator has no cursor. */ + cursor: { x: number; y: number } | null + /** The node id the collaborator currently has selected, if any. */ + selectedNodeId?: string +} diff --git a/packages/react/src/components/CollaboratorsOverlay.tsx b/packages/react/src/components/CollaboratorsOverlay.tsx new file mode 100644 index 0000000..71efac0 --- /dev/null +++ b/packages/react/src/components/CollaboratorsOverlay.tsx @@ -0,0 +1,214 @@ +import React, { useEffect, useRef } from 'react' +import type { CollaboratorInfo, ResolvedNode, ViewportState } from 'system-canvas' +import { + canvasToScreen, + canvasRectToScreenRect, +} from 'system-canvas' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface CollaboratorsOverlayProps { + collaborators: CollaboratorInfo[] + viewport: ViewportState + nodeMap: Map + flashNodeIds: Map +} + +// --------------------------------------------------------------------------- +// Keyframe style injection (once per document) +// --------------------------------------------------------------------------- + +const STYLE_ID = 'system-canvas-collab-keyframes' + +function ensureKeyframes(): void { + if (typeof document === 'undefined') return + if (document.getElementById(STYLE_ID)) return + const style = document.createElement('style') + style.id = STYLE_ID + style.textContent = ` +@keyframes sc-collab-flash { + 0% { background: rgba(255,255,255,0.15); transform: translate(0,0); } + 20% { background: rgba(255,255,255,0.18); transform: translate(-3px,0); } + 40% { background: rgba(255,255,255,0.15); transform: translate(3px,0); } + 60% { background: rgba(255,255,255,0.12); transform: translate(-2px,0); } + 80% { background: rgba(255,255,255,0.08); transform: translate(2px,0); } + 100% { background: rgba(255,255,255,0); transform: translate(0,0); } +} +` + document.head.appendChild(style) +} + +// --------------------------------------------------------------------------- +// CollaboratorsOverlay +// --------------------------------------------------------------------------- + +export function CollaboratorsOverlay({ + collaborators, + viewport, + nodeMap, + flashNodeIds, +}: CollaboratorsOverlayProps): React.ReactElement | null { + // Inject keyframes on first render (client-side only) + const keyframesInjected = useRef(false) + if (!keyframesInjected.current) { + ensureKeyframes() + keyframesInjected.current = true + } + + if (collaborators.length === 0 && flashNodeIds.size === 0) return null + + // Group halos by nodeId so we can apply a per-index outset + const halosByNode = new Map() + for (const collab of collaborators) { + if (collab.selectedNodeId) { + const arr = halosByNode.get(collab.selectedNodeId) ?? [] + arr.push(collab) + halosByNode.set(collab.selectedNodeId, arr) + } + } + + return ( +
+ {/* Halo layer */} + {Array.from(halosByNode.entries()).map(([nodeId, collabList]) => { + const node = nodeMap.get(nodeId) + if (!node) return null + const screenRect = canvasRectToScreenRect( + { x: node.x, y: node.y, width: node.width, height: node.height }, + viewport + ) + return collabList.map((collab, idx) => { + const outset = 3 + idx * 2 + return ( +
+ {/* Border ring */} +
+ {/* Name label anchored to top-left of halo */} +
+ {collab.name} +
+
+ ) + }) + })} + + {/* Cursor layer */} + {collaborators.map((collab) => { + if (!collab.cursor) return null + const screen = canvasToScreen(collab.cursor.x, collab.cursor.y, viewport) + // Clip guard: skip cursors well outside visible area + return ( +
+ {/* SVG arrow cursor */} + + + + {/* Name pill */} +
+ {collab.name} +
+
+ ) + })} + + {/* Conflict flash layer */} + {Array.from(flashNodeIds.keys()).map((nodeId) => { + const node = nodeMap.get(nodeId) + if (!node) return null + const screenRect = canvasRectToScreenRect( + { x: node.x, y: node.y, width: node.width, height: node.height }, + viewport + ) + return ( +
+ ) + })} +
+ ) +} diff --git a/packages/react/src/components/SystemCanvas.tsx b/packages/react/src/components/SystemCanvas.tsx index b9b6c67..f0adf32 100644 --- a/packages/react/src/components/SystemCanvas.tsx +++ b/packages/react/src/components/SystemCanvas.tsx @@ -14,6 +14,7 @@ import type { CanvasEdge, CanvasSelection, CanvasTheme, + CollaboratorInfo, EdgeStyle, ViewportState, ContextMenuEvent, @@ -70,6 +71,7 @@ import { type EdgeContextMenuOverlayState, } from './EdgeContextMenuOverlay.js' import { SearchOverlay } from './SearchOverlay.js' +import { CollaboratorsOverlay } from './CollaboratorsOverlay.js' export interface SystemCanvasProps { /** Canvas data to render */ @@ -375,6 +377,15 @@ export interface SystemCanvasProps { /** Called after a redo step. Receives the canvasRef that was affected. */ onRedo?: (canvasRef: string | undefined) => void + // --- Collaboration / presence --- + /** + * Remote collaborators to render as cursors, selection halos, and conflict + * flashes. The library owns zero transport — callers supply this array and + * update it in response to their own Pusher / WebSocket events. Passing an + * empty array (or omitting the prop) is a no-op. + */ + collaborators?: CollaboratorInfo[] + // --- Styling --- className?: string style?: React.CSSProperties @@ -407,6 +418,12 @@ export interface SystemCanvasHandle { navigateBack: () => void /** Reset to the root canvas. */ navigateToRoot: () => void + /** + * Returns the current viewport transform (pan + zoom). Useful for + * converting between screen-space and canvas-space coordinates from + * outside the component (e.g. a collaboration hook tracking the cursor). + */ + getViewport: () => ViewportState } export const SystemCanvas = forwardRef( @@ -458,6 +475,7 @@ export const SystemCanvas = forwardRef( historyDepth, onUndo, onRedo, + collaborators = [], className, style, }, @@ -623,6 +641,15 @@ export const SystemCanvas = forwardRef( ) const viewportHandleRef = useRef(null) + // Mirrors `viewportStateRef` as React state so the collaborators overlay + // re-renders on pan/zoom without requiring the full component to know about it. + const [collaboratorViewport, setCollaboratorViewport] = useState( + defaultViewport ?? { x: 0, y: 0, zoom: 1 } + ) + + // Map of nodeId → expiry timestamp (Date.now() + 600ms) for conflict flash. + const [flashNodeIds, setFlashNodeIds] = useState>(new Map()) + // Stable refs used by the imperative handle. We keep them updated on // every render so the handle methods (created once via // useImperativeHandle) always see fresh state without forcing @@ -682,6 +709,7 @@ export const SystemCanvas = forwardRef( navigateToRoot: () => { navigateToBreadcrumbRef.current(0) }, + getViewport: () => viewportStateRef.current ?? { x: 0, y: 0, zoom: 1 }, }), [forwardedRef] ) @@ -1224,6 +1252,7 @@ export const SystemCanvas = forwardRef( const handleViewportChange = useCallback( (vp: ViewportState) => { viewportStateRef.current = vp + setCollaboratorViewport(vp) handleZoomNavViewportChange(vp) onViewportChange?.(vp) }, @@ -1545,6 +1574,53 @@ export const SystemCanvas = forwardRef( const renderProps: AddNodeButtonRenderProps = { options: menuOptions, addNode, theme } + // Conflict-flash detection: when a collaborator has a node selected and that + // node's position changes externally (i.e. a remote update), briefly flash it. + const prevNodePositionsRef = useRef>(new Map()) + useEffect(() => { + const collaboratorSelectedNodeIds = new Set( + collaborators.map((c) => c.selectedNodeId).filter((id): id is string => !!id) + ) + if (collaboratorSelectedNodeIds.size === 0) { + prevNodePositionsRef.current = new Map( + nodes.map((n) => [n.id, { x: n.x, y: n.y }]) + ) + return + } + + const prev = prevNodePositionsRef.current + const newFlashes: string[] = [] + for (const node of nodes) { + if (!collaboratorSelectedNodeIds.has(node.id)) continue + const prevPos = prev.get(node.id) + if (prevPos && (prevPos.x !== node.x || prevPos.y !== node.y)) { + newFlashes.push(node.id) + } + } + + prevNodePositionsRef.current = new Map(nodes.map((n) => [n.id, { x: n.x, y: n.y }])) + + if (newFlashes.length > 0) { + const expiry = Date.now() + 600 + setFlashNodeIds((prev) => { + const next = new Map(prev) + for (const id of newFlashes) next.set(id, expiry) + return next + }) + setTimeout(() => { + setFlashNodeIds((prev) => { + const now = Date.now() + const next = new Map(prev) + for (const id of newFlashes) { + if ((next.get(id) ?? 0) <= now) next.delete(id) + } + return next + }) + }, 620) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodes, collaborators]) + return (
( highlightedNodeIds={matchingIds} /> + {/* Collaborators overlay — cursors, selection halos, conflict flashes */} + {collaborators.length > 0 || flashNodeIds.size > 0 ? ( + + ) : null} + {/* Sticky lane headers overlay (above the viewport SVG) */} {showLaneHeaders && ( { + const actual = await importOriginal() + return { + ...actual, + canvasToScreen: vi.fn((cx: number, cy: number, vp: ViewportState) => ({ + x: cx * vp.zoom + vp.x, + y: cy * vp.zoom + vp.y, + })), + canvasRectToScreenRect: vi.fn(( + rect: { x: number; y: number; width: number; height: number }, + vp: ViewportState + ) => ({ + x: rect.x * vp.zoom + vp.x, + y: rect.y * vp.zoom + vp.y, + width: rect.width * vp.zoom, + height: rect.height * vp.zoom, + })), + } +}) + +// Import AFTER vi.mock so the mock is in place. +import { CollaboratorsOverlay } from '../CollaboratorsOverlay.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeNode(id: string, x = 0, y = 0, width = 200, height = 100): ResolvedNode { + return { + id, + type: 'text', + x, + y, + width, + height, + text: id, + resolvedFill: '#fff', + resolvedStroke: '#000', + resolvedWidth: width, + resolvedHeight: height, + } as unknown as ResolvedNode +} + +function makeNodeMap(...nodes: ResolvedNode[]): Map { + return new Map(nodes.map((n) => [n.id, n])) +} + +const VIEWPORT: ViewportState = { x: 0, y: 0, zoom: 1 } +const VIEWPORT_2X: ViewportState = { x: 10, y: 20, zoom: 2 } + +// --------------------------------------------------------------------------- +// CollaboratorsOverlay tests +// --------------------------------------------------------------------------- + +describe('CollaboratorsOverlay', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // ------------------------------------------------------------------------- + // Baseline / empty state + // ------------------------------------------------------------------------- + + it('renders nothing when collaborators is empty and no flash', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + // ------------------------------------------------------------------------- + // Overlay container + // ------------------------------------------------------------------------- + + it('overlay container has pointer-events: none', () => { + const collab: CollaboratorInfo = { + id: 'u1', + name: 'Alice', + color: '#ff0000', + cursor: { x: 50, y: 100 }, + } + const { container } = render( + + ) + const overlay = container.firstChild as HTMLElement + expect(overlay).not.toBeNull() + expect(overlay.style.pointerEvents).toBe('none') + }) + + it('overlay container is absolutely positioned and fills its parent', () => { + const collab: CollaboratorInfo = { + id: 'u1', + name: 'Alice', + color: '#ff0000', + cursor: { x: 10, y: 20 }, + } + const { container } = render( + + ) + const overlay = container.firstChild as HTMLElement + expect(overlay.style.position).toBe('absolute') + expect(overlay.style.inset).toBe('0px') + expect(overlay.style.overflow).toBe('hidden') + }) + + // ------------------------------------------------------------------------- + // Cursor layer + // ------------------------------------------------------------------------- + + it('renders a cursor element for a collaborator with a non-null cursor', () => { + const collab: CollaboratorInfo = { + id: 'u1', + name: 'Alice', + color: '#ff0000', + cursor: { x: 50, y: 100 }, + } + render( + + ) + // Name pill should appear + expect(screen.getAllByText('Alice').length).toBeGreaterThanOrEqual(1) + }) + + it('does not render a cursor element when cursor is null', () => { + const collab: CollaboratorInfo = { + id: 'u1', + name: 'GhostUser', + color: '#0000ff', + cursor: null, + } + render( + + ) + // Only the halo label might appear, not the cursor name pill + // Since no selectedNodeId either, no text at all expected + expect(screen.queryByText('GhostUser')).toBeNull() + }) + + it('positions cursor at screen-space coordinates derived from viewport', () => { + // With VIEWPORT_2X: screen = (cx * 2 + 10, cy * 2 + 20) + // cursor (50, 100) → screen (110, 220) + const collab: CollaboratorInfo = { + id: 'u1', + name: 'Bob', + color: '#00ff00', + cursor: { x: 50, y: 100 }, + } + const { container } = render( + + ) + // Find the cursor container div + const overlay = container.firstChild as HTMLElement + const cursorEl = overlay.querySelector('[style*="left: 110px"]') as HTMLElement | null + expect(cursorEl).not.toBeNull() + expect(cursorEl!.style.top).toBe('220px') + }) + + // ------------------------------------------------------------------------- + // Halo layer + // ------------------------------------------------------------------------- + + it('renders a halo around a node that a collaborator has selected', () => { + const node = makeNode('node-1', 100, 200, 300, 150) + const collab: CollaboratorInfo = { + id: 'u2', + name: 'Carol', + color: '#aa00bb', + cursor: null, + selectedNodeId: 'node-1', + } + render( + + ) + // The collaborator name should appear as a halo label + expect(screen.getByText('Carol')).toBeTruthy() + }) + + it('uses collaborator color for the halo border', () => { + const node = makeNode('node-1', 0, 0, 200, 100) + const collab: CollaboratorInfo = { + id: 'u2', + name: 'Carol', + color: '#aa00bb', + cursor: null, + selectedNodeId: 'node-1', + } + const { container } = render( + + ) + // Border div should include the collaborator color + const overlay = container.firstChild as HTMLElement + const borderDiv = overlay.querySelector('[style*="border: 2px solid"]') as HTMLElement | null + expect(borderDiv).not.toBeNull() + // jsdom normalises hex → rgb; check either representation + const borderStyle = borderDiv!.style.border + const hasBorderColor = + borderStyle.includes('#aa00bb') || borderStyle.includes('rgb(170, 0, 187)') + expect(hasBorderColor).toBe(true) + }) + + it('does not render halo when selectedNodeId does not match any node', () => { + const collab: CollaboratorInfo = { + id: 'u2', + name: 'Carol', + color: '#aa00bb', + cursor: null, + selectedNodeId: 'nonexistent-node', + } + const { container } = render( + + ) + // No halo border div + const overlay = container.firstChild as HTMLElement + expect(overlay.querySelector('[style*="border: 2px solid"]')).toBeNull() + }) + + it('stacks halos for multiple collaborators selecting the same node', () => { + const node = makeNode('node-1', 0, 0, 200, 100) + const collaborators: CollaboratorInfo[] = [ + { id: 'u1', name: 'Alice', color: '#ff0000', cursor: null, selectedNodeId: 'node-1' }, + { id: 'u2', name: 'Bob', color: '#0000ff', cursor: null, selectedNodeId: 'node-1' }, + ] + const { container } = render( + + ) + const overlay = container.firstChild as HTMLElement + const haloDivs = overlay.querySelectorAll('[style*="border: 2px solid"]') + expect(haloDivs.length).toBe(2) + }) + + // ------------------------------------------------------------------------- + // Conflict flash + // ------------------------------------------------------------------------- + + it('renders a flash overlay for a nodeId present in flashNodeIds', () => { + const node = makeNode('flash-node', 0, 0, 200, 100) + const flashNodeIds = new Map([['flash-node', Date.now() + 600]]) + const { container } = render( + + ) + const overlay = container.firstChild as HTMLElement + expect(overlay).not.toBeNull() + // Flash div has animation style + const flashDiv = overlay.querySelector('[style*="sc-collab-flash"]') as HTMLElement | null + expect(flashDiv).not.toBeNull() + }) + + it('does not render a flash overlay for a nodeId not in flashNodeIds', () => { + const node = makeNode('other-node', 0, 0, 200, 100) + const flashNodeIds = new Map([['flash-node', Date.now() + 600]]) + const { container } = render( + + ) + // overlay renders because flashNodeIds is non-empty, but 'flash-node' is not in nodeMap + const overlay = container.firstChild as HTMLElement + // flash-node is not in nodeMap, so no flash div should appear + const flashDiv = overlay?.querySelector('[style*="sc-collab-flash"]') + expect(flashDiv).toBeNull() + }) + + it('does not render flash when flashNodeIds is empty', () => { + const { container } = render( + + ) + expect(container.firstChild).toBeNull() + }) + + it('does not crash when collaborators is empty', () => { + expect(() => + render( + + ) + ).not.toThrow() + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 3aa45f4..f790940 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -53,4 +53,6 @@ export { AlignmentGuidesLayer } from './components/AlignmentGuidesLayer.js' export type { AlignDirection } from './components/NodeToolbar.js' // Re-export everything from core for convenience +export { CollaboratorsOverlay } from './components/CollaboratorsOverlay.js' +export type { CollaboratorsOverlayProps } from './components/CollaboratorsOverlay.js' export * from 'system-canvas'