@tldraw/editor
Version:
tldraw infinite canvas SDK (editor).
584 lines (522 loc) • 16.3 kB
text/typescript
import type { TLRecord, TLShapeId } from '@tldraw/tlschema'
import { bind } from '@tldraw/utils'
import EventEmitter from 'eventemitter3'
import type { Editor } from '../../Editor'
import type {
TLCameraEndPerfEvent,
TLCameraStartPerfEvent,
TLFramePerfEvent,
TLInteractionEndPerfEvent,
TLInteractionStartPerfEvent,
TLPerfEventMap,
TLPerfLongAnimationFrame,
TLShapeOperationPerfEvent,
TLUndoRedoPerfEvent,
} from './perf-types'
function percentile(sorted: number[], p: number): number {
const idx = Math.ceil(p * sorted.length) - 1
return sorted[Math.max(0, idx)]
}
function computeFrameTimeStats(frameTimes: number[]) {
if (frameTimes.length === 0) return { avg: 0, median: 0, p95: 0, p99: 0, min: 0, max: 0 }
const sorted = [...frameTimes].sort((a, b) => a - b)
const sum = sorted.reduce((a, b) => a + b, 0)
return {
avg: sum / sorted.length,
median: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95),
p99: percentile(sorted, 0.99),
min: sorted[0],
max: sorted[sorted.length - 1],
}
}
function toLoafEntry(entry: PerformanceEntry): TLPerfLongAnimationFrame | null {
// LoAF entries have these properties but TypeScript doesn't know about them yet
const e = entry as PerformanceEntry & {
blockingDuration?: number
scripts?: ReadonlyArray<{
sourceURL?: string
invoker?: string
duration?: number
}>
}
if (typeof e.duration !== 'number') return null
return {
startTime: e.startTime,
duration: e.duration,
blockingDuration: e.blockingDuration ?? 0,
scripts: (e.scripts ?? []).map((s) => ({
sourceURL: s.sourceURL ?? '',
invoker: s.invoker ?? '',
duration: s.duration ?? 0,
})),
}
}
/**
* Manages performance event subscriptions for the editor. Available as `editor.performance`.
*
* Listeners are lazy — internal editor hooks (frame, shape events) are only attached while
* at least one subscriber exists, so there is zero overhead when unused.
*
* @example
* ```ts
* const unsub = editor.performance.on('interaction-end', (event) => {
* console.log(`${event.name}: ${event.fps.toFixed(1)} fps, p95=${event.p95FrameTime.toFixed(1)}ms`)
* })
* ```
*
* @public
*/
export class PerformanceManager {
/** @internal */
readonly emitter = new EventEmitter<TLPerfEventMap>()
private editor: Editor
// Active interaction tracking
private activeInteraction: {
name: string
path: string
startTime: number
frameTimes: number[]
selectedShapeTypes: Record<string, number>
loafEntries: TLPerfLongAnimationFrame[]
} | null = null
// Active camera tracking
private activeCamera: {
type: 'panning' | 'zooming'
startTime: number
frameTimes: number[]
timeout: number | null
loafEntries: TLPerfLongAnimationFrame[]
} | null = null
// Lazy listener cleanup functions
private frameCleanup: (() => void) | null = null
private shapeCreatedCleanup: (() => void) | null = null
private shapeEditedCleanup: (() => void) | null = null
private shapeDeletedCleanup: (() => void) | null = null
// LoAF observer
private loafObserver: PerformanceObserver | null = null
constructor(editor: Editor) {
this.editor = editor
}
/**
* Subscribe to a performance event. Returns an unsubscribe function.
*
* @example
* ```ts
* const unsub = editor.performance.on('interaction-end', (event) => {
* sendToAnalytics({ name: event.name, fps: event.fps, p95: event.p95FrameTime })
* })
* // later: unsub()
* ```
*
* @public
*/
on<K extends keyof TLPerfEventMap>(
event: K,
fn: (...args: TLPerfEventMap[K]) => void
): () => void {
this.emitter.on(event, fn as any)
this._maybeAttachLazyListeners(event)
return () => {
this.emitter.off(event, fn as any)
this._maybeDetachLazyListeners(event)
}
}
/**
* Subscribe to a performance event once. The listener is removed after the first invocation.
* Returns an unsubscribe function for early removal.
*
* @public
*/
once<K extends keyof TLPerfEventMap>(
event: K,
fn: (...args: TLPerfEventMap[K]) => void
): () => void {
const wrapped = (...args: TLPerfEventMap[K]) => {
;(fn as any)(...args)
this._maybeDetachLazyListeners(event)
}
this.emitter.once(event, wrapped as any)
this._maybeAttachLazyListeners(event)
return () => {
this.emitter.off(event, wrapped as any)
this._maybeDetachLazyListeners(event)
}
}
/** @internal */
dispose() {
if (this.activeCamera?.timeout) clearTimeout(this.activeCamera.timeout)
this.activeInteraction = null
this.activeCamera = null
this.frameCleanup?.()
this.frameCleanup = null
this.shapeCreatedCleanup?.()
this.shapeCreatedCleanup = null
this.shapeEditedCleanup?.()
this.shapeEditedCleanup = null
this.shapeDeletedCleanup?.()
this.shapeDeletedCleanup = null
this._stopLoafObserver()
this.emitter.removeAllListeners()
}
// --- Internal notification methods ---
/** @internal */
_notifyInteractionStart(name: string, path: string) {
if (
this.emitter.listenerCount('interaction-start') === 0 &&
this.emitter.listenerCount('interaction-end') === 0
) {
return
}
if (this.activeInteraction) {
console.warn(
`[tldraw] New interaction '${name}' started while '${this.activeInteraction.name}' was still active`
)
}
// Capture selected shape types at start
const selectedShapeTypes: Record<string, number> = {}
for (const shape of this.editor.getSelectedShapes()) {
selectedShapeTypes[shape.type] = (selectedShapeTypes[shape.type] || 0) + 1
}
this.activeInteraction = {
name,
path,
startTime: performance.now(),
frameTimes: [],
selectedShapeTypes,
loafEntries: [],
}
const event: TLInteractionStartPerfEvent = {
name,
path,
timestamp: performance.now(),
}
this.emitter.emit('interaction-start', event)
}
/** @internal */
_notifyInteractionEnd() {
const interaction = this.activeInteraction
if (!interaction) return
this.activeInteraction = null
if (this.emitter.listenerCount('interaction-end') === 0) return
const duration = performance.now() - interaction.startTime
const stats = computeFrameTimeStats(interaction.frameTimes)
const event: TLInteractionEndPerfEvent = {
name: interaction.name,
path: interaction.path,
duration,
fps:
interaction.frameTimes.length > 0 ? (interaction.frameTimes.length / duration) * 1000 : 0,
frameCount: interaction.frameTimes.length,
avgFrameTime: stats.avg,
medianFrameTime: stats.median,
p95FrameTime: stats.p95,
p99FrameTime: stats.p99,
minFrameTime: stats.min,
maxFrameTime: stats.max,
frameTimes: interaction.frameTimes,
shapeCount: this.editor.getCurrentPageShapeIds().size,
selectedShapeTypes: interaction.selectedShapeTypes,
longAnimationFrames: interaction.loafEntries.length > 0 ? interaction.loafEntries : undefined,
zoomLevel: this.editor.getCamera().z,
timestamp: performance.now(),
}
this.emitter.emit('interaction-end', event)
}
/** @internal */
_notifyCameraOperation(type: 'panning' | 'zooming') {
if (
this.emitter.listenerCount('camera-start') === 0 &&
this.emitter.listenerCount('camera-end') === 0
) {
return
}
if (this.activeCamera) {
// Extend existing camera session
if (this.activeCamera.timeout) {
clearTimeout(this.activeCamera.timeout)
}
// If type changed, end old and start new
if (this.activeCamera.type !== type) {
this._endCameraSession()
this._startCameraSession(type)
} else {
// Reset timeout
this.activeCamera.timeout = this.editor.timers.setTimeout(
() => this._endCameraSession(),
50
)
}
} else {
this._startCameraSession(type)
}
}
/** @internal */
_notifyUndoRedo(type: 'undo' | 'redo', undoDepth: number, redoDepth: number) {
if (this.emitter.listenerCount(type) === 0) return
const event: TLUndoRedoPerfEvent = {
type,
undoDepth,
redoDepth,
}
this.emitter.emit(type, event)
}
// --- Private helpers ---
private _startCameraSession(type: 'panning' | 'zooming') {
this.activeCamera = {
type,
startTime: performance.now(),
frameTimes: [],
timeout: this.editor.timers.setTimeout(() => this._endCameraSession(), 50),
loafEntries: [],
}
if (this.emitter.listenerCount('camera-start') > 0) {
const event: TLCameraStartPerfEvent = {
type,
timestamp: performance.now(),
}
this.emitter.emit('camera-start', event)
}
}
private _endCameraSession() {
const camera = this.activeCamera
if (!camera) return
this.activeCamera = null
if (camera.timeout) clearTimeout(camera.timeout)
if (this.emitter.listenerCount('camera-end') === 0) return
const duration = performance.now() - camera.startTime
const stats = computeFrameTimeStats(camera.frameTimes)
const viewportBounds = this.editor.getViewportScreenBounds()
const totalShapes = this.editor.getCurrentPageShapeIds().size
const culledShapeCount = this.editor.getCulledShapes().size
const event: TLCameraEndPerfEvent = {
type: camera.type,
duration,
fps: camera.frameTimes.length > 0 ? (camera.frameTimes.length / duration) * 1000 : 0,
frameCount: camera.frameTimes.length,
avgFrameTime: stats.avg,
medianFrameTime: stats.median,
p95FrameTime: stats.p95,
p99FrameTime: stats.p99,
minFrameTime: stats.min,
maxFrameTime: stats.max,
frameTimes: camera.frameTimes,
shapeCount: totalShapes,
viewportWidth: viewportBounds.w,
viewportHeight: viewportBounds.h,
longAnimationFrames: camera.loafEntries.length > 0 ? camera.loafEntries : undefined,
visibleShapeCount: totalShapes - culledShapeCount,
culledShapeCount,
zoomLevel: this.editor.getCamera().z,
timestamp: performance.now(),
}
this.emitter.emit('camera-end', event)
}
private _onFrame(elapsed: number) {
// Record frame time for active interaction/camera
if (this.activeInteraction) {
this.activeInteraction.frameTimes.push(elapsed)
}
if (this.activeCamera) {
this.activeCamera.frameTimes.push(elapsed)
}
// Emit standalone frame event if listeners exist
if (this.emitter.listenerCount('frame') > 0) {
const totalShapes = this.editor.getCurrentPageShapeIds().size
const culledShapes = this.editor.getCulledShapes()
const culledCount = culledShapes.size
const event: TLFramePerfEvent = {
elapsed,
shapeCount: totalShapes,
culledShapeCount: culledCount,
visibleShapeCount: totalShapes - culledCount,
}
this.emitter.emit('frame', event)
}
}
private _onShapesCreated(records: TLRecord[]) {
if (this.emitter.listenerCount('shapes-created') === 0) return
const shapeTypes: Record<string, number> = {}
for (const record of records) {
if (record.typeName === 'shape') {
shapeTypes[record.type] = (shapeTypes[record.type] || 0) + 1
}
}
const count = Object.values(shapeTypes).reduce((a, b) => a + b, 0)
if (count === 0) return
const event: TLShapeOperationPerfEvent = {
operation: 'create',
count,
shapeTypes,
timestamp: performance.now(),
}
this.emitter.emit('shapes-created', event)
}
private _onShapesEdited(records: TLRecord[]) {
if (this.emitter.listenerCount('shapes-updated') === 0) return
const shapeTypes: Record<string, number> = {}
for (const record of records) {
if (record.typeName === 'shape') {
shapeTypes[record.type] = (shapeTypes[record.type] || 0) + 1
}
}
const count = Object.values(shapeTypes).reduce((a, b) => a + b, 0)
if (count === 0) return
const event: TLShapeOperationPerfEvent = {
operation: 'update',
count,
shapeTypes,
timestamp: performance.now(),
}
this.emitter.emit('shapes-updated', event)
}
private _onShapesDeleted(ids: TLShapeId[]) {
if (this.emitter.listenerCount('shapes-deleted') === 0) return
const shapeTypes: Record<string, number> = {}
for (const id of ids) {
// Works because 'deleted-shapes' fires before store.remove() in Editor.deleteShapes
const shape = this.editor.getShape(id)
if (shape) {
shapeTypes[shape.type] = (shapeTypes[shape.type] || 0) + 1
}
}
const event: TLShapeOperationPerfEvent = {
operation: 'delete',
count: ids.length,
shapeTypes,
timestamp: performance.now(),
}
this.emitter.emit('shapes-deleted', event)
}
// --- LoAF observer ---
private _startLoafObserver() {
if (typeof PerformanceObserver === 'undefined') return
try {
const supported = PerformanceObserver.supportedEntryTypes
if (!supported?.includes('long-animation-frame')) return
} catch {
return
}
this.loafObserver = new PerformanceObserver((list) => {
const isInteractionActive = this.activeInteraction !== null
const isCameraActive = this.activeCamera !== null
if (!isInteractionActive && !isCameraActive) return
for (const entry of list.getEntries()) {
const loaf = toLoafEntry(entry)
if (!loaf) continue
if (isInteractionActive) {
this.activeInteraction!.loafEntries.push(loaf)
}
if (isCameraActive) {
this.activeCamera!.loafEntries.push(loaf)
}
}
})
this.loafObserver.observe({ type: 'long-animation-frame', buffered: false })
}
private _stopLoafObserver() {
if (this.loafObserver) {
this.loafObserver.disconnect()
this.loafObserver = null
}
}
// --- Lazy listener management ---
private _needsFrameListener(): boolean {
return (
this.emitter.listenerCount('frame') > 0 ||
this.emitter.listenerCount('interaction-start') > 0 ||
this.emitter.listenerCount('interaction-end') > 0 ||
this.emitter.listenerCount('camera-start') > 0 ||
this.emitter.listenerCount('camera-end') > 0
)
}
private _needsLoafObserver(): boolean {
return (
this.emitter.listenerCount('interaction-end') > 0 ||
this.emitter.listenerCount('camera-end') > 0
)
}
private _maybeAttachLazyListeners(event: keyof TLPerfEventMap) {
// Frame listener needed for frame event + interaction/camera frame time tracking
if (
!this.frameCleanup &&
(event === 'frame' ||
event === 'interaction-start' ||
event === 'interaction-end' ||
event === 'camera-start' ||
event === 'camera-end')
) {
if (this._needsFrameListener()) {
this.editor.on('frame', this._onFrame)
this.frameCleanup = () => this.editor.off('frame', this._onFrame)
}
}
// LoAF observer needed when interaction-end or camera-end listeners exist
if (!this.loafObserver && (event === 'interaction-end' || event === 'camera-end')) {
if (this._needsLoafObserver()) {
this._startLoafObserver()
}
}
if (!this.shapeCreatedCleanup && event === 'shapes-created') {
this.editor.on('created-shapes', this._onShapesCreated)
this.shapeCreatedCleanup = () => this.editor.off('created-shapes', this._onShapesCreated)
}
if (!this.shapeEditedCleanup && event === 'shapes-updated') {
this.editor.on('edited-shapes', this._onShapesEdited)
this.shapeEditedCleanup = () => this.editor.off('edited-shapes', this._onShapesEdited)
}
if (!this.shapeDeletedCleanup && event === 'shapes-deleted') {
this.editor.on('deleted-shapes', this._onShapesDeleted)
this.shapeDeletedCleanup = () => this.editor.off('deleted-shapes', this._onShapesDeleted)
}
}
private _maybeDetachLazyListeners(event: keyof TLPerfEventMap) {
if (
this.frameCleanup &&
(event === 'frame' ||
event === 'interaction-start' ||
event === 'interaction-end' ||
event === 'camera-start' ||
event === 'camera-end')
) {
if (!this._needsFrameListener()) {
this.frameCleanup()
this.frameCleanup = null
}
}
// Stop LoAF observer when no longer needed
if (this.loafObserver && (event === 'interaction-end' || event === 'camera-end')) {
if (!this._needsLoafObserver()) {
this._stopLoafObserver()
}
}
if (
this.shapeCreatedCleanup &&
event === 'shapes-created' &&
this.emitter.listenerCount('shapes-created') === 0
) {
this.shapeCreatedCleanup()
this.shapeCreatedCleanup = null
}
if (
this.shapeEditedCleanup &&
event === 'shapes-updated' &&
this.emitter.listenerCount('shapes-updated') === 0
) {
this.shapeEditedCleanup()
this.shapeEditedCleanup = null
}
if (
this.shapeDeletedCleanup &&
event === 'shapes-deleted' &&
this.emitter.listenerCount('shapes-deleted') === 0
) {
this.shapeDeletedCleanup()
this.shapeDeletedCleanup = null
}
}
}