import { TLScribble, VecModel } from '@tldraw/tlschema' import { uniqueId } from '@tldraw/utils' import { Vec } from '../../../primitives/Vec' import type { Editor } from '../../Editor' /** @public */ export interface ScribbleItem { id: string scribble: TLScribble timeoutMs: number delayRemaining: number prev: null | VecModel next: null | VecModel } /** @public */ export interface ScribbleSessionOptions { /** Session id. Auto-generated if not provided. */ id?: string /** * Whether scribbles self-consume (shrink from start) while drawing. * - true: scribbles eat their own tail as you draw (default, used for eraser/select) * - false: scribbles persist until session stops (used for laser) */ selfConsume?: boolean /** * How long to wait after last activity before auto-stopping the session. * Only applies when selfConsume is false. */ idleTimeoutMs?: number /** * How scribbles fade when stopping. * - 'individual': each scribble fades on its own (default) * - 'grouped': all scribbles fade together as one sequence */ fadeMode?: 'individual' | 'grouped' /** * Easing for grouped fade. */ fadeEasing?: 'linear' | 'ease-in' /** * Duration of the fade in milliseconds. */ fadeDurationMs?: number } // Internal session state (not exported) interface Session { id: string items: ScribbleItem[] state: 'active' | 'stopping' | 'complete' options: Required> idleTimeoutHandle?: number fadeElapsed: number totalPointsAtFadeStart: number } /** @public */ export class ScribbleManager { private sessions = new Map() constructor(private editor: Editor) {} // ==================== SESSION API ==================== /** * Start a new session for grouping scribbles. * Returns a session ID that can be used with other session methods. * * @param options - Session configuration * @returns Session ID * @public */ startSession(options: ScribbleSessionOptions = {}): string { const id = options.id ?? uniqueId() const session: Session = { id, items: [], state: 'active', options: { selfConsume: options.selfConsume ?? true, idleTimeoutMs: options.idleTimeoutMs ?? 0, fadeMode: options.fadeMode ?? 'individual', fadeEasing: options.fadeEasing ?? (options.fadeMode === 'grouped' ? 'ease-in' : 'linear'), fadeDurationMs: options.fadeDurationMs ?? this.editor.options.laserFadeoutMs, }, fadeElapsed: 0, totalPointsAtFadeStart: 0, } this.sessions.set(id, session) // Set up idle timeout if configured if (session.options.idleTimeoutMs > 0) { this.resetIdleTimeout(session) } return id } /** * Add a scribble to a session. * * @param sessionId - The session ID * @param scribble - Partial scribble properties * @param scribbleId - Optional scribble ID * @public */ addScribbleToSession( sessionId: string, scribble: Partial, scribbleId = uniqueId() ): ScribbleItem { const session = this.sessions.get(sessionId) if (!session) throw Error(`Session ${sessionId} not found`) const item: ScribbleItem = { id: scribbleId, scribble: { id: scribbleId, size: 20, color: 'accent', opacity: 0.8, delay: 0, points: [], shrink: 0.1, taper: true, ...scribble, state: 'starting', }, timeoutMs: 0, delayRemaining: scribble.delay ?? 0, prev: null, next: null, } session.items.push(item) // Reset idle timeout on activity if (session.options.idleTimeoutMs > 0) { this.resetIdleTimeout(session) } return item } /** * Add a point to a scribble in a session. * * @param sessionId - The session ID * @param scribbleId - The scribble ID * @param x - X coordinate * @param y - Y coordinate * @param z - Z coordinate (pressure) * @public */ addPointToSession( sessionId: string, scribbleId: string, x: number, y: number, z = 0.5 ): ScribbleItem { const session = this.sessions.get(sessionId) if (!session) throw Error(`Session ${sessionId} not found`) const item = session.items.find((i) => i.id === scribbleId) if (!item) throw Error(`Scribble ${scribbleId} not found in session ${sessionId}`) const point = { x, y, z } if (!item.prev || Vec.Dist(item.prev, point) >= 1) { item.next = point } // Reset idle timeout on activity if (session.options.idleTimeoutMs > 0) { this.resetIdleTimeout(session) } return item } /** * Extend a session, resetting its idle timeout. * * @param sessionId - The session ID * @public */ extendSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (!session) return if (session.options.idleTimeoutMs > 0) { this.resetIdleTimeout(session) } } /** * Stop a session, triggering fade-out. * * @param sessionId - The session ID * @public */ stopSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (!session || session.state !== 'active') return this.clearIdleTimeout(session) session.state = 'stopping' if (session.options.fadeMode === 'grouped') { session.totalPointsAtFadeStart = session.items.reduce( (sum, item) => sum + item.scribble.points.length, 0 ) session.fadeElapsed = 0 for (const item of session.items) { item.scribble.state = 'stopping' } } else { for (const item of session.items) { item.delayRemaining = Math.min(item.delayRemaining, 200) item.scribble.state = 'stopping' } } } /** * Clear all scribbles in a session immediately. * * @param sessionId - The session ID * @public */ clearSession(sessionId: string): void { const session = this.sessions.get(sessionId) if (!session) return this.clearIdleTimeout(session) for (const item of session.items) { item.scribble.points.length = 0 } session.state = 'complete' } /** * Check if a session is active. * * @param sessionId - The session ID * @public */ isSessionActive(sessionId: string): boolean { const session = this.sessions.get(sessionId) return session?.state === 'active' } // ==================== SIMPLE API (for eraser, select, etc.) ==================== /** * Add a scribble using the default self-consuming behavior. * Creates an implicit session for the scribble. * * @param scribble - Partial scribble properties * @param id - Optional scribble id * @returns The created scribble item * @public */ addScribble(scribble: Partial, id = uniqueId()): ScribbleItem { const sessionId = this.startSession() return this.addScribbleToSession(sessionId, scribble, id) } /** * Add a point to a scribble. Searches all sessions. * * @param id - The scribble id * @param x - X coordinate * @param y - Y coordinate * @param z - Z coordinate (pressure) * @public */ addPoint(id: string, x: number, y: number, z = 0.5): ScribbleItem { for (const session of this.sessions.values()) { const item = session.items.find((i) => i.id === id) if (item) { const point = { x, y, z } if (!item.prev || Vec.Dist(item.prev, point) >= 1) { item.next = point } if (session.options.idleTimeoutMs > 0) { this.resetIdleTimeout(session) } return item } } throw Error(`Scribble with id ${id} not found`) } /** * Mark a scribble as complete (done being drawn but not yet fading). * Searches all sessions. * * @param id - The scribble id * @public */ complete(id: string): ScribbleItem { for (const session of this.sessions.values()) { const item = session.items.find((i) => i.id === id) if (item) { if (item.scribble.state === 'starting' || item.scribble.state === 'active') { item.scribble.state = 'complete' } return item } } throw Error(`Scribble with id ${id} not found`) } /** * Stop a scribble. Searches all sessions. * * @param id - The scribble id * @public */ stop(id: string): ScribbleItem { for (const session of this.sessions.values()) { const item = session.items.find((i) => i.id === id) if (item) { item.delayRemaining = Math.min(item.delayRemaining, 200) item.scribble.state = 'stopping' return item } } throw Error(`Scribble with id ${id} not found`) } /** * Stop and remove all sessions. * * @public */ reset(): void { for (const session of this.sessions.values()) { this.clearIdleTimeout(session) } this.sessions.clear() this.editor.updateInstanceState({ scribbles: [] }) } /** * Update on each animation frame. * * @param elapsed - The number of milliseconds since the last tick. * @public */ tick(elapsed: number): void { const currentScribbles = this.editor.getInstanceState().scribbles if (this.sessions.size === 0 && currentScribbles.length === 0) return this.editor.run(() => { // Tick all sessions for (const session of this.sessions.values()) { this.tickSession(session, elapsed) } // Remove completed sessions for (const [id, session] of this.sessions) { if (session.state === 'complete') { this.clearIdleTimeout(session) this.sessions.delete(id) } } // Collect scribbles from all sessions const scribbles: TLScribble[] = [] for (const session of this.sessions.values()) { for (const item of session.items) { if (item.scribble.points.length > 0) { scribbles.push({ ...item.scribble, points: [...item.scribble.points], }) } } } this.editor.updateInstanceState({ scribbles }) }) } // ==================== PRIVATE HELPERS ==================== private resetIdleTimeout(session: Session): void { this.clearIdleTimeout(session) session.idleTimeoutHandle = this.editor.timers.setTimeout(() => { this.stopSession(session.id) }, session.options.idleTimeoutMs) } private clearIdleTimeout(session: Session): void { if (session.idleTimeoutHandle !== undefined) { clearTimeout(session.idleTimeoutHandle) session.idleTimeoutHandle = undefined } } private tickSession(session: Session, elapsed: number): void { if (session.state === 'complete') return if (session.state === 'stopping' && session.options.fadeMode === 'grouped') { this.tickGroupedFade(session, elapsed) } else { this.tickSessionItems(session, elapsed) } // Check if session is complete const hasContent = session.items.some((item) => item.scribble.points.length > 0) if (!hasContent && (session.state === 'stopping' || session.items.length === 0)) { session.state = 'complete' } } private tickSessionItems(session: Session, elapsed: number): void { for (const item of session.items) { const shouldSelfConsume = session.options.selfConsume || session.state === 'stopping' || item.scribble.state === 'stopping' if (shouldSelfConsume) { this.tickSelfConsumingItem(item, elapsed) } else { this.tickPersistentItem(item) } } // Remove completed items in individual fade mode if (session.options.fadeMode === 'individual') { for (let i = session.items.length - 1; i >= 0; i--) { if (session.items[i].scribble.points.length === 0) { session.items.splice(i, 1) } } } } private tickPersistentItem(item: ScribbleItem): void { const { scribble } = item if (scribble.state === 'starting') { const { next, prev } = item if (next && next !== prev) { item.prev = next scribble.points.push(next) } if (scribble.points.length > 8) { scribble.state = 'active' } return } if (scribble.state === 'active') { const { next, prev } = item if (next && next !== prev) { item.prev = next scribble.points.push(next) } } } private tickSelfConsumingItem(item: ScribbleItem, elapsed: number): void { const { scribble } = item if (scribble.state === 'starting') { const { next, prev } = item if (next && next !== prev) { item.prev = next scribble.points.push(next) } if (scribble.points.length > 8) { scribble.state = 'active' } return } if (item.delayRemaining > 0) { item.delayRemaining = Math.max(0, item.delayRemaining - elapsed) } item.timeoutMs += elapsed if (item.timeoutMs >= 16) { item.timeoutMs = 0 } const { delayRemaining, timeoutMs, prev, next } = item switch (scribble.state) { case 'active': { if (next && next !== prev) { item.prev = next scribble.points.push(next) if (delayRemaining === 0 && scribble.points.length > 8) { scribble.points.shift() } } else { if (timeoutMs === 0) { if (scribble.points.length > 1) { scribble.points.shift() } else { item.delayRemaining = scribble.delay } } } break } case 'stopping': { if (delayRemaining === 0 && timeoutMs === 0) { if (scribble.points.length <= 1) { scribble.points.length = 0 return } if (scribble.shrink) { scribble.size = Math.max(1, scribble.size * (1 - scribble.shrink)) } scribble.points.shift() } break } case 'paused': { break } } } private tickGroupedFade(session: Session, elapsed: number): void { session.fadeElapsed += elapsed let remainingPoints = 0 for (const item of session.items) { remainingPoints += item.scribble.points.length } if (remainingPoints === 0) return if (session.fadeElapsed >= session.options.fadeDurationMs) { for (const item of session.items) { item.scribble.points.length = 0 } return } const progress = session.fadeElapsed / session.options.fadeDurationMs const easedProgress = session.options.fadeEasing === 'ease-in' ? progress * progress : progress const targetRemoved = Math.floor(easedProgress * session.totalPointsAtFadeStart) const actuallyRemoved = session.totalPointsAtFadeStart - remainingPoints const pointsToRemove = Math.max(1, targetRemoved - actuallyRemoved) let removed = 0 let itemIndex = 0 while (removed < pointsToRemove && itemIndex < session.items.length) { const item = session.items[itemIndex] if (item.scribble.points.length > 0) { item.scribble.points.shift() removed++ } else { itemIndex++ } } } }