- Update docster.cabal to modern format with GHC 9.12.2 compatibility - Fix Mermaid code block detection using getDefaultExtensions - Switch from SVG to PNG output for PDF compatibility - Add CLAUDE.md with development instructions - Update tooling from mise to ghcup for Haskell management Known issues: - Generated diagram files are created in root directory instead of alongside source files - PDF generation fails with LaTeX errors for complex documents (missing \tightlist support) - HTML output lacks proper DOCTYPE (quirks mode) - Debug output still present in code 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1752 lines
59 KiB
Markdown
1752 lines
59 KiB
Markdown
# Participant Collaboration Design: List-First with Optional Cursors
|
|
|
|
## Executive Summary
|
|
|
|
After implementing cursor presence (Story 3), we gained valuable insights about collaboration awareness in drawing applications. While remote cursors provide rich feedback, they can create visual complexity that interferes with the primary drawing experience.
|
|
|
|
This design proposes a **participant list-first approach** where collaboration awareness is primarily communicated through a clean participant list UI, with optional remote cursors as progressive enhancement. The system uses `ParticipantsState` as the foundation for all collaboration features and maintains the tool-aware cursor architecture for when cursors are enabled.
|
|
|
|
### Collaboration Challenges
|
|
|
|
1. **Visual Complexity** - Multiple cursors can clutter the drawing canvas and distract from primary tasks
|
|
2. **Information Overload** - High-frequency cursor updates can overwhelm users during complex drawing
|
|
3. **Essential vs Optional** - Need to distinguish between essential collaboration awareness and optional visual feedback
|
|
4. **Canvas Clarity** - Drawing applications benefit from clean, unobscured workspace
|
|
5. **Progressive Enhancement** - Users should get immediate collaboration value with option for richer feedback
|
|
|
|
### Proposed Solution
|
|
|
|
Implement a **participant list-first collaboration system** with optional enhanced cursors:
|
|
- **Primary UI**: Clean participant list showing active collaborators with status indicators
|
|
- **ParticipantsState**: Centralized state management for all participant data and events
|
|
- **Optional Cursors**: Tool-aware remote cursor system as progressive enhancement
|
|
- **Toggle Control**: Users can enable/disable remote cursors based on preference and context
|
|
|
|
## Participant-First Architecture
|
|
|
|
### Core Components
|
|
|
|
```mermaid
|
|
graph TB
|
|
subgraph "Primary Collaboration UI"
|
|
PL[ParticipantList Component]
|
|
PS[ParticipantsState]
|
|
PI[ParticipantIndicators]
|
|
end
|
|
|
|
subgraph "Optional Cursor System"
|
|
TPC[ToolPointerCoordinator]
|
|
CT[CursorToggle]
|
|
FCS[FrequencyControlSystem]
|
|
end
|
|
|
|
subgraph "Plugin Handlers (When Cursors Enabled)"
|
|
EPH[ElementDragPointerHandler]
|
|
LPH[LineToolPointerHandler]
|
|
CPH[ConnectionToolPointerHandler]
|
|
NPH[NavigationPointerHandler]
|
|
end
|
|
|
|
subgraph "Phoenix Integration"
|
|
PP[Phoenix Presence]
|
|
LV[LiveView Events]
|
|
BC[Broadcast Channel]
|
|
end
|
|
|
|
PS --> PL
|
|
PS --> PI
|
|
PS <--> PP
|
|
PS --> TPC
|
|
|
|
CT --> TPC
|
|
TPC --> EPH
|
|
TPC --> LPH
|
|
TPC --> CPH
|
|
TPC --> NPH
|
|
|
|
PP <--> LV
|
|
LV <--> BC
|
|
```
|
|
|
|
### Data Flow
|
|
|
|
1. **Participant joins** → Phoenix Presence → ParticipantsState → ParticipantList update
|
|
2. **Activity indicators** → Tool/interaction events → ParticipantsState → Status indicators
|
|
3. **Optional cursors** → CursorToggle → ToolPointerCoordinator → Smart positioning
|
|
4. **Presence updates** → Phoenix Presence → Real-time participant list updates
|
|
5. **Clean canvas** → Cursor-free drawing experience with sidebar collaboration awareness
|
|
|
|
## Implementation Design
|
|
|
|
### 1. ParticipantsState - Foundation Layer
|
|
|
|
```typescript
|
|
// Core participant management - foundation for all collaboration features
|
|
export class ParticipantsState {
|
|
private participants = new Map<string, ParticipantContext>()
|
|
private currentSessionId: string
|
|
private presenceCallbacks = new Set<ParticipantPresenceCallback>()
|
|
private activityCallbacks = new Set<ParticipantActivityCallback>()
|
|
|
|
constructor(sessionId: string) {
|
|
this.currentSessionId = sessionId
|
|
}
|
|
|
|
// Core participant management
|
|
addParticipant(participant: ParticipantContext): void {
|
|
this.participants.set(participant.sessionId, participant)
|
|
this.notifyPresenceChange('joined', participant)
|
|
}
|
|
|
|
removeParticipant(sessionId: string): void {
|
|
const participant = this.participants.get(sessionId)
|
|
if (participant) {
|
|
this.participants.delete(sessionId)
|
|
this.notifyPresenceChange('left', participant)
|
|
}
|
|
}
|
|
|
|
updateParticipantActivity(sessionId: string, activity: ParticipantActivity): void {
|
|
const participant = this.participants.get(sessionId)
|
|
if (participant) {
|
|
const updated = { ...participant, lastActivity: activity, lastSeen: Date.now() }
|
|
this.participants.set(sessionId, updated)
|
|
this.notifyActivityChange(updated, activity)
|
|
}
|
|
}
|
|
|
|
// UI subscription methods
|
|
onPresenceChange(callback: ParticipantPresenceCallback): () => void {
|
|
this.presenceCallbacks.add(callback)
|
|
return () => this.presenceCallbacks.delete(callback)
|
|
}
|
|
|
|
onActivityChange(callback: ParticipantActivityCallback): () => void {
|
|
this.activityCallbacks.add(callback)
|
|
return () => this.activityCallbacks.delete(callback)
|
|
}
|
|
|
|
// Data access for UI components
|
|
getAllParticipants(): ParticipantContext[] {
|
|
return Array.from(this.participants.values())
|
|
}
|
|
|
|
getActiveParticipants(): ParticipantContext[] {
|
|
const now = Date.now()
|
|
return this.getAllParticipants().filter(p =>
|
|
(now - p.lastSeen) < PARTICIPANT_TIMEOUT_MS
|
|
)
|
|
}
|
|
|
|
getParticipantCount(): number {
|
|
return this.getActiveParticipants().length
|
|
}
|
|
|
|
private notifyPresenceChange(type: 'joined' | 'left', participant: ParticipantContext): void {
|
|
this.presenceCallbacks.forEach(callback => callback(type, participant))
|
|
}
|
|
|
|
private notifyActivityChange(participant: ParticipantContext, activity: ParticipantActivity): void {
|
|
this.activityCallbacks.forEach(callback => callback(participant, activity))
|
|
}
|
|
}
|
|
|
|
// Enhanced participant context with activity tracking
|
|
interface ParticipantContext {
|
|
readonly sessionId: string
|
|
readonly name: string
|
|
readonly color: string
|
|
readonly joinedAt: number
|
|
readonly lastSeen: number
|
|
readonly lastActivity?: ParticipantActivity
|
|
readonly metadata?: ParticipantMetadata
|
|
}
|
|
|
|
interface ParticipantActivity {
|
|
readonly type: 'drawing' | 'dragging' | 'connecting' | 'navigating' | 'idle'
|
|
readonly toolType?: string
|
|
readonly timestamp: number
|
|
readonly elementId?: string // For drag/connect activities
|
|
}
|
|
|
|
interface ParticipantMetadata {
|
|
readonly avatarUrl?: string
|
|
readonly role?: 'host' | 'participant' | 'observer'
|
|
readonly preferences?: UserPreferences
|
|
}
|
|
|
|
interface UserPreferences {
|
|
readonly showCursors: boolean
|
|
readonly cursorStyle: 'minimal' | 'detailed'
|
|
readonly activityNotifications: boolean
|
|
}
|
|
|
|
// Callback types for UI reactivity
|
|
type ParticipantPresenceCallback = (type: 'joined' | 'left', participant: ParticipantContext) => void
|
|
type ParticipantActivityCallback = (participant: ParticipantContext, activity: ParticipantActivity) => void
|
|
|
|
const PARTICIPANT_TIMEOUT_MS = 30000 // 30 seconds
|
|
```
|
|
|
|
### 2. ParticipantList UI Component
|
|
|
|
```typescript
|
|
// Primary collaboration UI - participant list with activity indicators
|
|
export class ParticipantListComponent {
|
|
private participantsState: ParticipantsState
|
|
private element: HTMLElement
|
|
private unsubscribers: (() => void)[] = []
|
|
|
|
constructor(participantsState: ParticipantsState, container: HTMLElement) {
|
|
this.participantsState = participantsState
|
|
this.element = this.createElement()
|
|
container.appendChild(this.element)
|
|
this.setupSubscriptions()
|
|
this.render()
|
|
}
|
|
|
|
private setupSubscriptions(): void {
|
|
// React to participant presence changes
|
|
this.unsubscribers.push(
|
|
this.participantsState.onPresenceChange((type, participant) => {
|
|
this.handlePresenceChange(type, participant)
|
|
this.render()
|
|
})
|
|
)
|
|
|
|
// React to participant activity changes
|
|
this.unsubscribers.push(
|
|
this.participantsState.onActivityChange((participant, activity) => {
|
|
this.updateParticipantActivity(participant, activity)
|
|
})
|
|
)
|
|
}
|
|
|
|
private render(): void {
|
|
const participants = this.participantsState.getActiveParticipants()
|
|
|
|
this.element.innerHTML = `
|
|
<div class="participant-list">
|
|
<div class="participant-header">
|
|
<h3>Collaborators (${participants.length})</h3>
|
|
<button class="cursor-toggle" title="Toggle remote cursors">
|
|
👁️
|
|
</button>
|
|
</div>
|
|
<div class="participant-items">
|
|
${participants.map(p => this.renderParticipant(p)).join('')}
|
|
</div>
|
|
</div>
|
|
`
|
|
|
|
this.setupEventHandlers()
|
|
}
|
|
|
|
private renderParticipant(participant: ParticipantContext): string {
|
|
const activityIcon = this.getActivityIcon(participant.lastActivity)
|
|
const activityText = this.getActivityText(participant.lastActivity)
|
|
const isCurrentUser = participant.sessionId === this.participantsState.getCurrentSessionId()
|
|
|
|
return `
|
|
<div class="participant-item ${isCurrentUser ? 'current-user' : ''}"
|
|
data-session-id="${participant.sessionId}">
|
|
<div class="participant-avatar" style="background-color: ${participant.color}">
|
|
${participant.name.charAt(0).toUpperCase()}
|
|
</div>
|
|
<div class="participant-info">
|
|
<div class="participant-name">
|
|
${participant.name}${isCurrentUser ? ' (You)' : ''}
|
|
</div>
|
|
<div class="participant-activity">
|
|
<span class="activity-icon">${activityIcon}</span>
|
|
<span class="activity-text">${activityText}</span>
|
|
</div>
|
|
</div>
|
|
<div class="participant-status ${this.getConnectionStatus(participant)}"></div>
|
|
</div>
|
|
`
|
|
}
|
|
|
|
private getActivityIcon(activity?: ParticipantActivity): string {
|
|
if (!activity) return '💤'
|
|
|
|
switch (activity.type) {
|
|
case 'drawing': return '✏️'
|
|
case 'dragging': return '🤏'
|
|
case 'connecting': return '🔗'
|
|
case 'navigating': return '👆'
|
|
case 'idle': return '💤'
|
|
default: return '👆'
|
|
}
|
|
}
|
|
|
|
private getActivityText(activity?: ParticipantActivity): string {
|
|
if (!activity) return 'Idle'
|
|
|
|
const timeSince = Date.now() - activity.timestamp
|
|
if (timeSince > 10000) return 'Idle'
|
|
|
|
switch (activity.type) {
|
|
case 'drawing': return `Drawing${activity.toolType ? ` with ${activity.toolType}` : ''}`
|
|
case 'dragging': return 'Moving element'
|
|
case 'connecting': return 'Creating connection'
|
|
case 'navigating': return 'Active'
|
|
case 'idle': return 'Idle'
|
|
default: return 'Active'
|
|
}
|
|
}
|
|
|
|
private getConnectionStatus(participant: ParticipantContext): string {
|
|
const timeSince = Date.now() - participant.lastSeen
|
|
if (timeSince < 5000) return 'online'
|
|
if (timeSince < 30000) return 'away'
|
|
return 'offline'
|
|
}
|
|
|
|
private handlePresenceChange(type: 'joined' | 'left', participant: ParticipantContext): void {
|
|
// Show subtle notification for joins/leaves
|
|
this.showPresenceNotification(type, participant)
|
|
}
|
|
|
|
private updateParticipantActivity(participant: ParticipantContext, activity: ParticipantActivity): void {
|
|
// Update specific participant's activity indicator without full re-render
|
|
const element = this.element.querySelector(`[data-session-id="${participant.sessionId}"]`)
|
|
if (element) {
|
|
const activityIcon = element.querySelector('.activity-icon')
|
|
const activityText = element.querySelector('.activity-text')
|
|
|
|
if (activityIcon) activityIcon.textContent = this.getActivityIcon(activity)
|
|
if (activityText) activityText.textContent = this.getActivityText(activity)
|
|
}
|
|
}
|
|
|
|
private setupEventHandlers(): void {
|
|
const cursorToggle = this.element.querySelector('.cursor-toggle')
|
|
if (cursorToggle) {
|
|
cursorToggle.addEventListener('click', () => {
|
|
this.toggleCursors()
|
|
})
|
|
}
|
|
}
|
|
|
|
private toggleCursors(): void {
|
|
// Toggle cursor display and update UI
|
|
const cursorsEnabled = this.getCursorsEnabled()
|
|
this.setCursorsEnabled(!cursorsEnabled)
|
|
|
|
// Update toggle button appearance
|
|
const toggle = this.element.querySelector('.cursor-toggle')
|
|
if (toggle) {
|
|
toggle.textContent = cursorsEnabled ? '👁️' : '🙈'
|
|
toggle.title = cursorsEnabled ? 'Show remote cursors' : 'Hide remote cursors'
|
|
}
|
|
}
|
|
|
|
private getCursorsEnabled(): boolean {
|
|
// Get cursor preference from user settings or local storage
|
|
return localStorage.getItem('showRemoteCursors') === 'true'
|
|
}
|
|
|
|
private setCursorsEnabled(enabled: boolean): void {
|
|
localStorage.setItem('showRemoteCursors', enabled.toString())
|
|
// Notify cursor system of preference change
|
|
window.dispatchEvent(new CustomEvent('cursorsToggled', { detail: { enabled } }))
|
|
}
|
|
|
|
destroy(): void {
|
|
this.unsubscribers.forEach(unsubscribe => unsubscribe())
|
|
this.element.remove()
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Optional Cursor System Integration
|
|
|
|
```typescript
|
|
// Enhanced cursor coordinator that respects user preferences
|
|
export class OptionalCursorCoordinator {
|
|
private toolPointerCoordinator: ToolPointerCoordinator
|
|
private participantsState: ParticipantsState
|
|
private cursorsEnabled: boolean = false
|
|
private cursorElements = new Map<string, HTMLElement>()
|
|
|
|
constructor(participantsState: ParticipantsState, canvas: HTMLCanvasElement) {
|
|
this.participantsState = participantsState
|
|
this.toolPointerCoordinator = new ToolPointerCoordinator()
|
|
this.cursorsEnabled = this.getCursorPreference()
|
|
|
|
this.setupEventListeners()
|
|
this.setupParticipantSubscriptions()
|
|
}
|
|
|
|
private setupEventListeners(): void {
|
|
// Listen for cursor toggle events from participant list
|
|
window.addEventListener('cursorsToggled', (event: CustomEvent) => {
|
|
this.cursorsEnabled = event.detail.enabled
|
|
|
|
if (!this.cursorsEnabled) {
|
|
this.hideAllCursors()
|
|
}
|
|
})
|
|
}
|
|
|
|
private setupParticipantSubscriptions(): void {
|
|
// Clean up cursors when participants leave
|
|
this.participantsState.onPresenceChange((type, participant) => {
|
|
if (type === 'left') {
|
|
this.removeCursor(participant.sessionId)
|
|
}
|
|
})
|
|
}
|
|
|
|
processPointerUpdate(rawPosition: Point, toolContext: ToolContext): void {
|
|
// Only process cursor updates if cursors are enabled
|
|
if (!this.cursorsEnabled) return
|
|
|
|
const pointerState = this.toolPointerCoordinator.processPointerUpdate(
|
|
rawPosition,
|
|
toolContext
|
|
)
|
|
|
|
if (pointerState) {
|
|
this.renderCursor(pointerState)
|
|
|
|
// Update participant activity in state
|
|
this.participantsState.updateParticipantActivity(
|
|
toolContext.participantId,
|
|
{
|
|
type: toolContext.interactionState.isDragging ? 'dragging' :
|
|
toolContext.interactionState.isDrawing ? 'drawing' :
|
|
toolContext.interactionState.isConnecting ? 'connecting' : 'navigating',
|
|
toolType: toolContext.toolType,
|
|
timestamp: Date.now(),
|
|
elementId: toolContext.draggedElement?.id
|
|
}
|
|
)
|
|
}
|
|
}
|
|
|
|
private renderCursor(pointerState: RemotePointerState): void {
|
|
if (!this.cursorsEnabled) return
|
|
|
|
const participant = this.participantsState.getParticipant(pointerState.participantId)
|
|
if (!participant) return
|
|
|
|
let cursorElement = this.cursorElements.get(pointerState.participantId)
|
|
if (!cursorElement) {
|
|
cursorElement = this.createCursorElement(participant)
|
|
this.cursorElements.set(pointerState.participantId, cursorElement)
|
|
}
|
|
|
|
this.updateCursorPosition(cursorElement, pointerState, participant)
|
|
}
|
|
|
|
private createCursorElement(participant: ParticipantContext): HTMLElement {
|
|
const cursor = document.createElement('div')
|
|
cursor.className = 'remote-cursor'
|
|
cursor.style.cssText = `
|
|
position: absolute;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
transition: transform 0.1s ease-out;
|
|
`
|
|
|
|
cursor.innerHTML = `
|
|
<div class="cursor-pointer" style="
|
|
width: 12px;
|
|
height: 12px;
|
|
background: ${participant.color};
|
|
border: 2px solid white;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
"></div>
|
|
<div class="cursor-label" style="
|
|
position: absolute;
|
|
top: 15px;
|
|
left: 15px;
|
|
background: ${participant.color};
|
|
color: white;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
">${participant.name}</div>
|
|
`
|
|
|
|
document.body.appendChild(cursor)
|
|
return cursor
|
|
}
|
|
|
|
private updateCursorPosition(
|
|
element: HTMLElement,
|
|
pointerState: RemotePointerState,
|
|
participant: ParticipantContext
|
|
): void {
|
|
const { calculatedPosition } = pointerState
|
|
element.style.transform = `translate(${calculatedPosition.x}px, ${calculatedPosition.y}px)`
|
|
|
|
// Update cursor style based on activity
|
|
const pointer = element.querySelector('.cursor-pointer') as HTMLElement
|
|
if (pointer) {
|
|
pointer.style.transform = pointerState.type === 'element_drag_pointer' ?
|
|
'translate(-50%, -50%) scale(1.2)' :
|
|
'translate(-50%, -50%) scale(1.0)'
|
|
}
|
|
}
|
|
|
|
private hideAllCursors(): void {
|
|
this.cursorElements.forEach(element => {
|
|
element.style.display = 'none'
|
|
})
|
|
}
|
|
|
|
private removeCursor(sessionId: string): void {
|
|
const element = this.cursorElements.get(sessionId)
|
|
if (element) {
|
|
element.remove()
|
|
this.cursorElements.delete(sessionId)
|
|
}
|
|
}
|
|
|
|
private getCursorPreference(): boolean {
|
|
return localStorage.getItem('showRemoteCursors') === 'true'
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Pointer State Interfaces (When Cursors Enabled)
|
|
|
|
```typescript
|
|
// Base interface for tool-aware pointer states
|
|
interface RemotePointerState {
|
|
readonly type: string
|
|
readonly participantId: string
|
|
readonly timestamp: number
|
|
readonly rawPosition: Point
|
|
readonly calculatedPosition: Point
|
|
readonly frequency: UpdateFrequency
|
|
}
|
|
|
|
// Tool-specific pointer state extensions
|
|
interface ElementDragPointerState extends RemotePointerState {
|
|
type: 'element_drag_pointer'
|
|
elementId: string
|
|
dragOffset: Vector2D
|
|
edgePosition: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
|
|
}
|
|
|
|
interface LineToolPointerState extends RemotePointerState {
|
|
type: 'line_tool_pointer'
|
|
lineStartPoint?: Point
|
|
currentSegment?: LineSegment
|
|
offsetDirection: Vector2D
|
|
showProgress: boolean
|
|
}
|
|
|
|
interface ConnectionPointerState extends RemotePointerState {
|
|
type: 'connection_pointer'
|
|
sourceElementId: string
|
|
sourcePosition: Point
|
|
targetElementId?: string
|
|
showTargeting: boolean
|
|
}
|
|
|
|
interface NavigationPointerState extends RemotePointerState {
|
|
type: 'navigation_pointer'
|
|
velocity: Vector2D
|
|
isIdle: boolean
|
|
}
|
|
|
|
// Update frequency control
|
|
enum UpdateFrequency {
|
|
HIGH = 60, // Active drawing/dragging
|
|
MEDIUM = 30, // Tool targeting/preview
|
|
LOW = 10, // Navigation/idle
|
|
ADAPTIVE = 0 // Dynamically determined
|
|
}
|
|
```
|
|
|
|
### 2. Tool-Specific Pointer Handlers
|
|
|
|
```typescript
|
|
// Base handler interface
|
|
interface PointerHandler<T extends RemotePointerState> {
|
|
canHandle(toolType: string, interactionState: InteractionState): boolean
|
|
calculatePosition(rawPosition: Point, context: ToolContext): Point
|
|
determineFrequency(interactionState: InteractionState): UpdateFrequency
|
|
createPointerState(rawPosition: Point, context: ToolContext): T
|
|
render(state: T, renderer: PointerRenderer): void
|
|
}
|
|
|
|
// Element drag handler - positions cursor at element edge
|
|
class ElementDragPointerHandler implements PointerHandler<ElementDragPointerState> {
|
|
canHandle(toolType: string, interactionState: InteractionState): boolean {
|
|
return toolType === 'element_tool' && interactionState.isDragging
|
|
}
|
|
|
|
calculatePosition(rawPosition: Point, context: ToolContext): Point {
|
|
const element = context.draggedElement
|
|
if (!element) return rawPosition
|
|
|
|
// Position cursor at top-left edge to show ownership without obscuring content
|
|
const bounds = element.getBounds()
|
|
return {
|
|
x: bounds.x - 20, // Offset left of element
|
|
y: bounds.y - 10 // Offset above element
|
|
}
|
|
}
|
|
|
|
determineFrequency(interactionState: InteractionState): UpdateFrequency {
|
|
return interactionState.dragVelocity > 50 ? UpdateFrequency.HIGH : UpdateFrequency.MEDIUM
|
|
}
|
|
|
|
createPointerState(rawPosition: Point, context: ToolContext): ElementDragPointerState {
|
|
const calculatedPosition = this.calculatePosition(rawPosition, context)
|
|
return {
|
|
type: 'element_drag_pointer',
|
|
participantId: context.participantId,
|
|
timestamp: Date.now(),
|
|
rawPosition,
|
|
calculatedPosition,
|
|
frequency: this.determineFrequency(context.interactionState),
|
|
elementId: context.draggedElement.id,
|
|
dragOffset: Vector2D.subtract(rawPosition, context.draggedElement.getCenter()),
|
|
edgePosition: 'top-left'
|
|
}
|
|
}
|
|
|
|
render(state: ElementDragPointerState, renderer: PointerRenderer): void {
|
|
const element = renderer.getElement(state.elementId)
|
|
|
|
// Render cursor at calculated position
|
|
renderer.renderCursor(state.calculatedPosition, {
|
|
participantId: state.participantId,
|
|
style: 'drag',
|
|
showTooltip: true
|
|
})
|
|
|
|
// Show drag indicator connecting cursor to element
|
|
renderer.renderDragIndicator(state.calculatedPosition, element.getCenter(), {
|
|
dashPattern: [5, 5],
|
|
opacity: 0.6
|
|
})
|
|
}
|
|
}
|
|
|
|
// Line tool handler - offsets cursor from line tip
|
|
class LineToolPointerHandler implements PointerHandler<LineToolPointerState> {
|
|
canHandle(toolType: string, interactionState: InteractionState): boolean {
|
|
return toolType === 'line_tool' && interactionState.isDrawing
|
|
}
|
|
|
|
calculatePosition(rawPosition: Point, context: ToolContext): Point {
|
|
if (!context.lineStartPoint) return rawPosition
|
|
|
|
// Calculate offset direction perpendicular to line direction
|
|
const lineDirection = Vector2D.subtract(rawPosition, context.lineStartPoint)
|
|
const lineLength = Vector2D.magnitude(lineDirection)
|
|
|
|
if (lineLength < 10) return rawPosition // Too short to offset
|
|
|
|
// Offset cursor perpendicular to line direction
|
|
const normalizedDirection = Vector2D.normalize(lineDirection)
|
|
const perpendicularDirection = { x: -normalizedDirection.y, y: normalizedDirection.x }
|
|
const offset = Vector2D.multiply(perpendicularDirection, 25) // 25px offset
|
|
|
|
return Vector2D.add(rawPosition, offset)
|
|
}
|
|
|
|
determineFrequency(interactionState: InteractionState): UpdateFrequency {
|
|
// Use medium frequency to avoid overwhelming line drawing
|
|
return UpdateFrequency.MEDIUM
|
|
}
|
|
|
|
createPointerState(rawPosition: Point, context: ToolContext): LineToolPointerState {
|
|
const calculatedPosition = this.calculatePosition(rawPosition, context)
|
|
return {
|
|
type: 'line_tool_pointer',
|
|
participantId: context.participantId,
|
|
timestamp: Date.now(),
|
|
rawPosition,
|
|
calculatedPosition,
|
|
frequency: this.determineFrequency(context.interactionState),
|
|
lineStartPoint: context.lineStartPoint,
|
|
currentSegment: context.currentLineSegment,
|
|
offsetDirection: Vector2D.subtract(calculatedPosition, rawPosition),
|
|
showProgress: true
|
|
}
|
|
}
|
|
|
|
render(state: LineToolPointerState, renderer: PointerRenderer): void {
|
|
// Render cursor at offset position
|
|
renderer.renderCursor(state.calculatedPosition, {
|
|
participantId: state.participantId,
|
|
style: 'line-drawing',
|
|
showTooltip: true
|
|
})
|
|
|
|
// Show line progress if available
|
|
if (state.showProgress && state.lineStartPoint) {
|
|
renderer.renderLineProgress(state.lineStartPoint, state.rawPosition, {
|
|
participantId: state.participantId,
|
|
style: 'preview',
|
|
opacity: 0.7
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Connection tool handler - positions at source element
|
|
class ConnectionPointerHandler implements PointerHandler<ConnectionPointerState> {
|
|
canHandle(toolType: string, interactionState: InteractionState): boolean {
|
|
return toolType === 'connection_tool' && interactionState.isConnecting
|
|
}
|
|
|
|
calculatePosition(rawPosition: Point, context: ToolContext): Point {
|
|
const sourceElement = context.connectionSource
|
|
if (!sourceElement) return rawPosition
|
|
|
|
// Position cursor at source element edge closest to target
|
|
const sourceCenter = sourceElement.getCenter()
|
|
const direction = Vector2D.subtract(rawPosition, sourceCenter)
|
|
const normalizedDirection = Vector2D.normalize(direction)
|
|
const sourceBounds = sourceElement.getBounds()
|
|
|
|
// Calculate edge point
|
|
const edgePoint = this.calculateEdgeIntersection(sourceCenter, normalizedDirection, sourceBounds)
|
|
|
|
return edgePoint
|
|
}
|
|
|
|
determineFrequency(interactionState: InteractionState): UpdateFrequency {
|
|
return interactionState.hasValidTarget ? UpdateFrequency.MEDIUM : UpdateFrequency.LOW
|
|
}
|
|
|
|
createPointerState(rawPosition: Point, context: ToolContext): ConnectionPointerState {
|
|
const calculatedPosition = this.calculatePosition(rawPosition, context)
|
|
return {
|
|
type: 'connection_pointer',
|
|
participantId: context.participantId,
|
|
timestamp: Date.now(),
|
|
rawPosition,
|
|
calculatedPosition,
|
|
frequency: this.determineFrequency(context.interactionState),
|
|
sourceElementId: context.connectionSource.id,
|
|
sourcePosition: calculatedPosition,
|
|
targetElementId: context.connectionTarget?.id,
|
|
showTargeting: context.interactionState.hasValidTarget
|
|
}
|
|
}
|
|
|
|
render(state: ConnectionPointerState, renderer: PointerRenderer): void {
|
|
// Render cursor at source position
|
|
renderer.renderCursor(state.sourcePosition, {
|
|
participantId: state.participantId,
|
|
style: 'connection-source',
|
|
showTooltip: true
|
|
})
|
|
|
|
// Show connection preview
|
|
renderer.renderConnectionPreview(state.sourcePosition, state.rawPosition, {
|
|
participantId: state.participantId,
|
|
validTarget: state.showTargeting,
|
|
opacity: 0.6
|
|
})
|
|
}
|
|
|
|
private calculateEdgeIntersection(center: Point, direction: Vector2D, bounds: Rectangle): Point {
|
|
// Calculate intersection of ray from center in direction with rectangle bounds
|
|
// Implementation details for edge intersection calculation
|
|
// Returns the point where the ray intersects the rectangle edge
|
|
return center // Simplified for brevity
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. Frequency Control System
|
|
|
|
```typescript
|
|
// Adaptive frequency control based on tool context and user activity
|
|
class FrequencyControlSystem {
|
|
private updateTimers = new Map<string, number>()
|
|
private lastUpdateTimes = new Map<string, number>()
|
|
private activityMonitor: ActivityMonitor
|
|
|
|
constructor() {
|
|
this.activityMonitor = new ActivityMonitor()
|
|
}
|
|
|
|
shouldUpdate(participantId: string, frequency: UpdateFrequency, force: boolean = false): boolean {
|
|
if (force) return true
|
|
|
|
const now = Date.now()
|
|
const lastUpdate = this.lastUpdateTimes.get(participantId) || 0
|
|
const intervalMs = frequency === UpdateFrequency.ADAPTIVE
|
|
? this.calculateAdaptiveInterval(participantId)
|
|
: 1000 / frequency
|
|
|
|
const shouldUpdate = (now - lastUpdate) >= intervalMs
|
|
|
|
if (shouldUpdate) {
|
|
this.lastUpdateTimes.set(participantId, now)
|
|
}
|
|
|
|
return shouldUpdate
|
|
}
|
|
|
|
private calculateAdaptiveInterval(participantId: string): number {
|
|
const activity = this.activityMonitor.getActivity(participantId)
|
|
|
|
// High activity (fast movements, active tool use): higher frequency
|
|
if (activity.velocity > 100 || activity.toolActive) {
|
|
return 1000 / UpdateFrequency.HIGH
|
|
}
|
|
|
|
// Medium activity (moderate movement, tool targeting): medium frequency
|
|
if (activity.velocity > 20 || activity.isTargeting) {
|
|
return 1000 / UpdateFrequency.MEDIUM
|
|
}
|
|
|
|
// Low activity (slow movement, navigation): low frequency
|
|
return 1000 / UpdateFrequency.LOW
|
|
}
|
|
|
|
registerActivity(participantId: string, activity: ActivityData): void {
|
|
this.activityMonitor.recordActivity(participantId, activity)
|
|
}
|
|
}
|
|
|
|
interface ActivityData {
|
|
velocity: number
|
|
toolActive: boolean
|
|
isTargeting: boolean
|
|
interactionType: 'drawing' | 'dragging' | 'connecting' | 'navigating'
|
|
}
|
|
|
|
class ActivityMonitor {
|
|
private activityHistory = new Map<string, ActivityData[]>()
|
|
private readonly HISTORY_LIMIT = 10
|
|
|
|
recordActivity(participantId: string, activity: ActivityData): void {
|
|
const history = this.activityHistory.get(participantId) || []
|
|
history.push(activity)
|
|
|
|
if (history.length > this.HISTORY_LIMIT) {
|
|
history.shift()
|
|
}
|
|
|
|
this.activityHistory.set(participantId, history)
|
|
}
|
|
|
|
getActivity(participantId: string): ActivityData {
|
|
const history = this.activityHistory.get(participantId) || []
|
|
if (history.length === 0) {
|
|
return { velocity: 0, toolActive: false, isTargeting: false, interactionType: 'navigating' }
|
|
}
|
|
|
|
// Calculate average activity over recent history
|
|
const avgVelocity = history.reduce((sum, act) => sum + act.velocity, 0) / history.length
|
|
const recentActivity = history[history.length - 1]
|
|
|
|
return {
|
|
velocity: avgVelocity,
|
|
toolActive: recentActivity.toolActive,
|
|
isTargeting: recentActivity.isTargeting,
|
|
interactionType: recentActivity.interactionType
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Tool-Aware Pointer Coordinator
|
|
|
|
```typescript
|
|
export class ToolPointerCoordinator {
|
|
private handlers = new Map<string, PointerHandler<any>>()
|
|
private frequencyControl: FrequencyControlSystem
|
|
private currentTool: string = 'navigation'
|
|
private interactionState: InteractionState
|
|
|
|
constructor() {
|
|
this.frequencyControl = new FrequencyControlSystem()
|
|
this.interactionState = new InteractionState()
|
|
this.registerDefaultHandlers()
|
|
}
|
|
|
|
registerHandler<T extends RemotePointerState>(
|
|
toolType: string,
|
|
handler: PointerHandler<T>
|
|
): void {
|
|
this.handlers.set(toolType, handler)
|
|
}
|
|
|
|
processPointerUpdate(
|
|
rawPosition: Point,
|
|
toolContext: ToolContext,
|
|
force: boolean = false
|
|
): RemotePointerState | null {
|
|
const handler = this.getActiveHandler(toolContext.toolType, toolContext.interactionState)
|
|
if (!handler) return null
|
|
|
|
const pointerState = handler.createPointerState(rawPosition, toolContext)
|
|
|
|
// Check if update should be sent based on frequency control
|
|
const shouldUpdate = this.frequencyControl.shouldUpdate(
|
|
toolContext.participantId,
|
|
pointerState.frequency,
|
|
force
|
|
)
|
|
|
|
if (!shouldUpdate) return null
|
|
|
|
// Record activity for adaptive frequency calculation
|
|
this.frequencyControl.registerActivity(toolContext.participantId, {
|
|
velocity: toolContext.interactionState.velocity,
|
|
toolActive: toolContext.interactionState.isToolActive,
|
|
isTargeting: toolContext.interactionState.isTargeting,
|
|
interactionType: this.getInteractionType(toolContext.toolType, toolContext.interactionState)
|
|
})
|
|
|
|
return pointerState
|
|
}
|
|
|
|
private getActiveHandler(
|
|
toolType: string,
|
|
interactionState: InteractionState
|
|
): PointerHandler<any> | undefined {
|
|
// Find handler that can handle current tool and interaction state
|
|
for (const [type, handler] of this.handlers) {
|
|
if (handler.canHandle(toolType, interactionState)) {
|
|
return handler
|
|
}
|
|
}
|
|
|
|
// Fallback to navigation handler
|
|
return this.handlers.get('navigation')
|
|
}
|
|
|
|
private registerDefaultHandlers(): void {
|
|
this.registerHandler('element_drag', new ElementDragPointerHandler())
|
|
this.registerHandler('line_tool', new LineToolPointerHandler())
|
|
this.registerHandler('connection_tool', new ConnectionPointerHandler())
|
|
this.registerHandler('navigation', new NavigationPointerHandler())
|
|
}
|
|
|
|
private getInteractionType(
|
|
toolType: string,
|
|
interactionState: InteractionState
|
|
): ActivityData['interactionType'] {
|
|
if (interactionState.isDragging) return 'dragging'
|
|
if (interactionState.isDrawing) return 'drawing'
|
|
if (interactionState.isConnecting) return 'connecting'
|
|
return 'navigating'
|
|
}
|
|
}
|
|
|
|
// Supporting interfaces
|
|
interface ToolContext {
|
|
participantId: string
|
|
toolType: string
|
|
interactionState: InteractionState
|
|
draggedElement?: DraggableElement
|
|
lineStartPoint?: Point
|
|
currentLineSegment?: LineSegment
|
|
connectionSource?: Element
|
|
connectionTarget?: Element
|
|
}
|
|
|
|
class InteractionState {
|
|
isDragging: boolean = false
|
|
isDrawing: boolean = false
|
|
isConnecting: boolean = false
|
|
isTargeting: boolean = false
|
|
isToolActive: boolean = false
|
|
velocity: number = 0
|
|
dragVelocity: number = 0
|
|
hasValidTarget: boolean = false
|
|
|
|
updateFromPointerEvent(event: PointerEvent, previousPosition?: Point): void {
|
|
if (previousPosition) {
|
|
const distance = Vector2D.distance(
|
|
{ x: event.clientX, y: event.clientY },
|
|
previousPosition
|
|
)
|
|
this.velocity = distance / 16 // Assume 60fps, so ~16ms per frame
|
|
}
|
|
|
|
this.isToolActive = event.pressure > 0 || event.buttons > 0
|
|
}
|
|
}
|
|
```
|
|
|
|
### 3. ParticipantsState
|
|
|
|
```typescript
|
|
export class ParticipantsState {
|
|
private participants = new Map<string, ParticipantContext>();
|
|
private currentSessionId: string;
|
|
|
|
constructor(sessionId: string) {
|
|
this.currentSessionId = sessionId;
|
|
}
|
|
|
|
getCurrentParticipant(): ParticipantContext | undefined {
|
|
return this.participants.get(this.currentSessionId);
|
|
}
|
|
|
|
addParticipant(participant: ParticipantContext): void {
|
|
this.participants.set(participant.sessionId, participant);
|
|
}
|
|
|
|
removeParticipant(sessionId: string): void {
|
|
this.participants.delete(sessionId);
|
|
}
|
|
|
|
updateParticipant(updates: Partial<ParticipantContext>): void {
|
|
const current = this.getCurrentParticipant();
|
|
if (current) {
|
|
this.participants.set(this.currentSessionId, {
|
|
...current,
|
|
...updates
|
|
});
|
|
}
|
|
}
|
|
|
|
getAllParticipants(): ParticipantContext[] {
|
|
return Array.from(this.participants.values());
|
|
}
|
|
|
|
getParticipant(sessionId: string): ParticipantContext | undefined {
|
|
return this.participants.get(sessionId);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4. Enhanced Canvas Hook Integration
|
|
|
|
```typescript
|
|
// In canvas-hook.ts setupEnhancedEventHandlers
|
|
setupEnhancedEventHandlers(this: PhoenixHookContext, canvas: HTMLCanvasElement, adapters: CanvasAdapters) {
|
|
const eventCoordinator = adapters.events as ParticipantAwareEventCoordinator;
|
|
|
|
// Initialize participant from URL or session
|
|
const participantName = this.getParticipantName();
|
|
if (participantName) {
|
|
eventCoordinator.setParticipantInfo({
|
|
name: participantName,
|
|
sessionId: this.sessionId,
|
|
color: this.getUserColor()
|
|
});
|
|
}
|
|
|
|
// Enhanced pointer event handlers
|
|
const pointerMoveHandler = (e: PointerEvent) => {
|
|
const normalizedEvent = eventCoordinator.normalizePointerEvent(e);
|
|
|
|
// Send cursor move with participant context
|
|
this.pushEvent('cursor_move', {
|
|
type: 'cursor_move',
|
|
position: {
|
|
x: normalizedEvent.canvasPoint.x,
|
|
y: normalizedEvent.canvasPoint.y
|
|
},
|
|
participant: normalizedEvent.participantContext?.name,
|
|
sessionId: normalizedEvent.participantContext?.sessionId,
|
|
timestamp: normalizedEvent.timestamp
|
|
});
|
|
};
|
|
|
|
// ... rest of event handlers
|
|
}
|
|
```
|
|
|
|
## Testing Strategy
|
|
|
|
### 1. Tool-Specific Pointer Handler Tests
|
|
|
|
```typescript
|
|
describe('ElementDragPointerHandler', () => {
|
|
let handler: ElementDragPointerHandler
|
|
let mockElement: DraggableElement
|
|
let mockRenderer: PointerRenderer
|
|
|
|
beforeEach(() => {
|
|
handler = new ElementDragPointerHandler()
|
|
mockElement = createMockStickyNote('sticky-1', { x: 100, y: 100, width: 80, height: 60 })
|
|
mockRenderer = createMockPointerRenderer()
|
|
})
|
|
|
|
describe('position calculation', () => {
|
|
it('should position cursor at element top-left edge during drag', () => {
|
|
const rawPosition = { x: 140, y: 130 } // Inside element
|
|
const context = createToolContext({
|
|
toolType: 'element_tool',
|
|
draggedElement: mockElement,
|
|
interactionState: { isDragging: true }
|
|
})
|
|
|
|
const calculatedPosition = handler.calculatePosition(rawPosition, context)
|
|
|
|
// Should be offset from element bounds
|
|
expect(calculatedPosition).toEqual({
|
|
x: 80, // element.x - 20
|
|
y: 90 // element.y - 10
|
|
})
|
|
})
|
|
|
|
it('should return raw position when no element is being dragged', () => {
|
|
const rawPosition = { x: 200, y: 300 }
|
|
const context = createToolContext({
|
|
toolType: 'element_tool',
|
|
draggedElement: undefined
|
|
})
|
|
|
|
const calculatedPosition = handler.calculatePosition(rawPosition, context)
|
|
|
|
expect(calculatedPosition).toEqual(rawPosition)
|
|
})
|
|
})
|
|
|
|
describe('frequency determination', () => {
|
|
it('should use high frequency for fast drag movements', () => {
|
|
const interactionState = { isDragging: true, dragVelocity: 80 }
|
|
|
|
const frequency = handler.determineFrequency(interactionState)
|
|
|
|
expect(frequency).toBe(UpdateFrequency.HIGH)
|
|
})
|
|
|
|
it('should use medium frequency for slow drag movements', () => {
|
|
const interactionState = { isDragging: true, dragVelocity: 30 }
|
|
|
|
const frequency = handler.determineFrequency(interactionState)
|
|
|
|
expect(frequency).toBe(UpdateFrequency.MEDIUM)
|
|
})
|
|
})
|
|
|
|
describe('rendering', () => {
|
|
it('should render cursor and drag indicator', () => {
|
|
const state: ElementDragPointerState = {
|
|
type: 'element_drag_pointer',
|
|
participantId: 'alice',
|
|
timestamp: Date.now(),
|
|
rawPosition: { x: 140, y: 130 },
|
|
calculatedPosition: { x: 80, y: 90 },
|
|
frequency: UpdateFrequency.MEDIUM,
|
|
elementId: 'sticky-1',
|
|
dragOffset: { x: 40, y: 30 },
|
|
edgePosition: 'top-left'
|
|
}
|
|
|
|
handler.render(state, mockRenderer)
|
|
|
|
expect(mockRenderer.renderCursor).toHaveBeenCalledWith(
|
|
{ x: 80, y: 90 },
|
|
expect.objectContaining({
|
|
participantId: 'alice',
|
|
style: 'drag',
|
|
showTooltip: true
|
|
})
|
|
)
|
|
|
|
expect(mockRenderer.renderDragIndicator).toHaveBeenCalledWith(
|
|
{ x: 80, y: 90 }, // cursor position
|
|
{ x: 140, y: 130 }, // element center
|
|
expect.objectContaining({
|
|
dashPattern: [5, 5],
|
|
opacity: 0.6
|
|
})
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('LineToolPointerHandler', () => {
|
|
let handler: LineToolPointerHandler
|
|
|
|
beforeEach(() => {
|
|
handler = new LineToolPointerHandler()
|
|
})
|
|
|
|
describe('position calculation', () => {
|
|
it('should offset cursor perpendicular to line direction', () => {
|
|
const rawPosition = { x: 150, y: 100 }
|
|
const context = createToolContext({
|
|
toolType: 'line_tool',
|
|
lineStartPoint: { x: 100, y: 100 },
|
|
interactionState: { isDrawing: true }
|
|
})
|
|
|
|
const calculatedPosition = handler.calculatePosition(rawPosition, context)
|
|
|
|
// Should be offset perpendicular to horizontal line
|
|
expect(calculatedPosition.x).toBe(150) // Same x as raw
|
|
expect(calculatedPosition.y).not.toBe(100) // Offset in y direction
|
|
expect(Math.abs(calculatedPosition.y - 100)).toBe(25) // 25px offset
|
|
})
|
|
|
|
it('should return raw position for very short lines', () => {
|
|
const rawPosition = { x: 105, y: 102 }
|
|
const context = createToolContext({
|
|
toolType: 'line_tool',
|
|
lineStartPoint: { x: 100, y: 100 },
|
|
interactionState: { isDrawing: true }
|
|
})
|
|
|
|
const calculatedPosition = handler.calculatePosition(rawPosition, context)
|
|
|
|
expect(calculatedPosition).toEqual(rawPosition)
|
|
})
|
|
})
|
|
|
|
describe('frequency control', () => {
|
|
it('should always use medium frequency to avoid overwhelming line drawing', () => {
|
|
const interactionState = { isDrawing: true, velocity: 120 }
|
|
|
|
const frequency = handler.determineFrequency(interactionState)
|
|
|
|
expect(frequency).toBe(UpdateFrequency.MEDIUM)
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
### 2. Frequency Control System Tests
|
|
|
|
```typescript
|
|
describe('FrequencyControlSystem', () => {
|
|
let frequencyControl: FrequencyControlSystem
|
|
|
|
beforeEach(() => {
|
|
frequencyControl = new FrequencyControlSystem()
|
|
jest.clearAllTimers()
|
|
jest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
jest.useRealTimers()
|
|
})
|
|
|
|
describe('update throttling', () => {
|
|
it('should allow high frequency updates at correct intervals', () => {
|
|
const participantId = 'alice'
|
|
|
|
// First update should always be allowed
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(true)
|
|
|
|
// Immediate second update should be blocked
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(false)
|
|
|
|
// After 1000/60 = ~16ms, should allow update
|
|
jest.advanceTimersByTime(17)
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)).toBe(true)
|
|
})
|
|
|
|
it('should allow forced updates regardless of frequency', () => {
|
|
const participantId = 'alice'
|
|
|
|
frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH)
|
|
|
|
// Forced update should work immediately
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.HIGH, true)).toBe(true)
|
|
})
|
|
|
|
it('should handle different participants independently', () => {
|
|
frequencyControl.shouldUpdate('alice', UpdateFrequency.HIGH)
|
|
frequencyControl.shouldUpdate('bob', UpdateFrequency.HIGH)
|
|
|
|
// Both should be able to update independently
|
|
jest.advanceTimersByTime(17)
|
|
expect(frequencyControl.shouldUpdate('alice', UpdateFrequency.HIGH)).toBe(true)
|
|
expect(frequencyControl.shouldUpdate('bob', UpdateFrequency.HIGH)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('adaptive frequency', () => {
|
|
it('should calculate high frequency for active tool use', () => {
|
|
const participantId = 'alice'
|
|
frequencyControl.registerActivity(participantId, {
|
|
velocity: 150,
|
|
toolActive: true,
|
|
isTargeting: false,
|
|
interactionType: 'drawing'
|
|
})
|
|
|
|
// First call to establish timing
|
|
frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)
|
|
|
|
// Should use high frequency interval
|
|
jest.advanceTimersByTime(17) // High frequency interval
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(true)
|
|
})
|
|
|
|
it('should calculate low frequency for idle navigation', () => {
|
|
const participantId = 'alice'
|
|
frequencyControl.registerActivity(participantId, {
|
|
velocity: 5,
|
|
toolActive: false,
|
|
isTargeting: false,
|
|
interactionType: 'navigating'
|
|
})
|
|
|
|
frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)
|
|
|
|
// Should require low frequency interval
|
|
jest.advanceTimersByTime(50) // Less than low frequency interval (100ms)
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(false)
|
|
|
|
jest.advanceTimersByTime(50) // Total 100ms = low frequency interval
|
|
expect(frequencyControl.shouldUpdate(participantId, UpdateFrequency.ADAPTIVE)).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('ActivityMonitor', () => {
|
|
let monitor: ActivityMonitor
|
|
|
|
beforeEach(() => {
|
|
monitor = new ActivityMonitor()
|
|
})
|
|
|
|
it('should track activity history with rolling window', () => {
|
|
const participantId = 'alice'
|
|
|
|
// Record 15 activities (more than HISTORY_LIMIT of 10)
|
|
for (let i = 0; i < 15; i++) {
|
|
monitor.recordActivity(participantId, {
|
|
velocity: i * 10,
|
|
toolActive: i % 2 === 0,
|
|
isTargeting: false,
|
|
interactionType: 'drawing'
|
|
})
|
|
}
|
|
|
|
const activity = monitor.getActivity(participantId)
|
|
|
|
// Should average only the last 10 entries (velocities 50-140)
|
|
expect(activity.velocity).toBe(95) // Average of 50,60,70,80,90,100,110,120,130,140
|
|
expect(activity.toolActive).toBe(false) // Last entry (i=14, even)
|
|
})
|
|
|
|
it('should return default activity for unknown participant', () => {
|
|
const activity = monitor.getActivity('unknown')
|
|
|
|
expect(activity).toEqual({
|
|
velocity: 0,
|
|
toolActive: false,
|
|
isTargeting: false,
|
|
interactionType: 'navigating'
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
### 3. Integration Tests
|
|
|
|
```typescript
|
|
describe('ToolPointerCoordinator Integration', () => {
|
|
let coordinator: ToolPointerCoordinator
|
|
let mockRenderer: PointerRenderer
|
|
|
|
beforeEach(() => {
|
|
coordinator = new ToolPointerCoordinator()
|
|
mockRenderer = createMockPointerRenderer()
|
|
})
|
|
|
|
describe('handler selection and processing', () => {
|
|
it('should select appropriate handler based on tool and interaction state', () => {
|
|
const elementDragContext = createToolContext({
|
|
toolType: 'element_tool',
|
|
interactionState: { isDragging: true },
|
|
draggedElement: createMockStickyNote('sticky-1')
|
|
})
|
|
|
|
const pointerState = coordinator.processPointerUpdate(
|
|
{ x: 150, y: 200 },
|
|
elementDragContext
|
|
)
|
|
|
|
expect(pointerState?.type).toBe('element_drag_pointer')
|
|
expect(pointerState?.calculatedPosition).not.toEqual({ x: 150, y: 200 })
|
|
})
|
|
|
|
it('should fallback to navigation handler for unknown tools', () => {
|
|
const unknownToolContext = createToolContext({
|
|
toolType: 'unknown_tool',
|
|
interactionState: { isToolActive: false }
|
|
})
|
|
|
|
const pointerState = coordinator.processPointerUpdate(
|
|
{ x: 100, y: 100 },
|
|
unknownToolContext
|
|
)
|
|
|
|
expect(pointerState?.type).toBe('navigation_pointer')
|
|
})
|
|
|
|
it('should respect frequency control and suppress updates when appropriate', () => {
|
|
const context = createToolContext({
|
|
toolType: 'line_tool',
|
|
interactionState: { isDrawing: true }
|
|
})
|
|
|
|
// First update should work
|
|
const firstUpdate = coordinator.processPointerUpdate({ x: 100, y: 100 }, context)
|
|
expect(firstUpdate).not.toBeNull()
|
|
|
|
// Immediate second update should be suppressed
|
|
const secondUpdate = coordinator.processPointerUpdate({ x: 105, y: 105 }, context)
|
|
expect(secondUpdate).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe('extensibility', () => {
|
|
it('should allow registration of custom handlers', () => {
|
|
class CustomToolHandler implements PointerHandler<NavigationPointerState> {
|
|
canHandle(toolType: string): boolean {
|
|
return toolType === 'custom_tool'
|
|
}
|
|
|
|
calculatePosition(rawPosition: Point): Point {
|
|
return { x: rawPosition.x + 50, y: rawPosition.y + 50 }
|
|
}
|
|
|
|
determineFrequency(): UpdateFrequency {
|
|
return UpdateFrequency.LOW
|
|
}
|
|
|
|
createPointerState(rawPosition: Point, context: ToolContext): NavigationPointerState {
|
|
return {
|
|
type: 'navigation_pointer',
|
|
participantId: context.participantId,
|
|
timestamp: Date.now(),
|
|
rawPosition,
|
|
calculatedPosition: this.calculatePosition(rawPosition),
|
|
frequency: UpdateFrequency.LOW,
|
|
velocity: { x: 0, y: 0 },
|
|
isIdle: false
|
|
}
|
|
}
|
|
|
|
render(): void {}
|
|
}
|
|
|
|
coordinator.registerHandler('custom_tool', new CustomToolHandler())
|
|
|
|
const customContext = createToolContext({
|
|
toolType: 'custom_tool'
|
|
})
|
|
|
|
const pointerState = coordinator.processPointerUpdate({ x: 100, y: 100 }, customContext)
|
|
|
|
expect(pointerState?.calculatedPosition).toEqual({ x: 150, y: 150 })
|
|
})
|
|
})
|
|
})
|
|
```
|
|
|
|
### 2. EventCoordinator Integration Tests
|
|
|
|
```typescript
|
|
describe('ParticipantAwareEventCoordinator', () => {
|
|
let coordinator: ParticipantAwareEventCoordinator;
|
|
let mockCanvas: HTMLCanvasElement;
|
|
|
|
beforeEach(() => {
|
|
mockCanvas = createMockCanvas();
|
|
coordinator = new ParticipantAwareEventCoordinator({
|
|
canvasElement: mockCanvas,
|
|
sessionId: 'test-session',
|
|
phoenixContext: createMockPhoenixContext()
|
|
});
|
|
});
|
|
|
|
it('should include participant context in normalized events', () => {
|
|
// Set participant info
|
|
coordinator.setParticipantInfo({
|
|
name: 'TestUser',
|
|
sessionId: 'test-session',
|
|
color: '#123456'
|
|
});
|
|
|
|
// Create mock pointer event
|
|
const pointerEvent = createMockPointerEvent(100, 200);
|
|
const normalized = coordinator.normalizePointerEvent(pointerEvent);
|
|
|
|
expect(normalized.participantContext).toEqual({
|
|
name: 'TestUser',
|
|
sessionId: 'test-session',
|
|
color: '#123456',
|
|
joinedAt: expect.any(Number)
|
|
});
|
|
});
|
|
|
|
it('should handle participant join/leave events', () => {
|
|
const joinEvent: UserJoinedEvent = {
|
|
sessionId: 'other-session',
|
|
participant: 'OtherUser',
|
|
color: '#654321',
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
coordinator.handleParticipantJoined(joinEvent);
|
|
|
|
const participants = coordinator.getParticipantsState().getAllParticipants();
|
|
expect(participants).toHaveLength(1);
|
|
expect(participants[0].name).toBe('OtherUser');
|
|
|
|
// Test leave
|
|
coordinator.handleParticipantLeft({ sessionId: 'other-session', timestamp: Date.now() });
|
|
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(0);
|
|
});
|
|
});
|
|
```
|
|
|
|
### 3. Mock-Based Scenario Testing
|
|
|
|
```typescript
|
|
describe('Complex Participant Scenarios', () => {
|
|
it('should handle rapid participant join/leave', async () => {
|
|
const coordinator = createTestCoordinator();
|
|
const participants = ['Alice', 'Bob', 'Charlie', 'Diana', 'Eve'];
|
|
|
|
// Rapid joins
|
|
for (const name of participants) {
|
|
coordinator.handleParticipantJoined(createJoinEvent(name));
|
|
await delay(10); // Simulate timing
|
|
}
|
|
|
|
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(5);
|
|
|
|
// Rapid leaves
|
|
for (const name of participants.slice(0, 3)) {
|
|
coordinator.handleParticipantLeft(createLeaveEvent(name));
|
|
await delay(10);
|
|
}
|
|
|
|
expect(coordinator.getParticipantsState().getAllParticipants()).toHaveLength(2);
|
|
});
|
|
|
|
it('should handle participant reconnection with same name', () => {
|
|
const coordinator = createTestCoordinator();
|
|
|
|
// Initial join
|
|
coordinator.handleParticipantJoined(createJoinEvent('Alice', 'session-1'));
|
|
|
|
// Disconnect
|
|
coordinator.handleParticipantLeft({ sessionId: 'session-1', timestamp: Date.now() });
|
|
|
|
// Reconnect with new session
|
|
coordinator.handleParticipantJoined(createJoinEvent('Alice', 'session-2'));
|
|
|
|
const participants = coordinator.getParticipantsState().getAllParticipants();
|
|
expect(participants).toHaveLength(1);
|
|
expect(participants[0].sessionId).toBe('session-2');
|
|
});
|
|
});
|
|
```
|
|
|
|
## Migration Path
|
|
|
|
### 1. Feature Flag Controlled Rollout
|
|
|
|
```typescript
|
|
// In canvas-hook.ts
|
|
if (isFeatureEnabled('PARTICIPANT_AWARE_EVENTS')) {
|
|
// Use ParticipantAwareEventCoordinator
|
|
const coordinator = new ParticipantAwareEventCoordinator(config);
|
|
// Enhanced participant handling
|
|
} else {
|
|
// Use standard EventCoordinator
|
|
const coordinator = new DOMEventCoordinator(config);
|
|
// Legacy URL parameter handling
|
|
}
|
|
```
|
|
|
|
### 2. Backward Compatibility
|
|
|
|
- Continue supporting `?participant=Name` URL parameters
|
|
- Maintain existing cursor_move event structure
|
|
- Graceful fallback when participant context is missing
|
|
|
|
### 3. Progressive Enhancement
|
|
|
|
```typescript
|
|
// Phase 1: Add participant context to events (backward compatible)
|
|
// Phase 2: Use participant context for rendering
|
|
// Phase 3: Deprecate URL parameter approach
|
|
// Phase 4: Remove legacy code
|
|
```
|
|
|
|
## Benefits & Implementation Strategy
|
|
|
|
### Benefits of Participant-First Approach
|
|
|
|
1. **Clean Canvas Experience**
|
|
- Unobstructed drawing workspace by default
|
|
- Collaboration awareness without visual interference
|
|
- Users can focus on their primary drawing tasks
|
|
|
|
2. **Immediate Collaboration Value**
|
|
- Participant list provides essential "who's here" information
|
|
- Activity indicators show what others are doing
|
|
- No complex setup or preferences required
|
|
|
|
3. **Progressive Enhancement**
|
|
- Start with simple, effective participant list
|
|
- Add cursor functionality as optional enhancement
|
|
- Users can toggle cursors based on preference/context
|
|
|
|
4. **Simplified Implementation**
|
|
- Single `ParticipantsState` foundation for all collaboration features
|
|
- Reduced network traffic when cursors disabled
|
|
- Easier testing and debugging with clear separation
|
|
|
|
5. **User Choice**
|
|
- Respects user preferences for visual complexity
|
|
- Adapts to different use cases (detailed design vs quick sketching)
|
|
- Maintains collaboration awareness regardless of cursor preference
|
|
|
|
### Implementation Phases
|
|
|
|
#### Phase 1: Participant List Foundation (Essential)
|
|
```typescript
|
|
// Priority 1: Core collaboration awareness
|
|
- Implement ParticipantsState class
|
|
- Build ParticipantList UI component
|
|
- Connect to Phoenix Presence for real-time updates
|
|
- Add activity status indicators
|
|
- Remove existing cursor rendering code
|
|
```
|
|
|
|
#### Phase 2: Activity Tracking (Enhanced Awareness)
|
|
```typescript
|
|
// Priority 2: Rich collaboration context
|
|
- Track tool usage and interaction states
|
|
- Update participant activity in real-time
|
|
- Show meaningful status ("Drawing with line tool", "Moving element")
|
|
- Add connection status indicators (online/away/offline)
|
|
```
|
|
|
|
#### Phase 3: Optional Cursors (Progressive Enhancement)
|
|
```typescript
|
|
// Priority 3: Visual enhancement for power users
|
|
- Implement cursor toggle in participant list
|
|
- Connect OptionalCursorCoordinator to existing tool system
|
|
- Preserve all tool-aware positioning logic
|
|
- Add user preference persistence
|
|
```
|
|
|
|
### Migration Strategy
|
|
|
|
#### Immediate: Replace Cursor-First with List-First
|
|
- **Remove**: Existing cursor rendering by default
|
|
- **Add**: ParticipantList component as primary collaboration UI
|
|
- **Keep**: All cursor logic for optional use
|
|
- **Benefit**: Cleaner canvas, better UX immediately
|
|
|
|
#### Progressive: Enhanced Activity Awareness
|
|
- **Leverage**: Existing tool system to track participant activity
|
|
- **Enhance**: ParticipantsState with real-time activity updates
|
|
- **Display**: Rich status information in participant list
|
|
- **Benefit**: Better collaboration context without visual clutter
|
|
|
|
#### Optional: Power User Features
|
|
- **Implement**: Cursor toggle for users who want visual cursors
|
|
- **Preserve**: All existing tool-aware cursor positioning
|
|
- **Optimize**: Only process cursor updates when enabled
|
|
- **Benefit**: Best of both worlds - clean by default, rich when desired
|
|
|
|
## Implementation Checklist
|
|
|
|
### Phase 1: Participant List Foundation
|
|
- [x] Implement ParticipantsState class with subscription system ✅ (assets/js/domain/participants-state.ts)
|
|
- [x] Create ParticipantList UI component with activity indicators ✅ (assets/js/components/participant-list.ts)
|
|
- [x] Connect ParticipantsState to Phoenix Presence events ✅ (canvas-hook.ts integration)
|
|
- [x] Add participant activity tracking interfaces ✅ (ParticipantActivity interface implemented)
|
|
- [ ] Remove existing cursor rendering from default experience ⚠️ (Multiple cursor systems active - causing flickering)
|
|
- [x] Create unit tests for ParticipantsState and ParticipantList ✅ (assets/js/__tests__/participants-state.test.ts)
|
|
- [x] Update E2E tests to use participant list for collaboration verification ✅ (simple-presence-list.spec.ts working)
|
|
|
|
### Phase 2: Activity Enhancement
|
|
- [x] Implement real-time activity status updates ✅ (updateParticipantActivity in ParticipantsState)
|
|
- [x] Add tool-specific activity indicators (drawing, dragging, connecting) ✅ (getActivityIcon/Text methods)
|
|
- [x] Create connection status tracking (online/away/offline) ✅ (getConnectionStatus method)
|
|
- [x] Add subtle presence notifications for joins/leaves ✅ (showPresenceNotification in ParticipantList)
|
|
- [ ] Test activity update performance with multiple participants ⚠️ (E2E tests needed)
|
|
|
|
### Phase 3: Optional Cursor System
|
|
- [x] Implement cursor toggle in participant list header ✅ (renderCursorToggle method implemented)
|
|
- [ ] Create OptionalCursorCoordinator with preference handling ⚠️ (Currently disabled: enableCursorToggle: false)
|
|
- [ ] Connect cursor system to tool-aware positioning logic
|
|
- [ ] Add user preference persistence (localStorage) ✅ (getCursorsEnabled/setCursorsEnabled implemented)
|
|
- [ ] Preserve all existing tool-specific cursor handlers ⚠️ (Multiple cursor systems causing conflicts)
|
|
- [ ] Test cursor toggle performance and user experience
|
|
|
|
## 🚨 Current Implementation Issues (2025-07-14)
|
|
|
|
### Cursor Flickering Root Cause
|
|
**Issue**: Multiple cursor systems active simultaneously:
|
|
1. **canvas-hook.ts**: Direct cursor management with `createCursorElement()` and `data-cursor-*` attributes
|
|
2. **dom-cursor-manager.ts**: Adapter-based cursor system with similar functionality
|
|
|
|
**Impact**: Both systems are creating and updating cursors, causing flickering and conflicts.
|
|
|
|
**Current Status**:
|
|
- ParticipantList cursor toggle is **disabled** (`enableCursorToggle: false`)
|
|
- Old cursor presence E2E tests are **skipped** (cursor-presence.spec.ts, simple-presence.spec.ts)
|
|
- Active debug tests should be removed (debug-presence.spec.ts, final-presence-debug.spec.ts)
|
|
- Working: Participant list with presence notifications (simple-presence-list.spec.ts)
|
|
|
|
### Feature Flag Gap
|
|
**Issue**: No specific feature flags for presence functionality migration
|
|
- Current flags focus on canvas adapters, not presence systems
|
|
- Need flags for: `PARTICIPANT_FIRST_PRESENCE`, `LEGACY_CURSOR_SYSTEM`, `OPTIONAL_CURSOR_TOGGLE`
|
|
|
|
### Immediate Action Items
|
|
1. **🔥 Fix Flickering**: Disable one of the cursor systems or add proper feature flag coordination
|
|
2. **🧹 Clean Tests**: Remove debug E2E tests, replace skipped cursor tests with participant-first tests
|
|
3. **⚙️ Feature Flags**: Add presence-specific feature flags for controlled rollout
|
|
4. **🎯 Default Experience**: Enable participant-first design (list-only, no cursors by default)
|
|
|
|
### Phase 4: Polish & Performance
|
|
- [ ] Optimize ParticipantsState subscription system
|
|
- [ ] Add graceful degradation for network issues
|
|
- [ ] Implement participant list scroll handling for many users
|
|
- [ ] Add accessibility features (screen reader support, keyboard navigation)
|
|
- [ ] Performance testing with 10+ concurrent participants
|
|
|
|
## Future Enhancements
|
|
|
|
1. **Rich Participant Profiles**
|
|
- Avatar support
|
|
- Role-based permissions
|
|
- Custom metadata
|
|
|
|
2. **Participant Groups**
|
|
- Team-based cursors
|
|
- Group color schemes
|
|
- Collaborative spaces
|
|
|
|
3. **Analytics Integration**
|
|
- Track participant interactions
|
|
- Measure collaboration patterns
|
|
- Performance metrics per participant
|
|
|
|
## Conclusion
|
|
|
|
This participant-first design represents a fundamental shift from cursor-centric to list-centric collaboration awareness. By prioritizing a clean, unobstructed drawing experience while maintaining rich collaboration context through the participant list, we create a system that serves both focused individual work and active collaboration scenarios.
|
|
|
|
### Key Design Insights
|
|
|
|
1. **Essential vs Optional**: Participant presence is essential; cursor visualization is optional enhancement
|
|
2. **Clean Canvas**: Drawing applications benefit from uncluttered workspace with sidebar collaboration awareness
|
|
3. **Progressive Enhancement**: Users get immediate value from participant list with option for richer cursor feedback
|
|
4. **User Choice**: Respecting user preferences for visual complexity improves overall experience
|
|
|
|
### Architectural Benefits
|
|
|
|
The `ParticipantsState` foundation provides a single source of truth for all collaboration features, whether that's the participant list, optional cursors, or future enhancements like activity notifications or team features. The modular cursor system remains available for users who want it, preserving all the tool-aware positioning logic while making it truly optional.
|
|
|
|
This approach transforms collaboration from a potential distraction into a helpful, unobtrusive presence that enhances the drawing experience without interfering with it. |