Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type {
NodeUpdate,
EdgeUpdate,
NodeMenuOption,
CollaboratorInfo,
NodeAction,
NodeActionGroup,
// Category slots
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
214 changes: 214 additions & 0 deletions packages/react/src/components/CollaboratorsOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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<string, ResolvedNode>
flashNodeIds: Map<string, number>
}

// ---------------------------------------------------------------------------
// 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<string, CollaboratorInfo[]>()
for (const collab of collaborators) {
if (collab.selectedNodeId) {
const arr = halosByNode.get(collab.selectedNodeId) ?? []
arr.push(collab)
halosByNode.set(collab.selectedNodeId, arr)
}
}

return (
<div
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
overflow: 'hidden',
zIndex: 30,
}}
>
{/* 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 (
<div key={`halo-${nodeId}-${collab.id}`}>
{/* Border ring */}
<div
style={{
position: 'absolute',
left: screenRect.x - outset,
top: screenRect.y - outset,
width: screenRect.width + outset * 2,
height: screenRect.height + outset * 2,
border: `2px solid ${collab.color}`,
borderRadius: 6 + outset,
opacity: 0.55,
boxSizing: 'border-box',
}}
/>
{/* Name label anchored to top-left of halo */}
<div
style={{
position: 'absolute',
left: screenRect.x - outset,
top: screenRect.y - outset - 18,
fontSize: 11,
lineHeight: '16px',
padding: '0 5px',
borderRadius: 3,
background: collab.color,
color: '#fff',
whiteSpace: 'nowrap',
fontFamily: 'sans-serif',
fontWeight: 500,
}}
>
{collab.name}
</div>
</div>
)
})
})}

{/* 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 (
<div
key={`cursor-${collab.id}`}
style={{
position: 'absolute',
left: screen.x,
top: screen.y,
transform: 'translate(0, 0)',
userSelect: 'none',
}}
>
{/* SVG arrow cursor */}
<svg
width="16"
height="20"
viewBox="0 0 16 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M1 1L1 15L5 11L8 18L10 17L7 10L13 10L1 1Z"
fill={collab.color}
stroke="#fff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
{/* Name pill */}
<div
style={{
position: 'absolute',
top: 18,
left: 8,
fontSize: 11,
lineHeight: '16px',
padding: '1px 6px',
borderRadius: 8,
background: collab.color,
color: '#fff',
whiteSpace: 'nowrap',
fontFamily: 'sans-serif',
fontWeight: 500,
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
}}
>
{collab.name}
</div>
</div>
)
})}

{/* 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 (
<div
key={`flash-${nodeId}`}
style={{
position: 'absolute',
left: screenRect.x,
top: screenRect.y,
width: screenRect.width,
height: screenRect.height,
borderRadius: 6,
animation: 'sc-collab-flash 600ms ease-out forwards',
pointerEvents: 'none',
}}
/>
)
})}
</div>
)
}
Loading
Loading