diff --git a/apps/examples/src/hooks/usePerformance.ts b/apps/examples/src/hooks/usePerformance.ts new file mode 100644 index 000000000..8b626ddf5 --- /dev/null +++ b/apps/examples/src/hooks/usePerformance.ts @@ -0,0 +1,23 @@ +import { TLUiEventSource, TLUiOverrides, debugFlags, measureCbDuration, useValue } from 'tldraw' + +export function usePerformance(): TLUiOverrides { + const measurePerformance = useValue( + 'measurePerformance', + () => debugFlags.measurePerformance.get(), + [debugFlags] + ) + if (!measurePerformance) return {} + return { + actions(_editor, actions) { + Object.keys(actions).forEach((key) => { + const action = actions[key] + const cb = action.onSelect + action.onSelect = (source: TLUiEventSource) => { + return measureCbDuration(`Action ${key}`, () => cb(source)) + } + }) + + return actions + }, + } +} diff --git a/apps/examples/src/misc/develop.tsx b/apps/examples/src/misc/develop.tsx index db14ab8cd..bd7206163 100644 --- a/apps/examples/src/misc/develop.tsx +++ b/apps/examples/src/misc/develop.tsx @@ -1,10 +1,13 @@ import { Tldraw } from 'tldraw' import 'tldraw/tldraw.css' +import { usePerformance } from '../hooks/usePerformance' export default function Develop() { + const performanceOverrides = usePerformance() return (
{ ;(window as any).app = editor diff --git a/packages/editor/api-report.md b/packages/editor/api-report.md index a8039822c..5db1d518c 100644 --- a/packages/editor/api-report.md +++ b/packages/editor/api-report.md @@ -23,6 +23,7 @@ import { JSX as JSX_2 } from 'react/jsx-runtime'; import { LegacyMigrations } from '@tldraw/store'; import { MigrationSequence } from '@tldraw/store'; import { NamedExoticComponent } from 'react'; +import { PerformanceTracker } from '@tldraw/utils'; import { PointerEventHandler } from 'react'; import { react } from '@tldraw/state'; import { default as React_2 } from 'react'; @@ -448,6 +449,7 @@ export const debugFlags: { readonly logElementRemoves: DebugFlag; readonly logPointerCaptures: DebugFlag; readonly logPreventDefaults: DebugFlag; + readonly measurePerformance: DebugFlag; readonly reconnectOnPing: DebugFlag; readonly showFps: DebugFlag; readonly throwToBlob: DebugFlag; @@ -1868,6 +1870,8 @@ export abstract class StateNode implements Partial { // (undocumented) _path: Computed; // (undocumented) + performanceTracker: PerformanceTracker; + // (undocumented) setCurrentToolIdMask(id: string | undefined): void; // (undocumented) shapeType?: string; diff --git a/packages/editor/api/api.json b/packages/editor/api/api.json index 7dc444dd9..e05933d1c 100644 --- a/packages/editor/api/api.json +++ b/packages/editor/api/api.json @@ -35454,6 +35454,37 @@ "isProtected": false, "isAbstract": false }, + { + "kind": "Property", + "canonicalReference": "@tldraw/editor!StateNode#performanceTracker:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "performanceTracker: " + }, + { + "kind": "Reference", + "text": "PerformanceTracker", + "canonicalReference": "@tldraw/utils!PerformanceTracker:class" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "performanceTracker", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, { "kind": "Method", "canonicalReference": "@tldraw/editor!StateNode#setCurrentToolIdMask:member(1)", diff --git a/packages/editor/src/lib/editor/Editor.ts b/packages/editor/src/lib/editor/Editor.ts index c74be045e..f3f3dee21 100644 --- a/packages/editor/src/lib/editor/Editor.ts +++ b/packages/editor/src/lib/editor/Editor.ts @@ -41,6 +41,7 @@ import { import { IndexKey, JsonObject, + PerformanceTracker, annotateError, assert, compact, @@ -95,6 +96,7 @@ import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from ' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' import { WeakMapCache } from '../utils/WeakMapCache' import { dataUrlToFile } from '../utils/assets' +import { debugFlags } from '../utils/debug-flags' import { getIncrementedName } from '../utils/getIncrementedName' import { getReorderingShapesChanges } from '../utils/reorderShapes' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' @@ -624,6 +626,8 @@ export class Editor extends EventEmitter { requestAnimationFrame(() => { this._tickManager.start() }) + + this.performanceTracker = new PerformanceTracker() } /** @@ -8367,6 +8371,12 @@ export class Editor extends EventEmitter { /** @internal */ capturedPointerId: number | null = null + /** @internal */ + private readonly performanceTracker: PerformanceTracker + + /** @internal */ + private performanceTrackerTimeout = -1 as any + /** * Dispatch an event to the editor. * @@ -8558,6 +8568,16 @@ export class Editor extends EventEmitter { this.stopFollowingUser() } if (inputs.ctrlKey) { + if (debugFlags.measurePerformance.get()) { + if (this.performanceTracker.isStarted()) { + clearTimeout(this.performanceTrackerTimeout) + } else { + this.performanceTracker.start('Zooming') + } + this.performanceTrackerTimeout = setTimeout(() => { + this.performanceTracker.stop() + }, 50) + } // todo: Start or update the zoom end interval // If the alt or ctrl keys are pressed, @@ -8584,7 +8604,6 @@ export class Editor extends EventEmitter { // statechart should respond to this event (a camera zoom) return } - // Update the camera here, which will dispatch a pointer move... // this will also update the pointer position, etc const { x: cx, y: cy, z: cz } = this.getCamera() @@ -8674,6 +8693,16 @@ export class Editor extends EventEmitter { } if (this.inputs.isPanning && this.inputs.isPointing) { + if (debugFlags.measurePerformance.get()) { + if (this.performanceTracker.isStarted()) { + clearTimeout(this.performanceTrackerTimeout) + } else { + this.performanceTracker.start('Panning') + } + this.performanceTrackerTimeout = setTimeout(() => { + this.performanceTracker.stop() + }, 50) + } clearTimeout(this._longPressTimeout) // Handle panning const { currentScreenPoint, previousScreenPoint } = this.inputs diff --git a/packages/editor/src/lib/editor/tools/StateNode.ts b/packages/editor/src/lib/editor/tools/StateNode.ts index ed09dc469..3089be5e8 100644 --- a/packages/editor/src/lib/editor/tools/StateNode.ts +++ b/packages/editor/src/lib/editor/tools/StateNode.ts @@ -1,4 +1,6 @@ import { Atom, Computed, atom, computed } from '@tldraw/state' +import { PerformanceTracker } from '@tldraw/utils' +import { debugFlags } from '../../utils/debug-flags' import type { Editor } from '../Editor' import { EVENT_NAME_MAP, @@ -10,6 +12,19 @@ import { } from '../types/event-types' type TLStateNodeType = 'branch' | 'leaf' | 'root' +const STATE_NODES_TO_MEASURE = [ + 'brushing', + 'cropping', + 'dragging', + 'dragging_handle', + 'drawing', + 'erasing', + 'lasering', + 'resizing', + 'rotating', + 'scribble_brushing', + 'translating', +] /** @public */ export interface TLStateNodeConstructor { @@ -21,6 +36,7 @@ export interface TLStateNodeConstructor { /** @public */ export abstract class StateNode implements Partial { + performanceTracker: PerformanceTracker constructor( public editor: Editor, parent?: StateNode @@ -60,6 +76,7 @@ export abstract class StateNode implements Partial { this._current.set(this.children[this.initial]) } } + this.performanceTracker = new PerformanceTracker() } static id: string @@ -159,6 +176,10 @@ export abstract class StateNode implements Partial { // todo: move this logic into transition enter = (info: any, from: string) => { + if (debugFlags.measurePerformance.get() && STATE_NODES_TO_MEASURE.includes(this.id)) { + this.performanceTracker.start(this.id) + } + this._isActive.set(true) this.onEnter?.(info, from) @@ -171,6 +192,9 @@ export abstract class StateNode implements Partial { // todo: move this logic into transition exit = (info: any, from: string) => { + if (debugFlags.measurePerformance.get() && this.performanceTracker.isStarted()) { + this.performanceTracker.stop() + } this._isActive.set(false) this.onExit?.(info, from) diff --git a/packages/editor/src/lib/utils/debug-flags.ts b/packages/editor/src/lib/utils/debug-flags.ts index 19d801de7..88081f052 100644 --- a/packages/editor/src/lib/utils/debug-flags.ts +++ b/packages/editor/src/lib/utils/debug-flags.ts @@ -41,6 +41,7 @@ export const debugFlags = { showFps: createDebugValue('showFps', { defaults: { all: false }, }), + measurePerformance: createDebugValue('measurePerformance', { defaults: { all: false } }), throwToBlob: createDebugValue('throwToBlob', { defaults: { all: false }, }), diff --git a/packages/utils/api-report.md b/packages/utils/api-report.md index 961caae88..ef035b160 100644 --- a/packages/utils/api-report.md +++ b/packages/utils/api-report.md @@ -242,6 +242,18 @@ export function omitFromStackTrace, Return>(fn: (... // @internal export function partition(arr: T[], predicate: (item: T) => boolean): [T[], T[]]; +// @public (undocumented) +export class PerformanceTracker { + // (undocumented) + isStarted(): boolean; + // (undocumented) + recordFrame: () => void; + // (undocumented) + start(name: string): void; + // (undocumented) + stop(): void; +} + // @public (undocumented) export class PngHelpers { // (undocumented) diff --git a/packages/utils/api/api.json b/packages/utils/api/api.json index 6d94200c1..62f6bcfb5 100644 --- a/packages/utils/api/api.json +++ b/packages/utils/api/api.json @@ -2353,6 +2353,165 @@ "endIndex": 2 } }, + { + "kind": "Class", + "canonicalReference": "@tldraw/utils!PerformanceTracker:class", + "docComment": "/**\n * @public\n */\n", + "excerptTokens": [ + { + "kind": "Content", + "text": "export declare class PerformanceTracker " + } + ], + "fileUrlPath": "packages/utils/src/lib/PerformanceTracker.ts", + "releaseTag": "Public", + "isAbstract": false, + "name": "PerformanceTracker", + "preserveMemberOrder": false, + "members": [ + { + "kind": "Method", + "canonicalReference": "@tldraw/utils!PerformanceTracker#isStarted:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "isStarted(): " + }, + { + "kind": "Content", + "text": "boolean" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "isStarted" + }, + { + "kind": "Property", + "canonicalReference": "@tldraw/utils!PerformanceTracker#recordFrame:member", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "recordFrame: " + }, + { + "kind": "Content", + "text": "() => void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isReadonly": false, + "isOptional": false, + "releaseTag": "Public", + "name": "recordFrame", + "propertyTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isStatic": false, + "isProtected": false, + "isAbstract": false + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/utils!PerformanceTracker#start:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "start(name: " + }, + { + "kind": "Content", + "text": "string" + }, + { + "kind": "Content", + "text": "): " + }, + { + "kind": "Content", + "text": "void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 3, + "endIndex": 4 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [ + { + "parameterName": "name", + "parameterTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "isOptional": false + } + ], + "isOptional": false, + "isAbstract": false, + "name": "start" + }, + { + "kind": "Method", + "canonicalReference": "@tldraw/utils!PerformanceTracker#stop:member(1)", + "docComment": "", + "excerptTokens": [ + { + "kind": "Content", + "text": "stop(): " + }, + { + "kind": "Content", + "text": "void" + }, + { + "kind": "Content", + "text": ";" + } + ], + "isStatic": false, + "returnTypeTokenRange": { + "startIndex": 1, + "endIndex": 2 + }, + "releaseTag": "Public", + "isProtected": false, + "overloadIndex": 1, + "parameters": [], + "isOptional": false, + "isAbstract": false, + "name": "stop" + } + ], + "implementsTokenRanges": [] + }, { "kind": "Class", "canonicalReference": "@tldraw/utils!PngHelpers:class", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 90273933b..b3b2ce6d8 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,3 +1,4 @@ +export { PerformanceTracker } from './lib/PerformanceTracker' export { areArraysShallowEqual, compact, diff --git a/packages/utils/src/lib/PerformanceTracker.ts b/packages/utils/src/lib/PerformanceTracker.ts new file mode 100644 index 000000000..1c15f9512 --- /dev/null +++ b/packages/utils/src/lib/PerformanceTracker.ts @@ -0,0 +1,52 @@ +import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf' + +/** @public */ +export class PerformanceTracker { + private startTime = 0 + private name = '' + private frames = 0 + private started = false + private frame: number | null = null + + recordFrame = () => { + this.frames++ + if (!this.started) return + this.frame = requestAnimationFrame(this.recordFrame) + } + + start(name: string) { + this.name = name + this.frames = 0 + this.started = true + if (this.frame !== null) cancelAnimationFrame(this.frame) + this.frame = requestAnimationFrame(this.recordFrame) + this.startTime = performance.now() + } + + stop() { + this.started = false + if (this.frame !== null) cancelAnimationFrame(this.frame) + const duration = (performance.now() - this.startTime) / 1000 + const fps = duration === 0 ? 0 : Math.floor(this.frames / duration) + const background = + fps > 55 + ? PERFORMANCE_COLORS.Good + : fps > 30 + ? PERFORMANCE_COLORS.Mid + : PERFORMANCE_COLORS.Poor + const color = background === PERFORMANCE_COLORS.Mid ? 'black' : 'white' + const capitalized = this.name[0].toUpperCase() + this.name.slice(1) + // eslint-disable-next-line no-console + console.debug( + `%cPerf%c ${capitalized} %c${fps}%c fps`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal', + `font-weight: bold; padding: 2px; background: ${background};color: ${color};`, + 'font-weight: normal' + ) + } + + isStarted() { + return this.started + } +} diff --git a/packages/utils/src/lib/perf.ts b/packages/utils/src/lib/perf.ts index 2f9283fd9..b97e50ef3 100644 --- a/packages/utils/src/lib/perf.ts +++ b/packages/utils/src/lib/perf.ts @@ -1,9 +1,21 @@ +export const PERFORMANCE_COLORS = { + Good: '#40C057', + Mid: '#FFC078', + Poor: '#E03131', +} + +export const PERFORMANCE_PREFIX_COLOR = PERFORMANCE_COLORS.Good + /** @internal */ export function measureCbDuration(name: string, cb: () => any) { - const now = performance.now() + const start = performance.now() const result = cb() // eslint-disable-next-line no-console - console.log(`${name} took`, performance.now() - now, 'ms') + console.debug( + `%cPerf%c ${name} took ${performance.now() - start}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' + ) return result } @@ -13,9 +25,12 @@ export function measureDuration(_target: any, propertyKey: string, descriptor: P descriptor.value = function (...args: any[]) { const start = performance.now() const result = originalMethod.apply(this, args) - const end = performance.now() // eslint-disable-next-line no-console - console.log(`${propertyKey} took ${end - start}ms `) + console.debug( + `%cPerf%c ${propertyKey} took: ${performance.now() - start}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' + ) return result } return descriptor @@ -41,8 +56,10 @@ export function measureAverageDuration( const count = value.count + 1 averages.set(descriptor.value, { total, count }) // eslint-disable-next-line no-console - console.log( - `${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` + console.debug( + `%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`, + `color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`, + 'font-weight: normal' ) } return result