UNPKG

@tldraw/editor

Version:

tldraw infinite canvas SDK (editor).

467 lines (466 loc) • 16.5 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); var __decorateClass = (decorators, target, key, kind) => { var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target; for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result; if (kind && result) __defProp(target, key, result); return result; }; var PerformanceManager_exports = {}; __export(PerformanceManager_exports, { PerformanceManager: () => PerformanceManager }); module.exports = __toCommonJS(PerformanceManager_exports); var import_utils = require("@tldraw/utils"); var import_eventemitter3 = __toESM(require("eventemitter3"), 1); function percentile(sorted, p) { const idx = Math.ceil(p * sorted.length) - 1; return sorted[Math.max(0, idx)]; } function computeFrameTimeStats(frameTimes) { 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) { const e = entry; 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 })) }; } class PerformanceManager { /** @internal */ emitter = new import_eventemitter3.default(); editor; // Active interaction tracking activeInteraction = null; // Active camera tracking activeCamera = null; // Lazy listener cleanup functions frameCleanup = null; shapeCreatedCleanup = null; shapeEditedCleanup = null; shapeDeletedCleanup = null; // LoAF observer loafObserver = null; constructor(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(event, fn) { this.emitter.on(event, fn); this._maybeAttachLazyListeners(event); return () => { this.emitter.off(event, fn); 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(event, fn) { const wrapped = (...args) => { ; fn(...args); this._maybeDetachLazyListeners(event); }; this.emitter.once(event, wrapped); this._maybeAttachLazyListeners(event); return () => { this.emitter.off(event, wrapped); 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, path) { 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` ); } const selectedShapeTypes = {}; 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 = { 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 = { name: interaction.name, path: interaction.path, duration, fps: interaction.frameTimes.length > 0 ? interaction.frameTimes.length / duration * 1e3 : 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 : void 0, zoomLevel: this.editor.getCamera().z, timestamp: performance.now() }; this.emitter.emit("interaction-end", event); } /** @internal */ _notifyCameraOperation(type) { if (this.emitter.listenerCount("camera-start") === 0 && this.emitter.listenerCount("camera-end") === 0) { return; } if (this.activeCamera) { if (this.activeCamera.timeout) { clearTimeout(this.activeCamera.timeout); } if (this.activeCamera.type !== type) { this._endCameraSession(); this._startCameraSession(type); } else { this.activeCamera.timeout = this.editor.timers.setTimeout( () => this._endCameraSession(), 50 ); } } else { this._startCameraSession(type); } } /** @internal */ _notifyUndoRedo(type, undoDepth, redoDepth) { if (this.emitter.listenerCount(type) === 0) return; const event = { type, undoDepth, redoDepth }; this.emitter.emit(type, event); } // --- Private helpers --- _startCameraSession(type) { 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 = { type, timestamp: performance.now() }; this.emitter.emit("camera-start", event); } } _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 = { type: camera.type, duration, fps: camera.frameTimes.length > 0 ? camera.frameTimes.length / duration * 1e3 : 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 : void 0, visibleShapeCount: totalShapes - culledShapeCount, culledShapeCount, zoomLevel: this.editor.getCamera().z, timestamp: performance.now() }; this.emitter.emit("camera-end", event); } _onFrame(elapsed) { if (this.activeInteraction) { this.activeInteraction.frameTimes.push(elapsed); } if (this.activeCamera) { this.activeCamera.frameTimes.push(elapsed); } if (this.emitter.listenerCount("frame") > 0) { const totalShapes = this.editor.getCurrentPageShapeIds().size; const culledShapes = this.editor.getCulledShapes(); const culledCount = culledShapes.size; const event = { elapsed, shapeCount: totalShapes, culledShapeCount: culledCount, visibleShapeCount: totalShapes - culledCount }; this.emitter.emit("frame", event); } } _onShapesCreated(records) { if (this.emitter.listenerCount("shapes-created") === 0) return; const shapeTypes = {}; 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 = { operation: "create", count, shapeTypes, timestamp: performance.now() }; this.emitter.emit("shapes-created", event); } _onShapesEdited(records) { if (this.emitter.listenerCount("shapes-updated") === 0) return; const shapeTypes = {}; 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 = { operation: "update", count, shapeTypes, timestamp: performance.now() }; this.emitter.emit("shapes-updated", event); } _onShapesDeleted(ids) { if (this.emitter.listenerCount("shapes-deleted") === 0) return; const shapeTypes = {}; for (const id of ids) { const shape = this.editor.getShape(id); if (shape) { shapeTypes[shape.type] = (shapeTypes[shape.type] || 0) + 1; } } const event = { operation: "delete", count: ids.length, shapeTypes, timestamp: performance.now() }; this.emitter.emit("shapes-deleted", event); } // --- LoAF observer --- _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 }); } _stopLoafObserver() { if (this.loafObserver) { this.loafObserver.disconnect(); this.loafObserver = null; } } // --- Lazy listener management --- _needsFrameListener() { 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; } _needsLoafObserver() { return this.emitter.listenerCount("interaction-end") > 0 || this.emitter.listenerCount("camera-end") > 0; } _maybeAttachLazyListeners(event) { 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); } } 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); } } _maybeDetachLazyListeners(event) { 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; } } 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; } } } __decorateClass([ import_utils.bind ], PerformanceManager.prototype, "_onFrame", 1); __decorateClass([ import_utils.bind ], PerformanceManager.prototype, "_onShapesCreated", 1); __decorateClass([ import_utils.bind ], PerformanceManager.prototype, "_onShapesEdited", 1); __decorateClass([ import_utils.bind ], PerformanceManager.prototype, "_onShapesDeleted", 1); //# sourceMappingURL=PerformanceManager.js.map