snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View File

@@ -0,0 +1 @@
This code is licensed under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md)

37
packages/editor/README.md Normal file
View File

@@ -0,0 +1,37 @@
# @tldraw/editor
The core editor and composable parts for tldraw. [Click here](https://tldraw.dev/docs/editor) to learn more.
You might be more interested in the general package [tldraw](https://github.com/tldraw/tldraw/tree/main/packages/tldraw) instead of using this package directly.
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
## License
This project is part of the tldraw SDK. It is provided under the [tldraw SDK license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
You can use the tldraw SDK in commercial or non-commercial projects so long as you preserve the "Made with tldraw" watermark on the canvas. To remove the watermark, you can purchase a [business license](https://tldraw.dev/pricing). Visit [tldraw.dev](https://tldraw.dev) to learn more.
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Distributions
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

1625
packages/editor/editor.css Normal file

File diff suppressed because it is too large Load Diff

4
packages/editor/modules.d.ts vendored Normal file
View File

@@ -0,0 +1,4 @@
declare module '*.svg' {
const content: any
export default content
}

View File

@@ -0,0 +1,92 @@
{
"name": "@tldraw/editor",
"description": "tldraw infinite canvas SDK (editor).",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "SEE LICENSE IN LICENSE.md",
"tldraw_product": {
"stableId": "tldraw:sdk-core",
"name": "SDK",
"type": "product",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"style": "./editor.css",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [
"editor.css"
],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"dependencies": {
"@tiptap/core": "^3.12.1",
"@tiptap/pm": "^3.12.1",
"@tiptap/react": "^3.12.1",
"@tldraw/state": "workspace:*",
"@tldraw/state-react": "workspace:*",
"@tldraw/store": "workspace:*",
"@tldraw/tlschema": "workspace:*",
"@tldraw/utils": "workspace:*",
"@tldraw/validate": "workspace:*",
"classnames": "^2.5.1",
"eventemitter3": "^4.0.7",
"idb": "^7.1.1",
"is-plain-object": "^5.0.0",
"rbush": "^3.0.1"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1",
"react-dom": "^18.2.0 || ^19.2.1"
},
"devDependencies": {
"@peculiar/webcrypto": "^1.5.0",
"@testing-library/dom": "^10.0.0",
"@testing-library/react": "^16.0.0",
"@types/rbush": "^3.0.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/wicg-file-system-access": "^2020.9.8",
"fake-indexeddb": "^4.0.2",
"lazyrepo": "0.0.0-alpha.27",
"react": "^19.2.1",
"react-dom": "^19.2.1",
"resize-observer-polyfill": "^1.5.1",
"vitest": "^5.0.0"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1,28 @@
require('fake-indexeddb/auto')
global.ResizeObserver = require('resize-observer-polyfill')
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
global.FontFace = class FontFace {
load() {
return Promise.resolve()
}
}
document.fonts = {
add: () => {},
delete: () => {},
forEach: () => {},
[Symbol.iterator]: () => [][Symbol.iterator](),
}
global.TextEncoder = require('util').TextEncoder
global.TextDecoder = require('util').TextDecoder
// Extract verson from package.json
const { version } = require('./package.json')
window.fetch = async (input, init) => {
return {
ok: true,
json: async () => [],
}
}

View File

@@ -0,0 +1,66 @@
// Vitest setup file for editor package
// IndexedDB polyfill for browser storage testing
require('fake-indexeddb/auto')
// ResizeObserver polyfill for DOM observation testing
global.ResizeObserver = require('resize-observer-polyfill')
// Crypto polyfill for cryptographic operations
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()
// FontFace API polyfill for font testing
global.FontFace = class FontFace {
load() {
return Promise.resolve()
}
}
// Document fonts API mock
document.fonts = {
add: () => {},
delete: () => {},
forEach: () => {},
[Symbol.iterator]: () => [][Symbol.iterator](),
}
// Window fetch mock for network requests
window.fetch = async (input, init) => {
return {
ok: true,
json: async () => [],
}
}
// Window.matchMedia polyfill for media queries
// Use a conditional approach to avoid conflicts with jsdom
window.matchMedia = (query) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => {},
})
// Mock DragEvent for jsdom
if (typeof window !== 'undefined' && !window.DragEvent) {
global.DragEvent = class DragEvent extends Event {
dataTransfer = {
getData: () => '',
setData: () => {},
files: [],
}
constructor(type, options = {}) {
super(type, options)
this.dataTransfer = options.dataTransfer || {
getData: () => '',
setData: () => {},
files: [],
}
}
}
}

View File

@@ -0,0 +1,526 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/state'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/state-react'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/store'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/tlschema'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/utils'
// eslint-disable-next-line tldraw/no-export-star
export * from '@tldraw/validate'
export { DefaultBackground } from './lib/components/default-components/DefaultBackground'
export {
DefaultCanvas,
type TLCanvasComponentProps,
} from './lib/components/default-components/DefaultCanvas'
export {
DefaultCursor,
type TLCursorProps,
} from './lib/components/default-components/DefaultCursor'
export {
DefaultErrorFallback,
type TLErrorFallbackComponent,
type TLErrorFallbackProps,
} from './lib/components/default-components/DefaultErrorFallback'
export { DefaultGrid, type TLGridProps } from './lib/components/default-components/DefaultGrid'
export { type TLShapeErrorFallbackComponent } from './lib/components/default-components/DefaultShapeErrorFallback'
export {
DefaultShapeWrapper,
type TLShapeWrapperProps,
} from './lib/components/default-components/DefaultShapeWrapper'
export {
DefaultSpinner,
type TLSpinnerProps,
} from './lib/components/default-components/DefaultSpinner'
export { DefaultSvgDefs } from './lib/components/default-components/DefaultSvgDefs'
export {
EditorPortal,
useEditorPortalHost,
type EditorPortalProps,
} from './lib/components/EditorPortal'
export {
ErrorBoundary,
OptionalErrorBoundary,
type TLErrorBoundaryProps,
} from './lib/components/ErrorBoundary'
export { HTMLContainer, type HTMLContainerProps } from './lib/components/HTMLContainer'
export { MenuClickCapture } from './lib/components/MenuClickCapture'
export { SVGContainer, type SVGContainerProps } from './lib/components/SVGContainer'
export {
createTLCurrentUser,
useTldrawCurrentUser,
type TLCurrentUser,
} from './lib/config/createTLCurrentUser'
export {
createTLSchemaFromUtils,
createTLStore,
defaultUserStore,
inlineBase64AssetStore,
type TLStoreBaseOptions,
type TLStoreEventInfo,
type TLStoreOptions,
type TLStoreSchemaOptions,
} from './lib/config/createTLStore'
export { type TLAnyAssetUtilConstructor } from './lib/config/defaultAssets'
export { type TLAnyBindingUtilConstructor } from './lib/config/defaultBindings'
export { coreShapes, type TLAnyShapeUtilConstructor } from './lib/config/defaultShapes'
export {
getSnapshot,
loadSnapshot,
type TLEditorSnapshot,
type TLLoadSnapshotOptions,
} from './lib/config/TLEditorSnapshot'
export {
createSessionStateSnapshotSignal,
extractSessionStateFromLegacySnapshot,
loadSessionStateSnapshotIntoStore,
TAB_ID,
type TLLoadSessionStateSnapshotOptions,
type TLSessionStateSnapshot,
} from './lib/config/TLSessionStateSnapshot'
export {
defaultUserPreferences,
getFreshUserPreferences,
getUserPreferences,
setUserPreferences,
USER_COLORS,
userTypeValidator,
type TLUserPreferences,
} from './lib/config/TLUserPreferences'
export { DEFAULT_ANIMATION_OPTIONS, DEFAULT_CAMERA_OPTIONS, SIDES } from './lib/constants'
export { AssetUtil, type TLAssetUtilConstructor } from './lib/editor/assets/AssetUtil'
export {
BindingUtil,
type BindingOnChangeOptions,
type BindingOnCreateOptions,
type BindingOnDeleteOptions,
type BindingOnShapeChangeOptions,
type BindingOnShapeDeleteOptions,
type BindingOnShapeIsolateOptions,
type TLBindingUtilConstructor,
} from './lib/editor/bindings/BindingUtil'
export {
Editor,
type TLEditorOptions,
type TLEditorRunOptions,
type TLRenderingShape,
type TLResizeShapeOptions,
} from './lib/editor/Editor'
export { ClickManager, type TLClickState } from './lib/editor/managers/ClickManager/ClickManager'
export { EdgeScrollManager } from './lib/editor/managers/EdgeScrollManager/EdgeScrollManager'
export { FontManager } from './lib/editor/managers/FontManager/FontManager'
export { HistoryManager } from './lib/editor/managers/HistoryManager/HistoryManager'
export { EditorManager } from './lib/editor/managers/EditorManager'
export { InputsManager } from './lib/editor/managers/InputsManager/InputsManager'
export {
ScribbleManager,
type ScribbleItem,
type ScribbleSessionOptions,
} from './lib/editor/managers/ScribbleManager/ScribbleManager'
export {
BoundsSnaps,
type BoundsSnapGeometry,
type BoundsSnapPoint,
} from './lib/editor/managers/SnapManager/BoundsSnaps'
export { HandleSnaps, type HandleSnapGeometry } from './lib/editor/managers/SnapManager/HandleSnaps'
export {
SnapManager,
type GapsSnapIndicator,
type PointsSnapIndicator,
type SnapData,
type SnapIndicator,
} from './lib/editor/managers/SnapManager/SnapManager'
export { SpatialIndexManager } from './lib/editor/managers/SpatialIndexManager/SpatialIndexManager'
export {
TextManager,
resolveLineHeightPx,
type BatchMeasurementRequest,
type TLMeasuredTextSize,
type TLMeasureTextOpts,
type TLMeasureTextSpanOpts,
} from './lib/editor/managers/TextManager/TextManager'
export { DEFAULT_THEME } from './lib/editor/managers/ThemeManager/defaultThemes'
export { ThemeManager, resolveThemes } from './lib/editor/managers/ThemeManager/ThemeManager'
export { TickManager } from './lib/editor/managers/TickManager/TickManager'
export { CollaboratorsManager } from './lib/editor/managers/CollaboratorsManager/CollaboratorsManager'
export { PerformanceApiAdapter } from './lib/editor/managers/PerformanceManager/PerformanceApiAdapter'
export { PerformanceManager } from './lib/editor/managers/PerformanceManager/PerformanceManager'
export {
type TLCameraEndPerfEvent,
type TLCameraStartPerfEvent,
type TLFramePerfEvent,
type TLInteractionEndPerfEvent,
type TLInteractionStartPerfEvent,
type TLPerfEventMap,
type TLPerfFrameTimeStats,
type TLPerfLongAnimationFrame,
type TLPerfLongAnimationFrameScript,
type TLShapeOperationPerfEvent,
type TLUndoRedoPerfEvent,
} from './lib/editor/managers/PerformanceManager/perf-types'
export { UserPreferencesManager } from './lib/editor/managers/UserPreferencesManager/UserPreferencesManager'
export { BaseBoxShapeUtil, type TLBaseBoxShape } from './lib/editor/shapes/BaseBoxShapeUtil'
export { BaseFrameLikeShapeUtil } from './lib/editor/shapes/BaseFrameLikeShapeUtil'
export { GroupShapeUtil } from './lib/editor/shapes/group/GroupShapeUtil'
export {
ShapeUtil,
type TLCropInfo,
type TLDragShapesInInfo,
type TLDragShapesOutInfo,
type TLDragShapesOverInfo,
type TLDropShapesOverInfo,
type TLEditStartInfo,
type TLGeometryOpts,
type TLHandleDragInfo,
type TLIndicatorPath,
type TLResizeInfo,
type TLResizeMode,
type TLShapeUtilCanBeLaidOutOpts,
type TLShapeUtilCanBindOpts,
type TLShapeUtilCanvasSvgDef,
type TLShapeUtilConstructor,
} from './lib/editor/shapes/ShapeUtil'
export {
getOverlayDisplayValues,
type OverlayOptionsWithDisplayValues,
} from './lib/editor/overlays/getOverlayDisplayValues'
export { OverlayManager, type TLOverlayEntry } from './lib/editor/overlays/OverlayManager'
export {
OverlayUtil,
type TLAnyOverlayUtilConstructor,
type TLOverlay,
type TLOverlayUtilConstructor,
} from './lib/editor/overlays/OverlayUtil'
export { strokeShapeIndicators } from './lib/editor/overlays/strokeShapeIndicators'
export {
getPerfectDashProps,
type PerfectDashTerminal,
} from './lib/editor/shapes/shared/getPerfectDashProps'
export { resizeBox, type ResizeBoxOptions } from './lib/editor/shapes/shared/resizeBox'
export { resizeScaled } from './lib/editor/shapes/shared/resizeScaled'
export { BaseBoxShapeTool } from './lib/editor/tools/BaseBoxShapeTool/BaseBoxShapeTool'
export { maybeSnapToGrid } from './lib/editor/tools/BaseBoxShapeTool/children/Pointing'
export { StateNode, type TLStateNodeConstructor } from './lib/editor/tools/StateNode'
export { type TLContent } from './lib/editor/types/clipboard-types'
export { type TLEventMap, type TLEventMapHandler } from './lib/editor/types/emit-types'
export {
EVENT_NAME_MAP,
type TLBaseEventInfo,
type TLCancelEvent,
type TLCancelEventInfo,
type TLClickEvent,
type TLClickEventInfo,
type TLCLickEventName,
type TLCompleteEvent,
type TLCompleteEventInfo,
type TLEnterEventHandler,
type TLEventHandlers,
type TLEventInfo,
type TLEventName,
type TLExitEventHandler,
type TLInterruptEvent,
type TLInterruptEventInfo,
type TLKeyboardEvent,
type TLKeyboardEventInfo,
type TLKeyboardEventName,
type TLPinchEvent,
type TLPinchEventInfo,
type TLPinchEventName,
type TLPointerEvent,
type TLPointerEventInfo,
type TLPointerEventName,
type TLPointerEventTarget,
type TLTickEvent,
type TLTickEventInfo,
type TLWheelEvent,
type TLWheelEventInfo,
type UiEvent,
type UiEventType,
} from './lib/editor/types/event-types'
export {
type TLBaseExternalContent,
type TLEmbedExternalContent,
type TLErrorExternalContentSource,
type TLExcalidrawExternalContent,
type TLExcalidrawExternalContentSource,
type TLExternalAsset,
type TLExternalContent,
type TLExternalContentSource,
type TLFileExternalAsset,
type TLFileReplaceExternalContent,
type TLFilesExternalContent,
type TLSvgTextExternalContent,
type TLTextExternalContent,
type TLTextExternalContentSource,
type TLTldrawExternalContent,
type TLTldrawExternalContentSource,
type TLUrlExternalAsset,
type TLUrlExternalContent,
} from './lib/editor/types/external-content'
export {
type TLHistoryBatchOptions,
type TLHistoryDiff,
type TLHistoryEntry,
type TLHistoryMark,
} from './lib/editor/types/history-types'
export {
type OptionalKeys,
type RequiredKeys,
type TLCameraConstraints,
type TLCameraMoveOptions,
type TLCameraOptions,
type TLExportType,
type TLGetShapeAtPointOptions,
type TLImageExportOptions,
type TLSvgExportOptions,
type TLUpdatePointerOptions,
} from './lib/editor/types/misc-types'
export {
type TLAdjacentDirection,
type TLResizeHandle,
type TLSelectionHandle,
} from './lib/editor/types/selection-types'
export {
useDelaySvgExport,
useSvgExportContext,
type SvgExportContext,
type SvgExportDef,
} from './lib/editor/types/SvgExportContext'
export { getOwnerDocument, getOwnerWindow } from './lib/exports/domUtils'
export { getSvgAsImage } from './lib/exports/getSvgAsImage'
export { tlenv, tlenvReactive } from './lib/globals/environment'
export { tleditors } from './lib/globals/editors'
export { tlmenus } from './lib/globals/menus'
export { tltime } from './lib/globals/time'
export {
ContainerProvider,
useContainer,
useContainerIfExists,
type ContainerProviderProps,
} from './lib/hooks/useContainer'
export { useColorMode } from './lib/hooks/useColorMode'
export { getCursor } from './lib/hooks/useCursor'
export {
EditorContext,
EditorProvider,
useEditor,
useMaybeEditor,
type EditorProviderProps,
} from './lib/hooks/useEditor'
export { useEditorComponents } from './lib/hooks/useEditorComponents'
export type { TLEditorComponents } from './lib/hooks/useEditorComponents'
export { useEvent, useReactiveEvent } from './lib/hooks/useEvent'
export { useGlobalMenuIsOpen } from './lib/hooks/useGlobalMenuIsOpen'
export { useShallowArrayIdentity, useShallowObjectIdentity } from './lib/hooks/useIdentity'
export { useIsCropping } from './lib/hooks/useIsCropping'
export { useIsEditing } from './lib/hooks/useIsEditing'
export { useLocalStore } from './lib/hooks/useLocalStore'
export { usePassThroughWheelEvents } from './lib/hooks/usePassThroughWheelEvents'
export { usePeerIds } from './lib/hooks/usePeerIds'
export { usePresence } from './lib/hooks/usePresence'
export { useRefState } from './lib/hooks/useRefState'
export {
sanitizeId,
suffixSafeId,
useSharedSafeId,
useUniqueSafeId,
type SafeId,
} from './lib/hooks/useSafeId'
export { useTransform } from './lib/hooks/useTransform'
export { isCursorInViewport } from './lib/utils/collaborators'
export { useTLSchemaFromUtils, useTLStore } from './lib/hooks/useTLStore'
// oxlint-disable-next-line typescript/no-deprecated
export { useViewportHeight } from './lib/hooks/useViewportHeight'
export {
LicenseManager,
type InvalidLicenseKeyResult,
type InvalidLicenseReason,
type LicenseFeatureName,
type LicenseFromKeyResult,
type LicenseInfo,
type LicenseState,
type ValidLicenseKeyResult,
} from './lib/license/LicenseManager'
export {
LICENSE_TIMEOUT,
useLicenseContext,
useMaybeLicenseManager,
} from './lib/license/LicenseProvider'
export { useLicenseFeatureFlag } from './lib/license/useLicenseManagerState'
export {
defaultTldrawOptions,
type TLClipboardPasteRawInfo,
type TLClipboardWriteInfo,
type TldrawOptions,
} from './lib/options'
export {
Box,
ROTATE_CORNER_TO_SELECTION_CORNER,
rotateSelectionHandle,
type BoxLike,
type RotateCorner,
type SelectionCorner,
type SelectionEdge,
type SelectionHandle,
} from './lib/primitives/Box'
export { EASINGS } from './lib/primitives/easings'
export { Arc2d } from './lib/primitives/geometry/Arc2d'
export { Circle2d } from './lib/primitives/geometry/Circle2d'
export { CubicBezier2d } from './lib/primitives/geometry/CubicBezier2d'
export { CubicSpline2d } from './lib/primitives/geometry/CubicSpline2d'
export { Edge2d } from './lib/primitives/geometry/Edge2d'
export { Ellipse2d } from './lib/primitives/geometry/Ellipse2d'
export { getVerticesCountForArcLength } from './lib/primitives/geometry/geometry-constants'
export {
Geometry2d,
Geometry2dFilters,
TransformedGeometry2d,
type Geometry2dOptions,
type TransformedGeometry2dOptions,
} from './lib/primitives/geometry/Geometry2d'
export { Group2d } from './lib/primitives/geometry/Group2d'
export { Point2d } from './lib/primitives/geometry/Point2d'
export { Polygon2d } from './lib/primitives/geometry/Polygon2d'
export { Polyline2d } from './lib/primitives/geometry/Polyline2d'
export { Rectangle2d } from './lib/primitives/geometry/Rectangle2d'
export { Stadium2d } from './lib/primitives/geometry/Stadium2d'
export {
intersectCircleCircle,
intersectCirclePolygon,
intersectCirclePolyline,
intersectLineSegmentCircle,
intersectLineSegmentLineSegment,
intersectLineSegmentPolygon,
intersectLineSegmentPolyline,
intersectPolygonBounds,
intersectPolygonPolygon,
linesIntersect,
polygonIntersectsPolyline,
polygonsIntersect,
} from './lib/primitives/intersect'
export { Mat, type MatLike, type MatModel } from './lib/primitives/Mat'
export {
angleDistance,
approximately,
areAnglesCompatible,
average,
canonicalizeRotation,
centerOfCircleFromThreePoints,
clamp,
clampRadians,
clockwiseAngleDist,
counterClockwiseAngleDist,
degreesToRadians,
getArcMeasure,
getPointInArcT,
getPointOnCircle,
getPointsOnArc,
getPolygonVertices,
HALF_PI,
isSafeFloat,
perimeterOfEllipse,
PI,
PI2,
pointInPolygon,
precise,
radiansToDegrees,
rangeIntersection,
shortAngleDist,
SIN,
snapAngle,
toDomPrecision,
toFixed,
toPrecision,
} from './lib/primitives/utils'
export { Vec, type VecLike } from './lib/primitives/Vec'
export {
ErrorScreen,
LoadingScreen,
TldrawEditor,
useOnMount,
type LoadingScreenProps,
type TldrawEditorBaseProps,
type TldrawEditorProps,
type TldrawEditorStoreProps,
type TldrawEditorWithoutStoreProps,
type TldrawEditorWithStoreProps,
type TLOnMountHandler,
} from './lib/TldrawEditor'
export { dataUrlToFile, getDefaultCdnBaseUrl } from './lib/utils/assets'
export { clampToBrowserMaxCanvasSize, type CanvasMaxSize } from './lib/utils/browserCanvasMaxSize'
export {
createDebugValue,
debugFlags,
featureFlags,
type DebugFlag,
type DebugFlagDef,
type DebugFlagDefaults,
} from './lib/utils/debug-flags'
export {
createDeepLinkString,
parseDeepLinkString,
type TLDeepLink,
type TLDeepLinkOptions,
} from './lib/utils/deepLinks'
export {
activeElementShouldCaptureKeys,
elementShouldCaptureKeys,
getGlobalDocument,
getGlobalWindow,
loopToHtmlElement,
moveElementInto,
preventDefault,
releasePointerCapture,
setPointerCapture,
// eslint-disable-next-line @typescript-eslint/no-deprecated
stopEventPropagation,
} from './lib/utils/dom'
export { EditorAtom } from './lib/utils/EditorAtom'
export { getIncrementedName } from './lib/utils/getIncrementedName'
export { getPointerInfo } from './lib/utils/getPointerInfo'
export { getSvgPathFromPoints } from './lib/utils/getSvgPathFromPoints'
export { isAccelKey } from './lib/utils/keyboard'
export { normalizeWheel } from './lib/utils/normalizeWheel'
export { getDroppedShapesToNewParents, kickoutOccludedShapes } from './lib/utils/reparenting'
export {
getFontsFromRichText,
type RichTextFontVisitor,
type RichTextFontVisitorState,
type TiptapEditor,
type TiptapNode,
type TLTextOptions,
} from './lib/utils/richText'
export {
applyRotationToSnapshotShapes,
getRotationSnapshot,
type TLRotationSnapshot,
} from './lib/utils/rotation'
export {
hardResetEditor,
openWindow,
refreshPage,
runtime,
setRuntimeOverrides,
} from './lib/utils/runtime'
export {
ReadonlySharedStyleMap,
SharedStyleMap,
type SharedStyle,
} from './lib/utils/SharedStylesMap'
export { hardReset } from './lib/utils/sync/hardReset'
export { LocalIndexedDb, Table, type StoreName } from './lib/utils/sync/LocalIndexedDb'
export { type TLStoreWithStatus } from './lib/utils/sync/StoreWithStatus'
export { uniq } from './lib/utils/uniq'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)
export { getColorValue } from './lib/editor/managers/ThemeManager/defaultThemes'

View File

@@ -0,0 +1,853 @@
import { MigrationSequence, Store } from '@tldraw/store'
import {
TLShape,
TLStore,
TLStoreSnapshot,
TLTheme,
TLThemeId,
TLThemes,
registerColorsFromThemes,
registerFontsFromThemes,
} from '@tldraw/tlschema'
import { annotateError, Required } from '@tldraw/utils'
import classNames from 'classnames'
import React, {
memo,
ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import { version } from '../version'
import { DefaultErrorFallback } from './components/default-components/DefaultErrorFallback'
import { EditorPortalProvider } from './components/EditorPortal'
import { OptionalErrorBoundary } from './components/ErrorBoundary'
import { createTLCurrentUser, TLCurrentUser } from './config/createTLCurrentUser'
import { TLStoreBaseOptions } from './config/createTLStore'
import { TLAnyAssetUtilConstructor } from './config/defaultAssets'
import { TLAnyBindingUtilConstructor } from './config/defaultBindings'
import { TLAnyShapeUtilConstructor } from './config/defaultShapes'
import { TLEditorSnapshot } from './config/TLEditorSnapshot'
import { Editor } from './editor/Editor'
import { resolveThemes } from './editor/managers/ThemeManager/ThemeManager'
import { TLAnyOverlayUtilConstructor } from './editor/overlays/OverlayUtil'
import { TLStateNodeConstructor } from './editor/tools/StateNode'
import { TLCameraOptions } from './editor/types/misc-types'
import { useEditorComponents } from './hooks/EditorComponentsContext'
import type { TLEditorComponents } from './hooks/EditorComponentsContext'
import { ContainerProvider, useContainer } from './hooks/useContainer'
import { useCursor } from './hooks/useCursor'
import { useDarkMode } from './hooks/useDarkMode'
import { EditorProvider, useEditor } from './hooks/useEditor'
import { EditorComponentsProvider } from './hooks/useEditorComponents'
import { useEvent } from './hooks/useEvent'
import { useForceUpdate } from './hooks/useForceUpdate'
import { useDeepObjectIdentity, useShallowObjectIdentity } from './hooks/useIdentity'
import { useLocalStore } from './hooks/useLocalStore'
import { useRefState } from './hooks/useRefState'
import { useStateAttribute } from './hooks/useStateAttribute'
import { useZoomCss } from './hooks/useZoomCss'
import { LicenseProvider, useLicenseContext } from './license/LicenseProvider'
import { Watermark } from './license/Watermark'
import { TldrawOptions } from './options'
import { TLDeepLinkOptions } from './utils/deepLinks'
import { getGlobalDocument, getGlobalWindow } from './utils/dom'
import { TLTextOptions } from './utils/richText'
import { TLStoreWithStatus } from './utils/sync/StoreWithStatus'
/**
* Props for the {@link tldraw#Tldraw} and {@link TldrawEditor} components, when passing in a
* `TLStore` directly. If you would like tldraw to create a store for you, use
* {@link TldrawEditorWithoutStoreProps}.
*
* @public
*/
export interface TldrawEditorWithStoreProps {
/**
* The store to use in the editor.
*/
store: TLStore | TLStoreWithStatus
}
/**
* Props for the {@link tldraw#Tldraw} and {@link TldrawEditor} components, when not passing in a
* `TLStore` directly. If you would like to pass in a store directly, use
* {@link TldrawEditorWithStoreProps}.
*
* @public
*/
export interface TldrawEditorWithoutStoreProps extends TLStoreBaseOptions {
store?: undefined
/**
* Additional migrations to use in the store
*/
migrations?: readonly MigrationSequence[]
/**
* A starting snapshot of data to pre-populate the store. Do not supply both this and
* `initialData`.
*/
snapshot?: TLEditorSnapshot | TLStoreSnapshot
/**
* If you would like to persist the store to the browser's local IndexedDB storage and sync it
* across tabs, provide a key here. Each key represents a single tldraw document.
*/
persistenceKey?: string
sessionId?: string
}
/** @public */
export type TldrawEditorStoreProps = TldrawEditorWithStoreProps | TldrawEditorWithoutStoreProps
/** @public */
export type TldrawEditorProps = TldrawEditorBaseProps & TldrawEditorStoreProps
/**
* Base props for the {@link tldraw#Tldraw} and {@link TldrawEditor} components.
*
* @public
*/
export interface TldrawEditorBaseProps {
/**
* The component's children.
*/
children?: ReactNode
/**
* An array of shape utils to use in the editor.
*/
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
/**
* An array of binding utils to use in the editor.
*/
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
/**
* An array of asset utils to use in the editor.
*/
assetUtils?: readonly TLAnyAssetUtilConstructor[]
/**
* An array of overlay utils to use in the editor for canvas overlay UI elements.
*/
overlayUtils?: readonly TLAnyOverlayUtilConstructor[]
/**
* An array of tools to add to the editor's state chart.
*/
tools?: readonly TLStateNodeConstructor[]
/**
* Whether to automatically focus the editor when it mounts.
*/
autoFocus?: boolean
/**
* Overrides for the editor's components, such as handles, collaborator cursors, etc.
*/
components?: TLEditorComponents
/**
* Called when the editor has mounted.
*/
onMount?: TLOnMountHandler
/**
* The editor's initial state (usually the id of the first active tool).
*/
initialState?: string
/**
* A classname to pass to the editor's container.
*/
className?: string
/**
* The user interacting with the editor.
*/
user?: TLCurrentUser
/**
* The editor's color scheme. Defaults to `'light'`.
*
* - `'light'` - Always use light mode.
* - `'dark'` - Always use dark mode.
* - `'system'` - Follow the OS color scheme preference.
*/
colorScheme?: 'light' | 'dark' | 'system'
/**
* Named themes for the editor.
*/
themes?: Partial<TLThemes>
/**
* The id of the initially active theme. Defaults to `'default'`.
*/
initialTheme?: TLThemeId
/**
* Camera options for the editor.
*
* @deprecated Use `options.cameraOptions` instead. This will be removed in a future release.
*/
cameraOptions?: Partial<TLCameraOptions>
/**
* Options for the editor.
*/
options?: Partial<TldrawOptions>
/**
* Text options for the editor.
*
* @deprecated Use `options.text` instead. This prop will be removed in a future release.
*/
textOptions?: TLTextOptions
/**
* The license key.
*/
licenseKey?: string
/**
* Options for syncing the editor's camera state with the URL.
*
* @deprecated Use `options.deepLinks` instead. This prop will be removed in a future release.
*/
deepLinks?: true | TLDeepLinkOptions
/**
* Provides a way to hide shapes.
*
* Hidden shapes will not render in the editor, and they will not be eligible for hit test via
* {@link @tldraw/editor#Editor.getShapeAtPoint} and {@link @tldraw/editor#Editor.getShapesAtPoint}. But otherwise they will
* remain in the store and participate in all other operations.
*
* @example
* ```ts
* getShapeVisibility={(shape, editor) => shape.meta.hidden ? 'hidden' : 'inherit'}
* ```
*
* - `'inherit' | undefined` - (default) The shape will be visible unless its parent is hidden.
* - `'hidden'` - The shape will be hidden.
* - `'visible'` - The shape will be visible.
*
* @param shape - The shape to check.
* @param editor - The editor instance.
*/
getShapeVisibility?(
shape: TLShape,
editor: Editor
): 'visible' | 'hidden' | 'inherit' | null | undefined
/**
* The URLs for the fonts to use in the editor.
*/
assetUrls?: { fonts?: { [key: string]: string | undefined } }
}
/**
* Called when the editor has mounted.
* @example
* ```ts
* <Tldraw onMount={(editor) => editor.selectAll()} />
* ```
* @param editor - The editor instance.
*
* @public
*/
export type TLOnMountHandler = (editor: Editor) => (() => void | undefined) | undefined | void
declare global {
interface Window {
tldrawReady: boolean
}
}
const EMPTY_SHAPE_UTILS_ARRAY = [] as const
const EMPTY_BINDING_UTILS_ARRAY = [] as const
const EMPTY_ASSET_UTILS_ARRAY = [] as const
const EMPTY_TOOLS_ARRAY = [] as const
/** @internal */
export const TL_CONTAINER_CLASS = 'tl-container'
/**
* The editor component without any of the default shapes, tools, or UI. It renders the canvas, or
* your own children in its place, with the shape utils, binding utils, tools, and components you
* pass in; the `Tldraw` component in the `tldraw` package wraps it with those defaults.
*
* @public @react
*/
export const TldrawEditor = memo(function TldrawEditor({
store,
components,
className,
user: _user,
options: _options,
// eslint-disable-next-line @typescript-eslint/no-deprecated
textOptions: _textOptions,
// eslint-disable-next-line @typescript-eslint/no-deprecated
deepLinks: _deepLinks,
...rest
}: TldrawEditorProps) {
// Register custom colors and fonts before effects run. For external stores,
// users should also pass themes to createTLStore so they are
// registered before data is loaded into the store.
const resolvedThemes = resolveThemes(rest.themes)
registerColorsFromThemes(resolvedThemes)
registerFontsFromThemes(resolvedThemes)
const [container, setContainer] = useState<HTMLElement | null>(null)
const [portalHost, setPortalHost] = useState<HTMLElement | null>(null)
const user = useMemo(() => _user ?? createTLCurrentUser(), [_user])
const ErrorFallback =
components?.ErrorFallback === undefined ? DefaultErrorFallback : components?.ErrorFallback
// Merge deprecated props with options (options win). `options` is a dependency of the
// editor-creating effect, so it's shallow-stabilised below; the nested objects are
// deep-stabilised here first, or an inline `options={{ camera: { ... } }}` would still
// recreate the editor on every render. `text` needs the deep comparison as well: `<Tldraw>`
// builds a fresh `tipTapConfig` inside it whenever its own `options.text` is a new identity.
const camera = useDeepObjectIdentity(_options?.camera)
const gridSteps = useDeepObjectIdentity(_options?.gridSteps)
const text = useDeepObjectIdentity(_options?.text ?? _textOptions)
const mergedDeepLinks = _options?.deepLinks ?? _deepLinks
const deepLinkOptions = useDeepObjectIdentity(
mergedDeepLinks === true ? undefined : mergedDeepLinks
)
const deepLinks = mergedDeepLinks === true ? true : deepLinkOptions
let mergedOptions = _options
if (camera !== undefined) mergedOptions = { ...mergedOptions, camera }
if (gridSteps !== undefined) mergedOptions = { ...mergedOptions, gridSteps }
if (text !== undefined) mergedOptions = { ...mergedOptions, text }
if (deepLinks !== undefined) mergedOptions = { ...mergedOptions, deepLinks }
// apply defaults. if you're using the bare @tldraw/editor package, we
// default these to the "tldraw zero" configuration. We have different
// defaults applied in tldraw.
const withDefaults = {
...rest,
shapeUtils: rest.shapeUtils ?? EMPTY_SHAPE_UTILS_ARRAY,
bindingUtils: rest.bindingUtils ?? EMPTY_BINDING_UTILS_ARRAY,
assetUtils: rest.assetUtils ?? EMPTY_ASSET_UTILS_ARRAY,
tools: rest.tools ?? EMPTY_TOOLS_ARRAY,
components,
options: useShallowObjectIdentity(mergedOptions),
}
return (
<div
ref={setContainer}
data-tldraw={version}
draggable={false}
className={classNames(`${TL_CONTAINER_CLASS} tl-theme__light`, className)}
tabIndex={-1}
role="application"
aria-label={_options?.branding ?? 'tldraw'}
>
<OptionalErrorBoundary
fallback={ErrorFallback}
onError={(error) => annotateError(error, { tags: { origin: 'react.tldraw-before-app' } })}
>
{container && (
<LicenseProvider licenseKey={rest.licenseKey}>
<ContainerProvider container={container}>
<EditorPortalProvider host={portalHost}>
<EditorComponentsProvider overrides={components}>
{store ? (
store instanceof Store ? (
// Store is ready to go, whether externally synced or not
<TldrawEditorWithReadyStore {...withDefaults} store={store} user={user} />
) : (
// Store is a synced store, so handle syncing stages internally
<TldrawEditorWithLoadingStore {...withDefaults} store={store} user={user} />
)
) : (
// We have no store (it's undefined) so create one and possibly sync it
<TldrawEditorWithOwnStore {...withDefaults} store={store} user={user} />
)}
</EditorComponentsProvider>
</EditorPortalProvider>
</ContainerProvider>
</LicenseProvider>
)}
</OptionalErrorBoundary>
{/* The host for <EditorPortal>, last among the container's children so that anything
portaled through it lands after the canvas and the UI — behind the UI's "skip to main
content" link in the tab order, which only works while nothing precedes it. */}
<div className="tl-portal-host" ref={setPortalHost} />
</div>
)
})
function TldrawEditorWithOwnStore(
props: Required<
TldrawEditorProps & { store: undefined; user: TLCurrentUser },
'shapeUtils' | 'bindingUtils' | 'assetUtils' | 'tools'
>
) {
const {
defaultName,
snapshot,
initialData,
shapeUtils,
bindingUtils,
assetUtils,
persistenceKey,
sessionId,
user,
assets,
users,
migrations,
themes,
} = props
const syncedStore = useLocalStore({
shapeUtils,
bindingUtils,
assetUtils,
initialData,
persistenceKey,
sessionId,
defaultName,
snapshot,
assets,
users,
migrations,
themes,
})
return <TldrawEditorWithLoadingStore {...props} store={syncedStore} user={user} />
}
const TldrawEditorWithLoadingStore = memo(function TldrawEditorBeforeLoading({
store,
user,
...rest
}: Required<
TldrawEditorProps & { store: TLStoreWithStatus; user: TLCurrentUser },
'shapeUtils' | 'bindingUtils' | 'assetUtils' | 'tools'
>) {
const container = useContainer()
useLayoutEffect(() => {
// Resolve the scheme the same way UserPreferencesManager.getIsDarkMode will once the
// editor mounts, so a 'system' user on a dark OS doesn't get a light loading screen that
// flips dark on mount.
const scheme = user.userPreferences.get().colorScheme ?? rest.colorScheme ?? 'light'
const isDark =
scheme === 'dark' ||
(scheme === 'system' &&
typeof window !== 'undefined' &&
!!getGlobalWindow().matchMedia?.('(prefers-color-scheme: dark)').matches)
if (isDark) {
container.classList.remove('tl-theme__light')
container.classList.add('tl-theme__dark')
}
}, [container, user, rest.colorScheme])
const { LoadingScreen } = useEditorComponents()
switch (store.status) {
case 'error': {
// for error handling, we fall back to the default error boundary.
// if users want to handle this error differently, they can render
// their own error screen before the TldrawEditor component
throw store.error
}
case 'loading': {
return LoadingScreen ? <LoadingScreen /> : null
}
case 'not-synced': {
break
}
case 'synced-local': {
break
}
case 'synced-remote': {
break
}
}
return <TldrawEditorWithReadyStore {...rest} store={store.store} user={user} />
})
const noAutoFocus = () => getGlobalDocument().location.search.includes('tldraw_preserve_focus')
function TldrawEditorWithReadyStore({
onMount,
children,
store,
tools,
shapeUtils,
bindingUtils,
assetUtils,
overlayUtils,
user,
initialState,
autoFocus = true,
// eslint-disable-next-line @typescript-eslint/no-deprecated
cameraOptions,
options,
licenseKey,
getShapeVisibility,
colorScheme,
assetUrls,
themes,
initialTheme,
}: Required<
TldrawEditorProps & {
store: TLStore
user: TLCurrentUser
},
'shapeUtils' | 'bindingUtils' | 'assetUtils' | 'tools'
>) {
const { ErrorFallback } = useEditorComponents()
const container = useContainer()
const licenseManager = useLicenseContext()
const [editor, setEditor] = useRefState<Editor | null>(null)
const canvasRef = useRef<HTMLDivElement | null>(null)
const _deepLinks = options?.deepLinks
const deepLinks = useShallowObjectIdentity(_deepLinks === true ? {} : _deepLinks)
// props in this ref can be changed without causing the editor to be recreated.
const editorOptionsRef = useRef({
// for these, it's because they're only used when the editor first mounts:
autoFocus: autoFocus && !noAutoFocus(),
initialState,
colorScheme,
// for these, it's because we keep them up to date in a separate effect:
cameraOptions,
deepLinks,
themes,
initialTheme,
})
useLayoutEffect(() => {
editorOptionsRef.current = {
autoFocus: autoFocus && !noAutoFocus(),
initialState,
colorScheme,
cameraOptions,
deepLinks,
themes,
initialTheme,
}
}, [autoFocus, initialState, colorScheme, cameraOptions, deepLinks, themes, initialTheme])
useLayoutEffect(
() => {
const {
autoFocus,
initialState,
colorScheme: initColorScheme,
cameraOptions,
deepLinks,
themes,
initialTheme,
} = editorOptionsRef.current
const editor = new Editor({
store,
shapeUtils,
bindingUtils,
assetUtils,
overlayUtils,
tools,
getContainer: () => container,
user,
initialState,
// we should check for some kind of query parameter that turns off autofocus
autoFocus,
cameraOptions,
options,
licenseKey,
getShapeVisibility,
colorScheme: initColorScheme,
fontAssetUrls: assetUrls?.fonts,
themes: themes,
initialTheme: initialTheme,
})
editor.licenseManager = licenseManager
editor.updateViewportScreenBounds(canvasRef.current ?? container)
// Use the ref here because we only want to do this once when the editor is created.
// We don't want changes to the urlStateSync prop to trigger creating new editors.
if (deepLinks) {
if (!deepLinks?.getUrl) {
// load the state from window.location
editor.navigateToDeepLink(deepLinks)
} else {
// load the state from the provided URL
editor.navigateToDeepLink({ ...deepLinks, url: deepLinks.getUrl(editor) })
}
}
setEditor(editor)
return () => {
editor.dispose()
}
},
// if any of these change, we need to recreate the editor.
[
assetUtils,
bindingUtils,
colorScheme,
container,
options,
overlayUtils,
shapeUtils,
store,
tools,
user,
setEditor,
licenseKey,
licenseManager,
getShapeVisibility,
assetUrls,
]
)
useLayoutEffect(() => {
if (!editor) return
if (deepLinks) {
return editor.registerDeepLinkListener(deepLinks)
}
}, [editor, deepLinks])
// keep the editor up to date with the latest camera options
// options.cameraOptions takes precedence over the deprecated cameraOptions prop
useLayoutEffect(() => {
if (editor && (cameraOptions || options?.camera)) {
editor.setCameraOptions({ ...cameraOptions, ...options?.camera })
}
}, [editor, cameraOptions, options?.camera])
// keep the editor up to date with the latest theme definitions
useLayoutEffect(() => {
if (editor && themes) {
for (const def of Object.values(themes) as TLTheme[]) {
editor.updateTheme(def)
}
}
}, [editor, themes])
useLayoutEffect(() => {
if (editor && initialTheme) {
editor.setCurrentTheme(initialTheme)
}
}, [editor, initialTheme])
const crashingError = useSyncExternalStore(
useCallback(
(onStoreChange) => {
if (editor) {
editor.on('crash', onStoreChange)
return () => editor.off('crash', onStoreChange)
}
return () => {
// noop
}
},
[editor]
),
() => editor?.getCrashingError() ?? null
)
// For our examples site, we want autoFocus to be true on the examples site, but not
// when embedded in our docs site. If present, the `tldraw_preserve_focus` search param
// overrides the `autoFocus` prop and prevents the editor from focusing immediately,
// however here we also add some logic to focus the editor when the user clicks
// on it and unfocus it when the user clicks away from it.
useEffect(
function handleFocusOnPointerDownForPreserveFocusMode() {
if (!editor) return
const container = editor.getContainer()
function handleFocusOnPointerDown() {
if (!editor) return
editor.focus()
}
function handleBlurOnPointerDown(e: PointerEvent) {
if (!editor) return
// The same pointerdown bubbles from the container to the body; blurring here would
// cancel the interaction the container listener just focused for. Check the composed
// path rather than `e.target`: when the editor lives in an open shadow root the target
// is retargeted to the shadow host by the time the event reaches the body. (A closed
// shadow root truncates the path at the host, so those still blur.)
if (e.composedPath().includes(container)) return
editor.blur()
}
if (autoFocus && noAutoFocus()) {
container.addEventListener('pointerdown', handleFocusOnPointerDown)
container.ownerDocument.body.addEventListener('pointerdown', handleBlurOnPointerDown)
return () => {
container.removeEventListener('pointerdown', handleFocusOnPointerDown)
container.ownerDocument.body.removeEventListener('pointerdown', handleBlurOnPointerDown)
}
}
},
[editor, autoFocus]
)
const [_fontLoadingState, setFontLoadingState] = useState<{
editor: Editor
isLoaded: boolean
} | null>(null)
let fontLoadingState = _fontLoadingState
if (editor !== fontLoadingState?.editor) {
fontLoadingState = null
}
useLayoutEffect(() => {
if (!editor) return
if (editor.options.maxFontsToLoadBeforeRender === 0) {
setFontLoadingState({ editor, isLoaded: true })
return
}
let isCancelled = false
setFontLoadingState({ editor, isLoaded: false })
editor.fonts
.loadRequiredFontsForCurrentPage(editor.options.maxFontsToLoadBeforeRender)
.finally(() => {
if (isCancelled) return
setFontLoadingState({ editor, isLoaded: true })
})
return () => {
isCancelled = true
}
}, [editor])
const { Canvas, LoadingScreen } = useEditorComponents()
if (!editor || !fontLoadingState?.isLoaded) {
return (
<>
{LoadingScreen && <LoadingScreen />}
<div className="tl-canvas" ref={canvasRef} />
</>
)
}
return (
// the top-level tldraw component also renders an error boundary almost
// identical to this one. the reason we have two is because this one has
// access to `App`, which means that here we can enrich errors with data
// from app for reporting, and also still attempt to render the user's
// document in the event of an error to reassure them that their work is
// not lost.
<OptionalErrorBoundary
fallback={ErrorFallback as any}
onError={(error) =>
editor.annotateError(error, { origin: 'react.tldraw', willCrashApp: true })
}
>
{crashingError ? (
<Crash crashingError={crashingError} />
) : (
<EditorProvider editor={editor}>
<Layout onMount={onMount}>
{children ?? (Canvas ? <Canvas key={editor.contextId} /> : null)}
<Watermark />
</Layout>
</EditorProvider>
)}
</OptionalErrorBoundary>
)
}
function Layout({ children, onMount }: { children: ReactNode; onMount?: TLOnMountHandler }) {
useZoomCss()
useCursor()
useDarkMode()
useForceUpdate()
useStateAttribute()
useOnMount((editor) => {
const teardownStore = editor.store.props.onMount(editor)
const teardownCallback = onMount?.(editor)
return () => {
teardownStore?.()
teardownCallback?.()
}
})
return children
}
function Crash({ crashingError }: { crashingError: unknown }): null {
throw crashingError
}
/** @public */
export interface LoadingScreenProps {
children: ReactNode
}
/** @public @react */
export function LoadingScreen({ children }: LoadingScreenProps) {
return (
<div className="tl-loading" aria-busy="true" tabIndex={0}>
{children}
</div>
)
}
/** @public @react */
export function ErrorScreen({ children }: LoadingScreenProps) {
return <div className="tl-loading">{children}</div>
}
/** @internal */
export function useOnMount(onMount?: TLOnMountHandler) {
const editor = useEditor()
const onMountEvent = useEvent((editor: Editor) => {
let teardown: (() => void) | void = undefined
// If the user wants to do something when the editor mounts, we make sure it doesn't effect the history.
// todo: is this reeeeally what we want to do, or should we leave it up to the caller?
editor.run(
() => {
teardown = onMount?.(editor)
editor.emit('mount')
},
{ history: 'ignore' }
)
window.tldrawReady = true
return () => {
teardown?.()
// The editor's component can unmount without the editor being disposed (for example when
// the canvas is swapped for an error fallback), so emit `unmount` here rather than relying
// on disposal. If the editor is being disposed, `dispose()` emits `unmount` itself, so we
// only emit here when it's still mounted and alive.
if (editor.getIsMounted() && !editor.isDisposed) {
editor.run(() => editor.emit('unmount'), { history: 'ignore' })
}
}
})
React.useLayoutEffect(() => {
if (editor) return onMountEvent?.(editor)
}, [editor, onMountEvent])
}

View File

@@ -0,0 +1,77 @@
import { ReactNode, createContext, useContext } from 'react'
import { createPortal } from 'react-dom'
const EditorPortalContext = createContext<HTMLElement | null>(null)
/** @internal */
export function EditorPortalProvider({
host,
children,
}: {
host: HTMLElement | null
children: ReactNode
}) {
return <EditorPortalContext.Provider value={host}>{children}</EditorPortalContext.Provider>
}
/**
* The DOM node that {@link EditorPortal} renders into: an empty `display: contents` element the
* editor renders last among its container's children, after the canvas and after the UI.
*
* Null until the container has mounted, so prefer {@link EditorPortal}, which handles that for you.
*
* @public
*/
export function useEditorPortalHost(): HTMLElement | null {
return useContext(EditorPortalContext)
}
/** @public */
export interface EditorPortalProps {
children: ReactNode
}
/**
* Renders its children into the end of the editor's container, escaping the canvas layer.
*
* Use this for anything anchored to canvas coordinates that also has to draw over the UI — a
* popover on a shape, a comment thread, an annotation panel. The `OnTheCanvas` and
* `InFrontOfTheCanvas` slots are fixed layers, each its own stacking context, so content mounted
* there can't paint above whatever outranks its layer whatever z-index it asks for. Mount in
* whichever slot suits your data flow, wrap the part that needs its own layer in this, and give
* that part a z-index.
*
* This package's own layers stop at 250 (`--tl-layer-canvas-in-front`); above that belongs to
* whatever renders the UI. In `tldraw` that's `--tl-layer-panels` (300) and `--tl-layer-menus`
* (400), defined by `tldraw`'s stylesheet rather than this one.
*
* Positioning still resolves against the editor's container, so a `position: absolute` child uses
* ordinary container coordinates.
*
* Prefer this over `createPortal(children, useContainer())`, which picks its DOM position from the
* commit that mounts it — landing the node ahead of the UI and of the "skip to main content" link
* that only works while nothing precedes it. This host is a real element placed last, so ordering
* is fixed rather than dependent on mount timing.
*
* @example
* ```tsx
* // 400 clears tldraw's menus layer; the bare editor's own layers stop at 250.
* function ShapeAnnotation({ point }: { point: VecLike }) {
* return (
* <EditorPortal>
* <div style={{ position: 'absolute', left: point.x, top: point.y, zIndex: 400 }}>
* Above the UI
* </div>
* </EditorPortal>
* )
* }
* ```
*
* @public
* @react
*/
export function EditorPortal({ children }: EditorPortalProps) {
const host = useEditorPortalHost()
if (!host) return null
return createPortal(children, host)
}

View File

@@ -0,0 +1,57 @@
import * as React from 'react'
import type { TLErrorFallbackComponent } from './default-components/DefaultErrorFallback'
/** @public */
export interface TLErrorBoundaryProps {
children: React.ReactNode
onError?: ((error: unknown) => void) | null
fallback: TLErrorFallbackComponent
}
const initialState = { error: null }
/** @public */
export class ErrorBoundary extends React.Component<
React.PropsWithChildren<TLErrorBoundaryProps>,
{ error: Error | null }
> {
static getDerivedStateFromError(error: Error) {
return { error }
}
override state = initialState
override componentDidCatch(error: unknown) {
this.props.onError?.(error)
}
override render() {
const { error } = this.state
if (error !== null) {
const { fallback: Fallback } = this.props
return <Fallback error={error} />
}
return this.props.children
}
}
/** @internal */
export function OptionalErrorBoundary({
children,
fallback,
...props
}: Omit<TLErrorBoundaryProps, 'fallback'> & {
fallback: TLErrorFallbackComponent
}) {
if (fallback === null) {
return children
}
return (
<ErrorBoundary fallback={fallback as any} {...props}>
{children}
</ErrorBoundary>
)
}

View File

@@ -0,0 +1,14 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
export type HTMLContainerProps = React.HTMLAttributes<HTMLDivElement>
/** @public @react */
export function HTMLContainer({ children, className = '', ...rest }: HTMLContainerProps) {
return (
<div {...rest} className={classNames('tl-html-container', className)}>
{children}
</div>
)
}

View File

@@ -0,0 +1,133 @@
import { track, useQuickReactor, useValue } from '@tldraw/state-react'
import { TLInstancePresence } from '@tldraw/tlschema'
import { useLayoutEffect, useRef } from 'react'
import { useEditorComponents } from '../hooks/EditorComponentsContext'
import { useEditor } from '../hooks/useEditor'
import { useSharedSafeId } from '../hooks/useSafeId'
import { isCursorInViewport } from '../utils/collaborators'
import { setStyleProperty } from '../utils/dom'
import { getHtmlLayerTransform } from '../utils/getHtmlLayerTransform'
/**
* The collaborator cursor layer: a DOM layer stacked as a sibling of the canvas — above all canvas
* content, below the in-front layer and the UI panels — hosting each visible collaborator's cursor
* (arrow, name tag, chat message). Off-viewport collaborators are the canvas-drawn hint arrows' job
* (CollaboratorHintOverlayUtil), not this layer's.
*
* Cursors are DOM rather than canvas-drawn so their chrome styles and composes like the rest of
* the UI. Re-render traffic is kept narrow: per-cursor positioning writes `transform` directly
* (see `useTransform`), so a pointer move re-renders only the moved cursor; the camera transform
* is written imperatively below, so a pure pan re-renders only cursors whose viewport visibility
* flips (an equality-gated boolean per cursor); a zoom change re-renders every visible cursor,
* because each one rescales by `1/zoom`.
*
* @public @react
*/
export const LiveCollaborators = track(function LiveCollaborators() {
const editor = useEditor()
const { CollaboratorCursor } = useEditorComponents()
// The inner layer carries the camera transform, so the cursor components position in page
// space exactly as canvas content does. Unlike the canvas's html layers this one unmounts
// while no collaborators are visible, so the transform is written at two moments: on every
// camera change (the reactor), and after every render (the layout effect, like useTransform
// does per cursor) — the reactor's last run hit a null ref while the layer was unmounted,
// and without the render-time write a reappearing layer would keep an identity transform
// until the next camera move.
const rHtmlLayer = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
const elm = rHtmlLayer.current
if (!elm) return
setStyleProperty(elm, 'transform', getHtmlLayerTransform(editor))
})
useQuickReactor(
'position collaborators layer',
function positionCollaboratorsWhenCameraMoves() {
// Reads the camera even while the layer is unmounted, keeping the subscription alive.
const transform = getHtmlLayerTransform(editor)
setStyleProperty(rHtmlLayer.current, 'transform', transform)
},
[editor]
)
// Visibility (activity state, following, highlighting) is handled by the editor.
const collaborators = editor.getVisibleCollaboratorsOnCurrentPage()
if (collaborators.length === 0) return null
if (!CollaboratorCursor) return null
return (
<div className="tl-collaborators">
<svg className="tl-svg-context" aria-hidden="true">
<defs>
<CursorDef />
</defs>
</svg>
<div ref={rHtmlLayer} className="tl-html-layer">
{collaborators.map((presence) => (
<Collaborator key={presence.userId} latestPresence={presence} />
))}
</div>
</div>
)
})
const Collaborator = track(function Collaborator({
latestPresence,
}: {
latestPresence: TLInstancePresence
}) {
const editor = useEditor()
const { CollaboratorCursor } = useEditorComponents()
const { userId, chatMessage, userName, cursor, color } = latestPresence
// The viewport read lives inside this equality-gated computed rather than the tracked render:
// pans move the viewport every frame, but this component only needs to know when the cursor
// crosses the edge, so it subscribes to the boolean and re-renders only when it flips.
const cursorInViewport = useValue(
'cursor in viewport',
() =>
!!cursor && isCursorInViewport(cursor, editor.getViewportPageBounds(), editor.getZoomLevel()),
[editor, cursor]
)
// The zoom read is tracked directly: the cursor scales by 1/zoom, so zoom changes must
// re-render it. Pure pans leave the zoom value unchanged, so they never invalidate this.
const zoomLevel = editor.getZoomLevel()
if (!cursor) return null
// Off-viewport collaborators show as the canvas-drawn hint arrows
// (CollaboratorHintOverlayUtil), which shares this predicate — only the cursor itself is DOM.
if (!cursorInViewport) return null
if (!CollaboratorCursor) return null
return (
<CollaboratorCursor
className="tl-collaborator__cursor"
userId={userId}
point={cursor}
color={color}
zoom={zoomLevel}
name={userName !== 'New User' ? userName : null}
chatMessage={chatMessage ?? ''}
/>
)
})
function CursorDef() {
return (
<g id={useSharedSafeId('cursor')}>
<g fill="rgba(0,0,0,.2)" transform="translate(-11,-11)">
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill="white" transform="translate(-12,-12)">
<path d="m12 24.4219v-16.015l11.591 11.619h-6.781l-.411.124z" />
<path d="m21.0845 25.0962-3.605 1.535-4.682-11.089 3.686-1.553z" />
</g>
<g fill="currentColor" transform="translate(-12,-12)">
<path d="m19.751 24.4155-1.844.774-3.1-7.374 1.841-.775z" />
<path d="m13 10.814v11.188l2.969-2.866.428-.139h4.768z" />
</g>
</g>
)
}

View File

@@ -0,0 +1,259 @@
import { useValue } from '@tldraw/state-react'
import { type PointerEvent, useCallback, useEffect, useRef, useState } from 'react'
import { flushSync } from 'react-dom'
import { useCanvasEvents } from '../hooks/useCanvasEvents'
import { useEditor } from '../hooks/useEditor'
import { Vec } from '../primitives/Vec'
import { releasePointerCapture, setPointerCapture } from '../utils/dom'
import { getPointerInfo } from '../utils/getPointerInfo'
import { getPointerEventButton } from '../utils/pointer'
/**
* When a menu is open, this component prevents the user from interacting with the canvas.
*
* @public @react
*/
export function MenuClickCapture() {
const editor = useEditor()
const isMenuOpen = useValue('is menu open', () => editor.menus.hasAnyOpenMenus(), [editor])
// Keep this component mounted while the pointer is down so pointerup/move still
// land here after a synchronous clearOpenMenus() flips isMenuOpen to false.
const [isPointing, setIsPointing] = useState(false)
const showElement = isMenuOpen || isPointing
const canvasEvents = useCanvasEvents()
const rPointerState = useRef({
isDown: false,
isDragging: false,
button: 0,
pointerId: -1,
start: new Vec(),
})
// Swallow the native contextmenu that follows a right-click pointerdown. Without
// this, Radix's trigger catches the native event and opens a menu at the pointer-
// DOWN position — then our own synthetic contextmenu (fired on pointerup) opens it
// again at the release position, producing a visible flash.
const rCancelContextMenuSwallow = useRef<null | (() => void)>(null)
useEffect(
() => () => {
rCancelContextMenuSwallow.current?.()
rCancelContextMenuSwallow.current = null
},
[]
)
const swallowNextNativeContextMenu = useCallback(() => {
rCancelContextMenuSwallow.current?.()
const doc = editor.getContainerDocument()
const onContextMenu = (event: MouseEvent) => {
// Skip our own synthetic contextmenu — only swallow the real browser one
if (!event.isTrusted) return
rCancelContextMenuSwallow.current?.()
rCancelContextMenuSwallow.current = null
event.preventDefault()
event.stopImmediatePropagation()
}
const cancel = () => doc.removeEventListener('contextmenu', onContextMenu, true)
rCancelContextMenuSwallow.current = cancel
doc.addEventListener('contextmenu', onContextMenu, true)
// Drop the listener on the next tick if it never fires (e.g. pointer moved off-screen)
doc.defaultView?.setTimeout(() => {
if (rCancelContextMenuSwallow.current === cancel) {
cancel()
rCancelContextMenuSwallow.current = null
}
}, 0)
}, [editor])
const handlePointerDown = useCallback(
(e: PointerEvent) => {
// This overlay tracks a single press. A second pointer landing while one is active —
// a palm resting on the glass beside a pen — must not take over the gesture, or its
// own cancel would end the press it stole.
if (rPointerState.current.isDown) return
const button = getPointerEventButton(e)
if (button !== 0 && button !== 2) return
if (button === 2 && editor.options.rightClickPanning) {
// Forward right-click pointerdown through the canvas's own handler so
// pointer capture is set on the canvas (load-bearing: without this
// the context menu briefly flashes closed during consecutive right-clicks).
// The canvas owns the rest of the gesture, so don't capture or mark
// pointing here: this element's pointerup would never fire, leaving it
// mounted over the canvas after the menu closes.
// We don't clearOpenMenus() — Radix's DismissableLayer closes the menu
// via outside-click detection, keeping its internal state in sync.
const canvas =
editor.getContainer().querySelector<HTMLDivElement>('.tl-canvas') ?? e.currentTarget
canvasEvents.onPointerDown?.({ ...e, currentTarget: canvas })
swallowNextNativeContextMenu()
return
}
flushSync(() => setIsPointing(true))
setPointerCapture(e.currentTarget, e)
rPointerState.current = {
isDown: true,
isDragging: false,
button,
pointerId: e.pointerId,
start: new Vec(e.clientX, e.clientY),
}
if (button === 2) {
// Right-click panning off: swallow the native contextmenu that would otherwise
// briefly open a new menu (causing a flash) once the open one closes below.
swallowNextNativeContextMenu()
}
editor.menus.clearOpenMenus()
},
[canvasEvents, editor, swallowNextNativeContextMenu]
)
const handlePointerMove = useCallback(
(e: PointerEvent) => {
const state = rPointerState.current
if (!state.isDown || state.pointerId !== e.pointerId) return
// Left-click: wait for the drag threshold before forwarding anything, then
// replay pointerdown at the original start so the editor records the
// correct drag origin. Right-click forwards moves immediately.
if (state.button !== 2 && !state.isDragging) {
if (
Vec.Dist2(state.start, new Vec(e.clientX, e.clientY)) <=
editor.options.dragDistanceSquared
) {
return
}
state.isDragging = true
editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_down',
...getPointerInfo(editor, { ...e, clientX: state.start.x, clientY: state.start.y }),
})
}
editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_move',
...getPointerInfo(editor, e),
})
},
[editor]
)
const handlePointerUp = useCallback(
(e: PointerEvent) => {
const state = rPointerState.current
if (!state.isDown || state.pointerId !== e.pointerId) return
const isStaticRightClick = state.button === 2 && !state.isDragging
editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_up',
...getPointerInfo(editor, e),
button: state.button === 2 ? 2 : getPointerEventButton(e),
})
if (isStaticRightClick && editor.options.rightClickPanning) {
// Dispatch contextmenu on the canvas's parent (Radix's trigger) so the
// menu opens at the release position. Bypassing the canvas avoids its
// own onContextMenu handler, which preventDefaults non-synthesized events.
const canvas = editor.getContainer().querySelector<HTMLDivElement>('.tl-canvas')
const trigger = canvas?.parentElement ?? e.currentTarget
editor.timers.requestAnimationFrame(() => {
trigger.dispatchEvent(
new PointerEvent('contextmenu', {
bubbles: true,
clientX: e.clientX,
clientY: e.clientY,
button: 2,
buttons: 0,
pointerId: e.pointerId,
pointerType: e.pointerType,
isPrimary: e.isPrimary,
})
)
})
}
releasePointerCapture(e.currentTarget, e)
setIsPointing(false)
rPointerState.current = {
isDown: false,
isDragging: false,
button: 0,
pointerId: -1,
start: new Vec(e.clientX, e.clientY),
}
},
[editor]
)
const handlePointerCancel = useCallback(
(e: PointerEvent) => {
const state = rPointerState.current
if (!state.isDown || state.pointerId !== e.pointerId) {
// Either a right-click press that was forwarded to the canvas, or a pointer this
// overlay never tracked. Neither is ours to end, so let the canvas handler decide.
canvasEvents.onPointerCancel?.(e)
return
}
// The canvas's own cancel handler doesn't know about this element's pointer
// state, so without this the overlay stays mounted over the canvas and
// swallows the next interaction after the browser cancels a press.
if (editor.inputs.getIsPointing()) {
editor.interrupt()
editor.dispatch({
type: 'pointer',
target: 'canvas',
name: 'pointer_up',
...getPointerInfo(editor, e),
// pointercancel reports button -1
button: state.button,
})
} else {
editor.markEventAsHandled(e)
}
releasePointerCapture(e.currentTarget, e)
setIsPointing(false)
rPointerState.current = {
isDown: false,
isDragging: false,
button: 0,
pointerId: -1,
start: new Vec(e.clientX, e.clientY),
}
},
[canvasEvents, editor]
)
return (
showElement && (
<div
className="tlui-menu-click-capture"
data-testid="menu-click-capture.content"
{...canvasEvents}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onContextMenu={(e) => {
e.preventDefault()
e.stopPropagation()
}}
/>
)
)
}

View File

@@ -0,0 +1,14 @@
import classNames from 'classnames'
import * as React from 'react'
/** @public */
export type SVGContainerProps = React.ComponentProps<'svg'>
/** @public @react */
export function SVGContainer({ children, className = '', ...rest }: SVGContainerProps) {
return (
<svg {...rest} className={classNames('tl-svg-container', className)} aria-hidden="true">
{children}
</svg>
)
}

View File

@@ -0,0 +1,283 @@
import { react } from '@tldraw/state'
import { useQuickReactor, useStateTracking } from '@tldraw/state-react'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import { warnOnce } from '@tldraw/utils'
import { memo, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
import { Editor } from '../editor/Editor'
import { ShapeUtil } from '../editor/shapes/ShapeUtil'
import { useEditorComponents } from '../hooks/EditorComponentsContext'
import { useEditor } from '../hooks/useEditor'
import { useShapeCulling } from '../hooks/useShapeCulling'
import { Mat } from '../primitives/Mat'
import { areShapesContentEqual } from '../utils/areShapesContentEqual'
import { moveElementInto, setStyleProperty } from '../utils/dom'
import { OptionalErrorBoundary } from './ErrorBoundary'
/*
This component renders shapes on the canvas. There are two stages: positioning
and styling the shape's container using CSS, and then rendering the shape's
JSX using its shape util's render method. Rendering the "inside" of a shape is
more expensive than positioning it or changing its color, so we use memo
to wrap the inner shape and only re-render it when the shape's props change.
The shape also receives props for its index and opacity. The index is used to
determine the z-index of the shape, and the opacity is used to set the shape's
opacity based on its own opacity and that of its parent's.
*/
export const Shape = memo(function Shape({
id,
shape,
util,
index,
backgroundIndex,
opacity,
}: {
id: TLShapeId
shape: TLShape
util: ShapeUtil
index: number
backgroundIndex: number
opacity: number
}) {
const editor = useEditor()
const { ShapeErrorFallback, ShapeWrapper } = useEditorComponents()
const containerRef = useRef<HTMLDivElement>(null)
const bgContainerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
return react('load fonts', () => {
const fonts = editor.fonts.getShapeFontFaces(id)
editor.fonts.requestFonts(fonts)
})
}, [editor, id])
const memoizedStuffRef = useRef({
transform: '',
clipPath: 'none',
width: 0,
height: 0,
x: 0,
y: 0,
})
useQuickReactor(
'set shape stuff',
() => {
const shape = editor.getShape(id)
if (!shape) return // probably the shape was just deleted
const prev = memoizedStuffRef.current
// Clip path
const clipPath = editor.getShapeClipPath(id) ?? 'none'
if (clipPath !== prev.clipPath) {
setStyleProperty(containerRef.current, 'clip-path', clipPath)
setStyleProperty(bgContainerRef.current, 'clip-path', clipPath)
prev.clipPath = clipPath
}
// Page transform
const pageTransform = editor.getShapePageTransform(id)
const transform = Mat.toCssString(pageTransform)
const bounds = editor.getShapeGeometry(shape).bounds
// Update if the tranform has changed
if (transform !== prev.transform) {
setStyleProperty(containerRef.current, 'transform', transform)
setStyleProperty(bgContainerRef.current, 'transform', transform)
prev.transform = transform
}
// Width / Height
const width = Math.max(bounds.width, 1)
const height = Math.max(bounds.height, 1)
if (width !== prev.width || height !== prev.height) {
setStyleProperty(containerRef.current, 'width', width + 'px')
setStyleProperty(containerRef.current, 'height', height + 'px')
setStyleProperty(bgContainerRef.current, 'width', width + 'px')
setStyleProperty(bgContainerRef.current, 'height', height + 'px')
prev.width = width
prev.height = height
}
},
[editor]
)
// This stuff changes pretty infrequently, so we can change them together
useLayoutEffect(() => {
const container = containerRef.current
const bgContainer = bgContainerRef.current
// Opacity
setStyleProperty(container, 'opacity', opacity)
setStyleProperty(bgContainer, 'opacity', opacity)
// Z-Index
setStyleProperty(container, 'z-index', index)
setStyleProperty(bgContainer, 'z-index', backgroundIndex)
}, [opacity, index, backgroundIndex])
// Register container refs with the centralized culling context.
// This runs on mount and handles initial display state.
const { register, unregister } = useShapeCulling()
useLayoutEffect(() => {
const container = containerRef.current
if (!container) return
// Check initial culling state and register with the context
const isCulled = editor.getCulledShapes().has(id)
register(id, container, bgContainerRef.current, isCulled)
return () => {
unregister(id)
}
}, [editor, id, register, unregister])
const annotateError = useCallback(
(error: any) => editor.annotateError(error, { origin: 'shape', willCrashApp: false }),
[editor]
)
if (!shape || !ShapeWrapper) return null
return (
<>
{util.backgroundComponent && (
<ShapeWrapper ref={bgContainerRef} shape={shape} isBackground={true}>
<OptionalErrorBoundary fallback={ShapeErrorFallback} onError={annotateError}>
<InnerShapeBackground shape={shape} util={util} />
</OptionalErrorBoundary>
</ShapeWrapper>
)}
<ShapeWrapper ref={containerRef} shape={shape} isBackground={false}>
<OptionalErrorBoundary fallback={ShapeErrorFallback as any} onError={annotateError}>
<InnerShape shape={shape} util={util} />
</OptionalErrorBoundary>
{util.getAppOwnedElement && <ContentElementSlot id={id} util={util} />}
</ShapeWrapper>
</>
)
})
/*
The content element slot hosts an app-owned element provided by the shape util's
getAppOwnedElement method. The contract: while the shape is mounted, tldraw never
unmounts, recreates, or relocates the element (the canvas renders shapes in stable
id-sorted DOM order, so reorders and reparenting never move DOM nodes), and before
the slot is destroyed — shape deletion, page change, error teardown, or the whole
editor unmounting — onReleaseAppOwnedElement is called while the slot is still
connected to the document, so the app can move the element to a new parent with
Node.moveBefore without resetting its state. That last guarantee relies on this
being a layout effect: React runs layout effect cleanups before it detaches the
host nodes of a deleted subtree, which is not true of passive effects.
*/
const ContentElementSlot = memo(function ContentElementSlot({
id,
util,
}: {
id: TLShapeId
util: ShapeUtil
}) {
const editor = useEditor()
const slotRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const slot = slotRef.current
if (!slot) return
const shape = editor.getShape(id)
if (!shape) return
// getAppOwnedElement is called once per shape mount (the effect deps below are
// [editor, id, util], none of which change on a prop edit), so the adopted element is
// not refreshed when the shape's props change. A util whose element identity depends on
// props must mutate the existing element in place rather than return a new one. Likewise,
// if this returns null now and an element only later, the element is never adopted for
// this mount — return a stable element up front and populate it asynchronously instead.
const element = util.getAppOwnedElement?.(shape)
if (!element) return
// An element that was connected before adoption (e.g. parked off-canvas by the
// app between editor sessions) should survive the move without reloading.
const wasConnectedBeforeAdoption = element.isConnected
if (element.parentNode !== slot) {
moveElementInto(slot, element)
}
if (process.env.NODE_ENV !== 'production' && wasConnectedBeforeAdoption) {
warnIfContentElementReloads(editor, slot, element)
}
return () => {
// Note: under React StrictMode (dev only) this effect runs mount → cleanup → mount,
// so onReleaseAppOwnedElement fires once even though the shape was not removed, and on
// the appendChild fallback path the element is detached and re-adopted (reloading it).
// onReleaseAppOwnedElement must therefore be safe to call when the shape still exists;
// don't treat it as a definitive "shape removed" signal (check the store if you need
// to know). With Node.moveBefore the re-adoption preserves state, so this is benign.
const latestShape = editor.getShape(id) ?? shape
util.onReleaseAppOwnedElement?.(latestShape, element)
if (process.env.NODE_ENV !== 'production' && element.parentNode === slot) {
warnOnce(
`The content element for shape "${id}" was not reclaimed by onReleaseAppOwnedElement and will be destroyed along with its slot, losing any state it holds.`
)
}
}
}, [editor, id, util])
return <div ref={slotRef} className="tl-content-slot" draggable={false} />
})
// Dev-mode assertion that adoption kept the platform's state-preservation promise: a
// load event firing on a previously-connected adopted element means the embed reloaded.
// load doesn't bubble, but capture-phase listeners on ancestors still observe it.
function warnIfContentElementReloads(editor: Editor, slot: HTMLElement, element: HTMLElement) {
const onLoad = (event: Event) => {
if (event.target !== element && !element.contains(event.target as Node)) return
warnOnce(
'A load event fired on an adopted content element. The element reloaded when it was moved into the shape, losing its state. State-preserving moves need Node.moveBefore (Chromium 133+, Firefox 144+) and a continuously connected element.'
)
}
slot.addEventListener('load', onLoad, { capture: true })
editor.timers.setTimeout(() => slot.removeEventListener('load', onLoad, { capture: true }), 1000)
}
export const InnerShape = memo(
function InnerShape<T extends TLShape>({ shape, util }: { shape: T; util: ShapeUtil<T> }) {
return useStateTracking(
'InnerShape:' + shape.type,
() =>
// always fetch the latest shape from the store even if the props/meta have not changed, to avoid
// calling the render method with stale data.
util.component(util.editor.store.unsafeGetWithoutCapture(shape.id) as T),
[util, shape.id]
)
},
(prev, next) => areShapesContentEqual(prev.shape, next.shape) && prev.util === next.util
)
export const InnerShapeBackground = memo(
function InnerShapeBackground<T extends TLShape>({
shape,
util,
}: {
shape: T
util: ShapeUtil<T>
}) {
return useStateTracking(
'InnerShape:' + shape.type,
() =>
// always fetch the latest shape from the store even if the props/meta have not changed, to avoid
// calling the render method with stale data.
util.backgroundComponent?.(util.editor.store.unsafeGetWithoutCapture(shape.id) as T),
[util, shape.id]
)
},
(prev, next) =>
prev.shape.props === next.shape.props &&
prev.shape.meta === next.shape.meta &&
prev.util === next.util
)

View File

@@ -0,0 +1,206 @@
import { EffectScheduler, computed } from '@tldraw/state'
import { memo, useEffect, useRef } from 'react'
import { useEditor } from '../../hooks/useEditor'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import { Group2d } from '../../primitives/geometry/Group2d'
import { debugFlags } from '../../utils/debug-flags'
interface RenderInputs {
dpr: number
w: number
h: number
cx: number
cy: number
zoom: number
}
/** @internal @react */
export const CanvasOverlays = memo(function CanvasOverlays() {
const editor = useEditor()
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
// Bundle the primitive scalars the renderer needs into one computed so the
// effect only refires on actual visual change. Reading the whole instance
// state directly would otherwise wake the renderer on every cursor move,
// brush update, etc.
const renderInputs$ = computed<RenderInputs>(
'canvas overlays render inputs',
() => {
const instance = editor.getInstanceState()
const camera = editor.getCamera()
return {
dpr: instance.devicePixelRatio,
w: instance.screenBounds.w,
h: instance.screenBounds.h,
cx: camera.x,
cy: camera.y,
zoom: camera.z,
}
},
{
isEqual: (a, b) =>
a.dpr === b.dpr &&
a.w === b.w &&
a.h === b.h &&
a.cx === b.cx &&
a.cy === b.cy &&
a.zoom === b.zoom,
}
)
const scheduler = new EffectScheduler('canvas overlays render', () => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const { dpr, w, h, cx, cy, zoom } = renderInputs$.get()
const canvasWidth = Math.ceil(w * dpr)
const canvasHeight = Math.ceil(h * dpr)
if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {
canvas.width = canvasWidth
canvas.height = canvasHeight
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
}
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.clearRect(0, 0, canvas.width, canvas.height)
// One setTransform = DPR scale * zoom scale * camera translate, into page space.
const s = dpr * zoom
ctx.setTransform(s, 0, 0, s, s * cx, s * cy)
// Render all active overlay utils in zIndex order (low to high).
for (const { util, overlays } of editor.overlays.getActiveOverlayEntries()) {
ctx.save()
util.render(ctx, overlays)
ctx.restore()
}
// Debug: draw all geometry
if (debugFlags.debugGeometry.get()) {
const currentPagePoint = editor.inputs.getCurrentPagePoint()
// Shape geometries
const renderingShapes = editor.getRenderingShapes()
for (const result of renderingShapes) {
const shape = editor.getShape(result.id)
if (!shape || shape.type === 'group') continue
const geometry = editor.getShapeGeometry(shape)
const m = editor.getShapePageTransform(shape)
ctx.save()
ctx.transform(m.a, m.b, m.c, m.d, m.e, m.f)
// Outline
ctx.strokeStyle = geometry.debugColor ?? 'red'
ctx.lineWidth = 2 / zoom
ctx.fillStyle = 'none'
drawGeometryStroke(ctx, geometry)
// Vertices
const { vertices } = geometry
for (let i = 0; i < vertices.length; i++) {
const v = vertices[i]
const hue = vertices.length > 1 ? 120 + ((200 - 120) * i) / (vertices.length - 1) : 160
ctx.fillStyle = `hsl(${hue}, 100%, 50%)`
ctx.strokeStyle = 'black'
ctx.lineWidth = 1 / zoom
ctx.beginPath()
ctx.arc(v.x, v.y, 2 / zoom, 0, Math.PI * 2)
ctx.fill()
ctx.stroke()
}
// Nearest point line
const pointInShapeSpace = editor.getPointInShapeSpace(shape, currentPagePoint)
const dist = Math.abs(geometry.distanceToPoint(pointInShapeSpace, true)) * zoom
if (dist < 150) {
const nearestPoint = geometry.nearestPoint(pointInShapeSpace)
const hitInside = geometry.distanceToPoint(pointInShapeSpace, true) < 0
ctx.strokeStyle = hitInside ? 'goldenrod' : 'dodgerblue'
ctx.lineWidth = 2 / zoom
ctx.globalAlpha = 1 - dist / 150
ctx.beginPath()
ctx.moveTo(nearestPoint.x, nearestPoint.y)
ctx.lineTo(pointInShapeSpace.x, pointInShapeSpace.y)
ctx.stroke()
ctx.globalAlpha = 1
}
ctx.restore()
}
// Overlay hit-test geometries
ctx.save()
ctx.strokeStyle = 'magenta'
ctx.fillStyle = 'rgba(255, 0, 255, 0.1)'
ctx.lineWidth = 1 / zoom
for (const { overlays } of editor.overlays.getActiveOverlayEntries()) {
for (const overlay of overlays) {
const geometry = editor.overlays.getOverlayGeometry(overlay)
if (!geometry) continue
const vertices = geometry.vertices
if (vertices.length < 2) continue
ctx.beginPath()
ctx.moveTo(vertices[0].x, vertices[0].y)
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y)
}
if (geometry.isClosed) {
ctx.closePath()
ctx.fill()
}
ctx.stroke()
for (const v of vertices) {
ctx.beginPath()
ctx.arc(v.x, v.y, 2 / zoom, 0, Math.PI * 2)
ctx.fill()
}
}
}
ctx.restore()
}
})
scheduler.attach()
scheduler.execute()
return () => scheduler.detach()
}, [editor])
return <canvas ref={canvasRef} className="tl-canvas-overlays" />
})
function drawGeometryStroke(ctx: CanvasRenderingContext2D, geometry: Geometry2d) {
if (geometry instanceof Group2d) {
const prevStroke = ctx.strokeStyle
for (const child of geometry.children) {
if (child.debugColor) ctx.strokeStyle = child.debugColor
drawGeometryStroke(ctx, child)
ctx.strokeStyle = prevStroke
}
for (const child of geometry.ignoredChildren) {
if (child.debugColor) ctx.strokeStyle = child.debugColor
drawGeometryStroke(ctx, child)
ctx.strokeStyle = prevStroke
}
return
}
const vertices = geometry.vertices
if (vertices.length < 2) return
ctx.beginPath()
ctx.moveTo(vertices[0].x, vertices[0].y)
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y)
}
if (geometry.isClosed) ctx.closePath()
ctx.stroke()
}

View File

@@ -0,0 +1,4 @@
/** @public @react */
export function DefaultBackground() {
return <div className="tl-background" />
}

View File

@@ -0,0 +1,358 @@
import { react } from '@tldraw/state'
import { useQuickReactor, useValue } from '@tldraw/state-react'
import { TLShapeId } from '@tldraw/tlschema'
import { objectMapValues } from '@tldraw/utils'
import classNames from 'classnames'
import { Fragment, JSX, useEffect, useRef, useState } from 'react'
import { tlenv } from '../../globals/environment'
import { useEditorComponents } from '../../hooks/EditorComponentsContext'
import { useCanvasEvents } from '../../hooks/useCanvasEvents'
import { useCoarsePointer } from '../../hooks/useCoarsePointer'
import { useContainer } from '../../hooks/useContainer'
import { useDocumentEvents } from '../../hooks/useDocumentEvents'
import { useEditor } from '../../hooks/useEditor'
import { useFixSafariDoubleTapZoomPencilEvents } from '../../hooks/useFixSafariDoubleTapZoomPencilEvents'
import { useGestureEvents } from '../../hooks/useGestureEvents'
import { useScreenBounds } from '../../hooks/useScreenBounds'
import { ShapeCullingProvider, useShapeCulling } from '../../hooks/useShapeCulling'
import { Box } from '../../primitives/Box'
import { debugFlags } from '../../utils/debug-flags'
import { setStyleProperty } from '../../utils/dom'
import { getHtmlLayerTransform } from '../../utils/getHtmlLayerTransform'
import { LiveCollaborators } from '../LiveCollaborators'
import { MenuClickCapture } from '../MenuClickCapture'
import { Shape } from '../Shape'
import { CanvasOverlays } from './CanvasOverlays'
/** @public */
export interface TLCanvasComponentProps {
className?: string
}
/** @public @react */
export function DefaultCanvas({ className }: TLCanvasComponentProps) {
const editor = useEditor()
const { Background, SvgDefs } = useEditorComponents()
const rCanvas = useRef<HTMLDivElement>(null)
const rHtmlLayer = useRef<HTMLDivElement>(null)
const container = useContainer()
useScreenBounds(rCanvas)
useDocumentEvents()
useCoarsePointer()
useGestureEvents(rCanvas)
useFixSafariDoubleTapZoomPencilEvents(rCanvas)
useQuickReactor(
'update canvas state data attributes',
() => {
const canvas = rCanvas.current
if (!canvas) return
canvas.setAttribute(
'data-iseditinganything',
editor.getEditingShapeId() === null ? 'false' : 'true'
)
canvas.setAttribute(
'data-isselectinganything',
editor.getSelectedShapeIds().length === 0 ? 'false' : 'true'
)
},
[editor]
)
const rMemoizedStuff = useRef({ lodDisableTextOutline: false, canUpdateTextOutline: true })
useQuickReactor(
'set text outline',
function setTextOutline() {
if (rMemoizedStuff.current.canUpdateTextOutline) {
if (tlenv.isSafari) {
// We don't allow text outlines on safari for performance reasons
container.style.setProperty('--tl-text-outline', 'none')
rMemoizedStuff.current.canUpdateTextOutline = false // will prevent this check in the future
} else {
const efficientZoom = editor.getEfficientZoomLevel()
// If we're zoomed way out, and have this option enabled, then we hide text outline
const lodDisableTextOutline = efficientZoom < editor.options.textShadowLod
// Skip the style update if the property is the same as it was before
if (lodDisableTextOutline !== rMemoizedStuff.current.lodDisableTextOutline) {
container.style.setProperty(
'--tl-text-outline',
lodDisableTextOutline ? 'none' : `var(--tl-text-outline-reference)`
)
}
rMemoizedStuff.current.lodDisableTextOutline = lodDisableTextOutline
}
}
},
[editor, container]
)
useQuickReactor(
'position layers',
function positionLayersWhenCameraMoves() {
setStyleProperty(rHtmlLayer.current, 'transform', getHtmlLayerTransform(editor))
},
[editor, container]
)
const events = useCanvasEvents()
const shapeSvgDefs = useValue(
'shapeSvgDefs',
() => {
const shapeSvgDefsByKey = new Map<string, JSX.Element>()
for (const util of objectMapValues(editor.shapeUtils)) {
if (!util) return
const defs = util.getCanvasSvgDefs()
for (const { key, component: Component } of defs) {
if (shapeSvgDefsByKey.has(key)) continue
shapeSvgDefsByKey.set(key, <Component key={key} />)
}
}
return [...shapeSvgDefsByKey.values()]
},
[editor]
)
// Gated on debug mode so a flag left on can't strand the user on a blank canvas with no
// debug menu to turn it back off.
const hideShapes = useValue(
'debug_shapes',
() => debugFlags.hideShapes.get() && editor.getInstanceState().isDebugMode,
[editor]
)
const isGridMode = useValue('isGridMode', () => editor.getInstanceState().isGridMode, [editor])
const { Grid } = useEditorComponents()
return (
<>
<div
ref={rCanvas}
draggable={false}
className={classNames('tl-canvas', className)}
data-testid="canvas"
{...events}
>
<svg className="tl-svg-context" aria-hidden="true">
<defs>
{shapeSvgDefs}
{SvgDefs && <SvgDefs />}
</defs>
</svg>
{Background && (
<div className="tl-background__wrapper">
<Background />
</div>
)}
{isGridMode && Grid && <GridWrapper />}
<div ref={rHtmlLayer} className="tl-html-layer tl-shapes" draggable={false}>
<OnTheCanvasWrapper />
{hideShapes ? null : <ShapesLayer canvasRef={rCanvas} />}
</div>
<CanvasOverlays />
<MovingCameraHitTestBlocker />
</div>
<InFrontOfTheCanvasWrapper />
<LiveCollaborators />
<MenuClickCapture />
</>
)
}
function InFrontOfTheCanvasWrapper() {
const editor = useEditor()
const { InFrontOfTheCanvas } = useEditorComponents()
if (!InFrontOfTheCanvas) return null
return (
<div
className="tl-canvas__in-front"
onPointerDown={editor.markEventAsHandled}
onPointerUp={editor.markEventAsHandled}
onTouchStart={editor.markEventAsHandled}
onTouchEnd={editor.markEventAsHandled}
>
<InFrontOfTheCanvas />
</div>
)
}
function GridWrapper() {
const editor = useEditor()
const gridSize = useValue('gridSize', () => editor.getDocumentSettings().gridSize, [editor])
const { x, y, z } = useValue('camera', () => editor.getCamera(), [editor])
const { Grid } = useEditorComponents()
if (!Grid) return null
return <Grid x={x} y={y} z={z} size={gridSize} />
}
function ShapesLayer({ canvasRef }: { canvasRef: { readonly current: HTMLDivElement | null } }) {
const editor = useEditor()
const debugSvg = useValue('debug svg', () => debugFlags.debugSvg.get(), [debugFlags])
const renderingShapes = useValue('rendering shapes', () => editor.getRenderingShapes(), [editor])
return (
<ShapeCullingProvider>
{renderingShapes.map((result) =>
debugSvg ? (
<Fragment key={result.id + '_fragment'}>
<Shape {...result} />
<DebugSvgCopy id={result.id} mode="iframe" />
</Fragment>
) : (
<Shape key={result.id + '_shape'} {...result} />
)
)}
<CullingController />
{tlenv.isSafari && <ReflowIfNeeded canvasRef={canvasRef} />}
</ShapeCullingProvider>
)
}
function ReflowIfNeeded({ canvasRef }: { canvasRef: { readonly current: HTMLDivElement | null } }) {
const editor = useEditor()
const culledShapesRef = useRef<Set<TLShapeId>>(new Set())
useQuickReactor(
'reflow for culled shapes',
() => {
const culledShapes = editor.getCulledShapes()
if (culledShapesRef.current === culledShapes) return
culledShapesRef.current = culledShapes
const canvas = canvasRef.current
if (!canvas) return
// This causes a reflow
// https://gist.github.com/paulirish/5d52fb081b3570c81e3a
const _height = canvas.offsetHeight
},
[editor, canvasRef]
)
return null
}
/**
* Centralized culling controller that updates shape container visibility.
* This single reactor replaces per-shape subscriptions for O(1) instead of O(N) subscriptions.
*/
function CullingController() {
const editor = useEditor()
const { updateCulling } = useShapeCulling()
useQuickReactor(
'update shape culling',
() => {
const culledShapes = editor.getCulledShapes()
updateCulling(culledShapes)
},
[editor, updateCulling]
)
return null
}
function DebugSvgCopy({ id, mode }: { id: TLShapeId; mode: 'img' | 'iframe' }) {
const editor = useEditor()
const [image, setImage] = useState<{ src: string; bounds: Box } | null>(null)
const isInRoot = useValue(
'is in root',
() => {
const shape = editor.getShape(id)
return shape?.parentId === editor.getCurrentPageId()
},
[editor, id]
)
useEffect(() => {
if (!isInRoot) return
let latest = null
const unsubscribe = react('shape to svg', async () => {
const renderId = Math.random()
latest = renderId
const shape = editor.getShape(id)
const isSingleFrame = !!shape && editor.isShapeFrameLike(shape)
const padding = isSingleFrame ? 0 : 10
let bounds = editor.getShapePageBounds(id)
if (!bounds) return
bounds = bounds.clone().expandBy(padding)
const result = await editor.getSvgString([id], { padding })
if (latest !== renderId || !result) return
const svgDataUrl = `data:image/svg+xml;utf8,${encodeURIComponent(result.svg)}`
setImage({ src: svgDataUrl, bounds })
})
return () => {
latest = null
unsubscribe()
}
}, [editor, id, isInRoot])
if (!isInRoot || !image) return null
if (mode === 'iframe') {
return (
<iframe
src={image.src}
width={image.bounds.width}
height={image.bounds.height}
referrerPolicy="no-referrer"
style={{
position: 'absolute',
top: 0,
left: 0,
border: 'none',
transform: `translate(${image.bounds.x}px, ${image.bounds.maxY + 12}px)`,
outline: '1px solid black',
maxWidth: 'none',
}}
/>
)
}
return (
<img
src={image.src}
width={image.bounds.width}
height={image.bounds.height}
referrerPolicy="no-referrer"
style={{
position: 'absolute',
top: 0,
left: 0,
transform: `translate(${image.bounds.x}px, ${image.bounds.maxY + 12}px)`,
outline: '1px solid black',
maxWidth: 'none',
}}
/>
)
}
function OnTheCanvasWrapper() {
const { OnTheCanvas } = useEditorComponents()
if (!OnTheCanvas) return null
return <OnTheCanvas />
}
function MovingCameraHitTestBlocker() {
const editor = useEditor()
const cameraState = useValue('camera state', () => editor.getCameraState(), [editor])
return (
<div
className={classNames('tl-hit-test-blocker', {
'tl-hit-test-blocker__hidden': cameraState === 'idle',
})}
/>
)
}

View File

@@ -0,0 +1,65 @@
import { VecModel } from '@tldraw/tlschema'
import classNames from 'classnames'
import { memo, useRef } from 'react'
import { useSharedSafeId } from '../../hooks/useSafeId'
import { useTransform } from '../../hooks/useTransform'
/** @public */
export interface TLCursorProps {
userId: string
className?: string
point: VecModel | null
zoom: number
color?: string
name: string | null
chatMessage: string
}
/**
* The default collaborator cursor: the arrow glyph plus a name tag or chat bubble. The arrow is a
* `<use>` of a shared `<defs>` symbol that `LiveCollaborators` renders, so a `DefaultCursor`
* used outside that layer draws no arrow.
*
* @public @react
*/
export const DefaultCursor = memo(function DefaultCursor({
className,
zoom,
point,
color,
name,
chatMessage,
}: TLCursorProps) {
const rCursor = useRef<HTMLDivElement>(null)
useTransform(rCursor, point?.x, point?.y, 1 / zoom)
const cursorId = useSharedSafeId('cursor')
if (!point) return null
return (
<div ref={rCursor} className={classNames('tl-collaborators__item', className)}>
<svg className="tl-cursor" aria-hidden="true">
<use href={`#${cursorId}`} color={color} />
</svg>
{chatMessage ? (
<>
{name && (
<div className="tl-nametag-title" style={{ color }}>
{name}
</div>
)}
<div className="tl-nametag-chat" style={{ backgroundColor: color }}>
{chatMessage}
</div>
</>
) : (
name && (
<div className="tl-nametag" style={{ backgroundColor: color }}>
{name}
</div>
)
)}
</div>
)
})

View File

@@ -0,0 +1,240 @@
import { useValue } from '@tldraw/state-react'
import { noop } from '@tldraw/utils'
import classNames from 'classnames'
import { ComponentType, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Editor } from '../../editor/Editor'
import { getOwnerDocument } from '../../exports/domUtils'
import { useEditorComponents } from '../../hooks/EditorComponentsContext'
import { EditorProvider } from '../../hooks/useEditor'
import { getGlobalWindow } from '../../utils/dom'
import { hardResetEditor, refreshPage } from '../../utils/runtime'
import { ErrorBoundary } from '../ErrorBoundary'
const BASE_ERROR_URL = 'https://github.com/tldraw/tldraw/issues/new'
/** @public */
export interface TLErrorFallbackProps {
/** The error that was caught. */
error: unknown
/**
* Only set when the caller renders the fallback with an editor; the editor's own error
* boundaries pass just `error`.
*/
editor?: Editor
}
/** @public */
export type TLErrorFallbackComponent = ComponentType<TLErrorFallbackProps>
/** @public @react */
export function DefaultErrorFallback({ error, editor }: TLErrorFallbackProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [shouldShowError, setShouldShowError] = useState(process.env.NODE_ENV === 'development')
const [didCopy, setDidCopy] = useState(false)
const [shouldShowResetConfirmation, setShouldShowResetConfirmation] = useState(false)
let Canvas: React.ComponentType | null = null
try {
// eslint-disable-next-line react-hooks/rules-of-hooks
const components = useEditorComponents()
Canvas = components.Canvas ?? null
} catch {
// allow this to fail silently
}
const errorMessage = error instanceof Error ? error.message : String(error)
const errorStack = error instanceof Error ? error.stack : null
const isDarkModeFromApp = useValue(
'isDarkMode',
() => {
try {
if (editor) {
return editor.user.getIsDarkMode()
}
} catch {
// we're in a funky error state so this might not work for spooky
// reasons. if not, we'll have another attempt later:
}
return null
},
[editor]
)
const [isDarkMode, setIsDarkMode] = useState<null | boolean>(null)
useLayoutEffect(() => {
// if we found a theme class from the app, we can just use that
if (isDarkModeFromApp !== null) {
setIsDarkMode(isDarkModeFromApp)
}
// do any of our parents have a theme class? if yes then we can just
// rely on that and don't need to set our own class
let parent = containerRef.current?.parentElement
let foundParentThemeClass = false
while (parent) {
if (
parent.classList.contains('tl-theme__dark') ||
parent.classList.contains('tl-theme__light')
) {
foundParentThemeClass = true
break
}
parent = parent.parentElement
}
if (foundParentThemeClass) {
setIsDarkMode(null)
return
}
// if we can't find a theme class from the app or from a parent, we have
// to fall back on using a media query:
if (typeof window !== 'undefined' && getGlobalWindow().matchMedia) {
setIsDarkMode(getGlobalWindow().matchMedia('(prefers-color-scheme: dark)').matches)
}
}, [isDarkModeFromApp])
useEffect(() => {
if (didCopy) {
const timeout = editor?.timers.setTimeout(() => {
setDidCopy(false)
}, 2000)
return () => clearTimeout(timeout)
}
}, [didCopy, editor])
const copyError = () => {
const doc = getOwnerDocument(containerRef.current)
const textarea = doc.createElement('textarea')
textarea.value = errorStack ?? errorMessage
doc.body.appendChild(textarea)
textarea.select()
// eslint-disable-next-line @typescript-eslint/no-deprecated
doc.execCommand('copy')
textarea.remove()
setDidCopy(true)
}
const refresh = () => {
refreshPage()
}
const resetLocalState = async () => {
hardResetEditor()
}
const url = new URL(BASE_ERROR_URL)
url.searchParams.set('title', errorMessage)
url.searchParams.set('labels', `bug`)
url.searchParams.set(
'body',
`Hey, I ran into an error while using tldraw:
\`\`\`js
${errorStack ?? errorMessage}
\`\`\`
My browser: ${navigator.userAgent}`
)
return (
<div
ref={containerRef}
className={classNames(
'tl-container tl-error-boundary',
// error-boundary is sometimes used outside of the theme
// container, so we need to provide it with a theme for our
// styles to work correctly
isDarkMode === null ? '' : isDarkMode ? 'tl-theme__dark' : 'tl-theme__light'
)}
>
<div className="tl-error-boundary__overlay" />
{editor && (
// opportunistically attempt to render the canvas to reassure
// the user that their document is still there. there's a good
// chance this won't work (ie the error that we're currently
// notifying the user about originates in the canvas) so it's
// not a big deal if it doesn't work - in that case we just have
// a plain grey background.
<ErrorBoundary onError={noop} fallback={() => null}>
<EditorProvider editor={editor}>
<div className="tl-overlay tl-error-boundary__canvas">{Canvas ? <Canvas /> : null}</div>
</EditorProvider>
</ErrorBoundary>
)}
<div
className={classNames('tl-modal', 'tl-error-boundary__content', {
'tl-error-boundary__content__expanded': shouldShowError && !shouldShowResetConfirmation,
})}
>
{shouldShowResetConfirmation ? (
<>
<h2>Are you sure?</h2>
<p>Resetting your data will delete your drawing and cannot be undone.</p>
<div className="tl-error-boundary__content__actions">
<button className="tlui-button" onClick={() => setShouldShowResetConfirmation(false)}>
Cancel
</button>
<button className="tlui-button tl-error-boundary__reset" onClick={resetLocalState}>
Reset data
</button>
</div>
</>
) : (
<>
<h2>Something went wrong</h2>
<p>Please refresh your browser.</p>
<p>
If the issue continues after refreshing, you may need to reset the tldraw data stored
on your device.
</p>
<p>
<strong>Note:</strong> Resetting will erase your current project and any unsaved work.
</p>
{process.env.NODE_ENV !== 'production' && (
<p>
If you&apos;re developing with the SDK and need help, join us on{' '}
<a href="https://discord.tldraw.com/?utm_source=sdk&utm_medium=organic&utm_campaign=error-screen">
Discord
</a>
.
</p>
)}
{shouldShowError && (
<>
Message:
<h4>
<code>{errorMessage}</code>
</h4>
Stack trace:
<div className="tl-error-boundary__content__error">
<pre>
<code>{errorStack ?? errorMessage}</code>
</pre>
<button className="tlui-button" onClick={copyError}>
{didCopy ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
<div className="tl-error-boundary__content__actions">
<button className="tlui-button" onClick={() => setShouldShowError(!shouldShowError)}>
{shouldShowError ? 'Hide details' : 'Show details'}
</button>
<div className="tl-error-boundary__content__actions__group">
<button
className="tlui-button tl-error-boundary__reset"
onClick={() => setShouldShowResetConfirmation(true)}
>
Reset data
</button>
<button className="tlui-button tl-error-boundary__refresh" onClick={refresh}>
Refresh page
</button>
</div>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
import { modulate } from '@tldraw/utils'
import { useEditor } from '../../hooks/useEditor'
import { suffixSafeId, useUniqueSafeId } from '../../hooks/useSafeId'
/** @public */
export interface TLGridProps {
x: number
y: number
z: number
size: number
}
/** @public @react */
export function DefaultGrid({ x, y, z, size }: TLGridProps) {
const id = useUniqueSafeId('grid')
const editor = useEditor()
const { gridSteps } = editor.options
return (
<svg className="tl-grid" version="1.1" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
{gridSteps.map(({ min, mid, step }, i) => {
const s = step * size * z
const xo = 0.5 + x * z
const yo = 0.5 + y * z
const gxo = xo > 0 ? xo % s : s + (xo % s)
const gyo = yo > 0 ? yo % s : s + (yo % s)
const opacity = z < mid ? modulate(z, [min, mid], [0, 1]) : 1
return (
<pattern
key={i}
id={suffixSafeId(id, `${step}`)}
width={s}
height={s}
patternUnits="userSpaceOnUse"
>
<circle className="tl-grid-dot" cx={gxo} cy={gyo} r={1} opacity={opacity} />
</pattern>
)
})}
</defs>
{gridSteps.map(({ step }, i) => (
<rect key={i} width="100%" height="100%" fill={`url(#${id}_${step})`} />
))}
</svg>
)
}

View File

@@ -0,0 +1,11 @@
import { useEditorComponents } from '../../hooks/EditorComponentsContext'
/** @public @react */
export function DefaultLoadingScreen() {
const { Spinner } = useEditorComponents()
return (
<div className="tl-loading" aria-busy="true" tabIndex={0}>
{Spinner ? <Spinner /> : null}
</div>
)
}

View File

@@ -0,0 +1,10 @@
import { ComponentType } from 'react'
/** @public */
export type TLShapeErrorFallbackComponent = ComponentType<{ error: any }>
/** @internal */
export const DefaultShapeErrorFallback: TLShapeErrorFallbackComponent =
function DefaultShapeErrorFallback() {
return <div className="tl-shape-error-boundary" />
}

View File

@@ -0,0 +1,35 @@
import { TLShape } from '@tldraw/tlschema'
import classNames from 'classnames'
import { forwardRef, ReactNode } from 'react'
/** @public */
export interface TLShapeWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
/** The shape being rendered. */
shape: TLShape
/** Whether this is the shapes regular, or background component. */
isBackground: boolean
/** The shape's rendered component. */
children: ReactNode
}
/** @public @react */
export const DefaultShapeWrapper = forwardRef(function DefaultShapeWrapper(
{ children, shape, isBackground, ...props }: TLShapeWrapperProps,
ref: React.Ref<HTMLDivElement>
) {
const isFilledShape = 'fill' in shape.props && shape.props.fill !== 'none'
return (
<div
ref={ref}
data-shape-type={shape.type}
data-shape-is-filled={isBackground ? undefined : isFilledShape}
data-shape-id={shape.id}
draggable={false}
{...props}
className={classNames('tl-shape', isBackground && 'tl-shape-background', props.className)}
>
{children}
</div>
)
})

View File

@@ -0,0 +1,23 @@
import classNames from 'classnames'
/** @public */
export type TLSpinnerProps = React.SVGProps<SVGSVGElement>
/** @public @react */
export function DefaultSpinner(props: TLSpinnerProps) {
return (
<svg
width={16}
height={16}
viewBox="0 0 16 16"
aria-hidden="false"
{...props}
className={classNames('tl-spinner', props.className)}
>
<g strokeWidth={2} fill="none" fillRule="evenodd">
<circle strokeOpacity={0.25} cx={8} cy={8} r={7} stroke="currentColor" />
<path strokeLinecap="round" d="M15 8c0-4.5-4.5-7-7-7" stroke="currentColor" />
</g>
</svg>
)
}

View File

@@ -0,0 +1,4 @@
/** @public @react */
export function DefaultSvgDefs() {
return null
}

View File

@@ -0,0 +1,244 @@
import { PageRecordType, TLStore, createShapeId } from '@tldraw/tlschema'
import { IndexKey } from '@tldraw/utils'
import { Editor } from '../editor/Editor'
import { Box } from '../primitives/Box'
import { createTLStore } from './createTLStore'
import { TLEditorSnapshot, getSnapshot, loadSnapshot } from './TLEditorSnapshot'
const createEditor = (store: TLStore) => {
return new Editor({
store,
bindingUtils: [],
shapeUtils: [],
getContainer: () => document.createElement('div'),
tools: [],
})
}
describe('getSnapshot', () => {
it('should return a TLEditorSnapshot', () => {
const store = createTLStore({})
store.ensureStoreIsUsable()
const snapshot = getSnapshot(store)
expect(snapshot).toMatchObject({
document: {
schema: {},
store: {
'document:document': {},
'page:page': {},
},
},
session: {
currentPageId: 'page:page',
exportBackground: true,
isDebugMode: false,
isFocusMode: false,
isGridMode: false,
isToolLocked: false,
pageStates: [
{
camera: { x: 0, y: 0, z: 1 },
focusedGroupId: null,
pageId: 'page:page',
selectedShapeIds: [],
},
],
version: 0,
},
})
const editor = createEditor(store)
editor.updateInstanceState({ isDebugMode: true })
editor.createPage({ id: PageRecordType.createId('page2') })
editor.setCurrentPage(PageRecordType.createId('page2'))
const snapshot2 = getSnapshot(store)
expect(snapshot2).toMatchObject({
document: {
schema: {},
store: {
'document:document': {},
'page:page': {},
'page:page2': {},
},
},
session: {
currentPageId: 'page:page2',
isDebugMode: true,
pageStates: [{}, {}],
version: 0,
},
})
})
})
const page2Id = PageRecordType.createId('page2')
function addPage2(snapshot: TLEditorSnapshot) {
// sneakily add a page
snapshot = structuredClone(snapshot)
snapshot.document.store[page2Id] = PageRecordType.create({
id: page2Id,
name: 'my lovely page',
index: 'a4' as IndexKey,
})
// and set the current page id
snapshot.session.currentPageId = page2Id
return snapshot
}
describe('loadSnapshot', () => {
it('loads a snapshot into the store', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = addPage2(getSnapshot(store))
loadSnapshot(store, snapshot)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe(page2Id)
})
it('does not overwrite changes to the viewport page bounds', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = addPage2(getSnapshot(store))
expect(editor.getViewportScreenBounds()).not.toEqual(new Box(0, 0, 100, 100))
editor.updateViewportScreenBounds(new Box(0, 0, 100, 100))
expect(editor.getViewportScreenBounds()).toEqual(new Box(0, 0, 100, 100))
loadSnapshot(store, snapshot)
expect(editor.getViewportScreenBounds()).toEqual(new Box(0, 0, 100, 100))
})
it('works with just the document bits (1)', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = addPage2(getSnapshot(store))
loadSnapshot(store, snapshot.document)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
})
it('works with just the document bits (2)', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = addPage2(getSnapshot(store))
loadSnapshot(store, { document: snapshot.document })
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
})
it('allows loading the session bits later', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = addPage2(getSnapshot(store))
loadSnapshot(store, snapshot.document)
expect(
editor
.getPages()
.map((p) => p.id)
.sort()
).toEqual(['page:page', 'page:page2'])
expect(editor.getCurrentPageId()).toBe('page:page')
loadSnapshot(store, { session: snapshot.session })
expect(editor.getCurrentPageId()).toBe('page:page2')
})
it('preserves session stuff if no session snapshot is provided', () => {
const store = createTLStore({})
const editor = createEditor(store)
const snapshot = getSnapshot(store)
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
editor.setCamera({ x: 1, y: 2, z: 3 })
loadSnapshot(store, snapshot.document)
expect(editor.getCamera()).toMatchObject({ x: 1, y: 2, z: 3 })
loadSnapshot(store, { document: snapshot.document })
expect(editor.getCamera()).toMatchObject({ x: 1, y: 2, z: 3 })
loadSnapshot(store, snapshot)
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
})
it('preserves session stuff if only a partial session snapshot is provided', () => {
const store = createTLStore({})
const editor = createEditor(store)
const page1Id = editor.getCurrentPageId()
const page2Id = PageRecordType.createId('page2')
editor.createPage({ id: page2Id })
editor.setCurrentPage(PageRecordType.createId('page2'))
const snapshot = getSnapshot(store)
delete snapshot.session.pageStates
editor.setCurrentPage(page1Id)
editor.setCamera({ x: 1, y: 2, z: 3 })
loadSnapshot(store, snapshot)
// the page should have switched back to page2 with the default camera
expect(editor.getCurrentPageId()).toBe(page2Id)
expect(editor.getCamera()).toMatchObject({ x: 0, y: 0, z: 1 })
// and because we updated the page1 camera after the snapshot was taken, it should still be the same
editor.setCurrentPage(page1Id)
expect(editor.getCamera()).toMatchObject({ x: 1, y: 2, z: 3 })
})
it('cleans up references to missing shapes from page state', () => {
const store = createTLStore({})
const editor = createEditor(store)
const shapeA = createShapeId('a')
editor.createShape({ type: 'group', id: shapeA })
const snapshot = getSnapshot(store)
const shapeB = createShapeId('b')
editor.createShape({ type: 'group', id: shapeB }).select(shapeB, shapeA)
loadSnapshot(store, snapshot.document)
expect(editor.getCurrentPageState().selectedShapeIds).toEqual([shapeA])
})
})

View File

@@ -0,0 +1,107 @@
import { Signal } from '@tldraw/state'
import { TLINSTANCE_ID, TLStore, TLStoreSnapshot, pluckPreservingValues } from '@tldraw/tlschema'
import { WeakCache, filterEntries } from '@tldraw/utils'
import {
TLSessionStateSnapshot,
createSessionStateSnapshotSignal,
loadSessionStateSnapshotIntoStore,
} from './TLSessionStateSnapshot'
/** @public */
export interface TLEditorSnapshot {
document: TLStoreSnapshot
session: TLSessionStateSnapshot
}
/**
* Options for {@link loadSnapshot}
* @public
*/
export interface TLLoadSnapshotOptions {
/**
* By default, some session state flags like `isDebugMode` are not overwritten when loading a snapshot.
* These are usually considered "sticky" by users while the document data is not.
* If you want to overwrite these flags, set this to `true`.
*/
forceOverwriteSessionState?: boolean
}
/**
* Loads a snapshot into a store.
* @public
*/
export function loadSnapshot(
store: TLStore,
_snapshot: Partial<TLEditorSnapshot> | TLStoreSnapshot,
opts?: TLLoadSnapshotOptions
) {
let snapshot: Partial<TLEditorSnapshot> = {}
if ('store' in _snapshot) {
// regular old TLStoreSnapshot
// let's migrate it and then filter out the non-doc state to help folks out
const migrationResult = store.schema.migrateStoreSnapshot(_snapshot)
if (migrationResult.type !== 'success') {
throw new Error('Failed to migrate store snapshot: ' + migrationResult.reason)
}
snapshot.document = {
schema: store.schema.serialize(),
store: filterEntries(migrationResult.value, (_, { typeName }) =>
store.scopedTypes.document.has(typeName)
),
}
} else {
// TLEditorSnapshot
snapshot = _snapshot
}
// We need to preserve a bunch of instance state properties that the Editor sets
// to avoid breaking the editor or causing jarring changes when loading a snapshot.
const preservingInstanceState = pluckPreservingValues(store.get(TLINSTANCE_ID))
const preservingSessionState = sessionStateCache
.get(store, createSessionStateSnapshotSignal)
.get()
store.atomic(() => {
// first load the document state (this will wipe the store if it happens)
if (snapshot.document) {
store.loadStoreSnapshot(snapshot.document)
}
// then make sure we preserve those instance state properties that must be preserved
// this is a noop if the document state wasn't loaded above
if (preservingInstanceState) {
store.update(TLINSTANCE_ID, (r) => ({ ...r, ...preservingInstanceState }))
}
if (preservingSessionState) {
// there's some duplication here with the instanceState but it's fine
loadSessionStateSnapshotIntoStore(store, preservingSessionState)
}
// finally reinstate the UI state
if (snapshot.session) {
loadSessionStateSnapshotIntoStore(store, snapshot.session, {
forceOverwrite: opts?.forceOverwriteSessionState,
})
}
})
}
const sessionStateCache = new WeakCache<
TLStore,
Signal<TLSessionStateSnapshot | undefined | null>
>()
/** @public */
export function getSnapshot(store: TLStore): TLEditorSnapshot {
const sessionState$ = sessionStateCache.get(store, createSessionStateSnapshotSignal)
const session = sessionState$.get()
if (!session) {
throw new Error('Session state is not ready yet')
}
return {
document: store.getStoreSnapshot(),
session,
}
}

View File

@@ -0,0 +1,332 @@
import { Signal, computed } from '@tldraw/state'
import { UnknownRecord } from '@tldraw/store'
import {
CameraRecordType,
InstancePageStateRecordType,
TLINSTANCE_ID,
TLPageId,
TLShapeId,
TLStore,
pageIdValidator,
pluckPreservingValues,
shapeIdValidator,
} from '@tldraw/tlschema'
import {
deleteFromSessionStorage,
getFromSessionStorage,
isEqual,
setInSessionStorage,
structuredClone,
uniqueId,
} from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { tlenv } from '../globals/environment'
import { getGlobalDocument, getGlobalWindow } from '../utils/dom'
const tabIdKey = 'TLDRAW_TAB_ID_v2' as const
const window = globalThis.window as
| {
navigator: Window['navigator']
addEventListener: Window['addEventListener']
TLDRAW_TAB_ID_v2?: string
}
| undefined
// https://stackoverflow.com/a/9039885
function iOS() {
if (!window) return false
return (
['iPad Simulator', 'iPhone Simulator', 'iPod Simulator', 'iPad', 'iPhone', 'iPod'].includes(
// eslint-disable-next-line @typescript-eslint/no-deprecated
getGlobalWindow().navigator.platform
) ||
// iPad on iOS 13 detection
(tlenv.isDarwin && 'ontouchend' in getGlobalDocument())
)
}
/**
* A string that is unique per browser tab
* @public
*/
export const TAB_ID: string = window
? (window[tabIdKey] ??
getFromSessionStorage(tabIdKey) ??
`TLDRAW_INSTANCE_STATE_V1_` + uniqueId())
: '<error>'
if (window) {
window[tabIdKey] = TAB_ID
if (iOS()) {
// iOS does not trigger beforeunload
// so we need to keep the sessionStorage value around
// and hope the user doesn't figure out a way to duplicate their tab
// in which case they'll have two tabs with the same UI state.
// It's not a big deal, but it's not ideal.
// And anyway I can't see a way to duplicate a tab in iOS Safari.
setInSessionStorage(tabIdKey, TAB_ID)
} else {
deleteFromSessionStorage(tabIdKey)
}
}
if (typeof window !== 'undefined') {
getGlobalWindow().addEventListener('beforeunload', () => {
setInSessionStorage(tabIdKey, TAB_ID)
})
}
const Versions = {
Initial: 0,
} as const
const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Math.max(...Object.values(Versions))
function migrate(snapshot: any) {
if (snapshot.version < Versions.Initial) {
// initial version
// noop
}
// add further migrations down here. see TLUserPreferences.ts for an example.
// finally
snapshot.version = CURRENT_SESSION_STATE_SNAPSHOT_VERSION
}
/**
* The state of the editor instance, not including any document state.
*
* @public
*/
export interface TLSessionStateSnapshot {
version: number
currentPageId?: TLPageId
isFocusMode?: boolean
exportBackground?: boolean
isDebugMode?: boolean
isToolLocked?: boolean
isGridMode?: boolean
pageStates?: Array<{
pageId: TLPageId
camera?: { x: number; y: number; z: number }
selectedShapeIds?: TLShapeId[]
focusedGroupId?: TLShapeId | null
}>
}
const sessionStateSnapshotValidator: T.Validator<TLSessionStateSnapshot> = T.object({
version: T.number,
currentPageId: pageIdValidator.optional(),
isFocusMode: T.boolean.optional(),
exportBackground: T.boolean.optional(),
isDebugMode: T.boolean.optional(),
isToolLocked: T.boolean.optional(),
isGridMode: T.boolean.optional(),
pageStates: T.arrayOf(
T.object({
pageId: pageIdValidator,
camera: T.object({
x: T.number,
y: T.number,
z: T.number,
}).optional(),
selectedShapeIds: T.arrayOf(shapeIdValidator).optional(),
focusedGroupId: shapeIdValidator.nullable().optional(),
})
).optional(),
})
function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null {
if (!state || typeof state !== 'object') {
console.warn('Invalid instance state')
return null
}
if (!('version' in state) || typeof state.version !== 'number') {
console.warn('No version in instance state')
return null
}
if (state.version !== CURRENT_SESSION_STATE_SNAPSHOT_VERSION) {
state = structuredClone(state)
migrate(state)
}
try {
return sessionStateSnapshotValidator.validate(state)
} catch (e) {
console.warn(e)
return null
}
}
/**
* Creates a signal of the instance state for a given store.
* @public
* @param store - The store to create the instance state snapshot signal for
* @returns
*/
export function createSessionStateSnapshotSignal(
store: TLStore
): Signal<TLSessionStateSnapshot | null> {
const $allPageIds = store.query.ids('page')
return computed<TLSessionStateSnapshot | null>(
'sessionStateSnapshot',
() => {
const instanceState = store.get(TLINSTANCE_ID)
if (!instanceState) return null
const allPageIds = [...$allPageIds.get()]
return {
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
currentPageId: instanceState.currentPageId,
exportBackground: instanceState.exportBackground,
isFocusMode: instanceState.isFocusMode,
isDebugMode: instanceState.isDebugMode,
isToolLocked: instanceState.isToolLocked,
isGridMode: instanceState.isGridMode,
pageStates: allPageIds.map((id) => {
const ps = store.get(InstancePageStateRecordType.createId(id))
const camera = store.get(CameraRecordType.createId(id))
return {
pageId: id,
camera: {
x: camera?.x ?? 0,
y: camera?.y ?? 0,
z: camera?.z ?? 1,
},
selectedShapeIds: ps?.selectedShapeIds ?? [],
focusedGroupId: ps?.focusedGroupId ?? null,
} satisfies NonNullable<TLSessionStateSnapshot['pageStates']>[0]
}),
} satisfies TLSessionStateSnapshot
},
{ isEqual }
)
}
/**
* Options for {@link loadSessionStateSnapshotIntoStore}
* @public
*/
export interface TLLoadSessionStateSnapshotOptions {
/**
* By default, some session state flags like `isDebugMode` are not overwritten when loading a snapshot.
* These are usually considered "sticky" by users while the document data is not.
* If you want to overwrite these flags, set this to `true`.
*/
forceOverwrite?: boolean
}
/**
* Loads a snapshot of the editor's instance state into the store of a new editor instance.
*
* @public
* @param store - The store to load the instance state into
* @param snapshot - The instance state snapshot to load
* @returns
*/
export function loadSessionStateSnapshotIntoStore(
store: TLStore,
snapshot: TLSessionStateSnapshot,
opts?: TLLoadSessionStateSnapshotOptions
) {
const res = migrateAndValidateSessionStateSnapshot(snapshot)
if (!res) return
const existingInstance = store.get(TLINSTANCE_ID)
const preserved = pluckPreservingValues(existingInstance)
const primary = opts?.forceOverwrite ? res : preserved
const secondary = opts?.forceOverwrite ? preserved : res
const instanceState = store.schema.types.instance.create({
id: TLINSTANCE_ID,
...preserved,
// the integrity checker will ensure that the currentPageId is valid
currentPageId: res.currentPageId ?? existingInstance?.currentPageId,
isDebugMode: primary?.isDebugMode ?? secondary?.isDebugMode,
isFocusMode: primary?.isFocusMode ?? secondary?.isFocusMode,
isToolLocked: primary?.isToolLocked ?? secondary?.isToolLocked,
isGridMode: primary?.isGridMode ?? secondary?.isGridMode,
exportBackground: primary?.exportBackground ?? secondary?.exportBackground,
})
store.atomic(() => {
for (const ps of res.pageStates ?? []) {
if (!store.has(ps.pageId)) continue
const cameraId = CameraRecordType.createId(ps.pageId)
const instancePageState = InstancePageStateRecordType.createId(ps.pageId)
const previousCamera = store.get(cameraId)
const previousInstanceState = store.get(instancePageState)
store.put([
CameraRecordType.create({
id: cameraId,
x: ps.camera?.x ?? previousCamera?.x,
y: ps.camera?.y ?? previousCamera?.y,
z: ps.camera?.z ?? previousCamera?.z,
}),
InstancePageStateRecordType.create({
id: instancePageState,
pageId: ps.pageId,
selectedShapeIds: ps.selectedShapeIds ?? previousInstanceState?.selectedShapeIds,
focusedGroupId: ps.focusedGroupId ?? previousInstanceState?.focusedGroupId,
}),
])
}
store.put([instanceState])
store.ensureStoreIsUsable()
})
}
/**
* @internal
*/
export function extractSessionStateFromLegacySnapshot(
store: Record<string, UnknownRecord>
): TLSessionStateSnapshot | null {
const instanceRecords = []
for (const record of Object.values(store)) {
if (record.typeName?.match(/^(instance.*|pointer|camera)$/)) {
instanceRecords.push(record)
}
}
// for scratch documents, we need to extract the most recently-used instance and it's associated page states
// but oops we don't have the concept of "most recently-used" so we'll just take the first one
const oldInstance = instanceRecords.filter(
(r) => r.typeName === 'instance' && r.id !== TLINSTANCE_ID
)[0] as any
if (!oldInstance) return null
const result: TLSessionStateSnapshot = {
version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
currentPageId: oldInstance.currentPageId,
exportBackground: !!oldInstance.exportBackground,
isFocusMode: !!oldInstance.isFocusMode,
isDebugMode: !!oldInstance.isDebugMode,
isToolLocked: !!oldInstance.isToolLocked,
isGridMode: false,
pageStates: instanceRecords
.filter((r: any) => r.typeName === 'instance_page_state' && r.instanceId === oldInstance.id)
.map((ps: any) => {
const camera = (store[ps.cameraId] as any) ?? { x: 0, y: 0, z: 1 }
return {
pageId: ps.pageId,
camera: {
x: camera.x,
y: camera.y,
z: camera.z,
},
selectedShapeIds: ps.selectedShapeIds,
focusedGroupId: ps.focusedGroupId,
} satisfies NonNullable<TLSessionStateSnapshot['pageStates']>[0]
}),
}
try {
sessionStateSnapshotValidator.validate(result)
return result
} catch {
return null
}
}

View File

@@ -0,0 +1,58 @@
import { deleteFromLocalStorage, setInLocalStorage } from '@tldraw/utils'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { defaultUserPreferences, userTypeValidator } from './TLUserPreferences'
describe('TLUserPreferences consistency', () => {
// When adding a new user preference, add it to this list AND update:
// 1. TLUserPreferences interface
// 2. userTypeValidator
// 3. defaultUserPreferences
// 4. Versions enum and migrateSnapshot()
const interfaceKeys = [
'name',
'color',
'locale',
'animationSpeed',
'areKeyboardShortcutsEnabled',
'edgeScrollSpeed',
'colorScheme',
'isSnapMode',
'isWrapMode',
'isDynamicSizeMode',
'isPasteAtCursorMode',
'enhancedA11yMode',
'inputMode',
'isZoomDirectionInverted',
] as const
it('defaultUserPreferences contains all TLUserPreferences keys (except id)', () => {
const defaultKeys = Object.keys(defaultUserPreferences).sort()
const expected = [...interfaceKeys].sort()
expect(defaultKeys).toEqual(expected)
})
it('userTypeValidator validates all TLUserPreferences keys', () => {
// Access the internal config property to check which keys the validator covers
const validatorKeys = Object.keys((userTypeValidator as any).config).sort()
const expected = ['id', ...interfaceKeys].sort()
expect(validatorKeys).toEqual(expected)
})
})
describe('loading stored preferences', () => {
afterEach(() => {
deleteFromLocalStorage('TLDRAW_USER_DATA_v3')
vi.resetModules()
})
it.each([
['corrupt json', '{not json'],
['a snapshot whose user is not an object', JSON.stringify({ version: 1, user: null })],
])('falls back to fresh preferences for %s', async (_label, stored) => {
setInLocalStorage('TLDRAW_USER_DATA_v3', stored)
const { getUserPreferences } = await import('./TLUserPreferences')
expect(getUserPreferences().id).toEqual(expect.any(String))
})
})

View File

@@ -0,0 +1,286 @@
import { atom } from '@tldraw/state'
import { getDefaultTranslationLocale } from '@tldraw/tlschema'
import { getFromLocalStorage, setInLocalStorage, structuredClone, uniqueId } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { getGlobalWindow } from '../utils/dom'
const USER_DATA_KEY = 'TLDRAW_USER_DATA_v3'
/**
* A user of tldraw
*
* @public
*/
export interface TLUserPreferences {
id: string
name?: string | null
color?: string | null
// N.B. These are duplicated in TLdrawAppUser.
locale?: string | null
animationSpeed?: number | null
areKeyboardShortcutsEnabled?: boolean | null
edgeScrollSpeed?: number | null
colorScheme?: 'light' | 'dark' | 'system'
isSnapMode?: boolean | null
isWrapMode?: boolean | null
isDynamicSizeMode?: boolean | null
isPasteAtCursorMode?: boolean | null
enhancedA11yMode?: boolean | null
inputMode?: 'trackpad' | 'mouse' | null
isZoomDirectionInverted?: boolean | null
}
interface UserDataSnapshot {
version: number
user: TLUserPreferences
}
interface UserChangeBroadcastMessage {
type: typeof broadcastEventKey
origin: string
data: UserDataSnapshot
}
/** @public */
export const userTypeValidator: T.Validator<TLUserPreferences> = T.object<TLUserPreferences>({
id: T.string,
name: T.string.nullable().optional(),
color: T.string.nullable().optional(),
// N.B. These are duplicated in TLdrawAppUser.
locale: T.string.nullable().optional(),
animationSpeed: T.number.nullable().optional(),
areKeyboardShortcutsEnabled: T.boolean.nullable().optional(),
edgeScrollSpeed: T.number.nullable().optional(),
colorScheme: T.literalEnum('light', 'dark', 'system').optional(),
isSnapMode: T.boolean.nullable().optional(),
isWrapMode: T.boolean.nullable().optional(),
isDynamicSizeMode: T.boolean.nullable().optional(),
isPasteAtCursorMode: T.boolean.nullable().optional(),
enhancedA11yMode: T.boolean.nullable().optional(),
inputMode: T.literalEnum('trackpad', 'mouse').nullable().optional(),
isZoomDirectionInverted: T.boolean.nullable().optional(),
})
const Versions = {
AddAnimationSpeed: 1,
AddIsSnapMode: 2,
MakeFieldsNullable: 3,
AddEdgeScrollSpeed: 4,
AddExcalidrawSelectMode: 5,
AddDynamicSizeMode: 6,
AllowSystemColorScheme: 7,
AddPasteAtCursor: 8,
AddKeyboardShortcuts: 9,
AddShowUiLabels: 10,
AddPointerPeripheral: 11,
RenameShowUiLabelsToEnhancedA11yMode: 12,
AddZoomDirectionInverted: 13,
} as const
const CURRENT_VERSION = Math.max(...Object.values(Versions))
function migrateSnapshot(data: { version: number; user: any }) {
if (data.version < Versions.AddAnimationSpeed) {
data.user.animationSpeed = 1
}
if (data.version < Versions.AddIsSnapMode) {
data.user.isSnapMode = false
}
if (data.version < Versions.MakeFieldsNullable) {
// noop
}
if (data.version < Versions.AddEdgeScrollSpeed) {
data.user.edgeScrollSpeed = 1
}
if (data.version < Versions.AddExcalidrawSelectMode) {
data.user.isWrapMode = false
}
if (data.version < Versions.AllowSystemColorScheme) {
if (data.user.isDarkMode === true) {
data.user.colorScheme = 'dark'
} else if (data.user.isDarkMode === false) {
data.user.colorScheme = 'light'
}
delete data.user.isDarkMode
}
if (data.version < Versions.AddDynamicSizeMode) {
data.user.isDynamicSizeMode = false
}
if (data.version < Versions.AddPasteAtCursor) {
data.user.isPasteAtCursorMode = false
}
if (data.version < Versions.AddKeyboardShortcuts) {
data.user.areKeyboardShortcutsEnabled = true
}
if (data.version < Versions.AddShowUiLabels) {
data.user.showUiLabels = false
}
if (data.version < Versions.RenameShowUiLabelsToEnhancedA11yMode) {
data.user.enhancedA11yMode = data.user.showUiLabels
delete data.user.showUiLabels
}
if (data.version < Versions.AddPointerPeripheral) {
data.user.inputMode = null
}
if (data.version < Versions.AddZoomDirectionInverted) {
data.user.isZoomDirectionInverted = false
}
// finally
data.version = CURRENT_VERSION
}
/** @internal */
export const USER_COLORS = [
'#FF802B',
'#EC5E41',
'#F2555A',
'#F04F88',
'#E34BA9',
'#BD54C6',
'#9D5BD2',
'#7B66DC',
'#02B1CC',
'#11B3A3',
'#39B178',
'#55B467',
] as const
function getRandomColor() {
return USER_COLORS[Math.floor(Math.random() * USER_COLORS.length)]
}
/** @internal */
export function userPrefersReducedMotion() {
if (typeof window !== 'undefined' && getGlobalWindow().matchMedia) {
return getGlobalWindow().matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false
}
return false
}
/** @public */
export const defaultUserPreferences = Object.freeze({
name: '',
locale: getDefaultTranslationLocale(),
color: getRandomColor(),
// N.B. These are duplicated in TLdrawAppUser.
edgeScrollSpeed: 1,
animationSpeed: userPrefersReducedMotion() ? 0 : 1,
areKeyboardShortcutsEnabled: true,
isSnapMode: false,
isWrapMode: false,
isDynamicSizeMode: false,
isPasteAtCursorMode: false,
enhancedA11yMode: false,
colorScheme: 'light',
inputMode: null,
isZoomDirectionInverted: false,
}) satisfies Readonly<Omit<TLUserPreferences, 'id'>>
/** @public */
export function getFreshUserPreferences(): TLUserPreferences {
return {
id: uniqueId(),
color: getRandomColor(),
}
}
function migrateUserPreferences(userData: unknown): TLUserPreferences {
if (userData === null || typeof userData !== 'object') {
return getFreshUserPreferences()
}
if (!('version' in userData) || !('user' in userData) || typeof userData.version !== 'number') {
return getFreshUserPreferences()
}
const snapshot = structuredClone(userData) as any
try {
// migration dereferences `user`, which malformed stored data may not have as an object
migrateSnapshot(snapshot)
return userTypeValidator.validate(snapshot.user)
} catch {
return getFreshUserPreferences()
}
}
function loadUserPreferences(): TLUserPreferences {
let userData: unknown = null
try {
userData = JSON.parse(getFromLocalStorage(USER_DATA_KEY) || 'null')
} catch {
// corrupt stored data is treated as absent rather than blocking the editor from mounting
}
return migrateUserPreferences(userData)
}
const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', null)
function storeUserPreferences() {
setInLocalStorage(
USER_DATA_KEY,
JSON.stringify({
version: CURRENT_VERSION,
user: globalUserPreferences.get(),
})
)
}
/** @public */
export function setUserPreferences(user: TLUserPreferences) {
userTypeValidator.validate(user)
globalUserPreferences.set(user)
storeUserPreferences()
broadcastUserPreferencesChange()
}
const isTest = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
const channel =
typeof BroadcastChannel !== 'undefined' && !isTest
? new BroadcastChannel('tldraw-user-sync')
: null
channel?.addEventListener('message', (e) => {
const data = e.data as undefined | UserChangeBroadcastMessage
if (data?.type === broadcastEventKey && data?.origin !== getBroadcastOrigin()) {
globalUserPreferences.set(migrateUserPreferences(data.data))
}
})
let _broadcastOrigin = null as null | string
function getBroadcastOrigin() {
if (_broadcastOrigin === null) {
_broadcastOrigin = uniqueId()
}
return _broadcastOrigin
}
const broadcastEventKey = 'tldraw-user-preferences-change' as const
function broadcastUserPreferencesChange() {
channel?.postMessage({
type: broadcastEventKey,
origin: getBroadcastOrigin(),
data: {
user: getUserPreferences(),
version: CURRENT_VERSION,
},
} satisfies UserChangeBroadcastMessage)
}
/** @public */
export function getUserPreferences(): TLUserPreferences {
let prefs = globalUserPreferences.get()
if (!prefs) {
prefs = loadUserPreferences()
setUserPreferences(prefs)
}
return prefs
}

View File

@@ -0,0 +1,57 @@
import { Signal, computed, isSignal } from '@tldraw/state'
import { useAtom } from '@tldraw/state-react'
import { useEffect, useMemo } from 'react'
import { useShallowObjectIdentity } from '../hooks/useIdentity'
import { TLUserPreferences, getUserPreferences, setUserPreferences } from './TLUserPreferences'
/** @public */
export interface TLCurrentUser {
readonly userPreferences: Signal<TLUserPreferences>
// eslint-disable-next-line tldraw/method-signature-style
readonly setUserPreferences: (userPreferences: TLUserPreferences) => void
}
const defaultLocalStorageUserPrefs = computed('defaultLocalStorageUserPrefs', () =>
getUserPreferences()
)
/** @public */
export function createTLCurrentUser(
opts = {} as {
userPreferences?: Signal<TLUserPreferences>
// eslint-disable-next-line tldraw/method-signature-style
setUserPreferences?: (userPreferences: TLUserPreferences) => void
}
): TLCurrentUser {
return {
userPreferences: opts.userPreferences ?? defaultLocalStorageUserPrefs,
setUserPreferences: opts.setUserPreferences ?? setUserPreferences,
}
}
/**
* @public
*/
export function useTldrawCurrentUser(opts: {
userPreferences?: Signal<TLUserPreferences> | TLUserPreferences
// eslint-disable-next-line tldraw/method-signature-style
setUserPreferences?: (userPreferences: TLUserPreferences) => void
}): TLCurrentUser {
const prefs = useShallowObjectIdentity(opts.userPreferences ?? defaultLocalStorageUserPrefs)
const userAtom = useAtom<TLUserPreferences | Signal<TLUserPreferences>>('userAtom', prefs)
useEffect(() => {
userAtom.set(prefs)
}, [prefs, userAtom])
return useMemo(
() =>
createTLCurrentUser({
userPreferences: computed('userPreferences', () => {
const userStuff = userAtom.get()
return isSignal(userStuff) ? userStuff.get() : userStuff
}),
setUserPreferences: opts.setUserPreferences,
}),
[userAtom, opts.setUserPreferences]
)
}

View File

@@ -0,0 +1,208 @@
import { Signal, computed } from '@tldraw/state'
import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
import {
CustomRecordInfo,
SchemaPropsInfo,
TLAssetStore,
TLRecord,
TLStore,
TLStoreProps,
TLStoreSnapshot,
TLThemes,
TLUser,
TLUserStore,
UserRecordType,
createCachedUserResolve,
createTLSchema,
createUserId,
registerColorsFromThemes,
registerFontsFromThemes,
} from '@tldraw/tlschema'
import { FileHelpers, assert } from '@tldraw/utils'
import { Editor } from '../editor/Editor'
import { resolveThemes } from '../editor/managers/ThemeManager/ThemeManager'
import { TLAnyAssetUtilConstructor, checkAssets } from './defaultAssets'
import { TLAnyBindingUtilConstructor, checkBindings } from './defaultBindings'
import { TLAnyShapeUtilConstructor, checkShapesAndAddCore } from './defaultShapes'
import { TLEditorSnapshot, loadSnapshot } from './TLEditorSnapshot'
import { defaultUserPreferences, getUserPreferences } from './TLUserPreferences'
/** @public */
export interface TLStoreBaseOptions {
/** The initial data for the store. */
initialData?: SerializedStore<TLRecord>
/** A snapshot of initial data to migrate and load into the store. */
snapshot?: Partial<TLEditorSnapshot> | TLStoreSnapshot
/** The default name for the store. */
defaultName?: string
/** How should this store upload & resolve assets? */
assets?: TLAssetStore
/**
* Named theme definitions. When provided, custom color names are automatically
* registered before the store is constructed so persisted data with those
* colors passes validation on load.
*/
themes?: Partial<TLThemes>
/** How should this store resolve users for attribution? */
users?: TLUserStore
/** Called when the store is connected to an {@link @tldraw/editor#Editor}. */
onMount?(editor: Editor): void | (() => void)
}
/** @public */
export type TLStoreSchemaOptions =
| {
schema?: StoreSchema<TLRecord, TLStoreProps>
}
| {
shapeUtils?: readonly TLAnyShapeUtilConstructor[]
bindingUtils?: readonly TLAnyBindingUtilConstructor[]
assetUtils?: readonly TLAnyAssetUtilConstructor[]
migrations?: readonly MigrationSequence[]
records?: Record<string, CustomRecordInfo>
}
/** @public */
export type TLStoreOptions = TLStoreBaseOptions & {
id?: string
/** Collaboration options for the store. */
collaboration?: {
status: Signal<'online' | 'offline'> | null
mode?: Signal<'readonly' | 'readwrite'> | null
}
} & TLStoreSchemaOptions
/** @public */
export type TLStoreEventInfo = HistoryEntry<TLRecord>
const defaultAssetResolve: NonNullable<TLAssetStore['resolve']> = (asset) => asset.props.src
const _defaultCurrentUser: Signal<TLUser | null> = computed('defaultCurrentUser', () => {
const prefs = getUserPreferences()
if (!prefs.id) return null
return UserRecordType.create({
id: createUserId(prefs.id),
name: prefs.name ?? '',
color: prefs.color ?? defaultUserPreferences.color,
})
})
/** @public */
export const defaultUserStore: TLUserStore = {
currentUser: _defaultCurrentUser,
}
/** @public */
export const inlineBase64AssetStore: TLAssetStore = {
upload: async (_, file) => {
return { src: await FileHelpers.blobToDataUrl(file) }
},
}
/**
* A helper for creating a TLStore schema from either an object with shapeUtils, bindingUtils, and
* migrations, or a schema.
*
* @param opts - Options for creating the schema.
*
* @public
*/
export function createTLSchemaFromUtils(
opts: TLStoreSchemaOptions
): StoreSchema<TLRecord, TLStoreProps> {
if ('schema' in opts && opts.schema) return opts.schema
return createTLSchema({
shapes:
'shapeUtils' in opts && opts.shapeUtils
? utilsToMap(checkShapesAndAddCore(opts.shapeUtils))
: undefined,
bindings:
'bindingUtils' in opts && opts.bindingUtils
? utilsToMap(checkBindings(opts.bindingUtils))
: undefined,
assets:
'assetUtils' in opts && opts.assetUtils
? utilsToMap(checkAssets(opts.assetUtils))
: undefined,
records: 'records' in opts ? opts.records : undefined,
migrations: 'migrations' in opts ? opts.migrations : undefined,
})
}
/**
* A helper for creating a TLStore.
*
* @param opts - Options for creating the store.
*
* @public
*/
export function createTLStore({
initialData,
defaultName = '',
id,
assets = inlineBase64AssetStore,
users = defaultUserStore,
onMount,
collaboration,
themes,
...rest
}: TLStoreOptions = {}): TLStore {
const resolvedThemes = resolveThemes(themes)
registerColorsFromThemes(resolvedThemes)
registerFontsFromThemes(resolvedThemes)
const schema = createTLSchemaFromUtils(rest)
const store = new Store({
id,
schema,
initialData,
props: {
defaultName,
assets: {
upload: assets.upload,
resolve: assets.resolve ?? defaultAssetResolve,
remove: assets.remove ?? (() => Promise.resolve()),
},
users: {
currentUser: users.currentUser,
resolve:
users.resolve ??
createCachedUserResolve((userId) => {
const current = users.currentUser.get()
return current && current.id === createUserId(userId) ? current : null
}),
},
onMount: (editor) => {
assert(editor instanceof Editor)
return onMount?.(editor)
},
collaboration,
},
})
if (rest.snapshot) {
if (initialData) throw new Error('Cannot provide both initialData and snapshot')
loadSnapshot(store, rest.snapshot, { forceOverwriteSessionState: true })
}
return store
}
function utilsToMap<T extends SchemaPropsInfo & { type: string }>(utils: T[]) {
return Object.fromEntries(
utils.map((s): [string, SchemaPropsInfo] => [
s.type,
{
props: s.props,
migrations: s.migrations,
},
])
)
}

View File

@@ -0,0 +1,19 @@
import { TLAssetUtilConstructor } from '../editor/assets/AssetUtil'
/** @public */
export type TLAnyAssetUtilConstructor = TLAssetUtilConstructor<any>
export function checkAssets(customAssets: readonly TLAnyAssetUtilConstructor[]) {
const assets = [] as TLAnyAssetUtilConstructor[]
const addedCustomAssetTypes = new Set<string>()
for (const customAsset of customAssets) {
if (addedCustomAssetTypes.has(customAsset.type)) {
throw new Error(`Asset type "${customAsset.type}" is defined more than once`)
}
assets.push(customAsset)
addedCustomAssetTypes.add(customAsset.type)
}
return assets
}

View File

@@ -0,0 +1,19 @@
import { TLBindingUtilConstructor } from '../editor/bindings/BindingUtil'
/** @public */
export type TLAnyBindingUtilConstructor = TLBindingUtilConstructor<any>
export function checkBindings(customBindings: readonly TLAnyBindingUtilConstructor[]) {
const bindings = [] as TLAnyBindingUtilConstructor[]
const addedCustomBindingTypes = new Set<string>()
for (const customBinding of customBindings) {
if (addedCustomBindingTypes.has(customBinding.type)) {
throw new Error(`Binding type "${customBinding.type}" is defined more than once`)
}
bindings.push(customBinding)
addedCustomBindingTypes.add(customBinding.type)
}
return bindings
}

View File

@@ -0,0 +1,33 @@
import { GroupShapeUtil } from '../editor/shapes/group/GroupShapeUtil'
import { TLShapeUtilConstructor } from '../editor/shapes/ShapeUtil'
/** @public */
export type TLAnyShapeUtilConstructor = TLShapeUtilConstructor<any>
/** @public */
export const coreShapes = [
// created by grouping interactions, probably the corest core shape that we have
GroupShapeUtil,
] as const
const coreShapeTypes = new Set<string>(coreShapes.map((s) => s.type))
export function checkShapesAndAddCore(customShapes: readonly TLAnyShapeUtilConstructor[]) {
const shapes = [...coreShapes] as TLAnyShapeUtilConstructor[]
const addedCustomShapeTypes = new Set<string>()
for (const customShape of customShapes) {
if (coreShapeTypes.has(customShape.type)) {
throw new Error(
`Shape type "${customShape.type}" is a core shapes type and cannot be overridden`
)
}
if (addedCustomShapeTypes.has(customShape.type)) {
throw new Error(`Shape type "${customShape.type}" is defined more than once`)
}
shapes.push(customShape)
addedCustomShapeTypes.add(customShape.type)
}
return shapes
}

View File

@@ -0,0 +1,41 @@
import { TLCameraOptions } from './editor/types/misc-types'
import { EASINGS } from './primitives/easings'
/** @internal */
export const DEFAULT_CAMERA_OPTIONS: TLCameraOptions = {
isLocked: false,
wheelBehavior: 'pan',
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [0.05, 0.1, 0.25, 0.5, 1, 2, 4, 8],
}
/** @internal */
export const DEFAULT_ANIMATION_OPTIONS = {
duration: 0,
easing: EASINGS.easeInOutCubic,
}
/**
* Negative pointer ids are reserved for internal use.
*
* @internal */
export const INTERNAL_POINTER_IDS = {
CAMERA_MOVE: -10,
} as const
/**
* Per-tick tuning constants (`cameraSlideFriction`, `edgeScrollSpeed`) assume a 60 Hz tick. Scaling
* motion by `elapsed / FRAME_MS_60HZ` keeps that feel on displays with other refresh rates.
*
* @internal
*/
export const FRAME_MS_60HZ = 1000 / 60
/** @public */
export const SIDES = ['top', 'right', 'bottom', 'left'] as const
export const LEFT_MOUSE_BUTTON = 0
export const RIGHT_MOUSE_BUTTON = 2
export const MIDDLE_MOUSE_BUTTON = 1
export const STYLUS_ERASER_BUTTON = 5

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,85 @@
import { LegacyMigrations, MigrationSequence } from '@tldraw/store'
import {
RecordProps,
TLAsset,
TLAssetId,
TLPropsMigrations,
TLUnknownAsset,
} from '@tldraw/tlschema'
import type { Editor } from '../Editor'
/** @public */
export interface TLAssetUtilConstructor<
T extends TLAsset = TLAsset,
U extends AssetUtil<T> = AssetUtil<T>,
> {
new (editor: Editor): U
type: T['type']
props?: RecordProps<T>
migrations?: LegacyMigrations | TLPropsMigrations | MigrationSequence
}
/**
* Abstract base class for defining asset-type-specific behavior.
*
* Each asset type (image, video, bookmark, etc.) has a corresponding AssetUtil that handles
* type-specific operations like determining supported MIME types and creating assets from files.
*
* @public
*/
export abstract class AssetUtil<Asset extends TLAsset = TLAsset> {
/** Configure this asset util's {@link AssetUtil.options | `options`}. */
static configure<T extends TLAssetUtilConstructor<any, any>>(
this: T,
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
): T {
// @ts-expect-error -- typescript has no idea what's going on here but it's fine
return class extends this {
// @ts-expect-error
options = { ...this.options, ...options }
}
}
constructor(public editor: Editor) {}
/**
* Options for this asset util. Override this to provide customization options for your asset.
* Use {@link AssetUtil.configure} to customize existing asset utils.
*/
options = {}
static props?: RecordProps<TLUnknownAsset>
static migrations?: LegacyMigrations | TLPropsMigrations | MigrationSequence
/**
* The type of the asset util, which should match the asset's type.
*/
static type: string
/**
* Get the default props for an asset of this type.
*/
abstract getDefaultProps(): Asset['props']
/**
* Get the MIME types that this asset type supports.
* Return an empty array if this asset type doesn't support files (e.g. bookmarks).
*/
getSupportedMimeTypes(): readonly string[] {
return []
}
/**
* Check whether this asset type accepts a given MIME type.
*/
acceptsMimeType(mimeType: string): boolean {
return this.getSupportedMimeTypes().includes(mimeType)
}
/**
* Create an asset from a file. Return null if this asset type can't handle the file.
*/
async getAssetFromFile(_file: File, _assetId: TLAssetId): Promise<Asset | null> {
return null
}
}

View File

@@ -0,0 +1,278 @@
import {
RecordProps,
TLBinding,
TLPropsMigrations,
TLShape,
TLUnknownBinding,
} from '@tldraw/tlschema'
import type { Editor } from '../Editor'
/** @public */
export interface TLBindingUtilConstructor<
T extends TLBinding,
U extends BindingUtil<T> = BindingUtil<T>,
> {
new (editor: Editor): U
type: T['type']
/** Validations for this binding's props. */
props?: RecordProps<T>
/** Migrations for this binding's props. */
migrations?: TLPropsMigrations
}
/**
* Options passed to {@link BindingUtil.onBeforeCreate} and {@link BindingUtil.onAfterCreate},
* describing a the creating a binding.
*
* @public
*/
export interface BindingOnCreateOptions<Binding extends TLBinding = TLBinding> {
/** The binding being created. */
binding: Binding
}
/**
* Options passed to {@link BindingUtil.onBeforeChange} and {@link BindingUtil.onAfterChange},
* describing the data associated with a binding being changed.
*
* @public
*/
export interface BindingOnChangeOptions<Binding extends TLBinding = TLBinding> {
/** The binding record before the change is made. */
bindingBefore: Binding
/** The binding record after the change is made. */
bindingAfter: Binding
}
/**
* Options passed to {@link BindingUtil.onBeforeDelete} and {@link BindingUtil.onAfterDelete},
* describing a binding being deleted.
*
* @public
*/
export interface BindingOnDeleteOptions<Binding extends TLBinding = TLBinding> {
/** The binding being deleted. */
binding: Binding
}
/**
* Options passed to {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape}, describing a bound shape being changed.
*
* @public
*/
export interface BindingOnShapeChangeOptions<Binding extends TLBinding = TLBinding> {
/** The binding record linking these two shapes. */
binding: Binding
/** The shape record before the change is made. */
shapeBefore: TLShape
/** The shape record after the change is made. */
shapeAfter: TLShape
/**
* Why did this shape change?
* - 'self': the shape itself changed
* - 'ancestry': the ancestry of the shape changed, but the shape itself may not have done
*/
reason: 'self' | 'ancestry'
}
/**
* Options passed to {@link BindingUtil.onBeforeIsolateFromShape} and
* {@link BindingUtil.onBeforeIsolateToShape}, describing a shape that is about to be isolated from
* the one that it's bound to.
*
* Isolation happens whenever two bound shapes are separated. For example
* 1. One is deleted, but the other is not.
* 1. One is copied, but the other is not.
* 1. One is duplicated, but the other is not.
*
* In each of these cases, if the remaining shape depends on the binding for its rendering, it may
* now be in an inconsistent state. For example, tldraw's arrow shape depends on the binding to know
* where the end of the arrow is. If we removed the binding without doing anything else, the arrow
* would suddenly be pointing to the wrong location. Instead, when the shape the arrow is pointing
* to is deleted, or the arrow is copied/duplicated, we use an isolation callback. The callback
* updates the arrow based on the binding that's about to be removed, so it doesn't end up pointing
* to the wrong place.
*
* For this style of consistency update, use isolation callbacks. For actions specific to deletion
* (like deleting a sticker when the shape it's bound to is removed), use the delete callbacks
* ({@link BindingUtil.onBeforeDeleteFromShape} and {@link BindingUtil.onBeforeDeleteToShape})
* instead.
*
* @public
*/
export interface BindingOnShapeIsolateOptions<Binding extends TLBinding = TLBinding> {
/** The binding record that refers to the shape in question. */
binding: Binding
/**
* The shape being removed. For deletion, this is the deleted shape. For copy/duplicate, this is
* the shape that _isn't_ being copied/duplicated and is getting left behind.
*/
removedShape: TLShape
}
/**
* Options passed to {@link BindingUtil.onBeforeDeleteFromShape} and
* {@link BindingUtil.onBeforeDeleteToShape}, describing a bound shape that is about to be deleted.
*
* See {@link BindingOnShapeIsolateOptions} for discussion on when to use the delete vs. the isolate
* callbacks.
*
* @public
*/
export interface BindingOnShapeDeleteOptions<Binding extends TLBinding = TLBinding> {
/** The binding record that refers to the shape in question. */
binding: Binding
/** The shape that is about to be deleted. */
shape: TLShape
}
/** @public */
export abstract class BindingUtil<Binding extends TLBinding = TLBinding> {
constructor(public editor: Editor) {}
static props?: RecordProps<TLUnknownBinding>
static migrations?: TLPropsMigrations
/**
* The type of the binding util, which should match the binding's type.
*
* @public
*/
static type: string
/**
* Get the default props for a binding.
*
* @public
*/
abstract getDefaultProps(): Partial<Binding['props']>
/**
* Called whenever a store operation involving this binding type has completed. This is useful
* for working with networks of related bindings that may need to update together.
*
* @example
* ```ts
* class MyBindingUtil extends BindingUtil<MyBinding> {
* changedBindingIds = new Set<TLBindingId>()
*
* onOperationComplete() {
* doSomethingWithChangedBindings(this.changedBindingIds)
* this.changedBindingIds.clear()
* }
*
* onAfterChange({ bindingAfter }: BindingOnChangeOptions<MyBinding>) {
* this.changedBindingIds.add(bindingAfter.id)
* }
* }
* ```
*
* @public
*/
onOperationComplete?(): void
/**
* Called when a binding is about to be created. See {@link BindingOnCreateOptions} for details.
*
* You can optionally return a new binding to replace the one being created - for example, to
* set different initial props.
*
* @public
*/
onBeforeCreate?(options: BindingOnCreateOptions<Binding>): Binding | void
/**
* Called after a binding has been created. See {@link BindingOnCreateOptions} for details.
*
* @public
*/
onAfterCreate?(options: BindingOnCreateOptions<Binding>): void
/**
* Called when a binding is about to be changed. See {@link BindingOnChangeOptions} for details.
*
* Note that this only fires when the binding record is changing, not when the shapes
* associated change. Use {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape} for that.
*
* You can optionally return a new binding to replace the one being changed - for example, to
* enforce constraints on the binding's props.
*
* @public
*/
onBeforeChange?(options: BindingOnChangeOptions<Binding>): Binding | void
/**
* Called after a binding has been changed. See {@link BindingOnChangeOptions} for details.
*
* Note that this only fires when the binding record is changing, not when the shapes
* associated change. Use {@link BindingUtil.onAfterChangeFromShape} and
* {@link BindingUtil.onAfterChangeToShape} for that.
*
* @public
*/
onAfterChange?(options: BindingOnChangeOptions<Binding>): void
/**
* Called when a binding is about to be deleted. See {@link BindingOnDeleteOptions} for details.
*
* @public
*/
onBeforeDelete?(options: BindingOnDeleteOptions<Binding>): void
/**
* Called after a binding has been deleted. See {@link BindingOnDeleteOptions} for details.
*
* @public
*/
onAfterDelete?(options: BindingOnDeleteOptions<Binding>): void
/**
* Called after the shape referenced in a binding's `fromId` is changed. Use this to propagate
* any changes to the binding itself or the other shape as needed. See
* {@link BindingOnShapeChangeOptions} for details.
*
* @public
*/
onAfterChangeFromShape?(options: BindingOnShapeChangeOptions<Binding>): void
/**
* Called after the shape referenced in a binding's `toId` is changed. Use this to propagate any
* changes to the binding itself or the other shape as needed. See
* {@link BindingOnShapeChangeOptions} for details.
*
* @public
*/
onAfterChangeToShape?(options: BindingOnShapeChangeOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `fromId` is about to be deleted. Use this
* with care - you may want to use {@link BindingUtil.onBeforeIsolateToShape} instead. See
* {@link BindingOnShapeDeleteOptions} for details.
*
* @public
*/
onBeforeDeleteFromShape?(options: BindingOnShapeDeleteOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `toId` is about to be deleted. Use this
* with care - you may want to use {@link BindingUtil.onBeforeIsolateFromShape} instead. See
* {@link BindingOnShapeDeleteOptions} for details.
*
* @public
*/
onBeforeDeleteToShape?(options: BindingOnShapeDeleteOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `fromId` is about to be isolated from the
* shape referenced in `toId`. See {@link BindingOnShapeIsolateOptions} for discussion on what
* isolation means, and when/how to use this callback.
*/
onBeforeIsolateFromShape?(options: BindingOnShapeIsolateOptions<Binding>): void
/**
* Called before the shape referenced in a binding's `toId` is about to be isolated from the
* shape referenced in `fromId`. See {@link BindingOnShapeIsolateOptions} for discussion on what
* isolation means, and when/how to use this callback.
*/
onBeforeIsolateToShape?(options: BindingOnShapeIsolateOptions<Binding>): void
}

View File

@@ -0,0 +1,110 @@
import { Computed, RESET_VALUE, computed, isUninitialized } from '@tldraw/state'
import { TLBinding, TLShapeId } from '@tldraw/tlschema'
import { objectMapValues } from '@tldraw/utils'
import type { Editor } from '../Editor'
type TLBindingsIndex = Map<TLShapeId, TLBinding[]>
function fromScratch(bindingsQuery: Computed<TLBinding[], unknown>) {
const allBindings = bindingsQuery.get() as TLBinding[]
const shapesToBindings: TLBindingsIndex = new Map()
for (const binding of allBindings) {
const { fromId, toId } = binding
const bindingsForFromShape = shapesToBindings.get(fromId)
if (!bindingsForFromShape) {
shapesToBindings.set(fromId, [binding])
} else {
bindingsForFromShape.push(binding)
}
// A binding from a shape to itself belongs in that shape's array once, not twice
if (toId === fromId) continue
const bindingsForToShape = shapesToBindings.get(toId)
if (!bindingsForToShape) {
shapesToBindings.set(toId, [binding])
} else {
bindingsForToShape.push(binding)
}
}
return shapesToBindings
}
export function bindingsIndex(editor: Editor): Computed<TLBindingsIndex> {
const { store } = editor
const bindingsHistory = store.query.filterHistory('binding')
const bindingsQuery = store.query.records('binding')
return computed<TLBindingsIndex>('arrowBindingsIndex', (_lastValue, lastComputedEpoch) => {
if (isUninitialized(_lastValue)) {
return fromScratch(bindingsQuery)
}
const lastValue = _lastValue
const diff = bindingsHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch(bindingsQuery)
}
let nextValue: TLBindingsIndex | undefined = undefined
function removingBinding(binding: TLBinding) {
nextValue ??= new Map(lastValue)
const prevFrom = nextValue.get(binding.fromId)
const nextFrom = prevFrom?.filter((b) => b.id !== binding.id)
if (!nextFrom?.length) {
nextValue.delete(binding.fromId)
} else {
nextValue.set(binding.fromId, nextFrom)
}
const prevTo = nextValue.get(binding.toId)
const nextTo = prevTo?.filter((b) => b.id !== binding.id)
if (!nextTo?.length) {
nextValue.delete(binding.toId)
} else {
nextValue.set(binding.toId, nextTo)
}
}
function ensureNewArray(shapeId: TLShapeId) {
nextValue ??= new Map(lastValue)
let result = nextValue.get(shapeId)
if (!result) {
result = []
nextValue.set(shapeId, result)
} else if (result === lastValue.get(shapeId)) {
result = result.slice(0)
nextValue.set(shapeId, result)
}
return result
}
function addBinding(binding: TLBinding) {
ensureNewArray(binding.fromId).push(binding)
if (binding.toId === binding.fromId) return
ensureNewArray(binding.toId).push(binding)
}
for (const changes of diff) {
for (const newBinding of objectMapValues(changes.added)) {
addBinding(newBinding)
}
for (const [prev, next] of objectMapValues(changes.updated)) {
removingBinding(prev)
addBinding(next)
}
for (const prev of objectMapValues(changes.removed)) {
removingBinding(prev)
}
}
// TODO: add diff entries if we need them
return nextValue ?? lastValue
})
}

View File

@@ -0,0 +1,72 @@
import { computed, isUninitialized } from '@tldraw/state'
import { TLShapeId } from '@tldraw/tlschema'
import type { Editor } from '../Editor'
import { ShapeUtil } from '../shapes/ShapeUtil'
/**
* Non visible shapes are shapes outside of the viewport page bounds.
*
* @param editor - Instance of the tldraw Editor.
* @returns Incremental derivation of non visible shapes.
*/
export function notVisibleShapes(editor: Editor) {
const emptySet = new Set<TLShapeId>()
const defaultCanCull = ShapeUtil.prototype.canCull
return computed<Set<TLShapeId>>('notVisibleShapes', function (prevValue) {
const allShapeIds = editor.getCurrentPageShapeIds()
const viewportPageBounds = editor.getViewportPageBounds()
const visibleIds = editor.getShapeIdsInsideBounds(viewportPageBounds)
// Fast path: if all shapes are visible, return empty set
if (visibleIds.size === allShapeIds.size) {
if (isUninitialized(prevValue) || prevValue.size > 0) {
return emptySet
}
return prevValue
}
const notVisibleIds = new Set<TLShapeId>()
for (const id of allShapeIds) {
if (visibleIds.has(id)) continue
// Peek at the shape without subscribing — we only need its type to look up the util.
// Type is treated as immutable for a given id, so this is safe.
const peek = editor.store.unsafeGetWithoutCapture(id)
if (!peek) continue
const util = editor.getShapeUtil(peek.type)
// If canCull is the default (always-true), skip per-shape subscription entirely.
// >99% of shapes hit this path in practice.
if (util.canCull === defaultCanCull) {
notVisibleIds.add(id)
continue
}
// Custom canCull — subscribe so prop flips invalidate this derivation.
const shape = editor.getShape(id)
if (!shape) continue
if (!util.canCull(shape)) continue
notVisibleIds.add(id)
}
// First run
if (isUninitialized(prevValue)) {
return notVisibleIds
}
// Reuse prev set when contents are unchanged
if (notVisibleIds.size === prevValue.size) {
let same = true
for (const id of notVisibleIds) {
if (!prevValue.has(id)) {
same = false
break
}
}
if (same) return prevValue
}
return notVisibleIds
})
}

View File

@@ -0,0 +1,123 @@
import { Computed, computed, isUninitialized, RESET_VALUE } from '@tldraw/state'
import { CollectionDiff, RecordsDiff } from '@tldraw/store'
import { isShape, TLParentId, TLRecord, TLShape, TLShapeId, TLStore } from '@tldraw/tlschema'
import { sortByIndex } from '@tldraw/utils'
type ParentShapeIdsToChildShapeIds = Record<TLParentId, TLShapeId[]>
function fromScratch(
shapeIdsQuery: Computed<Set<TLShapeId>, CollectionDiff<TLShapeId>>,
store: TLStore
) {
const result: ParentShapeIdsToChildShapeIds = {}
const shapeIds = shapeIdsQuery.get()
const sortedShapes = Array.from(shapeIds, (id) => store.get(id)!).sort(sortByIndex)
// Populate the result object with an array for each parent.
sortedShapes.forEach((shape) => {
result[shape.parentId] ??= []
result[shape.parentId].push(shape.id)
})
return result
}
export function parentsToChildren(store: TLStore) {
const shapeIdsQuery = store.query.ids<'shape'>('shape')
const shapeHistory = store.query.filterHistory('shape')
return computed<ParentShapeIdsToChildShapeIds>(
'parentsToChildrenWithIndexes',
(lastValue, lastComputedEpoch) => {
if (isUninitialized(lastValue)) {
return fromScratch(shapeIdsQuery, store)
}
const diff = shapeHistory.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch(shapeIdsQuery, store)
}
if (diff.length === 0) return lastValue
let newValue: Record<TLParentId, TLShapeId[]> | null = null
const ensureNewArray = (parentId: TLParentId) => {
if (!newValue) {
newValue = { ...lastValue }
}
if (!newValue[parentId]) {
newValue[parentId] = []
} else if (newValue[parentId] === lastValue[parentId]) {
newValue[parentId] = [...newValue[parentId]!]
}
}
const toSort = new Set<TLShapeId[]>()
let changes: RecordsDiff<TLRecord>
for (let i = 0, n = diff.length; i < n; i++) {
changes = diff[i]
// Iterate through the added shapes, add them to the new value and mark them for sorting
for (const record of Object.values(changes.added)) {
if (!isShape(record)) continue
ensureNewArray(record.parentId)
newValue![record.parentId].push(record.id)
toSort.add(newValue![record.parentId])
}
// Iterate through the updated shapes, add them to their parents in the new value and mark them for sorting
for (const [from, to] of Object.values(changes.updated)) {
if (!isShape(to)) continue
if (!isShape(from)) continue
if (from.parentId !== to.parentId) {
// If the parents have changed, remove the new value from the old parent and add it to the new parent
ensureNewArray(from.parentId)
ensureNewArray(to.parentId)
newValue![from.parentId].splice(newValue![from.parentId].indexOf(to.id), 1)
newValue![to.parentId].push(to.id)
toSort.add(newValue![to.parentId])
} else if (from.index !== to.index) {
// If the parent is the same but the index has changed (e.g. if they've been reordered), update the parent's array at the new index
ensureNewArray(to.parentId)
const idx = newValue![to.parentId].indexOf(to.id)
newValue![to.parentId][idx] = to.id
toSort.add(newValue![to.parentId])
}
}
// Iterate through the removed shapes, remove them from their parents in new value
for (const record of Object.values(changes.removed)) {
if (!isShape(record)) continue
ensureNewArray(record.parentId)
newValue![record.parentId].splice(newValue![record.parentId].indexOf(record.id), 1)
}
}
// Sort the arrays that have been marked for sorting (in-place to avoid intermediate arrays)
for (const arr of toSort) {
// Filter out any deleted shapes in-place
let writeIdx = 0
for (let readIdx = 0; readIdx < arr.length; readIdx++) {
if (store.get(arr[readIdx])) {
arr[writeIdx++] = arr[readIdx]
}
}
arr.length = writeIdx
// Sort in-place by index
arr.sort((a, b) => {
const shapeA = store.get(a) as TLShape
const shapeB = store.get(b) as TLShape
return sortByIndex(shapeA, shapeB)
})
}
return newValue ?? lastValue
}
)
}

View File

@@ -0,0 +1,102 @@
import { computed, isUninitialized, RESET_VALUE, withDiff } from '@tldraw/state'
import { IncrementalSetConstructor } from '@tldraw/store'
import {
isPageId,
isShape,
isShapeId,
TLPageId,
TLShape,
TLShapeId,
TLStore,
} from '@tldraw/tlschema'
/**
* Get whether a shape is in the current page.
*
* @param store - The tldraw store.
* @param pageId - The id of the page to check.
* @param shape - The the shape to check.
*/
const isShapeInPage = (store: TLStore, pageId: TLPageId, shape: TLShape): boolean => {
while (!isPageId(shape.parentId)) {
const parent = store.get(shape.parentId)
if (!parent) return false
shape = parent
}
return shape.parentId === pageId
}
/**
* A derivation that returns a list of shape ids in the current page.
*
* @param store - The tldraw store.
* @param getCurrentPageId - A function that returns the current page id.
*/
export function deriveShapeIdsInCurrentPage(store: TLStore, getCurrentPageId: () => TLPageId) {
const shapesIndex = store.query.ids('shape')
let lastPageId: null | TLPageId = null
function fromScratch() {
const currentPageId = getCurrentPageId()
lastPageId = currentPageId
const result = new Set<TLShapeId>()
for (const id of shapesIndex.get()) {
if (isShapeInPage(store, currentPageId, store.get(id)!)) {
result.add(id)
}
}
return result
}
return computed<Set<TLShapeId>>('_shapeIdsInCurrentPage', (prevValue, lastComputedEpoch) => {
if (isUninitialized(prevValue)) {
return fromScratch()
}
const currentPageId = getCurrentPageId()
if (currentPageId !== lastPageId) {
return fromScratch()
}
const diff = store.history.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) {
return fromScratch()
}
const builder = new IncrementalSetConstructor<TLShapeId>(
prevValue
) as IncrementalSetConstructor<TLShapeId>
for (const changes of diff) {
for (const record of Object.values(changes.added)) {
if (isShape(record) && isShapeInPage(store, currentPageId, record)) {
builder.add(record.id)
}
}
for (const [_from, to] of Object.values(changes.updated)) {
if (isShape(to)) {
if (isShapeInPage(store, currentPageId, to)) {
builder.add(to.id)
} else {
builder.remove(to.id)
}
}
}
for (const id of Object.keys(changes.removed)) {
if (isShapeId(id)) {
builder.remove(id)
}
}
}
const result = builder.get()
if (!result) {
return prevValue
}
return withDiff(result.value, result.diff)
})
}

View File

@@ -0,0 +1,410 @@
import { Mocked, vi } from 'vitest'
import { Vec } from '../../../primitives/Vec'
import { Editor } from '../../Editor'
import { TLClickEventInfo, TLPointerEventInfo } from '../../types/event-types'
import { ClickManager } from './ClickManager'
// Mock the Editor class
vi.mock('../../Editor')
describe('ClickManager', () => {
let editor: Mocked<Editor>
let clickManager: ClickManager
let mockTimers: any
const createPointerEvent = (
name: 'pointer_down' | 'pointer_up' | 'pointer_move',
point: { x: number; y: number } = { x: 0, y: 0 }
): TLPointerEventInfo => ({
type: 'pointer',
name,
point,
pointerId: 1,
button: 0,
isPen: false,
target: 'canvas',
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
accelKey: false,
})
beforeEach(() => {
vi.useFakeTimers()
mockTimers = {
setTimeout: vi.fn((fn, delay) => setTimeout(fn, delay)),
clearTimeout: vi.fn((id) => clearTimeout(id)),
}
const mockCurrentScreenPoint = new Vec(0, 0)
editor = {
timers: mockTimers,
dispatch: vi.fn(),
options: {
doubleClickDurationMs: 300,
multiClickDurationMs: 300,
dragDistanceSquared: 16,
coarseDragDistanceSquared: 36,
},
inputs: {
getCurrentScreenPoint: vi.fn(() => mockCurrentScreenPoint),
currentScreenPoint: mockCurrentScreenPoint, // deprecated getter for compatibility
},
getInstanceState: vi.fn(() => ({
isCoarsePointer: false,
})),
} as any
clickManager = new ClickManager(editor)
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
describe('constructor and initial state', () => {
it('should initialize with idle state', () => {
expect(clickManager.clickState).toBe('idle')
})
it('should store reference to editor', () => {
expect(clickManager.editor).toBe(editor)
})
it('should initialize lastPointerInfo as empty object', () => {
expect(clickManager.lastPointerInfo).toEqual({})
})
})
describe('single click behavior', () => {
it('should handle pointer_down in idle state', () => {
const pointerEvent = createPointerEvent('pointer_down', { x: 100, y: 100 })
const result = clickManager.handlePointerEvent(pointerEvent)
expect(result).toBe(pointerEvent)
expect(clickManager.clickState).toBe('pendingDouble')
expect(clickManager.lastPointerInfo).toBe(pointerEvent)
})
it('should handle pointer_up without generating click events in pending state', () => {
const downEvent = createPointerEvent('pointer_down', { x: 100, y: 100 })
const upEvent = createPointerEvent('pointer_up', { x: 100, y: 100 })
clickManager.handlePointerEvent(downEvent)
clickManager.handlePointerEvent(upEvent)
expect(clickManager.clickState).toBe('pendingDouble')
})
it('should return to idle state after timeout in pendingDouble', () => {
const pointerEvent = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerEvent)
expect(clickManager.clickState).toBe('pendingDouble')
vi.advanceTimersByTime(350)
expect(clickManager.clickState).toBe('idle')
})
})
describe('double click detection', () => {
it('should detect double click on second pointer_down', () => {
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(firstDown)
const result = clickManager.handlePointerEvent(secondDown) as TLClickEventInfo
expect(result.type).toBe('click')
expect(result.name).toBe('double_click')
expect(result.phase).toBe('down')
expect(clickManager.clickState).toBe('pendingOverflow')
})
it('should generate double_click up event on pointer_up after double_click down', () => {
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const secondUp = createPointerEvent('pointer_up', { x: 100, y: 100 })
clickManager.handlePointerEvent(firstDown)
clickManager.handlePointerEvent(secondDown)
const result = clickManager.handlePointerEvent(secondUp) as TLClickEventInfo
expect(result.type).toBe('click')
expect(result.name).toBe('double_click')
expect(result.phase).toBe('up')
})
it('should dispatch double_click settle-down event after timeout in pendingOverflow (pointer held)', () => {
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(firstDown)
clickManager.handlePointerEvent(secondDown)
// no pointer_up between or after — pointer is still down at settle time
vi.advanceTimersByTime(350)
expect(editor.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
name: 'double_click',
phase: 'settle-down',
})
)
expect(clickManager.clickState).toBe('idle')
})
it('should dispatch double_click settle-up event after timeout in pendingOverflow (pointer released)', () => {
const down = createPointerEvent('pointer_down', { x: 100, y: 100 })
const up = createPointerEvent('pointer_up', { x: 100, y: 100 })
clickManager.handlePointerEvent(down)
clickManager.handlePointerEvent(up)
clickManager.handlePointerEvent(down)
clickManager.handlePointerEvent(up)
vi.advanceTimersByTime(350)
expect(editor.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
type: 'click',
name: 'double_click',
phase: 'settle-up',
})
)
expect(clickManager.clickState).toBe('idle')
})
})
describe('overflow click handling', () => {
it('should enter overflow on the third pointer_down without emitting another click', () => {
const firstDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const secondDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const thirdDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(firstDown)
clickManager.handlePointerEvent(secondDown)
const result = clickManager.handlePointerEvent(thirdDown)
expect(result).toBe(thirdDown)
expect(clickManager.clickState).toBe('overflow')
})
it('should keep overflow active on further clicks', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerDown) // first
clickManager.handlePointerEvent(pointerDown) // second (double_click)
clickManager.handlePointerEvent(pointerDown) // third (overflow)
const result = clickManager.handlePointerEvent(pointerDown) // fourth
expect(result).toBe(pointerDown)
expect(clickManager.clickState).toBe('overflow')
})
it('should not emit double_click up events while in overflow', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerDown) // first
clickManager.handlePointerEvent(pointerDown) // second
clickManager.handlePointerEvent(pointerDown) // third
const result = clickManager.handlePointerEvent(pointerUp)
expect(result).toBe(pointerUp)
expect(clickManager.clickState).toBe('overflow')
})
it('should return to idle after overflow timeout without dispatching a settle event', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerDown) // first
clickManager.handlePointerEvent(pointerDown) // second
clickManager.handlePointerEvent(pointerDown) // third -> overflow
vi.advanceTimersByTime(350)
expect(editor.dispatch).not.toHaveBeenCalled()
expect(clickManager.clickState).toBe('idle')
})
})
describe('timeout behavior and settle events', () => {
it('should track press/release state across the pending window (settle-down then release → settle-up)', () => {
const down = createPointerEvent('pointer_down', { x: 100, y: 100 })
const up = createPointerEvent('pointer_up', { x: 100, y: 100 })
clickManager.handlePointerEvent(down)
clickManager.handlePointerEvent(up)
clickManager.handlePointerEvent(down) // second press — pointer is down...
clickManager.handlePointerEvent(up) // ...but released before timeout
vi.advanceTimersByTime(350)
expect(editor.dispatch).toHaveBeenCalledWith(
expect.objectContaining({
name: 'double_click',
phase: 'settle-up',
})
)
})
it('should use different timeout durations for different states', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
// First click - should use doubleClickDurationMs
clickManager.handlePointerEvent(pointerDown)
expect(mockTimers.setTimeout).toHaveBeenCalledWith(
expect.any(Function),
editor.options.doubleClickDurationMs
)
vi.clearAllMocks()
// Second click - should use multiClickDurationMs
clickManager.handlePointerEvent(pointerDown)
expect(mockTimers.setTimeout).toHaveBeenCalledWith(
expect.any(Function),
editor.options.multiClickDurationMs
)
})
})
describe('distance-based click cancellation', () => {
it('should reset to idle if clicks are too far apart', () => {
const firstDown = createPointerEvent('pointer_down', { x: 0, y: 0 })
const secondDown = createPointerEvent('pointer_down', { x: 50, y: 50 }) // > 40px distance
clickManager.handlePointerEvent(firstDown)
expect(clickManager.clickState).toBe('pendingDouble')
const result = clickManager.handlePointerEvent(secondDown)
expect(result).toBe(secondDown)
expect(clickManager.clickState).toBe('pendingDouble') // Reset and started new sequence
})
it('should continue sequence if clicks are close enough', () => {
const firstDown = createPointerEvent('pointer_down', { x: 0, y: 0 })
const secondDown = createPointerEvent('pointer_down', { x: 5, y: 5 }) // < 40px distance
clickManager.handlePointerEvent(firstDown)
const result = clickManager.handlePointerEvent(secondDown) as TLClickEventInfo
expect(result.type).toBe('click')
expect(result.name).toBe('double_click')
expect(clickManager.clickState).toBe('pendingOverflow')
})
})
describe('pointer move cancellation behavior', () => {
it('should cancel click sequence on significant pointer move', () => {
const downEvent = createPointerEvent('pointer_down', { x: 0, y: 0 })
const moveEvent = createPointerEvent('pointer_move', { x: 10, y: 10 })
const currentScreenPoint = editor.inputs.getCurrentScreenPoint()
currentScreenPoint.x = 10
currentScreenPoint.y = 10
clickManager.handlePointerEvent(downEvent)
expect(clickManager.clickState).toBe('pendingDouble')
const result = clickManager.handlePointerEvent(moveEvent)
expect(result).toBe(moveEvent)
expect(clickManager.clickState).toBe('idle')
})
it('should use coarse drag distance for coarse pointers', () => {
editor.getInstanceState.mockReturnValue({
...editor.getInstanceState(),
isCoarsePointer: true,
})
const downEvent = createPointerEvent('pointer_down', { x: 0, y: 0 })
const moveEvent1 = createPointerEvent('pointer_move', { x: 1, y: 1 })
const moveEvent2 = createPointerEvent('pointer_move', { x: 5, y: 5 }) // 50
clickManager.handlePointerEvent(downEvent)
expect(clickManager.clickState).toBe('pendingDouble')
// Should not cancel for coarse pointer with small movement
const currentScreenPoint1 = editor.inputs.getCurrentScreenPoint()
currentScreenPoint1.x = 1
currentScreenPoint1.y = 1
clickManager.handlePointerEvent(moveEvent1)
expect(clickManager.clickState).toBe('pendingDouble')
const currentScreenPoint2 = editor.inputs.getCurrentScreenPoint()
currentScreenPoint2.x = 5
currentScreenPoint2.y = 5
clickManager.handlePointerEvent(moveEvent2)
expect(clickManager.clickState).toBe('idle')
})
it('should not cancel in idle state', () => {
const moveEvent = createPointerEvent('pointer_move', { x: 100, y: 100 })
const currentScreenPoint = editor.inputs.getCurrentScreenPoint()
currentScreenPoint.x = 100
currentScreenPoint.y = 100
clickManager.handlePointerEvent(moveEvent)
expect(clickManager.clickState).toBe('idle')
})
})
describe('cancelDoubleClickTimeout method', () => {
it('should clear timeout and reset state to idle', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerDown)
expect(clickManager.clickState).toBe('pendingDouble')
clickManager.cancelDoubleClickTimeout()
expect(clickManager.clickState).toBe('idle')
})
it('should prevent timeout callback from executing after cancellation', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
clickManager.handlePointerEvent(pointerDown)
clickManager.handlePointerEvent(pointerDown) // double click
expect(clickManager.clickState).toBe('pendingOverflow')
clickManager.cancelDoubleClickTimeout()
// Advance time - should not dispatch settle event
vi.advanceTimersByTime(350)
expect(editor.dispatch).not.toHaveBeenCalled()
expect(clickManager.clickState).toBe('idle')
})
})
describe('edge cases', () => {
it('should handle overflow state correctly', () => {
const pointerDown = createPointerEvent('pointer_down', { x: 100, y: 100 })
const pointerUp = createPointerEvent('pointer_up', { x: 100, y: 100 })
// Get to overflow state
clickManager.handlePointerEvent(pointerDown) // 1
clickManager.handlePointerEvent(pointerDown) // 2
clickManager.handlePointerEvent(pointerDown) // 3 -> overflow
expect(clickManager.clickState).toBe('overflow')
// pointer_up in overflow should just return the event
clickManager.handlePointerEvent(pointerUp)
})
})
})

View File

@@ -0,0 +1,176 @@
import { bind, uniqueId } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec'
import type { Editor } from '../../Editor'
import { TLClickEventInfo, TLPointerEventInfo } from '../../types/event-types'
/** @public */
export type TLClickState = 'idle' | 'pendingDouble' | 'pendingOverflow' | 'overflow'
const MAX_CLICK_DISTANCE = 40
/** @public */
export class ClickManager {
constructor(public editor: Editor) {}
private _clickId = ''
private _clickTimeout?: any
private _clickScreenPoint?: Vec
private _previousScreenPoint?: Vec
private _isPressingWhilePending = false
@bind
_getClickTimeout(state: TLClickState, id = uniqueId()) {
this._clickId = id
this.editor.timers.clearTimeout(this._clickTimeout)
this._clickTimeout = this.editor.timers.setTimeout(
() => {
if (this._clickState === state && this._clickId === id) {
switch (this._clickState) {
case 'pendingOverflow': {
this.editor.dispatch({
...this.lastPointerInfo,
type: 'click',
name: 'double_click',
phase: this._isPressingWhilePending ? 'settle-down' : 'settle-up',
})
break
}
default: {
// noop
}
}
this._clickState = 'idle'
}
},
state === 'idle' || state === 'pendingDouble'
? this.editor.options.doubleClickDurationMs
: this.editor.options.multiClickDurationMs
)
}
/**
* The current click state.
*
* @internal
*/
private _clickState?: TLClickState = 'idle'
/**
* The current click state.
*
* @public
*/
// eslint-disable-next-line tldraw/no-setter-getter
get clickState() {
return this._clickState
}
lastPointerInfo = {} as TLPointerEventInfo
handlePointerEvent(info: TLPointerEventInfo): TLPointerEventInfo | TLClickEventInfo {
switch (info.name) {
case 'pointer_down': {
if (!this._clickState) return info
this._clickScreenPoint = Vec.From(info.point)
this._isPressingWhilePending = true
if (
this._previousScreenPoint &&
Vec.Dist2(this._previousScreenPoint, this._clickScreenPoint) > MAX_CLICK_DISTANCE ** 2
) {
this._clickState = 'idle'
}
this._previousScreenPoint = this._clickScreenPoint
this.lastPointerInfo = info
switch (this._clickState) {
case 'pendingDouble': {
this._clickState = 'pendingOverflow'
this._getClickTimeout(this._clickState)
return {
...info,
type: 'click',
name: 'double_click',
phase: 'down',
}
}
case 'idle': {
this._clickState = 'pendingDouble'
break
}
case 'pendingOverflow': {
this._clickState = 'overflow'
break
}
default: {
// overflow
}
}
this._getClickTimeout(this._clickState)
return info
}
case 'pointer_up': {
if (!this._clickState) return info
this._clickScreenPoint = Vec.From(info.point)
this._isPressingWhilePending = false
switch (this._clickState) {
case 'pendingOverflow': {
return {
...this.lastPointerInfo,
type: 'click',
name: 'double_click',
phase: 'up',
}
}
default: {
// idle, pendingDouble, overflow
}
}
return info
}
case 'pointer_move': {
// Compare in client coordinates like _clickScreenPoint; the container-relative
// inputs screen point would add the container offset and cancel on any movement.
if (
this._clickState !== 'idle' &&
this._clickScreenPoint &&
Vec.Dist2(this._clickScreenPoint, info.point) >
(this.editor.getInstanceState().isCoarsePointer
? this.editor.options.coarseDragDistanceSquared
: this.editor.options.dragDistanceSquared)
) {
this.cancelDoubleClickTimeout()
}
return info
}
}
return info
}
/**
* Cancel the double click timeout.
*
* @internal
*/
@bind
cancelDoubleClickTimeout() {
this.editor.timers.clearTimeout(this._clickTimeout)
this._clickTimeout = undefined
this._clickState = 'idle'
// when a double click is cancelled, we are no longer pending any further
// clicks, so we set this to false even if the user is still pressing
this._isPressingWhilePending = false
}
}

View File

@@ -0,0 +1,204 @@
import {
PageRecordType,
createUserId,
type TLInstancePresence,
type TLUserId,
} from '@tldraw/tlschema'
import { vi } from 'vitest'
import { createTLStore } from '../../../config/createTLStore'
import type { Editor } from '../../Editor'
import { CollaboratorsManager } from './CollaboratorsManager'
const currentPageId = PageRecordType.createId('page')
function createPresence(userId: TLUserId): TLInstancePresence {
return {
typeName: 'instance_presence',
id: `instance_presence:${userId}` as TLInstancePresence['id'],
userId,
userName: userId,
lastActivityTimestamp: Date.now(),
color: '#000000',
camera: null,
selectedShapeIds: [],
currentPageId,
brush: null,
scribbles: [],
screenBounds: null,
followingUserId: null,
cursor: null,
chatMessage: '',
meta: {},
}
}
function createEditor(presences: TLInstancePresence[] = []) {
const setInterval = vi.fn((_fn: () => void, _ms: number) => 123)
const getInstanceState = vi.fn(() => ({
followingUserId: null,
highlightedUserIds: [],
}))
const userGetId = vi.fn(() => 'current-user')
const store = createTLStore()
store.put(presences)
const editor = {
options: {
collaboratorCheckIntervalMs: 1000,
collaboratorIdleTimeoutMs: 3000,
collaboratorInactiveTimeoutMs: 5000,
},
timers: {
setInterval,
},
user: {
getId: userGetId,
getRecordId: () => createUserId(userGetId()),
},
store,
getInstanceState,
getCurrentPageId: vi.fn(() => currentPageId),
} as unknown as Editor
return { editor, setInterval, getInstanceState, userGetId }
}
describe(CollaboratorsManager, () => {
afterEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
})
it('starts the visibility clock on the first visible collaborators read', () => {
const { editor, setInterval } = createEditor()
const manager = new CollaboratorsManager(editor)
expect(setInterval).not.toHaveBeenCalled()
expect(manager.getVisibleCollaborators()).toEqual([])
expect(setInterval).toHaveBeenCalledTimes(1)
expect(setInterval).toHaveBeenCalledWith(expect.any(Function), 1000)
})
it('only starts the visibility clock once across repeated reads', () => {
const { editor, setInterval } = createEditor()
const manager = new CollaboratorsManager(editor)
manager.getVisibleCollaborators()
manager.getVisibleCollaborators()
manager.getVisibleCollaborators()
expect(setInterval).toHaveBeenCalledTimes(1)
})
it("excludes the local user's own other sessions", () => {
const ownSession = createPresence(createUserId('current-user'))
const peer = createPresence(createUserId('peer'))
const { editor } = createEditor([ownSession, peer])
const manager = new CollaboratorsManager(editor)
expect(manager.getCollaborators()).toEqual([peer])
})
it('reads instance state once when filtering visible collaborators', () => {
const { editor, getInstanceState } = createEditor([
createPresence(createUserId('user-1')),
createPresence(createUserId('user-2')),
])
const manager = new CollaboratorsManager(editor)
expect(manager.getVisibleCollaborators()).toHaveLength(2)
expect(getInstanceState).toHaveBeenCalledTimes(1)
})
it('hides idle collaborators that are following us', () => {
const presence = createPresence(createUserId('peer'))
presence.lastActivityTimestamp = Date.now() - 4000
presence.followingUserId = createUserId('current-user')
const { editor } = createEditor([presence])
const manager = new CollaboratorsManager(editor)
expect(manager.getVisibleCollaborators()).toEqual([])
})
it('shows idle collaborators that are following us when they have a chat message', () => {
const presence = createPresence(createUserId('peer'))
presence.lastActivityTimestamp = Date.now() - 4000
presence.followingUserId = createUserId('current-user')
presence.chatMessage = 'hi'
const { editor } = createEditor([presence])
const manager = new CollaboratorsManager(editor)
expect(manager.getVisibleCollaborators()).toHaveLength(1)
})
it('shows idle collaborators that are not following us', () => {
const presence = createPresence(createUserId('peer'))
presence.lastActivityTimestamp = Date.now() - 4000
const { editor } = createEditor([presence])
const manager = new CollaboratorsManager(editor)
expect(manager.getVisibleCollaborators()).toHaveLength(1)
})
it('keeps array identity when a presence update leaves the derived lists unchanged', () => {
const peerHere = createPresence(createUserId('peer-here'))
const peerElsewhere = createPresence(createUserId('peer-elsewhere'))
peerElsewhere.currentPageId = PageRecordType.createId('other-page')
const { editor } = createEditor([peerHere, peerElsewhere])
const manager = new CollaboratorsManager(editor)
const onPage = manager.getCollaboratorsOnCurrentPage()
const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
expect(onPage).toEqual([peerHere])
// The off-page peer sends a presence update: the current-page lists are unaffected, so
// they keep the exact same array identity and don't invalidate downstream subscribers.
editor.store.put([{ ...peerElsewhere, chatMessage: 'hello from another page' }])
expect(manager.getCollaboratorsOnCurrentPage()).toBe(onPage)
expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
})
it('keeps array identity across a visibility-clock tick that changes nothing', () => {
vi.useFakeTimers()
const peer = createPresence(createUserId('peer'))
const { editor, setInterval, getInstanceState } = createEditor([peer])
const manager = new CollaboratorsManager(editor)
const visible = manager.getVisibleCollaborators()
const visibleOnPage = manager.getVisibleCollaboratorsOnCurrentPage()
expect(visible).toEqual([peer])
expect(getInstanceState).toHaveBeenCalledTimes(1)
// The clock ticks on a fixed interval whether or not anything changed. Advance far enough
// to move the clock atom, but not far enough to change anyone's activity state.
const tick = setInterval.mock.calls[0][0]
vi.advanceTimersByTime(1000)
tick()
// The tick really did invalidate the query — it re-evaluates and re-reads instance state —
// but the result is element-wise identical, so it keeps the previous array's identity. An
// idle room therefore doesn't re-render the cursor layer once per interval.
const revisited = manager.getVisibleCollaborators()
expect(getInstanceState).toHaveBeenCalledTimes(2)
expect(revisited).toBe(visible)
expect(manager.getVisibleCollaboratorsOnCurrentPage()).toBe(visibleOnPage)
})
it('shows newly-joined collaborators that have not recorded any activity yet', () => {
// A peer who has joined but not moved their pointer broadcasts the default
// `lastActivityTimestamp` of 0. They should still be treated as active so
// they appear in the people menu / face pile. See issue #9017.
const zero = createPresence(createUserId('zero'))
zero.lastActivityTimestamp = 0
const nullish = createPresence(createUserId('nullish'))
nullish.lastActivityTimestamp = null
const { editor } = createEditor([zero, nullish])
const manager = new CollaboratorsManager(editor)
expect(manager.getVisibleCollaborators()).toHaveLength(2)
})
})

View File

@@ -0,0 +1,134 @@
import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
import type { TLInstancePresence } from '@tldraw/tlschema'
import { areArraysShallowEqual } from '@tldraw/utils'
import type { Editor } from '../../Editor'
/**
* Tracks remote peers and exposes the collaborator-related queries used by the
* editor and its overlays. Encapsulates the visibility clock that periodically
* re-evaluates which collaborators should be visible based on activity.
*
* Accessed via {@link Editor.collaborators}.
*
* @public
*/
export class CollaboratorsManager {
constructor(private readonly editor: Editor) {}
private _visibilityClockStarted = false
private _startVisibilityClock() {
if (this._visibilityClockStarted) return
this._visibilityClockStarted = true
// Editor disposes `editor.timers` on its own teardown, so the interval is
// automatically cleared when the editor is disposed.
this.editor.timers.setInterval(() => {
this._visibilityClock.set(Date.now())
}, this.editor.options.collaboratorCheckIntervalMs)
}
/**
* Drives reactive re-evaluation of {@link CollaboratorsManager.getVisibleCollaborators}.
* Ticked on a fixed interval so callers don't need to manage their own activity timers.
*/
private readonly _visibilityClock = atom('collaboratorVisibilityClock', Date.now())
@computed
private _getCollaboratorsQuery() {
return this.editor.store.query.records('instance_presence', () => ({
userId: { neq: this.editor.user.getRecordId() },
}))
}
// These queries all derive fresh arrays with map/filter, so they compare results with
// shallow (element-identity) equality: a presence update that leaves a derived list's
// elements unchanged — e.g. a peer moving on another page — keeps the previous array's
// identity and doesn't invalidate downstream subscribers such as the cursor layer.
/**
* Returns a list of presence records for all peer collaborators.
* This will return the latest presence record for each connected user.
*/
@computed({ isEqual: areArraysShallowEqual })
getCollaborators(): TLInstancePresence[] {
const allPresenceRecords = this._getCollaboratorsQuery().get()
if (!allPresenceRecords.length) return EMPTY_ARRAY
const latestByUserId = new Map<string, TLInstancePresence>()
for (const presence of allPresenceRecords) {
const latest = latestByUserId.get(presence.userId)
if (!latest || (presence.lastActivityTimestamp ?? 0) > (latest.lastActivityTimestamp ?? 0)) {
latestByUserId.set(presence.userId, presence)
}
}
return [...latestByUserId.keys()].sort().map((id) => latestByUserId.get(id)!)
}
/**
* Returns a list of presence records for all peer collaborators on the current page.
* This will return the latest presence record for each connected user.
*/
@computed({ isEqual: areArraysShallowEqual })
getCollaboratorsOnCurrentPage(): TLInstancePresence[] {
const currentPageId = this.editor.getCurrentPageId()
return this.getCollaborators().filter((c) => c.currentPageId === currentPageId)
}
/**
* Returns a list of presence records for peer collaborators who should currently be
* shown in the UI. Filters {@link CollaboratorsManager.getCollaborators} by activity
* state (active / idle / inactive) and visibility rules such as following and
* highlighted users. Re-evaluates on the visibility clock, so callers don't need to
* drive their own activity timer.
*/
@computed({ isEqual: areArraysShallowEqual })
getVisibleCollaborators(): TLInstancePresence[] {
const { editor } = this
const { collaboratorInactiveTimeoutMs, collaboratorIdleTimeoutMs } = editor.options
this._startVisibilityClock()
this._visibilityClock.get()
const now = Date.now()
const collaborators = this.getCollaborators()
if (!collaborators.length) return EMPTY_ARRAY
const { followingUserId, highlightedUserIds } = this.editor.getInstanceState()
const currentUserId = this.editor.user.getRecordId()
return collaborators.filter((presence) => {
const { lastActivityTimestamp, userId, chatMessage } = presence
// Treat a missing or zero `lastActivityTimestamp` as "active right now"
// (elapsed = 0) so newly-joined peers aren't immediately classified as
// idle/inactive. The broadcast default for peers who haven't moved their
// pointer yet is `0` (e.g. someone on a touch device who joins and just
// watches), so a plain `?? now` would leave them hidden. See issue #9017.
const elapsed = lastActivityTimestamp ? Math.max(0, now - lastActivityTimestamp) : 0
if (elapsed > collaboratorInactiveTimeoutMs) {
// Inactive: If they're inactive, only show if we're following them or they're highlighted
return followingUserId === userId || highlightedUserIds.includes(userId)
}
if (elapsed > collaboratorIdleTimeoutMs) {
// Idle: If they're idle and following us, hide them unless they have a chat message or are highlighted
if (presence.followingUserId === currentUserId) {
return !!(chatMessage || highlightedUserIds.includes(userId))
}
}
// Active
return true
})
}
/**
* Returns a list of presence records for peer collaborators who should currently be
* shown in the UI, filtered to those on the current page.
*/
@computed({ isEqual: areArraysShallowEqual })
getVisibleCollaboratorsOnCurrentPage(): TLInstancePresence[] {
const currentPageId = this.editor.getCurrentPageId()
return this.getVisibleCollaborators().filter((c) => c.currentPageId === currentPageId)
}
}

View File

@@ -0,0 +1,421 @@
import { Mock, Mocked, vi } from 'vitest'
import { Box } from '../../../primitives/Box'
import { Vec } from '../../../primitives/Vec'
import { Editor } from '../../Editor'
import { EdgeScrollManager } from './EdgeScrollManager'
// Mock the Editor class
vi.mock('../../Editor')
describe('EdgeScrollManager', () => {
let editor: Mocked<
Editor & {
user: { getEdgeScrollSpeed: Mock }
getCamera: Mock
getCameraOptions: Mock
getZoomLevel: Mock
getViewportScreenBounds: Mock
}
>
let edgeScrollManager: EdgeScrollManager
let mockInputs: {
_currentScreenPoint: Vec
currentScreenPoint: Vec
getCurrentScreenPoint(): Vec
setCurrentScreenPoint(value: Vec): void
_isDragging: boolean
isDragging: boolean
getIsDragging(): boolean
setIsDragging(value: boolean): void
_isPanning: boolean
isPanning: boolean
getIsPanning(): boolean
setIsPanning(value: boolean): void
}
beforeEach(() => {
// Create a mock inputs object with writable properties and getters
mockInputs = {
_currentScreenPoint: new Vec(500, 300),
get currentScreenPoint() {
return this._currentScreenPoint
},
getCurrentScreenPoint() {
return this._currentScreenPoint
},
setCurrentScreenPoint(value: Vec) {
this._currentScreenPoint = value
},
_isDragging: true,
get isDragging() {
return this._isDragging
},
getIsDragging() {
return this._isDragging
},
setIsDragging(value: boolean) {
this._isDragging = value
},
_isPanning: false,
get isPanning() {
return this._isPanning
},
getIsPanning() {
return this._isPanning
},
setIsPanning(value: boolean) {
this._isPanning = value
},
}
editor = {
options: {
edgeScrollDelay: 200,
edgeScrollEaseDuration: 200,
edgeScrollSpeed: 25,
edgeScrollDistance: 8,
coarsePointerWidth: 12,
},
inputs: mockInputs as unknown as Editor['inputs'],
user: {
getEdgeScrollSpeed: vi.fn(() => 1),
},
getViewportScreenBounds: vi.fn(() => new Box(0, 0, 1000, 600)),
getInstanceState: vi.fn(() => ({
isCoarsePointer: false,
insets: [false, false, false, false], // [top, right, bottom, left]
})),
getCameraOptions: vi.fn(() => ({
isLocked: false,
panSpeed: 1,
zoomSpeed: 1,
zoomSteps: [1],
wheelBehavior: 'pan' as const,
})),
getZoomLevel: vi.fn(() => 1),
getCamera: vi.fn(() => new Vec(0, 0, 1)),
setCamera: vi.fn(),
} as unknown as Mocked<
Editor & {
user: { getEdgeScrollSpeed: Mock }
getCamera: Mock
getCameraOptions: Mock
getZoomLevel: Mock
getViewportScreenBounds: Mock
}
>
edgeScrollManager = new EdgeScrollManager(editor)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('constructor and initialization', () => {
it('should initialize with editor reference', () => {
expect(edgeScrollManager.editor).toBe(editor)
})
})
describe('basic edge scrolling behavior', () => {
it('should not trigger edge scrolling when pointer is in center', () => {
mockInputs.setCurrentScreenPoint(new Vec(500, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should start edge scrolling when pointer is near edge', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
// Should not scroll immediately due to delay
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
// Should scroll after delay
edgeScrollManager.updateEdgeScrolling(200)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should stop edge scrolling when pointer moves away from edge', () => {
// Start edge scrolling near edge
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
// Move pointer to center - should stop scrolling
editor.setCamera.mockClear()
mockInputs.setCurrentScreenPoint(new Vec(500, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should respect edge scroll delay', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
// First update - should not scroll yet due to delay
edgeScrollManager.updateEdgeScrolling(100)
expect(editor.setCamera).not.toHaveBeenCalled()
// Second update - should trigger scrolling after delay
edgeScrollManager.updateEdgeScrolling(150)
expect(editor.setCamera).toHaveBeenCalled()
})
})
describe('edge proximity detection', () => {
it('should detect left edge proximity', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300) // Enough to trigger after delay
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll right when near left edge
})
it('should detect right edge proximity', () => {
mockInputs.setCurrentScreenPoint(new Vec(995, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeLessThan(0) // Should scroll left when near right edge
})
it('should detect top edge proximity', () => {
mockInputs.setCurrentScreenPoint(new Vec(500, 5))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.y).toBeGreaterThan(0) // Should scroll down when near top edge
})
it('should detect bottom edge proximity', () => {
mockInputs.setCurrentScreenPoint(new Vec(500, 595))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.y).toBeLessThan(0) // Should scroll up when near bottom edge
})
it('should handle corner proximity (both x and y)', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 5))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll right
expect(callArgs.y).toBeGreaterThan(0) // Should scroll down
})
})
describe('coarse pointer handling', () => {
it('should account for coarse pointer width', () => {
editor.getInstanceState.mockReturnValue({
...editor.getInstanceState(),
isCoarsePointer: true,
insets: [false, false, false, false],
})
mockInputs.setCurrentScreenPoint(new Vec(15, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should not trigger edge scrolling for fine pointer at same position', () => {
editor.getInstanceState.mockReturnValue({
...editor.getInstanceState(),
isCoarsePointer: false,
insets: [false, false, false, false],
})
mockInputs.setCurrentScreenPoint(new Vec(15, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).not.toHaveBeenCalled()
})
})
describe('camera movement calculation', () => {
it('should calculate scroll speed based on user preference', () => {
editor.user.getEdgeScrollSpeed.mockReturnValue(2)
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(0) // Should scroll when user speed is > 0
})
it('should apply screen size factor for small screens', () => {
editor.getViewportScreenBounds.mockReturnValue(new Box(0, 0, 800, 600))
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should adjust scroll speed based on zoom level', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
const atZoom1 = (editor.setCamera.mock.calls[0][0] as Vec).x
editor.setCamera.mockClear()
edgeScrollManager = new EdgeScrollManager(editor)
editor.getZoomLevel.mockReturnValue(2)
edgeScrollManager.updateEdgeScrolling(300)
const atZoom2 = (editor.setCamera.mock.calls[0][0] as Vec).x
// Higher zoom should result in smaller camera movement
expect(atZoom2).toBeCloseTo(atZoom1 / 2)
})
it('should move the same distance per second regardless of tick rate', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
// Get past the delay and easing ramp so each tick moves at full speed
edgeScrollManager.updateEdgeScrolling(1000)
editor.setCamera.mockClear()
// One second of 60 Hz ticks
for (let i = 0; i < 60; i++) edgeScrollManager.updateEdgeScrolling(1000 / 60)
const at60Hz = editor.setCamera.mock.calls.reduce(
(sum, [camera]) => sum + (camera as Vec).x,
0
)
editor.setCamera.mockClear()
// One second of 120 Hz ticks
for (let i = 0; i < 120; i++) edgeScrollManager.updateEdgeScrolling(1000 / 120)
const at120Hz = editor.setCamera.mock.calls.reduce(
(sum, [camera]) => sum + (camera as Vec).x,
0
)
expect(at60Hz).toBeGreaterThan(0)
expect(at120Hz).toBeCloseTo(at60Hz)
})
it('should add scroll delta to current camera position', () => {
const currentCamera = new Vec(100, 200, 1)
editor.getCamera.mockReturnValue(currentCamera)
mockInputs.setCurrentScreenPoint(new Vec(5, 5))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBeGreaterThan(100) // Should be added to current position
expect(callArgs.y).toBeGreaterThan(200) // Should be added to current position
expect(callArgs.z).toBe(1) // Z should remain unchanged
})
})
describe('proximity factor calculation', () => {
it('should return 0 when not near any edge', () => {
mockInputs.setCurrentScreenPoint(new Vec(500, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
})
it('should cap proximity factor at 1', () => {
mockInputs.setCurrentScreenPoint(new Vec(0, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
// The proximity factor should be capped, so movement shouldn't be infinite
})
})
describe('edge cases and error handling', () => {
it('should handle negative elapsed time', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
expect(() => edgeScrollManager.updateEdgeScrolling(-16)).not.toThrow()
})
it('should handle very large elapsed time', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
expect(() => edgeScrollManager.updateEdgeScrolling(100000)).not.toThrow()
})
it('should handle zero user edge scroll speed', () => {
editor.user.getEdgeScrollSpeed.mockReturnValue(0)
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
if (editor.setCamera.mock.calls.length > 0) {
const callArgs = editor.setCamera.mock.calls[0][0] as Vec
expect(callArgs.x).toBe(0)
expect(callArgs.y).toBe(0)
}
})
it('should handle extreme zoom levels', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
editor.getZoomLevel.mockReturnValue(0.01) // Very zoomed out
expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow()
editor.getZoomLevel.mockReturnValue(100) // Very zoomed in
expect(() => edgeScrollManager.updateEdgeScrolling(300)).not.toThrow()
})
})
describe('state transitions', () => {
it('should properly transition from not scrolling to scrolling', () => {
// Start with no edge scrolling
mockInputs.setCurrentScreenPoint(new Vec(500, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
// Move to edge - should start scrolling after delay
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled() // Not yet, due to delay
edgeScrollManager.updateEdgeScrolling(200)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should accumulate edge scroll duration over multiple updates', () => {
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
// First update - not enough time
edgeScrollManager.updateEdgeScrolling(50)
expect(editor.setCamera).not.toHaveBeenCalled()
// Second update - still not enough
edgeScrollManager.updateEdgeScrolling(50)
expect(editor.setCamera).not.toHaveBeenCalled()
// Third update - now should trigger (50 + 50 + 101 = 201ms > 200ms delay)
edgeScrollManager.updateEdgeScrolling(101)
expect(editor.setCamera).toHaveBeenCalled()
})
it('should reset duration when stopping edge scroll', () => {
// Start edge scrolling
mockInputs.setCurrentScreenPoint(new Vec(5, 300))
edgeScrollManager.updateEdgeScrolling(300)
expect(editor.setCamera).toHaveBeenCalled()
// Stop edge scrolling - move away
editor.setCamera.mockClear()
mockInputs.setCurrentScreenPoint(new Vec(500, 300))
edgeScrollManager.updateEdgeScrolling(16)
expect(editor.setCamera).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,147 @@
import { FRAME_MS_60HZ } from '../../../constants'
import { EASINGS } from '../../../primitives/easings'
import { Vec } from '../../../primitives/Vec'
import type { Editor } from '../../Editor'
// Tuned for touch/small-screen feel; adjust together with coarsePointerWidth.
const EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX = 1000
const EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR = 0.612
/** @public */
export class EdgeScrollManager {
constructor(public editor: Editor) {}
private _isEdgeScrolling = false
private _edgeScrollDuration = -1
getIsEdgeScrolling() {
return this._isEdgeScrolling
}
/**
* Update the camera position when the mouse is close to the edge of the screen.
* Run this on every tick when in a state where edge scrolling is enabled.
*
* @public
*/
updateEdgeScrolling(elapsed: number) {
const { editor } = this
if (editor.getCameraOptions().isLocked) return
const edgeScrollProximityFactor = this.getEdgeScroll()
if (edgeScrollProximityFactor.x === 0 && edgeScrollProximityFactor.y === 0) {
if (this._isEdgeScrolling) {
this._isEdgeScrolling = false
this._edgeScrollDuration = 0
}
} else {
if (!this._isEdgeScrolling) {
this._isEdgeScrolling = true
this._edgeScrollDuration = 0
}
this._edgeScrollDuration += elapsed
if (this._edgeScrollDuration > editor.options.edgeScrollDelay) {
const eased =
editor.options.edgeScrollEaseDuration > 0
? EASINGS.easeInCubic(
Math.min(
1,
this._edgeScrollDuration /
(editor.options.edgeScrollDelay + editor.options.edgeScrollEaseDuration)
)
)
: 1
this.moveCameraWhenCloseToEdge(
{
x: edgeScrollProximityFactor.x * eased,
y: edgeScrollProximityFactor.y * eased,
},
elapsed
)
}
}
}
/**
* Helper function to get the scroll proximity factor for a given position.
* @param position - The mouse position on the axis.
* @param dimension - The component dimension on the axis.
* @param isCoarse - Whether the pointer is coarse.
* @param insetStart - Whether the pointer is inset at the start of the axis.
* @param insetEnd - Whether the pointer is inset at the end of the axis.
* @internal
*/
private getEdgeProximityFactors(
position: number,
dimension: number,
isCoarse: boolean,
insetStart: boolean,
insetEnd: boolean
) {
const { editor } = this
const dist = editor.options.edgeScrollDistance
const pw = isCoarse ? editor.options.coarsePointerWidth : 0 // pointer width
const pMin = position - pw
const pMax = position + pw
const min = insetStart ? 0 : dist
const max = insetEnd ? dimension : dimension - dist
if (pMin < min) {
return Math.min(1, (min - pMin) / dist)
} else if (pMax > max) {
return -Math.min(1, (pMax - max) / dist)
}
return 0
}
private getEdgeScroll() {
const { editor } = this
const { x, y } = editor.inputs.getCurrentScreenPoint()
const screenBounds = editor.getViewportScreenBounds()
const {
isCoarsePointer,
insets: [t, r, b, l],
} = editor.getInstanceState()
const proximityFactorX = this.getEdgeProximityFactors(x, screenBounds.w, isCoarsePointer, l, r)
const proximityFactorY = this.getEdgeProximityFactors(y, screenBounds.h, isCoarsePointer, t, b)
return {
x: proximityFactorX,
y: proximityFactorY,
}
}
/**
* Moves the camera when the mouse is close to the edge of the screen.
* @public
*/
private moveCameraWhenCloseToEdge(proximityFactor: { x: number; y: number }, elapsed: number) {
if (proximityFactor.x === 0 && proximityFactor.y === 0) return
const { editor } = this
const screenBounds = editor.getViewportScreenBounds()
// Determines how much the speed is affected by the screen size
const screenSizeFactorX =
screenBounds.w < EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX
? EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR
: 1
const screenSizeFactorY =
screenBounds.h < EDGE_SCROLL_SMALL_SCREEN_THRESHOLD_PX
? EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR
: 1
// Determines the base speed of the scroll. edgeScrollSpeed is px per 60 Hz frame, so scale
// by elapsed time or a 120 Hz display scrolls twice as fast.
const zoomLevel = editor.getZoomLevel()
const pxSpeed =
editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed * (elapsed / FRAME_MS_60HZ)
const scrollDeltaX = (pxSpeed * proximityFactor.x * screenSizeFactorX) / zoomLevel
const scrollDeltaY = (pxSpeed * proximityFactor.y * screenSizeFactorY) / zoomLevel
// update the camera
const { x, y, z } = editor.getCamera()
editor.setCamera(new Vec(x + scrollDeltaX, y + scrollDeltaY, z))
}
}

View File

@@ -0,0 +1,82 @@
import { vi } from 'vitest'
import type { Editor } from '../Editor'
import { EditorManager } from './EditorManager'
class TestManager extends EditorManager {
onTestEvent = vi.fn()
removeTestEvent: () => void
constructor(editor: Editor) {
super(editor)
this.removeTestEvent = this.addEditorEvent('frame', this.onTestEvent)
}
detachTestEvent() {
this.unregister(this.removeTestEvent)
}
cancelRaf = vi.fn()
startRaf() {
this.register(this.cancelRaf)
}
stopRaf() {
this.unregister(this.cancelRaf)
}
}
describe('EditorManager', () => {
let on: ReturnType<typeof vi.fn>
let off: ReturnType<typeof vi.fn>
let editor: Editor
let manager: TestManager
beforeEach(() => {
on = vi.fn()
off = vi.fn()
editor = { on, off } as unknown as Editor
manager = new TestManager(editor)
})
it('addEditorEvent registers a listener on the editor', () => {
expect(on).toHaveBeenCalledWith('frame', manager.onTestEvent)
})
it('dispose removes registered editor listeners', () => {
manager.dispose()
expect(off).toHaveBeenCalledWith('frame', manager.onTestEvent)
})
it('register runs custom disposables on dispose', () => {
manager.startRaf()
manager.dispose()
expect(manager.cancelRaf).toHaveBeenCalled()
})
it('dispose is safe to call twice', () => {
manager.dispose()
expect(() => manager.dispose()).not.toThrow()
})
it('unregister runs the disposable now and drops it from dispose', () => {
manager.startRaf()
manager.stopRaf()
expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
manager.dispose()
expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
})
it('unregister ignores a disposable that already ran', () => {
manager.startRaf()
manager.dispose()
manager.stopRaf()
expect(manager.cancelRaf).toHaveBeenCalledTimes(1)
})
it('addEditorEvent returns the registered unsubscribe so it can be detached early', () => {
manager.detachTestEvent()
expect(off).toHaveBeenCalledTimes(1)
expect(off).toHaveBeenCalledWith('frame', manager.onTestEvent)
manager.dispose()
expect(off).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,72 @@
import type { Editor } from '../Editor'
import type { TLEventMap } from '../types/emit-types'
/**
* Base class for editor-attached managers that need to clean up after themselves.
*
* Extending `EditorManager` gives a manager one consistent teardown contract: register
* anything that needs undoing, and it runs automatically on `dispose()`. This keeps
* subscription lifecycle symmetric — whatever sets a thing up is what tears it down — so
* it can't be forgotten (the failure mode behind the strict-mode camera bug, #8892).
*
* Register cleanup by what you're creating:
*
* - Editor bus event (`tick`, `frame`, `change`, …): {@link EditorManager.addEditorEvent}
* - Store side effect (`editor.sideEffects.register…`): `this.register(disposer)`
* - Reaction (`react(...)` from `@tldraw/state`): `this.register(react(...))`
* - DOM listener: `this.register(() => el.removeEventListener(...))`
* - Other resource (cache, index, child manager): `this.register(() => x.dispose())`
* - Resource held only part of the time (a listener attached while subscribers exist):
* `this.register(...)` on attach and `this.unregister(...)` on detach, see `PerformanceManager`
*
* For timeouts, intervals, and animation frames prefer `editor.timers` (context-grouped
* and auto-disposed) over raw `setTimeout`. For cleanup on the editor itself rather than a
* manager, use `editor.disposables`.
*
* If a manager needs ordered teardown (e.g. pause a loop before cancelling it), override
* `dispose()` and call `super.dispose()` last — see `TickManager`.
*
* @public
*/
export abstract class EditorManager {
protected readonly disposables = new Set<() => void>()
constructor(protected readonly editor: Editor) {}
/**
* Register a teardown function to run on `dispose()`. The universal way for a manager to
* make a resource "see itself out". Returns the same function for convenience.
*/
protected register(dispose: () => void): () => void {
this.disposables.add(dispose)
return dispose
}
/**
* Run a registered teardown now instead of at `dispose()`. A teardown that is no longer
* registered (already run by `dispose()`, or never registered) is skipped, so nothing is
* torn down twice.
*/
protected unregister(dispose: () => void): void {
if (this.disposables.delete(dispose)) dispose()
}
/**
* Subscribe to an editor bus event and register the matching unsubscribe, so the listener
* is removed automatically on `dispose()`. Returns that unsubscribe, for passing to
* {@link EditorManager.unregister} to detach it before `dispose()`.
*/
protected addEditorEvent<E extends keyof TLEventMap>(
event: E,
fn: (...args: TLEventMap[E]) => void
): () => void {
this.editor.on(event, fn as any)
return this.register(() => this.editor.off(event, fn as any))
}
/** @internal */
dispose(): void {
this.disposables.forEach((d) => d())
this.disposables.clear()
}
}

View File

@@ -0,0 +1,465 @@
import { Mock, Mocked, vi } from 'vitest'
import { Editor } from '../../Editor'
import { FocusManager } from './FocusManager'
// Mock the Editor class
vi.mock('../../Editor')
describe('FocusManager', () => {
let editor: Mocked<
Editor & {
sideEffects: {
registerAfterChangeHandler: Mock
}
getInstanceState: Mock
updateInstanceState: Mock
getContainer: Mock
getEditingShapeId: Mock
getSelectedShapeIds: Mock
complete: Mock
}
>
let focusManager: FocusManager
let mockContainer: HTMLElement
let mockDispose: Mock
let originalAddEventListener: typeof document.body.addEventListener
let originalRemoveEventListener: typeof document.body.removeEventListener
beforeEach(() => {
// Create mock container element
mockContainer = document.createElement('div')
mockContainer.focus = vi.fn()
mockContainer.blur = vi.fn()
vi.spyOn(mockContainer.classList, 'add')
vi.spyOn(mockContainer.classList, 'remove')
// Create mock dispose function
mockDispose = vi.fn()
// Mock document.body event listeners
originalAddEventListener = document.body.addEventListener
originalRemoveEventListener = document.body.removeEventListener
document.body.addEventListener = vi.fn()
document.body.removeEventListener = vi.fn()
// Mock editor
editor = {
sideEffects: {
registerAfterChangeHandler: vi.fn(() => mockDispose),
},
getInstanceState: vi.fn(() => ({ isFocused: false })),
updateInstanceState: vi.fn(),
getContainer: vi.fn(() => mockContainer),
getContainerDocument: vi.fn(() => document),
getEditingShapeId: vi.fn(() => null),
getSelectedShapeIds: vi.fn(() => []),
complete: vi.fn(),
} as any
})
afterEach(() => {
// Restore original event listeners
document.body.addEventListener = originalAddEventListener
document.body.removeEventListener = originalRemoveEventListener
// Clean up any existing focus manager
if (focusManager) {
focusManager.dispose()
}
vi.clearAllMocks()
})
describe('constructor', () => {
it('should initialize with editor reference', () => {
focusManager = new FocusManager(editor)
expect(focusManager).toBeInstanceOf(FocusManager)
expect(editor.sideEffects.registerAfterChangeHandler).toHaveBeenCalled()
})
it('should register side effect listener for instance state changes', () => {
focusManager = new FocusManager(editor)
expect(editor.sideEffects.registerAfterChangeHandler).toHaveBeenCalledWith(
'instance',
expect.any(Function)
)
})
it('should update container class on initialization', () => {
focusManager = new FocusManager(editor)
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
it('should set up keyboard event listener', () => {
focusManager = new FocusManager(editor)
expect(document.body.addEventListener).toHaveBeenCalledWith('keydown', expect.any(Function))
})
it('should set up mouse event listener', () => {
focusManager = new FocusManager(editor)
expect(document.body.addEventListener).toHaveBeenCalledWith('mousedown', expect.any(Function))
})
it('should set focus state to true when autoFocus is true', () => {
editor.getInstanceState.mockReturnValue({ isFocused: false })
focusManager = new FocusManager(editor, true)
expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: true })
})
it('should set focus state to false when autoFocus is false', () => {
editor.getInstanceState.mockReturnValue({ isFocused: true })
focusManager = new FocusManager(editor, false)
expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: false })
})
it('should not change focus state when autoFocus matches current state', () => {
editor.getInstanceState.mockReturnValue({ isFocused: true })
focusManager = new FocusManager(editor, true)
expect(editor.updateInstanceState).not.toHaveBeenCalled()
})
it('should handle undefined autoFocus parameter', () => {
editor.getInstanceState.mockReturnValue({ isFocused: true })
focusManager = new FocusManager(editor)
expect(editor.updateInstanceState).toHaveBeenCalledWith({ isFocused: false })
})
})
describe('side effect handler', () => {
it('should update container class when focus state changes', () => {
focusManager = new FocusManager(editor)
// Get the registered handler function
const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0]
const handler = handlerCall[1]
// Clear previous calls
vi.clearAllMocks()
// Simulate focus state change
const prev = { isFocused: false }
const next = { isFocused: true }
editor.getInstanceState.mockReturnValue(next)
handler(prev, next)
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused')
})
it('should not update container class when focus state does not change', () => {
focusManager = new FocusManager(editor)
const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0]
const handler = handlerCall[1]
vi.clearAllMocks()
// Simulate no focus state change
const prev = { isFocused: true }
const next = { isFocused: true }
handler(prev, next)
expect(mockContainer.classList.add).not.toHaveBeenCalled()
expect(mockContainer.classList.remove).not.toHaveBeenCalled()
})
})
describe('updateContainerClass', () => {
let handler: (prev: any, next: any) => void
beforeEach(() => {
focusManager = new FocusManager(editor)
// Get the handler before clearing mocks
const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0]
handler = handlerCall[1]
vi.clearAllMocks()
})
it('should add focused class when editor is focused', () => {
editor.getInstanceState.mockReturnValue({ isFocused: true })
handler({ isFocused: false }, { isFocused: true })
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused')
})
it('should remove focused class when editor is not focused', () => {
editor.getInstanceState.mockReturnValue({ isFocused: false })
handler({ isFocused: true }, { isFocused: false })
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__focused')
})
it('should always add no-focus-ring class', () => {
editor.getInstanceState.mockReturnValue({ isFocused: true })
handler({ isFocused: false }, { isFocused: true })
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
})
describe('handleKeyDown', () => {
let keydownHandler: (event: KeyboardEvent) => void
beforeEach(() => {
focusManager = new FocusManager(editor)
// Get the keydown handler that was registered
const addEventListenerCalls = (document.body.addEventListener as Mock).mock.calls
const keydownCall = addEventListenerCalls.find((call: any) => call[0] === 'keydown')
keydownHandler = keydownCall![1]
vi.clearAllMocks()
})
it('should remove no-focus-ring class on Tab key', () => {
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
it('should remove no-focus-ring class on ArrowUp key', () => {
const event = new KeyboardEvent('keydown', { key: 'ArrowUp' })
keydownHandler(event)
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
it('should remove no-focus-ring class on ArrowDown key', () => {
const event = new KeyboardEvent('keydown', { key: 'ArrowDown' })
keydownHandler(event)
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
it('should not remove no-focus-ring class on other keys', () => {
const event = new KeyboardEvent('keydown', { key: 'Enter' })
keydownHandler(event)
expect(mockContainer.classList.remove).not.toHaveBeenCalled()
})
it('should return early when editor is in editing mode', () => {
editor.getEditingShapeId.mockReturnValue('shape:1')
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
expect(mockContainer.classList.remove).not.toHaveBeenCalled()
})
it('should return early when container is active element and shapes are selected', () => {
Object.defineProperty(document, 'activeElement', {
value: mockContainer,
configurable: true,
})
editor.getSelectedShapeIds.mockReturnValue(['shape1'])
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
expect(mockContainer.classList.remove).not.toHaveBeenCalled()
})
it('should process key when container is active but no shapes selected', () => {
Object.defineProperty(document, 'activeElement', {
value: mockContainer,
configurable: true,
})
editor.getSelectedShapeIds.mockReturnValue([])
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
})
describe('handleMouseDown', () => {
let mousedownHandler: () => void
beforeEach(() => {
focusManager = new FocusManager(editor)
// Get the mousedown handler that was registered
const addEventListenerCalls = (document.body.addEventListener as Mock).mock.calls
const mousedownCall = addEventListenerCalls.find((call: any) => call[0] === 'mousedown')
mousedownHandler = mousedownCall![1]
vi.clearAllMocks()
})
it('should add no-focus-ring class on mouse down', () => {
mousedownHandler()
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
})
describe('focus', () => {
beforeEach(() => {
focusManager = new FocusManager(editor)
})
it('should focus the container', () => {
focusManager.focus()
expect(mockContainer.focus).toHaveBeenCalled()
})
})
describe('blur', () => {
beforeEach(() => {
focusManager = new FocusManager(editor)
})
it('should complete editor interactions', () => {
focusManager.blur()
expect(editor.complete).toHaveBeenCalled()
})
it('should blur the container', () => {
focusManager.blur()
expect(mockContainer.blur).toHaveBeenCalled()
})
it('should complete before bluring', () => {
const callOrder: string[] = []
editor.complete.mockImplementation(() => callOrder.push('complete'))
mockContainer.blur = vi.fn(() => callOrder.push('blur'))
focusManager.blur()
expect(callOrder).toEqual(['complete', 'blur'])
})
it('should complete without blurring the container when blurContainer is false', () => {
focusManager.blur({ blurContainer: false })
expect(editor.complete).toHaveBeenCalled()
expect(mockContainer.blur).not.toHaveBeenCalled()
})
})
describe('dispose', () => {
beforeEach(() => {
focusManager = new FocusManager(editor)
vi.clearAllMocks()
})
it('should remove keyboard event listener', () => {
focusManager.dispose()
expect(document.body.removeEventListener).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should remove mouse event listener', () => {
focusManager.dispose()
expect(document.body.removeEventListener).toHaveBeenCalledWith(
'mousedown',
expect.any(Function)
)
})
it('should dispose side effect listener', () => {
focusManager.dispose()
expect(mockDispose).toHaveBeenCalled()
})
it('should handle missing side effect disposal gracefully', () => {
// Create a focus manager where the side effect registration returns undefined
editor.sideEffects.registerAfterChangeHandler.mockReturnValue(undefined)
const focusManagerWithoutDispose = new FocusManager(editor)
expect(() => focusManagerWithoutDispose.dispose()).not.toThrow()
})
})
describe('integration scenarios', () => {
it('should handle rapid focus state changes', () => {
focusManager = new FocusManager(editor)
const handlerCall = editor.sideEffects.registerAfterChangeHandler.mock.calls[0]
const handler = handlerCall[1]
vi.clearAllMocks()
// Rapid focus changes
editor.getInstanceState.mockReturnValue({ isFocused: true })
handler({ isFocused: false }, { isFocused: true })
editor.getInstanceState.mockReturnValue({ isFocused: false })
handler({ isFocused: true }, { isFocused: false })
editor.getInstanceState.mockReturnValue({ isFocused: true })
handler({ isFocused: false }, { isFocused: true })
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__focused')
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__focused')
})
it('should handle keyboard navigation while editing', () => {
focusManager = new FocusManager(editor)
const addEventListenerCalls = (document.body.addEventListener as Mock).mock.calls
const keydownCall = addEventListenerCalls.find((call: any) => call[0] === 'keydown')
const keydownHandler = keydownCall![1]
editor.getEditingShapeId.mockReturnValue('shape:1') // Editing mode
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
// Should not remove focus ring when editing
expect(mockContainer.classList.remove).not.toHaveBeenCalledWith('tl-container__no-focus-ring')
})
it('should handle mouse and keyboard interaction sequence', () => {
focusManager = new FocusManager(editor)
const addEventListenerCalls = (document.body.addEventListener as Mock).mock.calls
const mousedownCall = addEventListenerCalls.find((call: any) => call[0] === 'mousedown')
const keydownCall = addEventListenerCalls.find((call: any) => call[0] === 'keydown')
const mousedownHandler = mousedownCall![1]
const keydownHandler = keydownCall![1]
vi.clearAllMocks()
// Mouse down adds no-focus-ring
mousedownHandler()
expect(mockContainer.classList.add).toHaveBeenCalledWith('tl-container__no-focus-ring')
// Keyboard navigation removes no-focus-ring
const event = new KeyboardEvent('keydown', { key: 'Tab' })
keydownHandler(event)
expect(mockContainer.classList.remove).toHaveBeenCalledWith('tl-container__no-focus-ring')
})
})
describe('edge cases', () => {
it('should handle container being null', () => {
editor.getContainer.mockReturnValue(null as any)
expect(() => new FocusManager(editor)).toThrow()
})
it('should handle missing instance state', () => {
editor.getInstanceState.mockReturnValue(null as any)
expect(() => new FocusManager(editor)).toThrow()
})
it('should handle disposed manager gracefully', () => {
focusManager = new FocusManager(editor)
focusManager.dispose()
// Should not throw when calling methods after disposal
expect(() => focusManager.focus()).not.toThrow()
expect(() => focusManager.blur()).not.toThrow()
})
})
})

View File

@@ -0,0 +1,89 @@
import { bind } from '@tldraw/utils'
import type { Editor } from '../../Editor'
import { EditorManager } from '../EditorManager'
/**
* A manager for ensuring correct focus across the editor.
* It will listen for changes in the instance state to make sure the
* container is focused when the editor is focused.
* Also, it will make sure that the focus is on things like text
* labels when the editor is in editing mode.
*
* @internal
*/
export class FocusManager extends EditorManager {
constructor(editor: Editor, autoFocus?: boolean) {
super(editor)
const disposeSideEffectListener = editor.sideEffects.registerAfterChangeHandler(
'instance',
(prev, next) => {
if (prev.isFocused !== next.isFocused) {
this.updateContainerClass()
}
}
)
this.register(() => disposeSideEffectListener?.())
const currentFocusState = editor.getInstanceState().isFocused
if (autoFocus !== currentFocusState) {
editor.updateInstanceState({ isFocused: !!autoFocus })
}
this.updateContainerClass()
const body = editor.getContainerDocument().body
body.addEventListener('keydown', this.handleKeyDown)
body.addEventListener('mousedown', this.handleMouseDown)
this.register(() => {
body.removeEventListener('keydown', this.handleKeyDown)
body.removeEventListener('mousedown', this.handleMouseDown)
})
}
/**
* The editor's focus state and the container's focus state
* are not necessarily always in sync. For that reason we
* can't rely on the css `:focus` or `:focus-within` selectors to style the
* editor when it is in focus.
*
* For that reason we synchronize the editor's focus state with a
* special class on the container: tl-container__focused
*/
private updateContainerClass() {
const container = this.editor.getContainer()
const instanceState = this.editor.getInstanceState()
if (instanceState.isFocused) {
container.classList.add('tl-container__focused')
} else {
container.classList.remove('tl-container__focused')
}
container.classList.add('tl-container__no-focus-ring')
}
@bind private handleKeyDown(keyEvent: KeyboardEvent) {
const container = this.editor.getContainer()
const activeEl = container.ownerDocument.activeElement
// Edit mode should remove the focus ring, however if the active element's
// parent is the contextual toolbar, then allow it.
if (this.editor.getEditingShapeId() && !activeEl?.closest('.tlui-contextual-toolbar')) return
if (activeEl === container && this.editor.getSelectedShapeIds().length > 0) return
if (['Tab', 'ArrowUp', 'ArrowDown'].includes(keyEvent.key)) {
container.classList.remove('tl-container__no-focus-ring')
}
}
@bind private handleMouseDown() {
const container = this.editor.getContainer()
container.classList.add('tl-container__no-focus-ring')
}
focus() {
this.editor.getContainer().focus()
}
blur({ blurContainer = true } = {}) {
this.editor.complete() // stop any interaction
if (blurContainer) this.editor.getContainer().blur() // blur the container
}
}

View File

@@ -0,0 +1,406 @@
import {
TLFontFace,
TLParentId,
TLShape,
TLShapeId,
createShapeId,
toRichText,
} from '@tldraw/tlschema'
import { IndexKey } from '@tldraw/utils'
import { Mock, Mocked, vi } from 'vitest'
import { Editor } from '../../Editor'
import { clearFontFaceCacheForTests, FontManager } from './FontManager'
// Mock the Editor class
vi.mock('../../Editor')
// Mock globals
global.FontFace = vi.fn().mockImplementation(function (family: any, src: any, descriptors: any) {
return {
family,
src,
...descriptors,
load: vi.fn(() => Promise.resolve()),
}
})
Object.defineProperty(global.document, 'fonts', {
value: {
add: vi.fn(),
[Symbol.iterator]: vi.fn(() => [].values()),
},
configurable: true,
})
global.queueMicrotask = vi.fn((fn) => Promise.resolve().then(fn))
describe('FontManager', () => {
let editor: Mocked<Editor>
let fontManager: FontManager
let mockAssetUrls: { [key: string]: string }
let mockShapeFontFacesCacheGet: Mock
let mockShapeFontLoadStateCacheGet: Mock
const createMockFont = (overrides: Partial<TLFontFace> = {}): TLFontFace => ({
family: 'Test Font',
src: { url: 'test-font.woff2' },
...overrides,
})
const createMockShape = (id: TLShapeId = createShapeId('test')): TLShape => ({
id,
type: 'text',
x: 0,
y: 0,
rotation: 0,
index: 'a1' as IndexKey,
parentId: 'page:page' as TLParentId,
opacity: 1,
isLocked: false,
meta: {},
props: {
color: 'black',
size: 'xl',
font: 'serif',
textAlign: 'middle',
w: 100,
richText: toRichText('❤️'),
scale: 2,
autoSize: true,
},
typeName: 'shape' as const,
})
beforeEach(() => {
vi.clearAllMocks()
clearFontFaceCacheForTests()
mockAssetUrls = {
'test-font.woff2': 'https://example.com/fonts/test-font.woff2',
}
const mockShapeUtil = {
getFontFaces: vi.fn(() => []),
}
mockShapeFontFacesCacheGet = vi.fn(() => [])
mockShapeFontLoadStateCacheGet = vi.fn(() => ({ get: vi.fn(() => []) }))
const mockStore = {
createComputedCache: vi.fn(() => ({
get: mockShapeFontFacesCacheGet,
})),
createCache: vi.fn(() => ({
get: mockShapeFontLoadStateCacheGet,
})),
}
editor = {
store: mockStore,
getShapeUtil: vi.fn(() => mockShapeUtil),
getCurrentPageShapeIds: vi.fn(() => new Set()),
getShape: vi.fn(),
getContainerDocument: vi.fn(() => document),
isDisposed: false,
} as any
fontManager = new FontManager(editor, mockAssetUrls)
})
describe('constructor', () => {
it('should initialize with editor reference', () => {
expect(fontManager).toBeDefined()
})
it('should initialize without assetUrls', () => {
const managerWithoutUrls = new FontManager(editor)
expect(managerWithoutUrls).toBeDefined()
})
})
describe('dispose', () => {
it('clears font state and caches', async () => {
const font = createMockFont()
const shapeId = createShapeId('test')
mockShapeFontFacesCacheGet.mockReturnValue([font])
const firstPromise = fontManager.ensureFontIsLoaded(font)
expect(fontManager.getShapeFontFaces(shapeId)).toEqual([font])
fontManager.trackFontsForShape(shapeId)
fontManager.requestFonts([font])
await firstPromise
fontManager.dispose()
fontManager.requestFonts([font])
const secondPromise = fontManager.ensureFontIsLoaded(font)
expect(fontManager.getShapeFontFaces(shapeId)).toEqual([])
fontManager.trackFontsForShape(shapeId)
expect(mockShapeFontFacesCacheGet).toHaveBeenCalledTimes(1)
expect(mockShapeFontLoadStateCacheGet).toHaveBeenCalledTimes(1)
expect(queueMicrotask).toHaveBeenCalledTimes(2)
expect(secondPromise).not.toBe(firstPromise)
await secondPromise
})
it('ignores font load failures after disposal', async () => {
const font = createMockFont()
const error = new Error('Font load failed')
let rejectLoad: (err: Error) => void = () => {}
;(global.FontFace as Mock).mockImplementationOnce(function (family, src, descriptors) {
return {
family,
src,
...descriptors,
load: vi.fn(
() =>
new Promise<void>((_, reject) => {
rejectLoad = reject
})
),
}
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
const promise = fontManager.ensureFontIsLoaded(font)
fontManager.dispose()
rejectLoad(error)
await expect(promise).resolves.toBeUndefined()
expect(errorSpy).not.toHaveBeenCalled()
expect(debugSpy).toHaveBeenCalledWith(
`Font "${font.family}" load interrupted by editor dispose`,
error
)
errorSpy.mockRestore()
debugSpy.mockRestore()
})
it('ignores font loads that finish after disposal', async () => {
const font = createMockFont()
let resolveLoad: () => void = () => {}
;(global.FontFace as Mock).mockImplementationOnce(function (family, src, descriptors) {
return {
family,
src,
...descriptors,
load: vi.fn(
() =>
new Promise<void>((resolve) => {
resolveLoad = resolve
})
),
}
})
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
const promise = fontManager.ensureFontIsLoaded(font)
fontManager.dispose()
resolveLoad()
await expect(promise).resolves.toBeUndefined()
// only the creation-time add from findOrCreateFontFace, not a second
// post-load add after dispose
expect(document.fonts.add).toHaveBeenCalledTimes(1)
expect(debugSpy).toHaveBeenCalledWith(
`Font "${font.family}" load interrupted by editor dispose`
)
debugSpy.mockRestore()
})
})
describe('getShapeFontFaces', () => {
it('should return empty array when no fonts found', () => {
const shape = createMockShape()
const result = fontManager.getShapeFontFaces(shape)
expect(result).toEqual([])
})
it('should accept shape ID as parameter', () => {
const shapeId = createShapeId('test')
const result = fontManager.getShapeFontFaces(shapeId)
expect(result).toEqual([])
})
})
describe('trackFontsForShape', () => {
it('should track fonts for shape without throwing', () => {
const shape = createMockShape()
expect(() => fontManager.trackFontsForShape(shape)).not.toThrow()
})
it('should track fonts for shape ID without throwing', () => {
const shapeId = createShapeId('test')
expect(() => fontManager.trackFontsForShape(shapeId)).not.toThrow()
})
})
describe('loadRequiredFontsForCurrentPage', () => {
it('should complete without error when no fonts needed', async () => {
await expect(fontManager.loadRequiredFontsForCurrentPage()).resolves.toBeUndefined()
})
it('should respect font limit', async () => {
const shapeIds = Array.from({ length: 5 }, (_, i) => createShapeId(`test${i}`))
const shapes = shapeIds.map(createMockShape)
editor.getCurrentPageShapeIds.mockReturnValue(new Set(shapeIds))
editor.getShape.mockImplementation((id: any) => shapes.find((s) => s.id === id))
await expect(fontManager.loadRequiredFontsForCurrentPage(3)).resolves.toBeUndefined()
})
})
describe('ensureFontIsLoaded', () => {
it('should create and load font face', async () => {
const font = createMockFont()
await fontManager.ensureFontIsLoaded(font)
expect(global.FontFace).toHaveBeenCalledWith(
font.family,
expect.stringContaining('url('),
expect.any(Object)
)
})
it('should handle font loading errors gracefully', async () => {
const font = createMockFont()
const error = new Error('Font load failed')
;(global.FontFace as Mock).mockImplementationOnce(function () {
return {
family: font.family,
load: vi.fn(() => Promise.reject(error)),
}
})
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await fontManager.ensureFontIsLoaded(font)
expect(consoleSpy).toHaveBeenCalledWith(error)
consoleSpy.mockRestore()
})
it('should return same promise for concurrent requests', async () => {
const font = createMockFont()
const promise1 = fontManager.ensureFontIsLoaded(font)
const promise2 = fontManager.ensureFontIsLoaded(font)
expect(promise1).toBe(promise2)
await Promise.all([promise1, promise2])
})
})
describe('requestFonts', () => {
it('should queue fonts for loading', () => {
const fonts = [createMockFont({ family: 'Font1' }), createMockFont({ family: 'Font2' })]
fontManager.requestFonts(fonts)
expect(queueMicrotask).toHaveBeenCalled()
})
it('should deduplicate font requests', () => {
const font = createMockFont()
fontManager.requestFonts([font])
fontManager.requestFonts([font])
expect(queueMicrotask).toHaveBeenCalledTimes(1)
})
it('should handle editor disposal during async loading', () => {
const fonts = [createMockFont()]
editor.isDisposed = true
fontManager.requestFonts(fonts)
const callback = (queueMicrotask as Mock).mock.calls[0][0]
expect(() => callback()).not.toThrow()
})
})
describe('toEmbeddedCssDeclaration', () => {
it('should generate font CSS without data conversion (simplified test)', async () => {
const font = createMockFont()
// Mock the actual method implementation to avoid FileHelpers dependency
const mockCssDeclaration = `@font-face {
font-family: "${font.family}";
src: url("mock-data-url");
}`
vi.spyOn(fontManager, 'toEmbeddedCssDeclaration').mockResolvedValue(mockCssDeclaration)
const result = await fontManager.toEmbeddedCssDeclaration(font)
expect(result).toContain(`font-family: "${font.family}";`)
expect(result).toContain('src:')
expect(result).toContain('@font-face {')
expect(result).toContain('}')
})
it('should call toEmbeddedCssDeclaration method', async () => {
const font = createMockFont()
// Simple spy to verify the method is called
const spy = vi.spyOn(fontManager, 'toEmbeddedCssDeclaration').mockResolvedValue('mock-css')
await fontManager.toEmbeddedCssDeclaration(font)
expect(spy).toHaveBeenCalledWith(font)
spy.mockRestore()
})
})
describe('error handling and edge cases', () => {
it('should handle empty getCurrentPageShapeIds', () => {
editor.getCurrentPageShapeIds.mockReturnValue(new Set())
expect(() => fontManager.loadRequiredFontsForCurrentPage()).not.toThrow()
})
it('should handle null shape from getShape', async () => {
const shapeId = createShapeId('test')
editor.getCurrentPageShapeIds.mockReturnValue(new Set([shapeId]))
editor.getShape.mockReturnValue(undefined)
await expect(fontManager.loadRequiredFontsForCurrentPage()).rejects.toThrow()
})
it('should handle fonts with minimal properties', async () => {
const minimalFont: TLFontFace = {
family: 'Minimal Font',
src: { url: 'minimal.woff2' },
}
await expect(fontManager.ensureFontIsLoaded(minimalFont)).resolves.toBeUndefined()
})
})
describe('findOrCreateFontFace caching', () => {
it('reuses a font face across manager instances instead of recreating it on remount', async () => {
const font = createMockFont()
await fontManager.ensureFontIsLoaded(font)
expect(global.FontFace).toHaveBeenCalledTimes(1)
// A fresh manager on the same document simulates an editor remount.
const remounted = new FontManager(editor, mockAssetUrls)
await remounted.ensureFontIsLoaded(font)
// The per-document cache lets the second manager reuse the existing face
// rather than re-scanning document.fonts and creating a new one.
expect(global.FontFace).toHaveBeenCalledTimes(1)
})
it('creates separate font faces for fonts with different descriptors', async () => {
await fontManager.ensureFontIsLoaded(createMockFont({ weight: 'normal' }))
await fontManager.ensureFontIsLoaded(createMockFont({ weight: 'bold' }))
expect(global.FontFace).toHaveBeenCalledTimes(2)
})
})
})

View File

@@ -0,0 +1,267 @@
import { computed, EMPTY_ARRAY, transact } from '@tldraw/state'
import { AtomMap } from '@tldraw/store'
import { TLFontFace, TLShape, TLShapeId } from '@tldraw/tlschema'
import {
areArraysShallowEqual,
compact,
FileHelpers,
mapObjectMapValues,
objectMapEntries,
} from '@tldraw/utils'
import { areShapesContentEqual } from '../../../utils/areShapesContentEqual'
import type { Editor } from '../../Editor'
interface FontState {
readonly state: 'loading' | 'ready' | 'error'
readonly instance: FontFace
readonly loadingPromise: Promise<void>
}
interface ShapeFontFacesCache {
get(id: TLShapeId): TLFontFace[] | undefined
}
interface ShapeFontLoadStateCache {
get(id: TLShapeId): (FontState | null)[] | undefined
}
const EMPTY_SHAPE_FONT_FACES_CACHE: ShapeFontFacesCache = { get: () => undefined }
const EMPTY_SHAPE_FONT_LOAD_STATE_CACHE: ShapeFontLoadStateCache = { get: () => undefined }
/** @public */
export class FontManager {
constructor(
private readonly editor: Editor,
private readonly assetUrls?: { [key: string]: string | undefined }
) {
this.shapeFontFacesCache = editor.store.createComputedCache(
'shape font faces',
(shape: TLShape) => {
const shapeUtil = this.editor.getShapeUtil(shape)
return shapeUtil.getFontFaces(shape)
},
{
areResultsEqual: areArraysShallowEqual,
areRecordsEqual: areShapesContentEqual,
}
)
this.shapeFontLoadStateCache = editor.store.createCache<(FontState | null)[], TLShape>(
(id: TLShapeId) => {
const fontFacesComputed = computed('font faces', () => this.getShapeFontFaces(id))
return computed(
'font load state',
() => {
const states = fontFacesComputed.get().map((face) => this.getFontState(face))
return states
},
{ isEqual: areArraysShallowEqual }
)
}
)
}
dispose() {
this.fontStates.clear()
this.fontsToLoad.clear()
this.shapeFontFacesCache = EMPTY_SHAPE_FONT_FACES_CACHE
this.shapeFontLoadStateCache = EMPTY_SHAPE_FONT_LOAD_STATE_CACHE
}
private shapeFontFacesCache: ShapeFontFacesCache
private shapeFontLoadStateCache: ShapeFontLoadStateCache
getShapeFontFaces(shape: TLShape | TLShapeId): TLFontFace[] {
const shapeId = typeof shape === 'string' ? shape : shape.id
return this.shapeFontFacesCache.get(shapeId) ?? EMPTY_ARRAY
}
trackFontsForShape(shape: TLShape | TLShapeId) {
const shapeId = typeof shape === 'string' ? shape : shape.id
this.shapeFontLoadStateCache.get(shapeId)
}
async loadRequiredFontsForCurrentPage(limit = Infinity) {
const neededFonts = new Set<TLFontFace>()
for (const shapeId of this.editor.getCurrentPageShapeIds()) {
for (const font of this.getShapeFontFaces(this.editor.getShape(shapeId)!)) {
neededFonts.add(font)
}
}
if (neededFonts.size > limit) {
return
}
const promises = Array.from(neededFonts, (font) => this.ensureFontIsLoaded(font))
await Promise.all(promises)
}
private readonly fontStates = new AtomMap<TLFontFace, FontState>('font states')
private getFontState(font: TLFontFace): FontState | null {
return this.fontStates.get(font) ?? null
}
ensureFontIsLoaded(font: TLFontFace): Promise<void> {
const existingState = this.getFontState(font)
if (existingState) return existingState.loadingPromise
const instance = this.findOrCreateFontFace(font)
// dispose() clears fontStates while loads may still be in flight, so a late
// callback must only touch the exact entry it was created for - not a fresh
// entry from a later request. Check identity, not just presence.
const isStale = () => this.fontStates.__unsafe__getWithoutCapture(font) !== state
const state: FontState = {
state: 'loading',
instance,
loadingPromise: instance
.load()
.then(() => {
if (isStale()) {
// eslint-disable-next-line no-console
console.debug(`Font "${font.family}" load interrupted by editor dispose`)
return
}
this.editor.getContainerDocument().fonts.add(instance)
this.fontStates.update(font, (s) => ({ ...s, state: 'ready' }))
})
.catch((err) => {
if (isStale()) {
// eslint-disable-next-line no-console
console.debug(`Font "${font.family}" load interrupted by editor dispose`, err)
return
}
console.error(err)
this.fontStates.update(font, (s) => ({ ...s, state: 'error' }))
}),
}
this.fontStates.set(font, state)
return state.loadingPromise
}
private fontsToLoad = new Set<TLFontFace>()
requestFonts(fonts: TLFontFace[]) {
if (!this.fontsToLoad.size) {
queueMicrotask(() => {
if (this.editor.isDisposed) return
const toLoad = this.fontsToLoad
this.fontsToLoad = new Set()
transact(() => {
for (const font of toLoad) {
this.ensureFontIsLoaded(font)
}
})
})
}
for (const font of fonts) {
this.fontsToLoad.add(font)
}
}
private findOrCreateFontFace(font: TLFontFace) {
const containerDocument = this.editor.getContainerDocument()
// `findOrCreateFontFace` runs for every font on every editor mount, and a fresh
// editor (e.g. switching documents) gets a fresh FontManager with no memory of the
// previous one. The dedup below is an O(n) scan of the document's whole FontFaceSet,
// so without a cache that scan re-ran on every mount (measurably expensive on mobile
// Safari). Cache the resolved FontFace per document so repeated lookups - and
// remounts - are O(1). Keyed per document for cross-window embedding.
let cache = fontFaceCacheByDocument.get(containerDocument)
if (!cache) {
cache = new Map()
fontFaceCacheByDocument.set(containerDocument, cache)
}
const key = getFontFaceCacheKey(font)
const cached = cache.get(key)
if (cached) return cached
const fonts = containerDocument.fonts
// On a cache miss we still scan once, so font faces added outside this manager
// (e.g. preloaded fonts) are reused rather than duplicated.
for (const existing of fonts) {
if (
existing.family === font.family &&
objectMapEntries(defaultFontFaceDescriptors).every(
([key, defaultValue]) => existing[key] === (font[key] ?? defaultValue)
)
) {
cache.set(key, existing)
return existing
}
}
const url = this.assetUrls?.[font.src.url] ?? font.src.url
const instance = new FontFace(font.family, `url(${JSON.stringify(url)})`, {
...mapObjectMapValues(defaultFontFaceDescriptors, (key) => font[key]),
display: 'swap',
})
fonts.add(instance)
cache.set(key, instance)
return instance
}
async toEmbeddedCssDeclaration(font: TLFontFace) {
const url = this.assetUrls?.[font.src.url] ?? font.src.url
const dataUrl = await FileHelpers.urlToDataUrl(url)
const src = compact([
`url("${dataUrl}")`,
font.src.format ? `format(${font.src.format})` : null,
font.src.tech ? `tech(${font.src.tech})` : null,
]).join(' ')
return compact([
`@font-face {`,
` font-family: "${font.family}";`,
font.ascentOverride ? ` ascent-override: ${font.ascentOverride};` : null,
font.descentOverride ? ` descent-override: ${font.descentOverride};` : null,
font.stretch ? ` font-stretch: ${font.stretch};` : null,
font.style ? ` font-style: ${font.style};` : null,
font.weight ? ` font-weight: ${font.weight};` : null,
font.featureSettings ? ` font-feature-settings: ${font.featureSettings};` : null,
font.lineGapOverride ? ` line-gap-override: ${font.lineGapOverride};` : null,
font.unicodeRange ? ` unicode-range: ${font.unicodeRange};` : null,
` src: ${src};`,
`}`,
]).join('\n')
}
}
// From https://drafts.csswg.org/css-font-loading/#fontface-interface
const defaultFontFaceDescriptors = {
style: 'normal',
weight: 'normal',
stretch: 'normal',
unicodeRange: 'U+0-10FFFF',
featureSettings: 'normal',
ascentOverride: 'normal',
descentOverride: 'normal',
lineGapOverride: 'normal',
}
// A FontFace is fully determined by its family and descriptors, so resolved faces can be
// cached per document and reused across FontManager instances (e.g. editor remounts),
// turning the per-lookup FontFaceSet scan into an O(1) map lookup. Faces are never removed
// from `document.fonts` (FontManager.dispose leaves them), so the cache stays valid for the
// document's lifetime. Keyed per document for cross-window embedding.
let fontFaceCacheByDocument = new WeakMap<Document, Map<string, FontFace>>()
function getFontFaceCacheKey(font: TLFontFace): string {
return JSON.stringify([
font.family,
...objectMapEntries(defaultFontFaceDescriptors).map(
([key, defaultValue]) => font[key] ?? defaultValue
),
])
}
/**
* Resets the per-document font-face cache. Only intended for tests.
* @internal
*/
export function clearFontFaceCacheForTests() {
fontFaceCacheByDocument = new WeakMap()
}

View File

@@ -0,0 +1,910 @@
import { BaseRecord, RecordId, Store, StoreSchema, createRecordType } from '@tldraw/store'
import { vi } from 'vitest'
import { TLHistoryBatchOptions } from '../../types/history-types'
import { HistoryManager } from './HistoryManager'
interface TestRecord extends BaseRecord<'test', TestRecordId> {
value: number | string
}
type TestRecordId = RecordId<TestRecord>
const testSchema = StoreSchema.create<TestRecord, null>({
test: createRecordType<TestRecord>('test', { scope: 'document' }),
})
const ids = {
count: testSchema.types.test.createId('count'),
name: testSchema.types.test.createId('name'),
age: testSchema.types.test.createId('age'),
a: testSchema.types.test.createId('a'),
b: testSchema.types.test.createId('b'),
}
function createCounterHistoryManager() {
const store = new Store({ schema: testSchema, props: null })
store.put([
testSchema.types.test.create({ id: ids.count, value: 0 }),
testSchema.types.test.create({ id: ids.name, value: 'David' }),
testSchema.types.test.create({ id: ids.age, value: 35 }),
])
const manager = new HistoryManager<TestRecord>({ store })
function getCount() {
return store.get(ids.count)!.value as number
}
function getName() {
return store.get(ids.name)!.value as string
}
function getAge() {
return store.get(ids.age)!.value as number
}
function _setCount(n: number) {
store.update(ids.count, (c) => ({ ...c, value: n }))
}
function _setName(name: string) {
store.update(ids.name, (c) => ({ ...c, value: name }))
}
function _setAge(age: number) {
store.update(ids.age, (c) => ({ ...c, value: age }))
}
const increment = (n = 1) => {
_setCount(getCount() + n)
}
const decrement = (n = 1) => {
_setCount(getCount() - n)
}
const setName = (name = 'David') => {
manager.batch(() => _setName(name), { history: 'ignore' })
}
const setAge = (age = 35) => {
manager.batch(() => _setAge(age), { history: 'record-preserveRedoStack' })
}
const incrementTwice = () => {
manager.batch(() => {
increment()
increment()
})
}
return {
increment,
incrementTwice,
decrement,
setName,
setAge,
history: manager,
getCount,
getName,
getAge,
}
}
describe(HistoryManager, () => {
let editor = createCounterHistoryManager()
beforeEach(() => {
editor = createCounterHistoryManager()
})
it('creates a serializable undo stack', () => {
expect(editor.getCount()).toBe(0)
editor.increment()
editor.increment()
editor.history._mark('stop at 2')
editor.increment()
editor.increment()
editor.decrement()
expect(editor.getCount()).toBe(3)
const undos = editor.history.debug().undos
const parsedUndos = JSON.parse(JSON.stringify(undos))
expect(parsedUndos).toEqual(undos)
editor.history.undo()
expect(editor.getCount()).toBe(2)
})
it('allows undoing and redoing', () => {
expect(editor.getCount()).toBe(0)
editor.increment()
editor.history._mark('stop at 1')
editor.increment()
editor.history._mark('stop at 2')
editor.increment()
editor.increment()
editor.history._mark('stop at 4')
editor.increment()
editor.increment()
editor.increment()
expect(editor.getCount()).toBe(7)
editor.history.undo()
expect(editor.getCount()).toBe(4)
editor.history.undo()
expect(editor.getCount()).toBe(2)
editor.history.undo()
expect(editor.getCount()).toBe(1)
editor.history.undo()
expect(editor.getCount()).toBe(0)
editor.history.undo()
editor.history.undo()
editor.history.undo()
expect(editor.getCount()).toBe(0)
editor.history.redo()
expect(editor.getCount()).toBe(1)
editor.history.redo()
expect(editor.getCount()).toBe(2)
editor.history.redo()
expect(editor.getCount()).toBe(4)
editor.history.redo()
expect(editor.getCount()).toBe(7)
})
it('clears the redo stack if you execute commands, but not if you mark stopping points', () => {
expect(editor.getCount()).toBe(0)
editor.increment()
editor.history._mark('stop at 1')
editor.increment()
editor.history._mark('stop at 2')
editor.increment()
editor.increment()
editor.history._mark('stop at 4')
editor.increment()
editor.increment()
editor.increment()
expect(editor.getCount()).toBe(7)
editor.history.undo()
editor.history.undo()
expect(editor.getCount()).toBe(2)
editor.history._mark('wayward stopping point')
editor.history.redo()
editor.history.redo()
expect(editor.getCount()).toBe(7)
editor.history.undo()
editor.history.undo()
expect(editor.getCount()).toBe(2)
editor.increment()
expect(editor.getCount()).toBe(3)
editor.history.redo()
expect(editor.getCount()).toBe(3)
editor.history.redo()
expect(editor.getCount()).toBe(3)
})
it('allows squashing of commands', () => {
editor.increment()
editor.history._mark('stop at 1')
expect(editor.getCount()).toBe(1)
editor.increment(1)
editor.increment(1)
editor.increment(1)
editor.increment(1)
expect(editor.getCount()).toBe(5)
expect(editor.history.getNumUndos()).toBe(3)
})
it('allows ignore commands that do not affect the stack', () => {
editor.increment()
editor.history._mark('stop at 1')
editor.increment()
editor.setName('wilbur')
editor.increment()
expect(editor.getCount()).toBe(3)
expect(editor.getName()).toBe('wilbur')
editor.history.undo()
expect(editor.getCount()).toBe(1)
expect(editor.getName()).toBe('wilbur')
})
it('allows inconsequential commands that do not clear the redo stack', () => {
editor.increment()
editor.history._mark('stop at 1')
editor.increment()
expect(editor.getCount()).toBe(2)
editor.history.undo()
expect(editor.getCount()).toBe(1)
editor.history._mark('stop at age 35')
editor.setAge(23)
editor.history._mark('stop at age 23')
expect(editor.getCount()).toBe(1)
editor.history.redo()
expect(editor.getCount()).toBe(2)
expect(editor.getAge()).toBe(23)
editor.history.undo()
expect(editor.getCount()).toBe(1)
expect(editor.getAge()).toBe(23)
editor.history.undo()
expect(editor.getCount()).toBe(1)
expect(editor.getAge()).toBe(35)
editor.history.undo()
expect(editor.getCount()).toBe(0)
expect(editor.getAge()).toBe(35)
})
it('does not allow new history entries to be pushed if a command invokes them while doing or undoing', () => {
editor.incrementTwice()
expect(editor.history.getNumUndos()).toBe(1)
expect(editor.getCount()).toBe(2)
editor.history.undo()
expect(editor.getCount()).toBe(0)
expect(editor.history.getNumUndos()).toBe(0)
})
it('does not allow new history entries to be pushed if a command invokes them while bailing', () => {
editor.history._mark('0')
editor.incrementTwice()
editor.history._mark('2')
editor.incrementTwice()
editor.incrementTwice()
expect(editor.history.getNumUndos()).toBe(4)
expect(editor.getCount()).toBe(6)
editor.history.bail()
expect(editor.getCount()).toBe(2)
expect(editor.history.getNumUndos()).toBe(2)
editor.history.bailToMark('0')
expect(editor.history.getNumUndos()).toBe(0)
expect(editor.getCount()).toBe(0)
})
it('supports bailing to a particular mark', () => {
editor.increment()
editor.history._mark('1')
editor.increment()
editor.history._mark('2')
editor.increment()
editor.history._mark('3')
editor.increment()
expect(editor.getCount()).toBe(4)
editor.history.bailToMark('2')
expect(editor.getCount()).toBe(2)
})
})
describe('history options', () => {
let manager: HistoryManager<TestRecord>
let getState: () => { a: number; b: number }
let setA: (n: number, historyOptions?: TLHistoryBatchOptions) => any
let setB: (n: number, historyOptions?: TLHistoryBatchOptions) => any
beforeEach(() => {
const store = new Store({ schema: testSchema, props: null })
store.put([
testSchema.types.test.create({ id: ids.a, value: 0 }),
testSchema.types.test.create({ id: ids.b, value: 0 }),
])
manager = new HistoryManager<TestRecord>({ store })
getState = () => {
return { a: store.get(ids.a)!.value as number, b: store.get(ids.b)!.value as number }
}
setA = (n: number, historyOptions?: TLHistoryBatchOptions) => {
manager.batch(() => store.update(ids.a, (s) => ({ ...s, value: n })), historyOptions)
}
setB = (n: number, historyOptions?: TLHistoryBatchOptions) => {
manager.batch(() => store.update(ids.b, (s) => ({ ...s, value: n })), historyOptions)
}
})
it('undos, redoes, separate marks', () => {
manager._mark('')
setA(1)
manager._mark('')
setB(1)
manager._mark('')
setB(2)
expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(getState()).toMatchObject({ a: 1, b: 2 })
})
it('undos, redos, squashing', () => {
manager._mark('')
setA(1)
manager._mark('')
setB(1)
manager._mark('')
setB(2)
setB(3)
setB(4)
expect(getState()).toMatchObject({ a: 1, b: 4 })
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 1 })
manager.redo()
expect(getState()).toMatchObject({ a: 1, b: 4 })
})
it('undos, redos, ignore', () => {
manager._mark('')
setA(1)
manager._mark('')
setB(1) // B 0->1
manager._mark('')
setB(2, { history: 'ignore' }) // B 0->2, but ignore
expect(getState()).toMatchObject({ a: 1, b: 2 })
manager.undo() // undoes B 2->0
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo() // redoes B 0->1, but not B 1-> 2
expect(getState()).toMatchObject({ a: 1, b: 1 }) // no change, b 1->2 was ignore
})
it('squashing, undos, redos', () => {
manager._mark('')
setA(1)
manager._mark('')
setB(1)
setB(2) // squashes with the previous command
setB(3) // squashes with the previous command
expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(getState()).toMatchObject({ a: 1, b: 3 })
})
it('squashing, undos, redos, ignore', () => {
manager._mark('')
setA(1)
manager._mark('')
setB(1)
setB(2) // squashes with the previous command
setB(3, { history: 'ignore' }) // squashes with the previous command
expect(getState()).toMatchObject({ a: 1, b: 3 })
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 0 })
manager.redo()
expect(getState()).toMatchObject({ a: 1, b: 2 }) // B2->3 was ignore
})
it('nested ignore', () => {
manager._mark('')
manager.batch(
() => {
setA(1)
// even though we set this to record, it will still be ignored
manager.batch(() => setB(1), { history: 'record' })
setA(2)
},
{ history: 'ignore' }
)
expect(getState()).toMatchObject({ a: 2, b: 1 })
// changes were ignored:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 1 })
manager._mark('')
manager.batch(
() => {
setA(3)
manager.batch(() => setB(2), { history: 'ignore' })
},
{ history: 'record-preserveRedoStack' }
)
expect(getState()).toMatchObject({ a: 3, b: 2 })
// changes to A were recorded, but changes to B were ignore:
manager.undo()
expect(getState()).toMatchObject({ a: 2, b: 2 })
// We can still redo because we preserved the redo stack:
manager.redo()
expect(getState()).toMatchObject({ a: 3, b: 2 })
})
it('squashToMark works', () => {
manager._mark('a')
setA(1)
manager._mark('b')
setB(1)
setB(2)
setB(3)
manager._mark('')
setA(2)
setB(4)
manager._mark('')
setB(5)
setB(6)
expect(getState()).toMatchObject({ a: 2, b: 6 })
manager.squashToMark('b')
// does not affect state
expect(getState()).toMatchObject({ a: 2, b: 6 })
// but now undoing should take us back to a
manager.undo()
expect(getState()).toMatchObject({ a: 1, b: 0 })
// and redoing should take us back to the end
manager.redo()
expect(getState()).toMatchObject({ a: 2, b: 6 })
// and we can get back to the start with two undos
manager.undo().undo()
expect(getState()).toMatchObject({ a: 0, b: 0 })
manager.redo().redo()
manager.squashToMark('a')
expect(getState()).toMatchObject({ a: 2, b: 6 })
manager.undo()
expect(getState()).toMatchObject({ a: 0, b: 0 })
})
})
describe('HistoryManager constructor and lifecycle', () => {
let store: Store<TestRecord>
beforeEach(() => {
store = new Store({ schema: testSchema, props: null })
store.put([testSchema.types.test.create({ id: ids.a, value: 0 })])
})
it('should initialize with store reference', () => {
const manager = new HistoryManager({ store })
expect(manager).toBeDefined()
expect(manager.getNumUndos()).toBe(0)
expect(manager.getNumRedos()).toBe(0)
})
it('should initialize with optional annotateError callback', () => {
const mockAnnotateError = vi.fn()
const manager = new HistoryManager({ store, annotateError: mockAnnotateError })
expect(manager).toBeDefined()
})
it('should properly dispose and cleanup', () => {
const manager = new HistoryManager({ store })
expect(typeof manager.dispose).toBe('function')
// Should not throw when disposing
expect(() => manager.dispose()).not.toThrow()
})
it('should handle errors in batch operations with annotateError', () => {
const mockAnnotateError = vi.fn()
const manager = new HistoryManager({ store, annotateError: mockAnnotateError })
const errorFn = () => {
throw new Error('Test error')
}
expect(() => manager.batch(errorFn)).toThrow('Test error')
expect(mockAnnotateError).toHaveBeenCalledWith(expect.any(Error))
})
it('should handle nested batch error scenarios', () => {
const mockAnnotateError = vi.fn()
const manager = new HistoryManager({ store, annotateError: mockAnnotateError })
const nestedErrorFn = () => {
manager.batch(() => {
throw new Error('Nested error')
})
}
expect(() => manager.batch(nestedErrorFn)).toThrow('Nested error')
expect(mockAnnotateError).toHaveBeenCalledWith(expect.any(Error))
})
})
describe('HistoryManager getters and utilities', () => {
let manager: HistoryManager<TestRecord>
let store: Store<TestRecord>
beforeEach(() => {
store = new Store({ schema: testSchema, props: null })
store.put([testSchema.types.test.create({ id: ids.a, value: 0 })])
manager = new HistoryManager({ store })
})
describe('getNumUndos and getNumRedos', () => {
it('should return 0 for empty history', () => {
expect(manager.getNumUndos()).toBe(0)
expect(manager.getNumRedos()).toBe(0)
})
it('should count undos correctly with pending changes', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
expect(manager.getNumUndos()).toBe(1)
expect(manager.getNumRedos()).toBe(0)
})
it('should count undos and redos after operations', () => {
expect(manager.getNumUndos()).toBe(0)
expect(manager.getNumRedos()).toBe(0)
store.update(ids.a, (s) => ({ ...s, value: 1 }))
expect(manager.getNumUndos()).toBe(1)
expect(manager.getNumRedos()).toBe(0)
manager._mark('mark1')
expect(manager.getNumUndos()).toBe(2)
expect(manager.getNumRedos()).toBe(0)
store.update(ids.a, (s) => ({ ...s, value: 2 }))
// Based on actual behavior: 1 pending + 1 mark + 1 flushed = 3
expect(manager.getNumUndos()).toBe(3)
expect(manager.getNumRedos()).toBe(0)
manager.undo()
expect(manager.getNumUndos()).toBe(1) // After undo, we're back to just the first operation
expect(manager.getNumRedos()).toBe(2) // Undo moved 2 items to redo stack (pending + mark)
})
it('should count correctly after clearing redo stack', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager.undo()
expect(manager.getNumRedos()).toBe(1)
store.update(ids.a, (s) => ({ ...s, value: 2 }))
expect(manager.getNumRedos()).toBe(0)
})
it('should only report replaying while undoing or redoing', () => {
const replayStates: boolean[] = []
const dispose = store.addHistoryInterceptor(() => {
replayStates.push(manager.isReplaying())
})
store.update(ids.a, (s) => ({ ...s, value: 1 }))
expect(replayStates).toEqual([false])
replayStates.length = 0
manager.undo()
expect(replayStates).toEqual([true])
expect(manager.isReplaying()).toBe(false)
replayStates.length = 0
manager.redo()
expect(replayStates).toEqual([true])
expect(manager.isReplaying()).toBe(false)
replayStates.length = 0
manager.batch(
() => {
store.update(ids.a, (s) => ({ ...s, value: 2 }))
},
{ history: 'ignore' }
)
expect(replayStates).toEqual([false])
expect(manager.isReplaying()).toBe(false)
dispose()
})
})
describe('getMarkIdMatching', () => {
it('should return null when no marks exist', () => {
expect(manager.getMarkIdMatching('test')).toBeNull()
})
it('should find marks by substring', () => {
manager._mark('test-mark-1')
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager._mark('other-mark-2')
expect(manager.getMarkIdMatching('test')).toBe('test-mark-1')
expect(manager.getMarkIdMatching('other')).toBe('other-mark-2')
expect(manager.getMarkIdMatching('mark')).toBe('other-mark-2') // returns most recent
})
it('should return null for non-existent substrings', () => {
manager._mark('test-mark')
expect(manager.getMarkIdMatching('nonexistent')).toBeNull()
})
it('should find marks after undo operations', () => {
manager._mark('initial-mark')
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager._mark('second-mark')
store.update(ids.a, (s) => ({ ...s, value: 2 }))
// After undo, marks should still be findable in the undo stack
expect(manager.getMarkIdMatching('initial')).toBe('initial-mark')
expect(manager.getMarkIdMatching('second')).toBe('second-mark')
})
})
describe('clear method', () => {
it('should clear all history', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager._mark('test-mark')
store.update(ids.a, (s) => ({ ...s, value: 2 }))
expect(manager.getNumUndos()).toBeGreaterThan(0)
manager.clear()
expect(manager.getNumUndos()).toBe(0)
expect(manager.getNumRedos()).toBe(0)
})
it('should clear pending diffs', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
expect(manager.getNumUndos()).toBe(1)
manager.clear()
expect(manager.getNumUndos()).toBe(0)
})
it('should allow new operations after clearing', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager.clear()
store.update(ids.a, (s) => ({ ...s, value: 2 }))
expect(manager.getNumUndos()).toBe(1)
manager.undo()
expect(store.get(ids.a)!.value).toBe(1) // Should undo to value before the new operation
})
})
describe('debug method', () => {
it('should return debug information', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager._mark('test-mark')
store.update(ids.a, (s) => ({ ...s, value: 2 }))
const debug = manager.debug()
expect(debug).toHaveProperty('undos')
expect(debug).toHaveProperty('redos')
expect(debug).toHaveProperty('pendingDiff')
expect(debug).toHaveProperty('state')
expect(Array.isArray(debug.undos)).toBe(true)
expect(Array.isArray(debug.redos)).toBe(true)
})
it('should show correct state information', () => {
const debug = manager.debug()
expect(debug.state).toBe('recording')
})
it('should reflect changes after undo/redo', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
manager.undo()
const debug = manager.debug()
expect(debug.redos.length).toBeGreaterThan(0)
})
})
})
describe('HistoryManager error scenarios and edge cases', () => {
let manager: HistoryManager<TestRecord>
let store: Store<TestRecord>
beforeEach(() => {
store = new Store({ schema: testSchema, props: null })
store.put([testSchema.types.test.create({ id: ids.a, value: 0 })])
manager = new HistoryManager({ store })
})
describe('squashToMark error handling', () => {
it('should handle non-existent mark gracefully', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
manager.squashToMark('non-existent-mark')
expect(consoleSpy).toHaveBeenCalledWith(
'Could not find mark to squash to: ',
'non-existent-mark'
)
consoleSpy.mockRestore()
})
it('should handle empty stack when squashing', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
manager.squashToMark('non-existent')
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should return early when no changes to squash', () => {
manager._mark('test-mark')
// No operations between marks
const result = manager.squashToMark('test-mark')
expect(result).toBe(manager) // Should return manager instance
})
})
describe('bailToMark with non-existent marks', () => {
it('should handle non-existent mark in bailToMark', () => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
const originalValue = store.get(ids.a)!.value
manager.bailToMark('non-existent-mark')
// Should not change anything when mark doesn't exist
expect(store.get(ids.a)!.value).toBe(originalValue)
})
it('should preserve pending diff when mark is not found', () => {
manager._mark('real-mark')
store.update(ids.a, (s) => ({ ...s, value: 1 }))
// bail to a mark that doesn't exist
manager.bailToMark('non-existent-mark')
// the pending diff should still be intact
expect(store.get(ids.a)!.value).toBe(1)
expect(manager.getNumUndos()).toBeGreaterThan(0)
// a subsequent bail to the real mark should still work
manager.bailToMark('real-mark')
expect(store.get(ids.a)!.value).toBe(0)
})
it('should find mark correctly when it exists', () => {
manager._mark('existing-mark')
store.update(ids.a, (s) => ({ ...s, value: 1 }))
store.update(ids.a, (s) => ({ ...s, value: 2 }))
manager.bailToMark('existing-mark')
expect(store.get(ids.a)!.value).toBe(0)
})
})
describe('empty stack operations', () => {
it('should handle undo on empty stack', () => {
expect(() => manager.undo()).not.toThrow()
expect(manager.getNumUndos()).toBe(0)
})
it('should handle redo on empty stack', () => {
expect(() => manager.redo()).not.toThrow()
expect(manager.getNumRedos()).toBe(0)
})
it('should handle bail on empty stack', () => {
expect(() => manager.bail()).not.toThrow()
expect(manager.getNumUndos()).toBe(0)
})
})
describe('batch operation edge cases', () => {
it('should handle nested batches correctly', () => {
let callCount = 0
manager.batch(() => {
callCount++
manager.batch(() => {
callCount++
store.update(ids.a, (s) => ({ ...s, value: 1 }))
})
store.update(ids.a, (s) => ({ ...s, value: 2 }))
})
expect(callCount).toBe(2)
expect(store.get(ids.a)!.value).toBe(2)
})
it('should maintain batch state correctly during errors', () => {
const mockAnnotateError = vi.fn()
const errorManager = new HistoryManager({ store, annotateError: mockAnnotateError })
try {
errorManager.batch(() => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
throw new Error('Test error')
})
} catch (_e) {
// Expected to throw
}
// Should be able to perform normal operations after error
expect(() => {
errorManager.batch(() => {
store.update(ids.a, (s) => ({ ...s, value: 2 }))
})
}).not.toThrow()
})
it('should handle batch with undefined history option', () => {
expect(() => {
manager.batch(() => {
store.update(ids.a, (s) => ({ ...s, value: 1 }))
}, undefined)
}).not.toThrow()
})
})
describe('large history operations', () => {
it('should handle many undo operations', () => {
// Create operations with marks - based on existing test pattern
for (let i = 1; i <= 10; i++) {
store.update(ids.a, (s) => ({ ...s, value: i }))
if (i % 3 === 0) manager._mark(`mark-${i}`)
}
const undoCount = manager.getNumUndos()
expect(undoCount).toBeGreaterThan(3)
// Undo some operations
const undosToPerform = 3
for (let i = 0; i < undosToPerform; i++) {
manager.undo()
}
// Due to marks, the redo count might be different
expect(manager.getNumRedos()).toBeGreaterThan(0)
})
it('should handle alternating undo/redo operations', () => {
for (let i = 1; i <= 10; i++) {
store.update(ids.a, (s) => ({ ...s, value: i }))
}
// Alternate undo/redo
for (let i = 0; i < 5; i++) {
manager.undo()
manager.redo()
}
expect(store.get(ids.a)!.value).toBe(10)
})
})
describe('concurrent-like operations', () => {
it('should handle rapid sequential operations', () => {
const initialValue = store.get(ids.a)!.value
// Rapid fire updates
for (let i = 1; i <= 20; i++) {
store.update(ids.a, (s) => ({ ...s, value: i }))
}
manager.undo()
expect(store.get(ids.a)!.value).toBe(initialValue)
})
})
})

View File

@@ -0,0 +1,432 @@
import { atom, EMPTY_ARRAY, transact } from '@tldraw/state'
import {
createEmptyRecordsDiff,
isRecordsDiffEmpty,
RecordsDiff,
reverseRecordsDiff,
squashRecordDiffsMutable,
Store,
UnknownRecord,
} from '@tldraw/store'
import { exhaustiveSwitchError, noop } from '@tldraw/utils'
import { TLHistoryBatchOptions, TLHistoryEntry } from '../../types/history-types'
const HistoryRecorderState = {
Recording: 'recording',
RecordingPreserveRedoStack: 'recordingPreserveRedoStack',
Paused: 'paused',
} as const
type HistoryRecorderState = (typeof HistoryRecorderState)[keyof typeof HistoryRecorderState]
/** @public */
export class HistoryManager<R extends UnknownRecord> {
private readonly store: Store<R>
readonly dispose: () => void
private state: HistoryRecorderState = HistoryRecorderState.Recording
private readonly pendingDiff = new PendingDiff<R>()
private stacks = atom(
'HistoryManager.stacks',
{
undos: stack<TLHistoryEntry<R>>(),
redos: stack<TLHistoryEntry<R>>(),
},
{
isEqual: (a, b) => a.undos === b.undos && a.redos === b.redos,
}
)
private readonly annotateError: (error: unknown) => void
constructor(opts: { store: Store<R>; annotateError?(error: unknown): void }) {
this.store = opts.store
this.annotateError = opts.annotateError ?? noop
this.dispose = this.store.addHistoryInterceptor((entry, source) => {
if (source !== 'user') return
switch (this.state) {
case HistoryRecorderState.Recording:
this.pendingDiff.apply(entry.changes)
// this interceptor runs for every store change, so skip the update when the
// redo stack is already empty
if (this.stacks.get().redos.length > 0) {
this.stacks.update(({ undos }) => ({ undos, redos: stack() }))
}
break
case HistoryRecorderState.RecordingPreserveRedoStack:
this.pendingDiff.apply(entry.changes)
break
case HistoryRecorderState.Paused:
break
default:
exhaustiveSwitchError(this.state)
}
})
}
private flushPendingDiff() {
if (this.pendingDiff.isEmpty()) return
const diff = this.pendingDiff.clear()
this.stacks.update(({ undos, redos }) => ({
undos: undos.push({ type: 'diff', diff }),
redos,
}))
}
getNumUndos() {
return this.stacks.get().undos.length + (this.pendingDiff.isEmpty() ? 0 : 1)
}
getNumRedos() {
return this.stacks.get().redos.length
}
/** @internal */
private _isReplaying = false
/** @internal */
isReplaying() {
return this._isReplaying
}
/** @internal */
_isInBatch = false
batch(fn: () => void, opts?: TLHistoryBatchOptions) {
const previousState = this.state
// we move to the new state only if we haven't explicitly paused
if (previousState !== HistoryRecorderState.Paused && opts?.history) {
this.state = modeToState[opts.history]
}
try {
if (this._isInBatch) {
transact(fn)
return this
}
this._isInBatch = true
try {
transact(fn)
} catch (error) {
this.annotateError(error)
throw error
} finally {
this._isInBatch = false
}
return this
} finally {
this.state = previousState
}
}
// History
_undo({ pushToRedoStack, toMark = undefined }: { pushToRedoStack: boolean; toMark?: string }) {
const previousState = this.state
const previousIsReplaying = this._isReplaying
this.state = HistoryRecorderState.Paused
this._isReplaying = true
try {
let { undos, redos } = this.stacks.get()
// start by collecting the pending diff (everything since the last mark).
// we'll accumulate the diff to undo in this variable so we can apply it atomically.
const pendingDiff = this.pendingDiff.clear()
const isPendingDiffEmpty = isRecordsDiffEmpty(pendingDiff)
const diffToUndo = reverseRecordsDiff(pendingDiff)
if (pushToRedoStack && !isPendingDiffEmpty) {
redos = redos.push({ type: 'diff', diff: pendingDiff })
}
let didFindMark = false
if (isPendingDiffEmpty) {
// if nothing has happened since the last mark, pop any intermediate marks off the stack
while (undos.head?.type === 'stop') {
const mark = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(mark)
}
if (mark.id === toMark) {
didFindMark = true
break
}
}
}
if (!didFindMark) {
loop: while (undos.head) {
const undo = undos.head
undos = undos.tail
if (pushToRedoStack) {
redos = redos.push(undo)
}
switch (undo.type) {
case 'diff':
squashRecordDiffsMutable(diffToUndo, [reverseRecordsDiff(undo.diff)])
break
case 'stop':
if (!toMark) break loop
if (undo.id === toMark) {
didFindMark = true
break loop
}
break
default:
exhaustiveSwitchError(undo)
}
}
}
if (!didFindMark && toMark) {
// we didn't find the mark we were looking for — restore state and bail
this.pendingDiff.restore(pendingDiff)
return this
}
this.store.applyDiff(diffToUndo, { ignoreEphemeralKeys: true })
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this._isReplaying = previousIsReplaying
this.state = previousState
}
return this
}
undo() {
this._undo({ pushToRedoStack: true })
return this
}
redo() {
const previousState = this.state
const previousIsReplaying = this._isReplaying
this.state = HistoryRecorderState.Paused
this._isReplaying = true
try {
this.flushPendingDiff()
let { undos, redos } = this.stacks.get()
if (redos.length === 0) {
return this
}
// ignore any intermediate marks - this should take us to the first `diff` entry
while (redos.head?.type === 'stop') {
undos = undos.push(redos.head)
redos = redos.tail
}
// accumulate diffs to be redone so they can be applied atomically
const diffToRedo = createEmptyRecordsDiff<R>()
while (redos.head) {
const redo = redos.head
undos = undos.push(redo)
redos = redos.tail
if (redo.type === 'diff') {
squashRecordDiffsMutable(diffToRedo, [redo.diff])
} else {
break
}
}
this.store.applyDiff(diffToRedo, { ignoreEphemeralKeys: true })
this.store.ensureStoreIsUsable()
this.stacks.set({ undos, redos })
} finally {
this._isReplaying = previousIsReplaying
this.state = previousState
}
return this
}
bail() {
this._undo({ pushToRedoStack: false })
return this
}
bailToMark(id: string) {
if (id) {
this._undo({ pushToRedoStack: false, toMark: id })
}
return this
}
squashToMark(id: string) {
// remove marks between head and the mark
let top = this.stacks.get().undos
const popped: Array<RecordsDiff<R>> = []
while (top.head && !(top.head.type === 'stop' && top.head.id === id)) {
if (top.head.type === 'diff') {
popped.push(top.head.diff)
}
top = top.tail
}
if (!top.head || top.head?.id !== id) {
console.error('Could not find mark to squash to: ', id)
return this
}
if (popped.length === 0) {
return this
}
const diff = createEmptyRecordsDiff<R>()
squashRecordDiffsMutable(diff, popped.reverse())
this.stacks.update(({ redos }) => ({
undos: top.push({
type: 'diff',
diff,
}),
redos,
}))
return this
}
/** @internal */
_mark(id: string) {
transact(() => {
this.flushPendingDiff()
this.stacks.update(({ undos, redos }) => ({ undos: undos.push({ type: 'stop', id }), redos }))
})
}
clear() {
this.stacks.set({ undos: stack(), redos: stack() })
this.pendingDiff.clear()
}
/** @internal */
getMarkIdMatching(idSubstring: string) {
let top = this.stacks.get().undos
while (top.head) {
if (top.head.type === 'stop' && top.head.id.includes(idSubstring)) {
return top.head.id
}
top = top.tail
}
return null
}
/** @internal */
debug() {
const { undos, redos } = this.stacks.get()
return {
undos: stackToArray(undos),
redos: stackToArray(redos),
pendingDiff: this.pendingDiff.debug(),
state: this.state as string,
}
}
}
const modeToState = {
record: HistoryRecorderState.Recording,
'record-preserveRedoStack': HistoryRecorderState.RecordingPreserveRedoStack,
ignore: HistoryRecorderState.Paused,
} as const
class PendingDiff<R extends UnknownRecord> {
private diff = createEmptyRecordsDiff<R>()
private isEmptyAtom = atom('PendingDiff.isEmpty', true)
clear() {
const diff = this.diff
this.diff = createEmptyRecordsDiff<R>()
this.isEmptyAtom.set(true)
return diff
}
restore(diff: RecordsDiff<R>) {
this.diff = diff
this.isEmptyAtom.set(isRecordsDiffEmpty(diff))
}
isEmpty() {
return this.isEmptyAtom.get()
}
apply(diff: RecordsDiff<R>) {
squashRecordDiffsMutable(this.diff, [diff])
// Recomputing emptiness from the accumulated diff is O(N) (for-in over a
// dictionary-mode object pays an O(N) key-collection prologue), and this runs on
// every history interceptor call — e.g. every input tick while resizing many
// shapes. Updates can never cancel out existing entries during a squash, so the
// full recompute is only needed when the incoming diff adds or removes records.
if (hasAnyKey(diff.added) || hasAnyKey(diff.removed)) {
this.isEmptyAtom.set(isRecordsDiffEmpty(this.diff))
} else if (this.isEmptyAtom.__unsafe__getWithoutCapture()) {
this.isEmptyAtom.set(!hasAnyKey(diff.updated))
}
}
debug() {
return { diff: this.diff, isEmpty: this.isEmpty() }
}
}
function hasAnyKey(obj: object) {
for (const _ in obj) return true
return false
}
type Stack<T> = StackItem<T> | EmptyStackItem<T>
function stack<T>(): Stack<T> {
return EMPTY_STACK_ITEM as any
}
class EmptyStackItem<T> {
readonly length = 0
readonly head = null
readonly tail: Stack<T> = this
push(head: T): Stack<T> {
return new StackItem<T>(head, this)
}
}
const EMPTY_STACK_ITEM = new EmptyStackItem()
class StackItem<T> {
length: number
constructor(
public readonly head: T,
public readonly tail: Stack<T>
) {
this.length = tail.length + 1
}
push(head: T): Stack<T> {
return new StackItem(head, this)
}
}
function stackToArray<T>(stack: Stack<T>) {
if (!stack.length) {
return EMPTY_ARRAY
}
const arr: T[] = []
while (stack.length) {
arr.push(stack.head!)
stack = stack.tail
}
return arr
}

View File

@@ -0,0 +1,257 @@
import { InstancePresenceRecordType, TLPOINTER_ID, createUserId } from '@tldraw/tlschema'
import { vi } from 'vitest'
import { createTLStore } from '../../../config/createTLStore'
import { TldrawOptions } from '../../../options'
import { Editor } from '../../Editor'
import { StateNode } from '../../tools/StateNode'
function createPresence(editor: Editor) {
return InstancePresenceRecordType.create({
id: InstancePresenceRecordType.createId('peer'),
userId: createUserId('peer'),
userName: 'Peer',
currentPageId: editor.getCurrentPageId(),
})
}
function createTestEditor(options?: Partial<TldrawOptions>) {
const store = createTLStore({})
store.ensureStoreIsUsable()
// Attached to the document so that events dispatched on the container
// propagate to the window, where the activity listeners live.
const container = document.createElement('div')
document.body.appendChild(container)
const editor = new Editor({
store,
bindingUtils: [],
shapeUtils: [],
getContainer: () => container,
tools: [],
options,
})
editor.disposables.add(() => container.remove())
// Input listeners only attach once the editor is mounted, which is normally
// emitted by `TldrawEditor` when the container is in the DOM.
editor.emit('mount')
return editor
}
describe('InputsManager', () => {
let editor: Editor
beforeEach(() => {
editor = createTestEditor()
})
afterEach(() => {
editor.dispose()
})
it('updates pointer velocity on frame events', () => {
const point = editor.inputs.getCurrentScreenPoint()
point.x = 0
point.y = 0
editor.emit('frame', 16)
point.x = 100
point.y = 0
editor.emit('frame', 16)
expect(editor.inputs.getPointerVelocity().len()).toBeGreaterThan(0)
})
describe('markActivity', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('does nothing when there are no collaborators', () => {
editor.inputs.markActivity()
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(0)
})
it('stamps the pointer record, preserving its position', () => {
editor.store.put([createPresence(editor)])
editor.store.put([{ ...editor.store.get(TLPOINTER_ID)!, x: 5, y: 6 }])
editor.inputs.markActivity()
expect(editor.store.get(TLPOINTER_ID)!).toMatchObject({
x: 5,
y: 6,
lastActivityTimestamp: Date.now(),
})
})
it('throttles stamps on the leading edge', () => {
editor.store.put([createPresence(editor)])
editor.inputs.markActivity()
const firstStamp = editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
vi.advanceTimersByTime(500)
editor.inputs.markActivity()
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(firstStamp)
vi.advanceTimersByTime(600)
editor.inputs.markActivity()
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(firstStamp + 1100)
})
it('stamps on keyboard input in the container', () => {
editor.store.put([createPresence(editor)])
editor.getContainer().dispatchEvent(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA' }))
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
})
it('stamps on pointer input in the container', () => {
editor.store.put([createPresence(editor)])
editor.getContainer().dispatchEvent(new Event('pointermove', { bubbles: true }))
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
})
it('stamps on text input that fires no keydown, e.g. IME and gesture typing', () => {
editor.store.put([createPresence(editor)])
editor.getContainer().dispatchEvent(new Event('beforeinput', { bubbles: true }))
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
})
it('stamps on input elsewhere in the tab, outside the container', () => {
editor.store.put([createPresence(editor)])
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', code: 'KeyA' }))
expect(editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(Date.now())
})
it('keeps a minimum throttle window when the idle timeout is tiny', () => {
const tinyEditor = createTestEditor({ collaboratorIdleTimeoutMs: 0 })
try {
tinyEditor.store.put([createPresence(tinyEditor)])
tinyEditor.inputs.markActivity()
const stamp = tinyEditor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
vi.advanceTimersByTime(50)
tinyEditor.inputs.markActivity()
expect(tinyEditor.store.get(TLPOINTER_ID)!.lastActivityTimestamp).toBe(stamp)
} finally {
tinyEditor.dispose()
}
})
it('attaches input listeners only while sharing with someone', () => {
const addEventListener = vi.spyOn(window, 'addEventListener')
const removeEventListener = vi.spyOn(window, 'removeEventListener')
const soloEditor = createTestEditor()
try {
expect(addEventListener).not.toHaveBeenCalled()
soloEditor.store.put([createPresence(soloEditor)])
expect(addEventListener).toHaveBeenCalledWith('pointermove', expect.anything(), {
capture: true,
})
soloEditor.store.remove([createPresence(soloEditor).id])
expect(removeEventListener).toHaveBeenCalledWith('pointermove', expect.anything(), {
capture: true,
})
} finally {
soloEditor.dispose()
addEventListener.mockRestore()
removeEventListener.mockRestore()
}
})
it('attaches no input listeners when construction fails before mount', () => {
class DuplicateTool extends StateNode {
static override id = 'duplicate-tool'
}
const addEventListener = vi.spyOn(window, 'addEventListener')
try {
const store = createTLStore({})
store.ensureStoreIsUsable()
expect(
() =>
new Editor({
store,
bindingUtils: [],
shapeUtils: [],
getContainer: () => document.createElement('div'),
// The duplicate id makes the constructor throw after the inputs
// manager is built, so the editor is never disposed.
tools: [DuplicateTool, DuplicateTool],
})
).toThrow()
expect(addEventListener).not.toHaveBeenCalled()
} finally {
addEventListener.mockRestore()
}
})
it('does not stamp on pointer moves with no DOM input, e.g. following a user', () => {
editor.store.put([createPresence(editor)])
editor.inputs.markActivity()
const stamp = editor.store.get(TLPOINTER_ID)!.lastActivityTimestamp
vi.advanceTimersByTime(5000)
editor.inputs.updateFromEvent({
type: 'pointer',
name: 'pointer_move',
point: { x: 100, y: 50 },
pointerId: 1,
button: 0,
isPen: false,
target: 'canvas',
shiftKey: false,
altKey: false,
ctrlKey: false,
metaKey: false,
accelKey: false,
})
// The pointer position updates, but the activity clock doesn't.
expect(editor.store.get(TLPOINTER_ID)!).toMatchObject({
x: 100,
y: 50,
lastActivityTimestamp: stamp,
})
})
})
it('stops updating pointer velocity after dispose', () => {
const point = editor.inputs.getCurrentScreenPoint()
point.x = 0
point.y = 0
editor.emit('frame', 16)
point.x = 100
point.y = 0
editor.emit('frame', 16)
const velocityBeforeDispose = editor.inputs.getPointerVelocity().clone()
expect(velocityBeforeDispose.len()).toBeGreaterThan(0)
editor.inputs.dispose()
point.x = 200
point.y = 0
editor.emit('frame', 16)
expect(editor.inputs.getPointerVelocity()).toEqual(velocityBeforeDispose)
})
})

View File

@@ -0,0 +1,715 @@
import { atom, computed, react, unsafe__withoutCapture } from '@tldraw/state'
import { AtomSet } from '@tldraw/store'
import { TLINSTANCE_ID, TLPOINTER_ID } from '@tldraw/tlschema'
import { bind, throttle } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec'
import { isAccelKey } from '../../../utils/keyboard'
import type { Editor } from '../../Editor'
import { TLPinchEventInfo, TLPointerEventInfo, TLWheelEventInfo } from '../../types/event-types'
import { EditorManager } from '../EditorManager'
const POINTER_VELOCITY_REFERENCE_INTERVAL_MS = 16
const POINTER_VELOCITY_REFERENCE_SMOOTHING = 0.5
// How often `markActivity` is allowed to stamp the activity timestamp. Each
// stamp re-broadcasts presence, so this must be long enough to keep input
// bursts (e.g. typing, held keys) from flooding the network. It must also stay
// comfortably shorter than `collaboratorIdleTimeoutMs` so a continuously-active
// peer never flickers to idle between stamps; the throttle window is capped
// below that option when it's configured shorter than the default, but never
// below the floor, so a degenerate idle timeout can't disable throttling.
const ACTIVITY_TIMESTAMP_THROTTLE_MS = 1000
const ACTIVITY_TIMESTAMP_THROTTLE_FLOOR_MS = 100
// DOM events that count as presence activity. `beforeinput` covers IME and
// mobile keyboards (e.g. gesture typing), which often don't fire per-character
// keydown events. `gesturestart`/`gesturechange` are Safari's proprietary
// trackpad-pinch events, which produce neither pointer nor wheel events.
const ACTIVITY_EVENTS = [
'pointerdown',
'pointermove',
'pointerup',
'keydown',
'beforeinput',
'wheel',
'gesturestart',
'gesturechange',
]
/** @public */
export class InputsManager extends EditorManager {
constructor(editor: Editor) {
super(editor)
this.addEditorEvent('frame', this._onFrame)
// Track input only while mounted and sharing with someone: a solo user
// pays for no listeners at all, and the window is resolved on attach
// rather than up front, when the container is guaranteed to exist.
//
// The mount check must come first. This runs eagerly during the editor's
// constructor, where `editor.collaborators` isn't assigned yet, so reading
// collaborators here would throw; a never-mounted editor short-circuits
// before that, and re-runs once mount flips the atom.
this.register(
react('presence activity listeners', () => {
if (this.editor.getIsMounted() && this._getHasCollaborators()) {
this._attachActivityListeners()
} else {
this._detachActivityListeners()
}
})
)
this.register(() => {
this._detachActivityListeners()
this._throttledActivityStamp.cancel()
})
}
private _detachActivityListenersFn: null | (() => void) = null
/**
* User input anywhere in the editor's tab counts as activity for collaborator presence —
* presence means "this person is here", whether or not the input lands on the editor itself
* (e.g. typing in a sidebar next to the canvas). Listen in the capture phase so input that
* other handlers swallow still counts.
*/
private _attachActivityListeners() {
if (this._detachActivityListenersFn) return
const win = this.editor.getContainerWindow()
for (const name of ACTIVITY_EVENTS) {
win.addEventListener(name, this.markActivity, { capture: true })
}
this._detachActivityListenersFn = () => {
for (const name of ACTIVITY_EVENTS) {
win.removeEventListener(name, this.markActivity, { capture: true })
}
}
}
private _detachActivityListeners() {
this._detachActivityListenersFn?.()
this._detachActivityListenersFn = null
}
@bind
private _onFrame(elapsed: number) {
this.updatePointerVelocity(elapsed)
}
private _originPagePoint = atom<Vec>('originPagePoint', new Vec())
/**
* The most recent pointer down's position in the current page space.
*/
getOriginPagePoint() {
return this._originPagePoint.get()
}
/**
* @deprecated Use `getOriginPagePoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get originPagePoint() {
return this.getOriginPagePoint()
}
private _originScreenPoint = atom<Vec>('originScreenPoint', new Vec())
/**
* The most recent pointer down's position in screen space.
*/
getOriginScreenPoint() {
return this._originScreenPoint.get()
}
/**
* @deprecated Use `getOriginScreenPoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get originScreenPoint() {
return this.getOriginScreenPoint()
}
private _previousPagePoint = atom<Vec>('previousPagePoint', new Vec())
/**
* The previous pointer position in the current page space.
*/
getPreviousPagePoint() {
return this._previousPagePoint.get()
}
/**
* @deprecated Use `getPreviousPagePoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get previousPagePoint() {
return this.getPreviousPagePoint()
}
private _previousScreenPoint = atom<Vec>('previousScreenPoint', new Vec())
/**
* The previous pointer position in screen space.
*/
getPreviousScreenPoint() {
return this._previousScreenPoint.get()
}
/**
* @deprecated Use `getPreviousScreenPoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get previousScreenPoint() {
return this.getPreviousScreenPoint()
}
private _currentPagePoint = atom<Vec>('currentPagePoint', new Vec())
/**
* The most recent pointer position in the current page space.
*/
getCurrentPagePoint() {
return this._currentPagePoint.get()
}
/**
* @deprecated Use `getCurrentPagePoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get currentPagePoint() {
return this.getCurrentPagePoint()
}
private _currentScreenPoint = atom<Vec>('currentScreenPoint', new Vec())
/**
* The most recent pointer position in screen space.
*/
getCurrentScreenPoint() {
return this._currentScreenPoint.get()
}
/**
* @deprecated Use `getCurrentScreenPoint()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get currentScreenPoint() {
return this.getCurrentScreenPoint()
}
private _pointerVelocity = atom<Vec>('pointerVelocity', new Vec())
/**
* Velocity of mouse pointer, in pixels per millisecond.
*/
getPointerVelocity() {
return this._pointerVelocity.get()
}
/**
* @deprecated Use `getPointerVelocity()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get pointerVelocity() {
return this.getPointerVelocity()
}
/**
* Normally you shouldn't need to set the pointer velocity directly. Used in tests to fake pointer velocity.
* @param pointerVelocity - The pointer velocity.
* @internal
*/
setPointerVelocity(pointerVelocity: Vec) {
this._pointerVelocity.set(pointerVelocity)
}
/**
* A set containing the currently pressed keys.
*/
readonly keys = new AtomSet<string>('keys')
/**
* A set containing the currently pressed buttons.
*/
readonly buttons = new AtomSet<number>('buttons')
private _isPen = atom<boolean>('isPen', false)
/**
* Whether the input is from a pen.
*/
getIsPen() {
return this._isPen.get()
}
/**
* @deprecated Use `getIsPen()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isPen() {
return this.getIsPen()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isPen(isPen: boolean) {
this.setIsPen(isPen)
}
/**
* @param isPen - Whether the input is from a pen.
*/
setIsPen(isPen: boolean) {
this._isPen.set(isPen)
}
private _shiftKey = atom<boolean>('shiftKey', false)
/**
* Whether the shift key is currently pressed.
*/
getShiftKey() {
return this._shiftKey.get()
}
/**
* @deprecated Use `getShiftKey()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get shiftKey() {
return this.getShiftKey()
}
// eslint-disable-next-line tldraw/no-setter-getter
set shiftKey(shiftKey: boolean) {
this.setShiftKey(shiftKey)
}
/**
* @param shiftKey - Whether the shift key is pressed.
* @internal
*/
setShiftKey(shiftKey: boolean) {
this._shiftKey.set(shiftKey)
}
private _metaKey = atom<boolean>('metaKey', false)
/**
* Whether the meta key is currently pressed.
*/
getMetaKey() {
return this._metaKey.get()
}
/**
* @deprecated Use `getMetaKey()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get metaKey() {
return this.getMetaKey()
}
// eslint-disable-next-line tldraw/no-setter-getter
set metaKey(metaKey: boolean) {
this.setMetaKey(metaKey)
}
/**
* @param metaKey - Whether the meta key is pressed.
* @internal
*/
setMetaKey(metaKey: boolean) {
this._metaKey.set(metaKey)
}
private _ctrlKey = atom<boolean>('ctrlKey', false)
/**
* Whether the ctrl or command key is currently pressed.
*/
getCtrlKey() {
return this._ctrlKey.get()
}
/**
* @deprecated Use `getCtrlKey()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get ctrlKey() {
return this.getCtrlKey()
}
// eslint-disable-next-line tldraw/no-setter-getter
set ctrlKey(ctrlKey: boolean) {
this.setCtrlKey(ctrlKey)
}
/**
* @param ctrlKey - Whether the ctrl key is pressed.
* @internal
*/
setCtrlKey(ctrlKey: boolean) {
this._ctrlKey.set(ctrlKey)
}
private _altKey = atom<boolean>('altKey', false)
/**
* Whether the alt or option key is currently pressed.
*/
getAltKey() {
return this._altKey.get()
}
/**
* @deprecated Use `getAltKey()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get altKey() {
return this.getAltKey()
}
// eslint-disable-next-line tldraw/no-setter-getter
set altKey(altKey: boolean) {
this.setAltKey(altKey)
}
/**
* @param altKey - Whether the alt key is pressed.
* @internal
*/
setAltKey(altKey: boolean) {
this._altKey.set(altKey)
}
/**
* Is the accelerator key (cmd on mac, ctrl elsewhere) currently pressed.
*/
getAccelKey() {
return isAccelKey({ metaKey: this.getMetaKey(), ctrlKey: this.getCtrlKey() })
}
/**
* @deprecated Use `getAccelKey()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get accelKey() {
return this.getAccelKey()
}
private _isDragging = atom<boolean>('isDragging', false)
/**
* Whether the user is dragging.
*/
getIsDragging() {
return this._isDragging.get()
}
/**
* Soon to be deprecated, use `getIsDragging()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isDragging() {
return this.getIsDragging()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isDragging(isDragging: boolean) {
this.setIsDragging(isDragging)
}
/**
* @param isDragging - Whether the user is dragging.
*/
setIsDragging(isDragging: boolean) {
this._isDragging.set(isDragging)
}
private _isPointing = atom<boolean>('isPointing', false)
/**
* Whether the user is pointing.
*/
getIsPointing() {
return this._isPointing.get()
}
/**
* @deprecated Use `getIsPointing()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isPointing() {
return this.getIsPointing()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isPointing(isPointing: boolean) {
this.setIsPointing(isPointing)
}
/**
* @param isPointing - Whether the user is pointing.
* @internal
*/
setIsPointing(isPointing: boolean) {
this._isPointing.set(isPointing)
}
private _isRightPointing = atom<boolean>('isRightPointing', false)
/**
* Whether the user is right-click pointing (before drag threshold).
*/
getIsRightPointing() {
return this._isRightPointing.get()
}
/** @internal */
setIsRightPointing(isRightPointing: boolean) {
this._isRightPointing.set(isRightPointing)
}
private _isPinching = atom<boolean>('isPinching', false)
/**
* Whether the user is pinching.
*/
getIsPinching() {
return this._isPinching.get()
}
/**
* @deprecated Use `getIsPinching()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isPinching() {
return this.getIsPinching()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isPinching(isPinching: boolean) {
this.setIsPinching(isPinching)
}
/**
* @param isPinching - Whether the user is pinching.
* @internal
*/
setIsPinching(isPinching: boolean) {
this._isPinching.set(isPinching)
}
private _isEditing = atom<boolean>('isEditing', false)
/**
* Whether the user is editing.
*/
getIsEditing() {
return this._isEditing.get()
}
/**
* @deprecated Use `getIsEditing()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isEditing() {
return this.getIsEditing()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isEditing(isEditing: boolean) {
this.setIsEditing(isEditing)
}
/**
* @param isEditing - Whether the user is editing.
*/
setIsEditing(isEditing: boolean) {
this._isEditing.set(isEditing)
}
private _isPanning = atom<boolean>('isPanning', false)
/**
* Whether the user is panning.
*/
getIsPanning() {
return this._isPanning.get()
}
/**
* @deprecated Use `getIsPanning()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isPanning() {
return this.getIsPanning()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isPanning(isPanning: boolean) {
this.setIsPanning(isPanning)
}
/**
* @param isPanning - Whether the user is panning.
* @internal
*/
setIsPanning(isPanning: boolean) {
this._isPanning.set(isPanning)
}
private _isSpacebarPanning = atom<boolean>('isSpacebarPanning', false)
/**
* Whether the user is spacebar panning.
*/
getIsSpacebarPanning() {
return this._isSpacebarPanning.get()
}
/**
* @deprecated Use `getIsSpacebarPanning()` instead.
*/
// eslint-disable-next-line tldraw/no-setter-getter
get isSpacebarPanning() {
return this.getIsSpacebarPanning()
}
// eslint-disable-next-line tldraw/no-setter-getter
set isSpacebarPanning(isSpacebarPanning: boolean) {
this.setIsSpacebarPanning(isSpacebarPanning)
}
/**
* @param isSpacebarPanning - Whether the user is spacebar panning.
* @internal
*/
setIsSpacebarPanning(isSpacebarPanning: boolean) {
this._isSpacebarPanning.set(isSpacebarPanning)
}
@computed private _getHasCollaborators() {
return this.editor.getCollaborators().length > 0 // could we do this more efficiently?
}
private readonly _throttledActivityStamp = throttle(
() => {
const pointer = this.editor.store.unsafeGetWithoutCapture(TLPOINTER_ID)
if (!pointer) return
this.editor.run(
() => {
this.editor.store.put([{ ...pointer, lastActivityTimestamp: Date.now() }])
},
{ history: 'ignore' }
)
},
// Stay comfortably below the idle timeout so a continuously-active peer
// never flickers to idle between throttled stamps.
Math.max(
ACTIVITY_TIMESTAMP_THROTTLE_FLOOR_MS,
Math.min(ACTIVITY_TIMESTAMP_THROTTLE_MS, this.editor.options.collaboratorIdleTimeoutMs / 3)
),
{ trailing: false }
)
/**
* Mark the current user as active for collaborator presence. User input anywhere in the
* editor's tab counts as activity automatically; call this to make input that doesn't produce
* DOM events count too, so peers don't classify this user as idle or inactive while they're
* still interacting. Calls are throttled on the leading edge, so it's safe to call from
* high-frequency input events.
*
* @example
* ```ts
* // Gamepads are polled rather than event-driven, so stamp activity from the poll
* editor.timers.setInterval(() => {
* const isPressed = navigator
* .getGamepads()
* .some((pad) => pad?.buttons.some((button) => button.pressed))
* if (isPressed) editor.inputs.markActivity()
* }, 50)
* ```
*/
@bind
markActivity() {
// Check for collaborators before consuming the throttle window, so input
// while alone doesn't delay the first stamp after a peer joins.
if (!this._getHasCollaborators()) return
this._throttledActivityStamp()
}
/**
* The previous point used for velocity calculation (updated each tick, not each pointer event).
* @internal
*/
private _velocityPrevPoint = new Vec()
/**
* Update the pointer velocity based on elapsed time. Called by the tick manager.
* @param elapsed - The time elapsed since the last tick in milliseconds.
* @internal
*/
updatePointerVelocity(elapsed: number) {
const currentScreenPoint = this.getCurrentScreenPoint()
const pointerVelocity = this.getPointerVelocity()
if (elapsed === 0) return
const delta = Vec.Sub(currentScreenPoint, this._velocityPrevPoint)
this._velocityPrevPoint = currentScreenPoint.clone()
const length = delta.len()
const direction = length ? delta.div(length) : new Vec(0, 0)
// Preserve the old 16ms smoothing with alpha = 1 - (1 - 0.5)^(elapsed / 16).
const smoothing =
1 -
Math.pow(
1 - POINTER_VELOCITY_REFERENCE_SMOOTHING,
elapsed / POINTER_VELOCITY_REFERENCE_INTERVAL_MS
)
const next = pointerVelocity.clone().lrp(direction.mul(length / elapsed), smoothing)
// if the velocity is very small, just set it to 0
if (Math.abs(next.x) < 0.01) next.x = 0
if (Math.abs(next.y) < 0.01) next.y = 0
if (!pointerVelocity.equals(next)) {
this._pointerVelocity.set(next)
}
}
/**
* Update the input points from a pointer, pinch, or wheel event.
*
* @param info - The event info.
* @internal
*/
updateFromEvent(info: TLPointerEventInfo | TLPinchEventInfo | TLWheelEventInfo): void {
const currentScreenPoint = this._currentScreenPoint.__unsafe__getWithoutCapture()
const currentPagePoint = this._currentPagePoint.__unsafe__getWithoutCapture()
const isPinching = this._isPinching.__unsafe__getWithoutCapture()
const { screenBounds } = this.editor.store.unsafeGetWithoutCapture(TLINSTANCE_ID)!
const { x: cx, y: cy, z: cz } = unsafe__withoutCapture(() => this.editor.getCamera())
const sx = info.point.x - screenBounds.x
const sy = info.point.y - screenBounds.y
const sz = info.point.z ?? 0.5
this._previousScreenPoint.set(currentScreenPoint)
this._previousPagePoint.set(currentPagePoint)
// The "screen bounds" is relative to the user's actual screen.
// The "screen point" is relative to the "screen bounds";
// it will be 0,0 when its actual screen position is equal
// to screenBounds.point. This is confusing!
this._currentScreenPoint.set(new Vec(sx, sy))
const nx = sx / cz - cx
const ny = sy / cz - cy
if (isFinite(nx) && isFinite(ny)) {
this._currentPagePoint.set(new Vec(nx, ny, sz))
}
this._isPen.set(info.type === 'pointer' && info.isPen)
// Reset velocity on pointer down, or when a pinch starts or ends
if (info.name === 'pointer_down' || isPinching) {
this._pointerVelocity.set(new Vec())
this._originScreenPoint.set(this._currentScreenPoint.__unsafe__getWithoutCapture())
this._originPagePoint.set(this._currentPagePoint.__unsafe__getWithoutCapture())
}
if (this._getHasCollaborators()) {
this.editor.run(
() => {
const pagePoint = this._currentPagePoint.__unsafe__getWithoutCapture()
this.editor.store.put([
{
id: TLPOINTER_ID,
typeName: 'pointer',
x: pagePoint.x,
y: pagePoint.y,
// The activity timestamp is stamped by the container's input
// listeners (see `markActivity`), not by pointer position updates:
// synthetic moves, like the camera following another user, don't
// count as activity.
lastActivityTimestamp:
this.editor.store.unsafeGetWithoutCapture(TLPOINTER_ID)?.lastActivityTimestamp ?? 0,
meta: {},
},
])
},
{ history: 'ignore' }
)
}
}
toJson() {
return {
originPagePoint: this._originPagePoint.get().toJson(),
originScreenPoint: this._originScreenPoint.get().toJson(),
previousPagePoint: this._previousPagePoint.get().toJson(),
previousScreenPoint: this._previousScreenPoint.get().toJson(),
currentPagePoint: this._currentPagePoint.get().toJson(),
currentScreenPoint: this._currentScreenPoint.get().toJson(),
pointerVelocity: this._pointerVelocity.get().toJson(),
shiftKey: this._shiftKey.get(),
metaKey: this._metaKey.get(),
ctrlKey: this._ctrlKey.get(),
altKey: this._altKey.get(),
isPen: this._isPen.get(),
isDragging: this._isDragging.get(),
isPointing: this._isPointing.get(),
isPinching: this._isPinching.get(),
isEditing: this._isEditing.get(),
isPanning: this._isPanning.get(),
isSpacebarPanning: this._isSpacebarPanning.get(),
keys: Array.from(this.keys.keys()),
buttons: Array.from(this.buttons.keys()),
}
}
}

View File

@@ -0,0 +1,82 @@
import type { PerformanceManager } from './PerformanceManager'
/** Wrap performance.mark with try/catch — `detail` option may throw in Safari/Firefox. */
function safeMark(name: string, detail?: Record<string, unknown>) {
try {
performance.mark(name, detail ? { detail } : undefined)
} catch {
performance.mark(name)
}
}
/**
* Optional adapter that pipes PerformanceManager events into browser
* `performance.mark()` / `performance.measure()` for DevTools integration.
*
* Tree-shakeable — only included if imported.
*
* @example
* ```ts
* const adapter = new PerformanceApiAdapter(editor.performance)
* // ... later
* adapter.dispose()
* ```
*
* @public
*/
export class PerformanceApiAdapter {
private cleanups: (() => void)[] = []
constructor(perfManager: PerformanceManager) {
this.cleanups.push(
perfManager.on('interaction-start', (event) => {
safeMark(`tldraw:interaction:${event.name}:start`, { path: event.path })
})
)
this.cleanups.push(
perfManager.on('interaction-end', (event) => {
const startMark = `tldraw:interaction:${event.name}:start`
const endMark = `tldraw:interaction:${event.name}:end`
safeMark(endMark, {
path: event.path,
fps: event.fps,
frameCount: event.frameCount,
shapeCount: event.shapeCount,
})
try {
performance.measure(`tldraw:interaction:${event.name}`, startMark, endMark)
} catch {
// start mark may not exist if adapter attached mid-interaction
}
})
)
this.cleanups.push(
perfManager.on('camera-start', (event) => {
safeMark(`tldraw:camera:${event.type}:start`)
})
)
this.cleanups.push(
perfManager.on('camera-end', (event) => {
const startMark = `tldraw:camera:${event.type}:start`
const endMark = `tldraw:camera:${event.type}:end`
safeMark(endMark, { fps: event.fps, shapeCount: event.shapeCount })
try {
performance.measure(`tldraw:camera:${event.type}`, startMark, endMark)
} catch {
// start mark may not exist
}
})
)
}
/** Remove all listeners and stop piping events. @public */
dispose() {
for (const cleanup of this.cleanups) {
cleanup()
}
this.cleanups.length = 0
}
}

View File

@@ -0,0 +1,602 @@
import { describe, expect, it, vi } from 'vitest'
import { PerformanceManager } from './PerformanceManager'
function createMockEditor() {
const listeners: Record<string, ((...args: any[]) => void)[]> = {}
return {
on(event: string, handler: (...args: any[]) => void) {
;(listeners[event] ??= []).push(handler)
},
off(event: string, handler: (...args: any[]) => void) {
const arr = listeners[event]
if (arr) {
const idx = arr.indexOf(handler)
if (idx !== -1) arr.splice(idx, 1)
}
},
emit(event: string, ...args: any[]) {
for (const handler of listeners[event] ?? []) {
handler(...args)
}
},
getSelectedShapes: () => [
{ type: 'geo', id: '1' },
{ type: 'geo', id: '2' },
{ type: 'draw', id: '3' },
],
getCurrentPageShapeIds: () => ({ size: 10 }),
getCulledShapes: () => ({ size: 3 }),
getCamera: () => ({ x: 0, y: 0, z: 1 }),
getShape: (id: string) => {
const shapes: Record<string, { type: string }> = {
'shape:1': { type: 'geo' },
'shape:2': { type: 'draw' },
'shape:3': { type: 'geo' },
}
return shapes[id]
},
getViewportScreenBounds: () => ({ w: 1920, h: 1080 }),
timers: {
setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms),
clearTimeout: (id: number) => clearTimeout(id),
},
_listeners: listeners,
} as any
}
describe('PerformanceManager', () => {
describe('listener management', () => {
it('returns an unsubscribe function from on()', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
const unsub = pm.on('interaction-end', fn)
expect(typeof unsub).toBe('function')
unsub()
})
it('attaches frame listener lazily when interaction listener subscribes', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
expect(editor._listeners['frame'] ?? []).toHaveLength(0)
const unsub = pm.on('interaction-end', vi.fn())
expect(editor._listeners['frame']).toHaveLength(1)
unsub()
expect(editor._listeners['frame'] ?? []).toHaveLength(0)
})
it('attaches shape listeners lazily', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const unsub = pm.on('shapes-created', vi.fn())
expect(editor._listeners['created-shapes']).toHaveLength(1)
unsub()
expect(editor._listeners['created-shapes'] ?? []).toHaveLength(0)
})
it('does not double-attach frame listener', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const unsub1 = pm.on('interaction-end', vi.fn())
const unsub2 = pm.on('camera-end', vi.fn())
expect(editor._listeners['frame']).toHaveLength(1)
unsub1()
// still one camera-end listener
expect(editor._listeners['frame']).toHaveLength(1)
unsub2()
expect(editor._listeners['frame'] ?? []).toHaveLength(0)
})
})
describe('dispose()', () => {
it('removes every lazily attached editor listener', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
pm.on('interaction-end', vi.fn())
pm.on('shapes-created', vi.fn())
pm.on('shapes-updated', vi.fn())
pm.on('shapes-deleted', vi.fn())
expect(editor._listeners['frame']).toHaveLength(1)
expect(editor._listeners['created-shapes']).toHaveLength(1)
expect(editor._listeners['edited-shapes']).toHaveLength(1)
expect(editor._listeners['deleted-shapes']).toHaveLength(1)
pm.dispose()
expect(editor._listeners).toEqual({
frame: [],
'created-shapes': [],
'edited-shapes': [],
'deleted-shapes': [],
})
})
it('disconnects the LoAF observer', () => {
const origPO = globalThis.PerformanceObserver
const mockDisconnect = vi.fn()
globalThis.PerformanceObserver = class MockPO {
constructor(_cb: any) {}
observe() {}
disconnect = mockDisconnect
static supportedEntryTypes = ['long-animation-frame']
} as any
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
pm.on('interaction-end', vi.fn())
expect(mockDisconnect).not.toHaveBeenCalled()
pm.dispose()
expect(mockDisconnect).toHaveBeenCalledTimes(1)
globalThis.PerformanceObserver = origPO
})
it('does not tear down a listener that was already detached', () => {
const editor = createMockEditor()
const off = vi.spyOn(editor, 'off')
const pm = new PerformanceManager(editor)
pm.on('shapes-created', vi.fn())()
expect(off).toHaveBeenCalledTimes(1)
pm.dispose()
expect(off).toHaveBeenCalledTimes(1)
})
it('makes a stale unsubscribe a no-op after dispose', () => {
const editor = createMockEditor()
const off = vi.spyOn(editor, 'off')
const pm = new PerformanceManager(editor)
const unsub = pm.on('interaction-end', vi.fn())
pm.dispose()
expect(off).toHaveBeenCalledTimes(1)
unsub()
expect(off).toHaveBeenCalledTimes(1)
})
it('attaches again for a subscriber added after dispose', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
pm.on('shapes-created', vi.fn())
pm.dispose()
expect(editor._listeners['created-shapes']).toHaveLength(0)
pm.on('shapes-created', vi.fn())
expect(editor._listeners['created-shapes']).toHaveLength(1)
})
})
describe('interaction tracking', () => {
it('emits interaction-start when notified', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-start', fn)
pm._notifyInteractionStart('translating', 'select.translating')
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0][0]).toMatchObject({
name: 'translating',
path: 'select.translating',
})
expect(fn.mock.calls[0][0].timestamp).toBeGreaterThan(0)
})
it('emits interaction-end with frame stats', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('resizing', 'select.resizing')
// Simulate frames
editor.emit('frame', 16)
editor.emit('frame', 16)
editor.emit('frame', 32)
editor.emit('frame', 16)
editor.emit('frame', 50)
pm._notifyInteractionEnd()
expect(fn).toHaveBeenCalledTimes(1)
const event = fn.mock.calls[0][0]
expect(event.name).toBe('resizing')
expect(event.path).toBe('select.resizing')
expect(event.frameCount).toBe(5)
expect(event.frameTimes).toEqual([16, 16, 32, 16, 50])
expect(event.shapeCount).toBe(10)
expect(event.selectedShapeTypes).toEqual({ geo: 2, draw: 1 })
expect(event.avgFrameTime).toBe(26)
expect(event.duration).toBeGreaterThan(0)
})
it('does not emit when no listeners', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
// Should not throw
pm._notifyInteractionStart('resizing', 'select.resizing')
pm._notifyInteractionEnd()
})
it('handles end without start gracefully', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionEnd()
expect(fn).not.toHaveBeenCalled()
})
})
describe('camera tracking', () => {
it('emits camera-start and camera-end', () => {
vi.useFakeTimers()
const editor = createMockEditor()
// Override timers.setTimeout to use fake timers
editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
const pm = new PerformanceManager(editor)
const startFn = vi.fn()
const endFn = vi.fn()
pm.on('camera-start', startFn)
pm.on('camera-end', endFn)
pm._notifyCameraOperation('panning')
expect(startFn).toHaveBeenCalledTimes(1)
expect(startFn.mock.calls[0][0].type).toBe('panning')
// Simulate frames during camera operation
editor.emit('frame', 16)
editor.emit('frame', 16)
// Wait for debounce timeout
vi.advanceTimersByTime(100)
expect(endFn).toHaveBeenCalledTimes(1)
const event = endFn.mock.calls[0][0]
expect(event.type).toBe('panning')
expect(event.frameCount).toBe(2)
expect(event.viewportWidth).toBe(1920)
expect(event.viewportHeight).toBe(1080)
vi.useRealTimers()
})
it('extends camera session on repeated operations', () => {
vi.useFakeTimers()
const editor = createMockEditor()
editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
const pm = new PerformanceManager(editor)
const endFn = vi.fn()
pm.on('camera-end', endFn)
pm._notifyCameraOperation('panning')
editor.emit('frame', 16)
vi.advanceTimersByTime(30)
pm._notifyCameraOperation('panning')
editor.emit('frame', 16)
vi.advanceTimersByTime(30)
// Should not have ended yet
expect(endFn).not.toHaveBeenCalled()
vi.advanceTimersByTime(100)
expect(endFn).toHaveBeenCalledTimes(1)
expect(endFn.mock.calls[0][0].frameCount).toBe(2)
vi.useRealTimers()
})
it('ends old session and starts new on type change', () => {
vi.useFakeTimers()
const editor = createMockEditor()
editor.timers.setTimeout = (fn: () => void, ms: number) => setTimeout(fn, ms) as any
const pm = new PerformanceManager(editor)
const startFn = vi.fn()
const endFn = vi.fn()
pm.on('camera-start', startFn)
pm.on('camera-end', endFn)
pm._notifyCameraOperation('panning')
editor.emit('frame', 16)
pm._notifyCameraOperation('zooming')
// Panning session should have ended
expect(endFn).toHaveBeenCalledTimes(1)
expect(endFn.mock.calls[0][0].type).toBe('panning')
// Zooming session should have started
expect(startFn).toHaveBeenCalledTimes(2)
vi.advanceTimersByTime(100)
expect(endFn).toHaveBeenCalledTimes(2)
expect(endFn.mock.calls[1][0].type).toBe('zooming')
vi.useRealTimers()
})
})
describe('frame time stats', () => {
it('computes correct percentiles', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('translating', 'select.translating')
// 10 frames: 10, 11, 12, 13, 14, 15, 16, 17, 18, 100
for (let i = 10; i <= 18; i++) editor.emit('frame', i)
editor.emit('frame', 100)
pm._notifyInteractionEnd()
const event = fn.mock.calls[0][0]
expect(event.minFrameTime).toBe(10)
expect(event.maxFrameTime).toBe(100)
expect(event.medianFrameTime).toBe(14)
})
it('handles single frame', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('translating', 'select.translating')
editor.emit('frame', 42)
pm._notifyInteractionEnd()
const event = fn.mock.calls[0][0]
expect(event.avgFrameTime).toBe(42)
expect(event.medianFrameTime).toBe(42)
expect(event.p95FrameTime).toBe(42)
expect(event.p99FrameTime).toBe(42)
expect(event.minFrameTime).toBe(42)
expect(event.maxFrameTime).toBe(42)
})
it('handles zero frames', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('translating', 'select.translating')
pm._notifyInteractionEnd()
const event = fn.mock.calls[0][0]
expect(event.avgFrameTime).toBe(0)
expect(event.frameCount).toBe(0)
})
})
describe('undo/redo tracking', () => {
it('emits undo events', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('undo', fn)
pm._notifyUndoRedo('undo', 5, 2)
expect(fn).toHaveBeenCalledWith({ type: 'undo', undoDepth: 5, redoDepth: 2 })
})
it('emits redo events', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('redo', fn)
pm._notifyUndoRedo('redo', 4, 3)
expect(fn).toHaveBeenCalledWith({ type: 'redo', undoDepth: 4, redoDepth: 3 })
})
it('skips when no listeners', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
// Should not throw
pm._notifyUndoRedo('undo', 1, 0)
})
})
describe('shape operation tracking', () => {
it('emits shapes-created events', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('shapes-created', fn)
editor.emit('created-shapes', [
{ typeName: 'shape', type: 'geo' },
{ typeName: 'shape', type: 'geo' },
{ typeName: 'shape', type: 'draw' },
])
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0][0]).toMatchObject({
operation: 'create',
count: 3,
shapeTypes: { geo: 2, draw: 1 },
})
})
it('ignores non-shape records', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('shapes-created', fn)
editor.emit('created-shapes', [{ typeName: 'page', type: 'page' }])
expect(fn).not.toHaveBeenCalled()
})
it('emits shapes-deleted with shape types', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('shapes-deleted', fn)
editor.emit('deleted-shapes', ['shape:1', 'shape:2'])
expect(fn).toHaveBeenCalledTimes(1)
expect(fn.mock.calls[0][0]).toMatchObject({
operation: 'delete',
count: 2,
shapeTypes: { geo: 1, draw: 1 },
})
})
})
describe('frame event', () => {
it('emits frame events with shape counts', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('frame', fn)
editor.emit('frame', 16)
expect(fn).toHaveBeenCalledWith({
elapsed: 16,
shapeCount: 10,
culledShapeCount: 3,
visibleShapeCount: 7,
})
})
})
describe('once()', () => {
it('fires callback only once', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.once('interaction-start', fn)
pm._notifyInteractionStart('resizing', 'select.resizing')
pm._notifyInteractionEnd()
pm._notifyInteractionStart('resizing', 'select.resizing')
expect(fn).toHaveBeenCalledTimes(1)
})
it('detaches frame listener after once fires', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
pm.once('interaction-start', vi.fn())
expect(editor._listeners['frame']).toHaveLength(1)
pm._notifyInteractionStart('resizing', 'select.resizing')
// frame listener should be detached after once fires
expect(editor._listeners['frame'] ?? []).toHaveLength(0)
})
})
describe('LoAF integration', () => {
it('attaches LoAF entries to interaction-end events', () => {
let observerCallback: ((list: any) => void) | null = null
const mockDisconnect = vi.fn()
const origPO = globalThis.PerformanceObserver
globalThis.PerformanceObserver = class MockPO {
constructor(cb: (list: any) => void) {
observerCallback = cb
}
observe() {}
disconnect = mockDisconnect
static supportedEntryTypes = ['long-animation-frame']
} as any
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('resizing', 'select.resizing')
editor.emit('frame', 16)
// Simulate a LoAF entry
observerCallback!({
getEntries: () => [
{
startTime: 100,
duration: 80,
blockingDuration: 60,
scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }],
},
],
})
editor.emit('frame', 16)
pm._notifyInteractionEnd()
expect(fn).toHaveBeenCalledTimes(1)
const event = fn.mock.calls[0][0]
expect(event.longAnimationFrames).toHaveLength(1)
expect(event.longAnimationFrames[0]).toEqual({
startTime: 100,
duration: 80,
blockingDuration: 60,
scripts: [{ sourceURL: 'app.js', invoker: 'onPointerMove', duration: 55 }],
})
// Cleanup
pm.on('interaction-end', vi.fn())() // subscribe + unsub to trigger detach check
globalThis.PerformanceObserver = origPO
})
it('omits longAnimationFrames when none collected', () => {
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
const fn = vi.fn()
pm.on('interaction-end', fn)
pm._notifyInteractionStart('resizing', 'select.resizing')
editor.emit('frame', 16)
pm._notifyInteractionEnd()
expect(fn.mock.calls[0][0].longAnimationFrames).toBeUndefined()
})
it('starts LoAF observer only when interaction-end or camera-end listeners exist', () => {
const origPO = globalThis.PerformanceObserver
const mockObserve = vi.fn()
globalThis.PerformanceObserver = class MockPO {
constructor(_cb: any) {}
observe = mockObserve
disconnect = vi.fn()
static supportedEntryTypes = ['long-animation-frame']
} as any
const editor = createMockEditor()
const pm = new PerformanceManager(editor)
// interaction-start alone should NOT start LoAF observer
const unsub1 = pm.on('interaction-start', vi.fn())
expect(mockObserve).not.toHaveBeenCalled()
unsub1()
// interaction-end should start LoAF observer
const unsub2 = pm.on('interaction-end', vi.fn())
expect(mockObserve).toHaveBeenCalledTimes(1)
unsub2()
globalThis.PerformanceObserver = origPO
})
})
})

View File

@@ -0,0 +1,556 @@
import type { TLRecord, TLShapeId } from '@tldraw/tlschema'
import { bind } from '@tldraw/utils'
import EventEmitter from 'eventemitter3'
import { EditorManager } from '../EditorManager'
import type {
TLCameraEndPerfEvent,
TLCameraStartPerfEvent,
TLFramePerfEvent,
TLInteractionEndPerfEvent,
TLInteractionStartPerfEvent,
TLPerfEventMap,
TLPerfLongAnimationFrame,
TLShapeOperationPerfEvent,
TLUndoRedoPerfEvent,
} from './perf-types'
// The editor listeners and observer that are attached only while a subscriber needs them
type LazyListener = 'frame' | 'loaf' | 'shapes-created' | 'shapes-updated' | 'shapes-deleted'
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 extends EditorManager {
/** @internal */
readonly emitter = new EventEmitter<TLPerfEventMap>()
// 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
// Cleanups for whatever is currently attached, keyed so a detach unregisters only its own
private readonly lazyCleanups = new Map<LazyListener, () => void>()
/**
* 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) this.editor.timers.clearTimeout(this.activeCamera.timeout)
this.activeInteraction = null
this.activeCamera = null
this.lazyCleanups.clear()
this.emitter.removeAllListeners()
super.dispose()
}
// --- 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) {
this.editor.timers.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) this.editor.timers.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)
}
@bind
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)
}
}
@bind
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)
}
@bind
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)
}
@bind
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
}
const observer = 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)
}
}
})
observer.observe({ type: 'long-animation-frame', buffered: false })
this.lazyCleanups.set(
'loaf',
this.register(() => observer.disconnect())
)
}
// --- 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.lazyCleanups.has('frame') &&
(event === 'frame' ||
event === 'interaction-start' ||
event === 'interaction-end' ||
event === 'camera-start' ||
event === 'camera-end')
) {
if (this._needsFrameListener()) {
this.lazyCleanups.set('frame', this.addEditorEvent('frame', this._onFrame))
}
}
// LoAF observer needed when interaction-end or camera-end listeners exist
if (!this.lazyCleanups.has('loaf') && (event === 'interaction-end' || event === 'camera-end')) {
if (this._needsLoafObserver()) {
this._startLoafObserver()
}
}
if (!this.lazyCleanups.has('shapes-created') && event === 'shapes-created') {
this.lazyCleanups.set(
'shapes-created',
this.addEditorEvent('created-shapes', this._onShapesCreated)
)
}
if (!this.lazyCleanups.has('shapes-updated') && event === 'shapes-updated') {
this.lazyCleanups.set(
'shapes-updated',
this.addEditorEvent('edited-shapes', this._onShapesEdited)
)
}
if (!this.lazyCleanups.has('shapes-deleted') && event === 'shapes-deleted') {
this.lazyCleanups.set(
'shapes-deleted',
this.addEditorEvent('deleted-shapes', this._onShapesDeleted)
)
}
}
private _maybeDetachLazyListeners(event: keyof TLPerfEventMap) {
if (
(event === 'frame' ||
event === 'interaction-start' ||
event === 'interaction-end' ||
event === 'camera-start' ||
event === 'camera-end') &&
!this._needsFrameListener()
) {
this._detachLazy('frame')
}
// Stop LoAF observer when no longer needed
if ((event === 'interaction-end' || event === 'camera-end') && !this._needsLoafObserver()) {
this._detachLazy('loaf')
}
if (event === 'shapes-created' && this.emitter.listenerCount('shapes-created') === 0) {
this._detachLazy('shapes-created')
}
if (event === 'shapes-updated' && this.emitter.listenerCount('shapes-updated') === 0) {
this._detachLazy('shapes-updated')
}
if (event === 'shapes-deleted' && this.emitter.listenerCount('shapes-deleted') === 0) {
this._detachLazy('shapes-deleted')
}
}
private _detachLazy(key: LazyListener) {
const cleanup = this.lazyCleanups.get(key)
if (!cleanup) return
this.unregister(cleanup)
this.lazyCleanups.delete(key)
}
}

View File

@@ -0,0 +1,196 @@
/**
* Common frame time statistics shared by interaction and camera end events.
* @public
*/
export interface TLPerfFrameTimeStats {
/** Total duration of the session in ms. */
duration: number
/** Average frames per second during the session. */
fps: number
/** Total number of frames recorded. */
frameCount: number
/** Mean frame duration in ms. */
avgFrameTime: number
/** Median (p50) frame duration in ms. */
medianFrameTime: number
/** 95th percentile frame duration in ms. */
p95FrameTime: number
/** 99th percentile frame duration in ms. */
p99FrameTime: number
/** Shortest frame duration in ms. */
minFrameTime: number
/** Longest frame duration in ms. */
maxFrameTime: number
/** Raw frame durations for local analysis. Exclude when sending to analytics. */
frameTimes: number[]
/**
* Long animation frames observed during this period (Chromium 123+).
* Only present when the browser supports the Long Animation Frames API and
* at least one long frame was observed.
* Exclude when sending to analytics — entries are large and contain script URLs.
*/
longAnimationFrames?: TLPerfLongAnimationFrame[]
}
/**
* Emitted when an interaction state (e.g. translating, resizing) is entered.
* @public
*/
export interface TLInteractionStartPerfEvent {
/** The state node id (e.g. `'translating'`). */
name: string
/** Full tool path (e.g. `'select.translating'`). */
path: string
/** `performance.now()` when the interaction started. */
timestamp: number
}
/**
* Emitted when an interaction state is exited, with aggregated frame time stats.
* @public
*/
export interface TLInteractionEndPerfEvent extends TLPerfFrameTimeStats {
/** The state node id (e.g. `'translating'`). */
name: string
/** Full tool path (e.g. `'select.translating'`). */
path: string
/** Total shapes on the current page. */
shapeCount: number
/** Breakdown of selected shape types at interaction start (e.g. `{ geo: 2, draw: 1 }`). */
selectedShapeTypes: Record<string, number>
/** Camera zoom level (`camera.z`) at interaction end. */
zoomLevel: number
/** `performance.now()` when the interaction ended. */
timestamp: number
}
/**
* Emitted when a camera operation (pan or zoom) begins.
* @public
*/
export interface TLCameraStartPerfEvent {
/** Whether this is a pan or zoom operation. */
type: 'panning' | 'zooming'
/** `performance.now()` when the camera session started. */
timestamp: number
}
/**
* Emitted when a camera operation ends (after a 50ms debounce), with aggregated frame time stats.
* @public
*/
export interface TLCameraEndPerfEvent extends TLPerfFrameTimeStats {
/** Whether this was a pan or zoom operation. */
type: 'panning' | 'zooming'
/** Total shapes on the current page. */
shapeCount: number
/** Number of shapes visible (not culled) in the viewport. */
visibleShapeCount: number
/** Number of shapes culled (off-screen) from rendering. */
culledShapeCount: number
/** Viewport width in screen pixels. */
viewportWidth: number
/** Viewport height in screen pixels. */
viewportHeight: number
/** Camera zoom level (`camera.z`) at session end. */
zoomLevel: number
/** `performance.now()` when the camera session ended. */
timestamp: number
}
/**
* Emitted when shapes are created, updated, or deleted.
* @public
*/
export interface TLShapeOperationPerfEvent {
/** The operation type. */
operation: 'create' | 'update' | 'delete'
/** Number of shapes affected. */
count: number
/** Breakdown by shape type (e.g. `{ geo: 2, draw: 1 }`). */
shapeTypes: Record<string, number>
/** `performance.now()` when the operation occurred. */
timestamp: number
}
/**
* Emitted every animation frame when at least one `'frame'` listener is registered.
* @public
*/
export interface TLFramePerfEvent {
/** Time since the last frame in ms. */
elapsed: number
/** Total shapes on the current page. */
shapeCount: number
/** Number of shapes culled (off-screen) from rendering. */
culledShapeCount: number
/** Number of shapes visible (not culled) in the viewport. */
visibleShapeCount: number
}
/**
* Emitted after an undo or redo operation.
* @public
*/
export interface TLUndoRedoPerfEvent {
/** Whether this was an undo or redo. */
type: 'undo' | 'redo'
/** Number of undo steps remaining. */
undoDepth: number
/** Number of redo steps remaining. */
redoDepth: number
}
/**
* A long animation frame observed by the browser during an interaction.
* Available only in browsers that support the Long Animation Frames API (Chromium 123+).
* @public
*/
export interface TLPerfLongAnimationFrame {
/** Frame start time (relative to timeOrigin). */
startTime: number
/** Total frame duration in ms. */
duration: number
/** Time the main thread was blocked in ms. */
blockingDuration: number
/** Scripts that contributed to the long frame. */
scripts: TLPerfLongAnimationFrameScript[]
}
/** A script attribution entry from a long animation frame. @public */
export interface TLPerfLongAnimationFrameScript {
/** The script source URL (may be empty for inline scripts). */
sourceURL: string
/** The function name or invoker description. */
invoker: string
/** Time spent in this script in ms. */
duration: number
}
/**
* Map of all performance event names to their payload types.
* Used with {@link PerformanceManager.on} and {@link PerformanceManager.once}.
* @public
*/
export interface TLPerfEventMap {
/** An interaction state was entered. */
'interaction-start': [TLInteractionStartPerfEvent]
/** An interaction state was exited, with aggregated frame time stats. */
'interaction-end': [TLInteractionEndPerfEvent]
/** A camera operation (pan/zoom) began. */
'camera-start': [TLCameraStartPerfEvent]
/** A camera operation ended (after debounce), with aggregated frame time stats. */
'camera-end': [TLCameraEndPerfEvent]
/** Shapes were created. */
'shapes-created': [TLShapeOperationPerfEvent]
/** Shapes were updated. */
'shapes-updated': [TLShapeOperationPerfEvent]
/** Shapes were deleted. */
'shapes-deleted': [TLShapeOperationPerfEvent]
/** An animation frame was rendered. Only fires when listeners are registered. */
frame: [TLFramePerfEvent]
/** An undo operation was performed. */
undo: [TLUndoRedoPerfEvent]
/** A redo operation was performed. */
redo: [TLUndoRedoPerfEvent]
}

View File

@@ -0,0 +1,581 @@
import { TLPageId, 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
pageId: TLPageId
items: ScribbleItem[]
state: 'active' | 'stopping' | 'complete'
options: Required<Omit<ScribbleSessionOptions, 'id'>>
idleTimeoutHandle?: number
fadeElapsed: number
totalPointsAtFadeStart: number
}
/** @public */
export class ScribbleManager {
private sessions = new Map<string, Session>()
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,
pageId: this.editor.getCurrentPageId(),
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<TLScribble>,
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<TLScribble>, 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(() => {
// Drop sessions from other pages, otherwise a still-fading laser trail
// keeps playing out over the page the user switched to (#10449).
const currentPageId = this.editor.getCurrentPageId()
for (const session of this.sessions.values()) {
if (session.pageId !== currentPageId) {
this.clearSession(session.id)
}
}
// 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) {
this.editor.timers.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++
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,298 @@
import { computed } from '@tldraw/state'
import { TLHandle, TLShape, TLShapeId, VecModel } from '@tldraw/tlschema'
import { assertExists, uniqueId } from '@tldraw/utils'
import { Geometry2d, Geometry2dFilters } from '../../../primitives/geometry/Geometry2d'
import { Group2d } from '../../../primitives/geometry/Group2d'
import { Vec } from '../../../primitives/Vec'
import type { Editor } from '../../Editor'
import type { PointsSnapIndicator, SnapData, SnapManager } from './SnapManager'
/**
* When dragging a handle, users can snap the handle to key geometry on other nearby shapes.
* Customize how handles snap to a shape by returning this from
* {@link ShapeUtil.getHandleSnapGeometry}.
*
* Any co-ordinates here should be in the shape's local space.
*
* @public
*/
export interface HandleSnapGeometry {
/**
* A `Geometry2d` that describe the outline of the shape that the handle will snap to - fills
* are ignored. By default, this is the same geometry returned by {@link ShapeUtil.getGeometry}.
* Set this to `null` to disable handle snapping to this shape's outline.
*/
outline?: Geometry2d | null
/**
* Key points on the shape that the handle will snap to. For example, the corners of a
* rectangle, or the centroid of a triangle. By default, no points are used.
*/
points?: VecModel[]
/**
* By default, handles can't snap to their own shape because moving the handle might change the
* snapping location which can cause feedback loops. You can override this by returning a
* version of `outline` that won't be affected by the current handle's position to use for
* self-snapping.
*/
getSelfSnapOutline?(handle: TLHandle): Geometry2d | null
/**
* By default, handles can't snap to their own shape because moving the handle might change the
* snapping location which can cause feedback loops. You can override this by returning a
* version of `points` that won't be affected by the current handle's position to use for
* self-snapping.
*/
getSelfSnapPoints?(handle: TLHandle): VecModel[]
}
interface AlignPointsSnap {
snaps: PointsSnapIndicator[]
nudge: Vec
}
const defaultGetSelfSnapOutline = () => null
const defaultGetSelfSnapPoints = () => []
/** @public */
export class HandleSnaps {
readonly editor: Editor
constructor(readonly manager: SnapManager) {
this.editor = manager.editor
}
@computed private getSnapGeometryCache() {
const { editor } = this
return editor.store.createComputedCache('handle snap geometry', (shape: TLShape) => {
const snapGeometry = editor.getShapeUtil(shape).getHandleSnapGeometry(shape)
const getSelfSnapOutline = snapGeometry.getSelfSnapOutline
? snapGeometry.getSelfSnapOutline.bind(snapGeometry)
: defaultGetSelfSnapOutline
const getSelfSnapPoints = snapGeometry.getSelfSnapPoints
? snapGeometry.getSelfSnapPoints.bind(snapGeometry)
: defaultGetSelfSnapPoints
return {
outline:
snapGeometry.outline === undefined
? editor.getShapeGeometry(shape)
: snapGeometry.outline,
points: snapGeometry.points ?? [],
getSelfSnapOutline,
getSelfSnapPoints,
}
})
}
private *iterateSnapPointsInPageSpace(currentShapeId: TLShapeId, currentHandle: TLHandle) {
const selfSnapPoints = this.getSnapGeometryCache()
.get(currentShapeId)
?.getSelfSnapPoints(currentHandle)
if (selfSnapPoints && selfSnapPoints.length) {
const shapePageTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
for (const point of selfSnapPoints) {
yield shapePageTransform.applyToPoint(point)
}
}
for (const shapeId of this.manager.getSnappableShapes()) {
if (shapeId === currentShapeId) continue
const snapPoints = this.getSnapGeometryCache().get(shapeId)?.points
if (!snapPoints || !snapPoints.length) continue
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
for (const point of snapPoints) {
yield shapePageTransform.applyToPoint(point)
}
}
}
private *iterateSnapOutlines(currentShapeId: TLShapeId, currentHandle: TLHandle) {
const selfSnapOutline = this.getSnapGeometryCache()
.get(currentShapeId)
?.getSelfSnapOutline(currentHandle)
if (selfSnapOutline) {
yield { shapeId: currentShapeId, outline: selfSnapOutline }
}
for (const shapeId of this.manager.getSnappableShapes()) {
if (shapeId === currentShapeId) continue
const snapOutline = this.getSnapGeometryCache().get(shapeId)?.outline
if (!snapOutline) continue
yield { shapeId, outline: snapOutline }
}
}
private getHandleSnapPosition({
currentShapeId,
handle,
handleInPageSpace,
}: {
currentShapeId: TLShapeId
handle: TLHandle
handleInPageSpace: Vec
}): Vec | null {
const snapThreshold = this.manager.getSnapThreshold()
// We snap to two different parts of the shape's handle snap geometry:
// 1. The `points`. These are handles or other key points that we want to snap to with a
// higher priority than the normal outline snapping.
// 2. The `outline`. This describes the outline of the shape, and we just snap to the
// nearest point on that outline.
// Start with the points:
let minDistanceForSnapPoint = snapThreshold
let nearestSnapPoint: Vec | null = null
for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
if (Vec.DistMin(handleInPageSpace, snapPoint, minDistanceForSnapPoint)) {
minDistanceForSnapPoint = Vec.Dist(handleInPageSpace, snapPoint)
nearestSnapPoint = snapPoint
}
}
// if we found a snap point, return it - we don't need to check the outlines because points
// have a higher priority
if (nearestSnapPoint) return nearestSnapPoint
let minDistanceForOutline = snapThreshold
let nearestPointOnOutline: Vec | null = null
// Labels and internal detail (a note's label, a geo check mark) are not outlines
// to snap to. Group2d.nearestPoint throws when the filter leaves no child, so
// skip those groups.
const filters = Geometry2dFilters.EXCLUDE_NON_STANDARD
for (const { shapeId, outline } of this.iterateSnapOutlines(currentShapeId, handle)) {
if (
outline instanceof Group2d &&
outline.children.every((child) => child.isExcludedByFilter(filters))
) {
continue
}
const shapePageTransform = assertExists(this.editor.getShapePageTransform(shapeId))
const pointInShapeSpace = this.editor.getPointInShapeSpace(shapeId, handleInPageSpace)
const nearestShapePointInShapeSpace = outline.nearestPoint(pointInShapeSpace, filters)
const nearestInPageSpace = shapePageTransform.applyToPoint(nearestShapePointInShapeSpace)
if (Vec.DistMin(handleInPageSpace, nearestInPageSpace, minDistanceForOutline)) {
minDistanceForOutline = Vec.Dist(handleInPageSpace, nearestInPageSpace)
nearestPointOnOutline = nearestInPageSpace
}
}
// if we found a point on the outline, return it
if (nearestPointOnOutline) return nearestPointOnOutline
// if not, there's no nearby snap point
return null
}
private getHandleSnapData({
handle,
currentShapeId,
}: {
handle: TLHandle
currentShapeId: TLShapeId
}): AlignPointsSnap | null {
const snapThreshold = this.manager.getSnapThreshold()
const currentShapeTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
const handleInPageSpace = currentShapeTransform.applyToPoint(handle)
let nearestXSnap: Vec | null = null
let nearestYSnap: Vec | null = null
let minOffsetX = snapThreshold
let minOffsetY = snapThreshold
for (const snapPoint of this.iterateSnapPointsInPageSpace(currentShapeId, handle)) {
const offsetX = Math.abs(handleInPageSpace.x - snapPoint.x)
const offsetY = Math.abs(handleInPageSpace.y - snapPoint.y)
if (offsetX < minOffsetX) {
minOffsetX = offsetX
nearestXSnap = snapPoint
}
if (offsetY < minOffsetY) {
minOffsetY = offsetY
nearestYSnap = snapPoint
}
}
if (!nearestXSnap && !nearestYSnap) {
return null
}
const nudge = new Vec(
nearestXSnap ? nearestXSnap.x - handleInPageSpace.x : 0,
nearestYSnap ? nearestYSnap.y - handleInPageSpace.y : 0
)
const snappedHandle = Vec.Add(handleInPageSpace, nudge)
const snaps: PointsSnapIndicator[] = []
if (nearestXSnap) {
const snappedHandleOnX = new Vec(nearestXSnap.x, snappedHandle.y)
snaps.push({
id: uniqueId(),
type: 'points',
points: [nearestXSnap, snappedHandleOnX],
})
}
if (nearestYSnap) {
const snappedHandleOnY = new Vec(snappedHandle.x, nearestYSnap.y)
snaps.push({
id: uniqueId(),
type: 'points',
points: [nearestYSnap, snappedHandleOnY],
})
}
return { snaps, nudge }
}
snapHandle({
currentShapeId,
handle,
}: {
currentShapeId: TLShapeId
handle: TLHandle
}): SnapData | null {
const currentShapeTransform = assertExists(this.editor.getShapePageTransform(currentShapeId))
const handleInPageSpace = currentShapeTransform.applyToPoint(handle)
// eslint-disable-next-line @typescript-eslint/no-deprecated
const snapType = handle.canSnap ? 'point' : handle.snapType
if (snapType === 'point') {
const snapPosition = this.getHandleSnapPosition({ currentShapeId, handle, handleInPageSpace })
if (!snapPosition) {
return null
}
this.manager.setIndicators([
{
id: uniqueId(),
type: 'points',
points: [snapPosition],
},
])
return { nudge: Vec.Sub(snapPosition, handleInPageSpace) }
}
if (snapType === 'align') {
const snapData = this.getHandleSnapData({
handle,
currentShapeId,
})
if (!snapData) {
return null
}
this.manager.setIndicators(snapData.snaps)
return { nudge: snapData.nudge }
}
return null
}
}

View File

@@ -0,0 +1,528 @@
import {
TLFrameShape,
TLGroupShape,
TLPageId,
TLShape,
TLShapeId,
createShapeId,
} from '@tldraw/tlschema'
import { Mocked, vi } from 'vitest'
import { Box } from '../../../primitives/Box'
import { Vec } from '../../../primitives/Vec'
import { Editor } from '../../Editor'
import { BoundsSnaps } from './BoundsSnaps'
import { HandleSnaps } from './HandleSnaps'
import { GapsSnapIndicator, PointsSnapIndicator, SnapManager } from './SnapManager'
// Mock the Editor class
vi.mock('../../Editor')
vi.mock('./BoundsSnaps')
vi.mock('./HandleSnaps')
describe('SnapManager', () => {
let editor: Mocked<Editor>
let snapManager: SnapManager
const createMockShape = (
id: TLShapeId,
type: string = 'geo',
parentId: TLShapeId | string = 'page:page'
) =>
({
id,
type,
parentId,
x: 0,
y: 0,
rotation: 0,
index: 'a1' as const,
opacity: 1,
isLocked: false,
meta: {},
props: {},
typeName: 'shape' as const,
}) as TLShape
const createMockFrameShape = (id: TLShapeId): TLFrameShape =>
({
...createMockShape(id, 'frame'),
type: 'frame',
props: {
w: 100,
h: 100,
name: '',
},
}) as TLFrameShape
const createMockGroupShape = (id: TLShapeId): TLGroupShape =>
({
...createMockShape(id, 'group'),
type: 'group',
props: {},
}) as TLGroupShape
beforeEach(() => {
editor = {
getZoomLevel: vi.fn(() => 1),
options: { snapThreshold: 8 },
getViewportPageBounds: vi.fn(() => new Box(0, 0, 1000, 1000)),
getSelectedShapeIds: vi.fn(() => []),
getSelectedShapes: vi.fn(() => []),
findCommonAncestor: vi.fn(() => createShapeId('page')),
getCurrentPageId: vi.fn(() => 'page:page' as TLPageId),
getSortedChildIdsForParent: vi.fn(() => []),
getShape: vi.fn(),
getShapeUtil: vi.fn(() => ({
canSnap: vi.fn(() => true),
})),
getShapePageBounds: vi.fn(),
isShapeOfType: vi.fn(),
isShapeFrameLike: vi.fn(() => false),
isShapeHidden: vi.fn(() => false),
} as any
editor.getShapeUtil.mockReturnValue({
canSnap: vi.fn(() => true),
} as any)
snapManager = new SnapManager(editor)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('constructor and initialization', () => {
it('should initialize with editor reference', () => {
expect(snapManager.editor).toBe(editor)
})
it('should create BoundsSnaps instance', () => {
expect(BoundsSnaps).toHaveBeenCalledWith(snapManager)
expect(snapManager.shapeBounds).toBeInstanceOf(BoundsSnaps)
})
it('should create HandleSnaps instance', () => {
expect(HandleSnaps).toHaveBeenCalledWith(snapManager)
expect(snapManager.handles).toBeInstanceOf(HandleSnaps)
})
it('should initialize snap indicators as undefined', () => {
expect(snapManager.getIndicators()).toEqual([])
})
})
describe('indicator management', () => {
describe('getIndicators', () => {
it('should return empty array when no indicators are set', () => {
expect(snapManager.getIndicators()).toEqual([])
})
it('should return set indicators', () => {
const indicators: PointsSnapIndicator[] = [
{
id: 'test-indicator',
type: 'points',
points: [
{ x: 10, y: 20 },
{ x: 30, y: 40 },
],
},
]
snapManager.setIndicators(indicators)
expect(snapManager.getIndicators()).toEqual(indicators)
})
})
describe('setIndicators', () => {
it('should set points indicators', () => {
const indicators: PointsSnapIndicator[] = [
{
id: 'points-indicator',
type: 'points',
points: [new Vec(10, 20), new Vec(30, 40)],
},
]
snapManager.setIndicators(indicators)
expect(snapManager.getIndicators()).toEqual(indicators)
})
it('should set gaps indicators', () => {
const indicators: GapsSnapIndicator[] = [
{
id: 'gaps-indicator',
type: 'gaps',
direction: 'horizontal',
gaps: [
{
startEdge: [
{ x: 0, y: 0 },
{ x: 10, y: 0 },
],
endEdge: [
{ x: 20, y: 0 },
{ x: 30, y: 0 },
],
},
],
},
]
snapManager.setIndicators(indicators)
expect(snapManager.getIndicators()).toEqual(indicators)
})
it('should set mixed indicator types', () => {
const indicators = [
{
id: 'points-indicator',
type: 'points' as const,
points: [{ x: 10, y: 20 }],
},
{
id: 'gaps-indicator',
type: 'gaps' as const,
direction: 'vertical' as const,
gaps: [
{
startEdge: [
{ x: 0, y: 0 },
{ x: 0, y: 10 },
] as [Vec, Vec],
endEdge: [
{ x: 0, y: 20 },
{ x: 0, y: 30 },
] as [Vec, Vec],
},
],
},
]
snapManager.setIndicators(indicators)
expect(snapManager.getIndicators()).toEqual(indicators)
})
})
describe('clearIndicators', () => {
it('should clear indicators when they exist', () => {
const indicators: PointsSnapIndicator[] = [
{
id: 'test-indicator',
type: 'points',
points: [{ x: 10, y: 20 }],
},
]
snapManager.setIndicators(indicators)
expect(snapManager.getIndicators()).toHaveLength(1)
snapManager.clearIndicators()
expect(snapManager.getIndicators()).toEqual([])
})
it('should not throw when clearing empty indicators', () => {
expect(() => snapManager.clearIndicators()).not.toThrow()
expect(snapManager.getIndicators()).toEqual([])
})
})
})
describe('getSnapThreshold', () => {
it('should calculate threshold based on zoom level', () => {
editor.getZoomLevel.mockReturnValue(1)
expect(snapManager.getSnapThreshold()).toBe(8)
})
it('should adjust threshold for different zoom levels', () => {
// Create a new SnapManager for each zoom level to avoid computed value caching
editor.getZoomLevel.mockReturnValue(2)
const snapManager2 = new SnapManager(editor)
expect(snapManager2.getSnapThreshold()).toBe(4)
editor.getZoomLevel.mockReturnValue(0.5)
const snapManager3 = new SnapManager(editor)
expect(snapManager3.getSnapThreshold()).toBe(16)
})
it('should handle very small zoom levels', () => {
editor.getZoomLevel.mockReturnValue(0.1)
expect(snapManager.getSnapThreshold()).toBe(80)
})
it('should handle very large zoom levels', () => {
editor.getZoomLevel.mockReturnValue(10)
expect(snapManager.getSnapThreshold()).toBe(0.8)
})
it('should use custom snap threshold when configured', () => {
;(editor as any).options = { snapThreshold: 16 }
editor.getZoomLevel.mockReturnValue(1)
const customSnapManager = new SnapManager(editor)
expect(customSnapManager.getSnapThreshold()).toBe(16)
editor.getZoomLevel.mockReturnValue(2)
const customSnapManager2 = new SnapManager(editor)
expect(customSnapManager2.getSnapThreshold()).toBe(8)
})
})
describe('getCurrentCommonAncestor', () => {
it('should return common ancestor of selected shapes', () => {
const shapeId = createShapeId('shape1')
const selectedShapes = [createMockShape(shapeId)]
editor.getSelectedShapes.mockReturnValue(selectedShapes as any)
editor.findCommonAncestor.mockReturnValue(shapeId)
expect(snapManager.getCurrentCommonAncestor()).toBe(shapeId)
expect(editor.findCommonAncestor).toHaveBeenCalledWith(selectedShapes)
})
it('should return null when no common ancestor found', () => {
editor.getSelectedShapes.mockReturnValue([])
editor.findCommonAncestor.mockReturnValue(undefined)
expect(snapManager.getCurrentCommonAncestor()).toBeUndefined()
})
})
describe('getSnappableShapes', () => {
it('should return empty set when no shapes in viewport', () => {
editor.getSortedChildIdsForParent.mockReturnValue([])
const result = snapManager.getSnappableShapes()
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(0)
})
it('should include shapes that can snap and are in viewport', () => {
const shapeId = createShapeId('shape1')
const shape = createMockShape(shapeId)
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(shape as any)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
editor.getViewportPageBounds.mockReturnValue(new Box(0, 0, 100, 100))
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(true)
})
it('should exclude selected shapes', () => {
const selectedId = createShapeId('selected')
const unselectedId = createShapeId('unselected')
editor.getSelectedShapeIds.mockReturnValue([selectedId])
editor.getSortedChildIdsForParent.mockReturnValue([selectedId, unselectedId])
editor.getShape.mockReturnValue(createMockShape(unselectedId) as any)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
const result = snapManager.getSnappableShapes()
expect(result.has(selectedId)).toBe(false)
expect(result.has(unselectedId)).toBe(true)
})
it('should exclude shapes that cannot snap', () => {
const shapeId = createShapeId('shape1')
const shape = createMockShape(shapeId)
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(shape as any)
editor.getShapeUtil.mockReturnValue({
canSnap: vi.fn(() => false),
} as any)
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(false)
})
it('should exclude hidden shapes', () => {
const shapeId = createShapeId('shape1')
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(createMockShape(shapeId) as any)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
editor.isShapeHidden.mockReturnValue(true)
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(false)
})
it('should exclude shapes outside viewport bounds', () => {
const shapeId = createShapeId('shape1')
const shape = createMockShape(shapeId)
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(shape as any)
editor.getShapePageBounds.mockReturnValue(new Box(200, 200, 50, 50))
editor.getViewportPageBounds.mockReturnValue(new Box(0, 0, 100, 100))
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(false)
})
it('should include frame shapes as snappable', () => {
const frameId = createShapeId('frame1')
const frameShape = createMockFrameShape(frameId)
editor.getSortedChildIdsForParent.mockReturnValue([frameId])
editor.getShape.mockReturnValue(frameShape)
editor.getShapeUtil.mockReturnValue({
canSnap: vi.fn(() => true),
} as any)
editor.isShapeFrameLike.mockReturnValue(true)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
const result = snapManager.getSnappableShapes()
expect(result.has(frameId)).toBe(true)
})
it('should recurse into group shapes but not include group itself', () => {
const groupId = createShapeId('group1')
const childId = createShapeId('child1')
const groupShape = createMockGroupShape(groupId)
const childShape = createMockShape(childId)
editor.getSortedChildIdsForParent
.mockReturnValueOnce([groupId]) // Root level
.mockReturnValueOnce([childId]) // Inside group
editor.getShape.mockImplementation((id) => {
if (id === groupId) return groupShape
if (id === childId) return childShape
return undefined
})
editor.isShapeOfType.mockImplementation((shape: any, type) => shape && shape.type === type)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
const result = snapManager.getSnappableShapes()
expect(result.has(groupId)).toBe(false) // Group itself not included
expect(result.has(childId)).toBe(true) // Child is included
})
it('should handle nested frame structures', () => {
const parentFrameId = createShapeId('parent-frame')
const childFrameId = createShapeId('child-frame')
const parentFrame = createMockFrameShape(parentFrameId)
const childFrame = createMockFrameShape(childFrameId)
// Override the getCurrentCommonAncestor mock for this specific test
const originalGetCurrentCommonAncestor = snapManager.getCurrentCommonAncestor
vi.spyOn(snapManager, 'getCurrentCommonAncestor').mockReturnValue(parentFrameId)
editor.getSortedChildIdsForParent.mockReturnValueOnce([childFrameId]) // Children of parent frame
editor.getShape.mockImplementation((id: any) => {
if (id === parentFrameId) return parentFrame as any
if (id === childFrameId) return childFrame as any
return undefined
})
editor.getShapeUtil.mockReturnValue({
canSnap: vi.fn(() => true),
} as any)
editor.isShapeFrameLike.mockReturnValue(true)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
const result = snapManager.getSnappableShapes()
expect(result.has(childFrameId)).toBe(true)
// Restore original method
vi.spyOn(snapManager, 'getCurrentCommonAncestor').mockImplementation(
originalGetCurrentCommonAncestor
)
})
it('should handle missing shape bounds gracefully', () => {
const shapeId = createShapeId('shape1')
const shape = createMockShape(shapeId)
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(shape as any)
editor.getShapePageBounds.mockReturnValue(undefined)
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(false)
})
it('should handle missing shapes gracefully', () => {
const shapeId = createShapeId('shape1')
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(undefined)
const result = snapManager.getSnappableShapes()
expect(result.has(shapeId)).toBe(false)
})
it('should use current page as fallback when no common ancestor', () => {
const shapeId = createShapeId('shape1')
const shape = createMockShape(shapeId)
// Override the getCurrentCommonAncestor mock for this specific test
const originalGetCurrentCommonAncestor = snapManager.getCurrentCommonAncestor
vi.spyOn(snapManager, 'getCurrentCommonAncestor').mockReturnValue(undefined)
editor.getCurrentPageId.mockReturnValue('page:current' as TLPageId)
editor.getSortedChildIdsForParent.mockReturnValue([shapeId])
editor.getShape.mockReturnValue(shape as any)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
snapManager.getSnappableShapes()
expect(editor.getSortedChildIdsForParent).toHaveBeenCalledWith('page:current')
// Restore original method
vi.spyOn(snapManager, 'getCurrentCommonAncestor').mockImplementation(
originalGetCurrentCommonAncestor
)
})
})
describe('computed properties behavior', () => {
it('should calculate threshold based on current zoom level', () => {
// Test with different SnapManager instances to verify the computation works
editor.getZoomLevel.mockReturnValue(1)
const snapManager1 = new SnapManager(editor)
const threshold1 = snapManager1.getSnapThreshold()
editor.getZoomLevel.mockReturnValue(2)
const snapManager2 = new SnapManager(editor)
const threshold2 = snapManager2.getSnapThreshold()
expect(threshold1).toBe(8)
expect(threshold2).toBe(4)
})
it('should calculate snappable shapes based on current editor state', () => {
const shapeId1 = createShapeId('shape1')
const shapeId2 = createShapeId('shape2')
// Test with first set of shapes
editor.getSortedChildIdsForParent.mockReturnValue([shapeId1])
editor.getShape.mockReturnValue(createMockShape(shapeId1) as any)
editor.getShapePageBounds.mockReturnValue(new Box(10, 10, 50, 50))
const snapManager1 = new SnapManager(editor)
const result1 = snapManager1.getSnappableShapes()
expect(result1.has(shapeId1)).toBe(true)
expect(result1.has(shapeId2)).toBe(false)
// Test with second set of shapes
editor.getSortedChildIdsForParent.mockReturnValue([shapeId1, shapeId2])
editor.getShape.mockImplementation((id: any) => {
if (id === shapeId1) return createMockShape(shapeId1) as any
if (id === shapeId2) return createMockShape(shapeId2) as any
return undefined
})
const snapManager2 = new SnapManager(editor)
const result2 = snapManager2.getSnappableShapes()
expect(result2.has(shapeId1)).toBe(true)
expect(result2.has(shapeId2)).toBe(true)
})
})
})

View File

@@ -0,0 +1,111 @@
import { EMPTY_ARRAY, atom, computed } from '@tldraw/state'
import { TLParentId, TLShapeId, isShapeId } from '@tldraw/tlschema'
import { Vec, VecLike } from '../../../primitives/Vec'
import type { Editor } from '../../Editor'
import { BoundsSnaps } from './BoundsSnaps'
import { HandleSnaps } from './HandleSnaps'
/** @public */
export interface PointsSnapIndicator {
id: string
type: 'points'
points: VecLike[]
}
/** @public */
export interface GapsSnapIndicator {
id: string
type: 'gaps'
direction: 'horizontal' | 'vertical'
gaps: Array<{
startEdge: [VecLike, VecLike]
endEdge: [VecLike, VecLike]
}>
}
/** @public */
export type SnapIndicator = PointsSnapIndicator | GapsSnapIndicator
/** @public */
export interface SnapData {
nudge: Vec
}
/** @public */
export class SnapManager {
readonly shapeBounds: BoundsSnaps
readonly handles: HandleSnaps
private _snapIndicators = atom<SnapIndicator[] | undefined>('snapLines', undefined)
constructor(public readonly editor: Editor) {
this.shapeBounds = new BoundsSnaps(this)
this.handles = new HandleSnaps(this)
}
getIndicators() {
return this._snapIndicators.get() ?? (EMPTY_ARRAY as SnapIndicator[])
}
clearIndicators() {
if (this.getIndicators().length) {
this._snapIndicators.set(undefined)
}
}
setIndicators(indicators: SnapIndicator[]) {
this._snapIndicators.set(indicators)
}
@computed getSnapThreshold() {
return this.editor.options.snapThreshold / this.editor.getZoomLevel()
}
// TODO: make this an incremental derivation
@computed getSnappableShapes(): Set<TLShapeId> {
const { editor } = this
const renderingBounds = editor.getViewportPageBounds()
const selectedShapeIds = new Set(editor.getSelectedShapeIds())
const snappableShapes: Set<TLShapeId> = new Set()
const collectSnappableShapesFromParent = (parentId: TLParentId) => {
if (isShapeId(parentId)) {
const parent = editor.getShape(parentId)
if (parent && editor.isShapeFrameLike(parent) && !editor.isShapeHidden(parent)) {
snappableShapes.add(parentId)
}
}
const sortedChildIds = editor.getSortedChildIdsForParent(parentId)
for (const childId of sortedChildIds) {
// Skip any selected ids
if (selectedShapeIds.has(childId)) continue
const childShape = editor.getShape(childId)
if (!childShape) continue
const util = editor.getShapeUtil(childShape)
// Skip any shapes that don't allow snapping
if (!util.canSnap(childShape)) continue
// Only consider shapes if they're inside of the viewport page bounds
const pageBounds = editor.getShapePageBounds(childId)
if (!(pageBounds && renderingBounds.includes(pageBounds))) continue
// Snap to children of groups but not group itself. A hidden group's
// children may still override to visible, so recurse before the check.
if (editor.isShapeOfType(childShape, 'group')) {
collectSnappableShapesFromParent(childId)
continue
}
if (editor.isShapeHidden(childShape)) continue
snappableShapes.add(childId)
}
}
collectSnappableShapesFromParent(this.getCurrentCommonAncestor() ?? editor.getCurrentPageId())
return snappableShapes
}
// This needs to be external from any expensive work
@computed getCurrentCommonAncestor() {
return this.editor.findCommonAncestor(this.editor.getSelectedShapes())
}
}

View File

@@ -0,0 +1,129 @@
import type { TLShapeId } from '@tldraw/tlschema'
import RBush from 'rbush'
import { Box } from '../../../primitives/Box'
/**
* Element stored in the R-tree spatial index.
* Contains bounds (minX, minY, maxX, maxY) and shape ID.
*/
export interface SpatialElement {
minX: number
minY: number
maxX: number
maxY: number
id: TLShapeId
}
/**
* Custom RBush class for tldraw shapes.
*/
class TldrawRBush extends RBush<SpatialElement> {}
/**
* Wrapper around RBush R-tree for efficient spatial queries.
* Maintains a map of elements currently in the tree for efficient updates.
*/
export class RBushIndex {
private rBush: TldrawRBush
private elementsInTree: Map<TLShapeId, SpatialElement>
constructor() {
this.rBush = new TldrawRBush()
this.elementsInTree = new Map()
}
/**
* Search for shapes within the given bounds.
* Returns set of shape IDs that intersect with the bounds.
*/
search(bounds: Box): Set<TLShapeId> {
const results = this.rBush.search({
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.maxX,
maxY: bounds.maxY,
})
return new Set(results.map((e: SpatialElement) => e.id))
}
/**
* Remove a shape from the spatial index.
*/
remove(id: TLShapeId): void {
const element = this.elementsInTree.get(id)
if (element) {
this.rBush.remove(element)
this.elementsInTree.delete(id)
}
}
/**
* Apply the removals and upserts collected during one incremental update.
* Mutations are applied per item: bulk-rebuilding the whole tree instead
* was measured slower even with ~10% of the index in the batch.
*/
applyBatch(removes: Set<TLShapeId>, upserts: SpatialElement[]): void {
for (const id of removes) {
this.remove(id)
}
for (const element of upserts) {
const existing = this.elementsInTree.get(element.id)
if (existing) {
this.rBush.remove(existing)
}
this.rBush.insert(element)
this.elementsInTree.set(element.id, element)
}
}
/**
* Bulk load elements into the spatial index.
* More efficient than individual inserts for initial loading.
*/
bulkLoad(elements: SpatialElement[]): void {
this.rBush.load(elements)
for (const element of elements) {
this.elementsInTree.set(element.id, element)
}
}
/**
* Clear all elements from the spatial index.
*/
clear(): void {
this.rBush.clear()
this.elementsInTree.clear()
}
/**
* Check if a shape is in the spatial index.
*/
has(id: TLShapeId): boolean {
return this.elementsInTree.has(id)
}
/**
* Get the number of elements in the spatial index.
*/
getSize(): number {
return this.elementsInTree.size
}
/**
* Get the raw stored element for a shape, without allocating a Box.
* Use when you only need to read the indexed bounds for comparison.
*
* @internal
*/
getElement(id: TLShapeId): SpatialElement | undefined {
return this.elementsInTree.get(id)
}
/**
* Dispose of the spatial index.
* Clears all data structures to prevent memory leaks.
*/
dispose(): void {
this.clear()
}
}

View File

@@ -0,0 +1,335 @@
import {
Computed,
RESET_VALUE,
computed,
isUninitialized,
unsafe__withoutCapture,
} from '@tldraw/state'
import type { RecordsDiff } from '@tldraw/store'
import { TLPageId, TLShape, TLShapeId, isBinding, isPageId, isShape } from '@tldraw/tlschema'
import type { TLRecord } from '@tldraw/tlschema'
import { objectMapValues } from '@tldraw/utils'
import { Box } from '../../../primitives/Box'
import type { Editor } from '../../Editor'
import { RBushIndex, type SpatialElement } from './RBushIndex'
/**
* Manages spatial indexing for efficient shape location queries.
*
* Uses an R-tree (via RBush) to enable O(log n) spatial queries instead of O(n) iteration.
* Derived-bounds changes (arrows following bound shapes, groups resizing with children) are
* tracked structurally: each record diff is expanded into a dirty set covering the changed
* shapes' descendants, ancestors, and binding partners, and only those shapes' bounds are
* rechecked — O(affected) per update instead of O(page).
*
* Key features:
* - Incremental updates using filterHistory pattern (shape and binding history)
* - Dirty-set expansion via parent/child and binding relationships
* - Per-page index (rebuilds on page change)
* - Optimized for viewport culling queries
*
* Known limitation: a custom shape whose geometry reads other shapes outside the
* parent/child and binding relationships is not rechecked when those shapes change.
* Core shapes only depend on other shapes via bindings (arrows) and parent/child
* relationships (groups), which are both covered. This narrows the previous
* approach, which rechecked every indexed shape on every update and so happened to
* cover such shapes; the structural expansion trades that for O(affected) updates.
*
* @internal
*/
export class SpatialIndexManager {
private rbush: RBushIndex
private spatialIndexComputed: Computed<number>
private lastPageId: TLPageId | null = null
// Bumps only when the rbush content may have changed. Consumers subscribe
// via the computed; a stable epoch lets prop-only diffs skip downstream
// invalidations.
private _boundsEpoch = 0
constructor(public readonly editor: Editor) {
this.rbush = new RBushIndex()
this.spatialIndexComputed = this.createSpatialIndexComputed()
}
private rebuildAndBumpEpoch(): number {
this.buildFromScratch()
this._boundsEpoch++
return this._boundsEpoch
}
private createSpatialIndexComputed() {
const shapeHistory = this.editor.store.query.filterHistory('shape')
// Binding changes can move a shape's derived bounds (e.g. creating or
// deleting an arrow binding relocates the arrow's body) without
// touching any shape record, so they must also invalidate the index.
const bindingHistory = this.editor.store.query.filterHistory('binding')
return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
// These three reads are the computed's only tracked dependencies.
// Every input to a shape's page bounds is a shape or binding record,
// so the two histories cover all invalidation; the rebuild/update
// paths below run without capture to avoid registering a dependency
// per checked shape on every update (and ~page-size dependencies on
// every rebuild).
const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
const currentPageId = this.editor.getCurrentPageId()
if (
isUninitialized(_prevValue) ||
shapeDiff === RESET_VALUE ||
bindingDiff === RESET_VALUE ||
this.lastPageId !== currentPageId
) {
return unsafe__withoutCapture(() => this.rebuildAndBumpEpoch())
}
if (shapeDiff.length === 0 && bindingDiff.length === 0) return this._boundsEpoch
if (unsafe__withoutCapture(() => this.processIncrementalUpdate(shapeDiff, bindingDiff))) {
this._boundsEpoch++
}
return this._boundsEpoch
})
}
private buildFromScratch(): void {
this.rbush.clear()
this.lastPageId = this.editor.getCurrentPageId()
const elements: SpatialElement[] = []
for (const shape of this.editor.getCurrentPageShapes()) {
const bounds = this.editor.getShapePageBounds(shape.id)
if (bounds && bounds.isValid()) {
elements.push({
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.maxX,
maxY: bounds.maxY,
id: shape.id,
})
}
}
// Bulk load for efficiency
this.rbush.bulkLoad(elements)
}
private processIncrementalUpdate(
shapeDiff: RecordsDiff<TLRecord>[],
bindingDiff: RecordsDiff<TLRecord>[]
): boolean {
// Rbush mutations are collected and applied as one batch at the end so
// large updates can use a bulk rebuild instead of per-item tree ops.
const pendingRemoves = new Set<TLShapeId>()
const pendingUpserts: SpatialElement[] = []
// Shapes whose page transform may have moved (their record was added or
// updated). Their descendants' transforms move with them.
const transformChanged = new Set<TLShapeId>()
// Shapes whose page bounds need rechecking against the index.
const dirty = new Set<TLShapeId>()
// Subset of `dirty` not yet expanded into ancestors/binding partners.
const queue: TLShapeId[] = []
const markDirty = (id: TLShapeId) => {
if (!dirty.has(id)) {
dirty.add(id)
queue.push(id)
}
}
// Step 1: seed the dirty set from the record diffs.
for (const changes of shapeDiff) {
for (const shape of objectMapValues(changes.added) as TLShape[]) {
if (!isShape(shape)) continue
transformChanged.add(shape.id)
markDirty(shape.id)
}
for (const shape of objectMapValues(changes.removed) as TLShape[]) {
if (!isShape(shape)) continue
if (this.rbush.has(shape.id)) {
pendingRemoves.add(shape.id)
}
// The old parent's derived bounds (groups) may shrink. Bindings
// involving the shape are deleted in the same transaction, so the
// binding diff below dirties the other ends.
if (!isPageId(shape.parentId)) markDirty(shape.parentId)
}
for (const [from, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
if (!isShape(to)) continue
transformChanged.add(to.id)
markDirty(to.id)
// On reparent, the old ancestor chain needs rechecking too
if (isShape(from) && from.parentId !== to.parentId && !isPageId(from.parentId)) {
markDirty(from.parentId)
}
}
}
for (const changes of bindingDiff) {
for (const binding of objectMapValues(changes.added)) {
if (!isBinding(binding)) continue
markDirty(binding.fromId)
markDirty(binding.toId)
}
for (const binding of objectMapValues(changes.removed)) {
if (!isBinding(binding)) continue
markDirty(binding.fromId)
markDirty(binding.toId)
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (!isBinding(to)) continue
markDirty(to.fromId)
markDirty(to.toId)
// A reassigned binding leaves its old endpoints needing a
// recheck too (mirrors the reparent case for shapes above).
if (isBinding(from) && (from.fromId !== to.fromId || from.toId !== to.toId)) {
markDirty(from.fromId)
markDirty(from.toId)
}
}
}
// Step 2: a moved record moves the page transforms of all its
// descendants, so their bounds need rechecking even though their
// records never changed. Expand before the fixpoint below so each
// descendant also gets its ancestors and binding partners visited.
const transformQueue = [...transformChanged]
while (transformQueue.length) {
const id = transformQueue.pop()!
for (const childId of this.editor.getSortedChildIdsForParent(id)) {
if (!transformChanged.has(childId)) {
transformChanged.add(childId)
transformQueue.push(childId)
}
markDirty(childId)
}
}
// Step 3: expand to a fixpoint. Any shape whose bounds may have changed
// can resize its ancestor groups and move arrows bound to it. A derived
// bounds change does not move the shape's own transform, so descendants
// are not affected by this expansion (only by step 2).
while (queue.length) {
const id = queue.pop()!
const record = this.editor.store.unsafeGetWithoutCapture(id)
if (record && isShape(record) && !isPageId(record.parentId)) {
markDirty(record.parentId)
}
for (const binding of this.editor.getBindingsInvolvingShape(id)) {
markDirty(binding.fromId)
markDirty(binding.toId)
}
}
// Step 4: recheck only the dirty shapes' bounds. The batch stays empty
// on prop-only updates with unchanged bounds, so they don't bump the
// epoch.
for (const id of dirty) {
// A shape removed in an earlier diff entry may have been re-added in
// a later one; its pending removal means the indexed bounds no
// longer count.
const indexedElement = pendingRemoves.has(id) ? undefined : this.rbush.getElement(id)
const record = this.editor.store.unsafeGetWithoutCapture(id)
if (
!record ||
!isShape(record) ||
this.editor.getAncestorPageId(record) !== this.lastPageId
) {
if (this.rbush.has(id)) {
pendingRemoves.add(id)
}
continue
}
const bounds = this.editor.getShapePageBounds(id)
if (bounds && bounds.isValid()) {
if (!this.areBoundsEqualToSpatialElement(bounds, indexedElement)) {
pendingRemoves.delete(id)
pendingUpserts.push({
minX: bounds.minX,
minY: bounds.minY,
maxX: bounds.maxX,
maxY: bounds.maxY,
id,
})
}
} else if (this.rbush.has(id)) {
pendingRemoves.add(id)
}
}
if (pendingRemoves.size === 0 && pendingUpserts.length === 0) return false
this.rbush.applyBatch(pendingRemoves, pendingUpserts)
return true
}
private areBoundsEqualToSpatialElement(
a: Box | undefined,
b: SpatialElement | undefined
): boolean {
if (!a && !b) return true
if (!a || !b) return false
return a.minX === b.minX && a.minY === b.minY && a.maxX === b.maxX && a.maxY === b.maxY
}
/**
* Get shape IDs within the given bounds.
* Optimized for viewport culling queries.
*
* Note: Results are unordered. If you need z-order, combine with sorted shapes:
* ```ts
* const candidates = editor.spatialIndex.getShapeIdsInsideBounds(bounds)
* const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
* ```
*
* @param bounds - The bounds to search within
* @returns Unordered set of shape IDs within the bounds
*
* @public
*/
getShapeIdsInsideBounds(bounds: Box): Set<TLShapeId> {
this.spatialIndexComputed.get()
return this.rbush.search(bounds)
}
/**
* Get shape IDs at a point (with optional margin).
* Creates a small bounding box around the point and searches the spatial index.
*
* Note: Results are unordered. If you need z-order, combine with sorted shapes:
* ```ts
* const candidates = editor.spatialIndex.getShapeIdsAtPoint(point, margin)
* const sorted = editor.getCurrentPageShapesSorted().filter(s => candidates.has(s.id))
* ```
*
* @param point - The point to search at
* @param margin - The margin around the point to search (default: 0)
* @returns Unordered set of shape IDs that could potentially contain the point
*
* @public
*/
getShapeIdsAtPoint(point: { x: number; y: number }, margin = 0): Set<TLShapeId> {
this.spatialIndexComputed.get()
return this.rbush.search(new Box(point.x - margin, point.y - margin, margin * 2, margin * 2))
}
/**
* Dispose of the spatial index manager.
* Clears the R-tree to prevent memory leaks.
*
* @public
*/
dispose(): void {
this.rbush.dispose()
this.lastPageId = null
}
}

View File

@@ -0,0 +1,623 @@
import { vi } from 'vitest'
import { Editor } from '../../Editor'
import { TextManager, TLMeasureTextSpanOpts } from './TextManager'
// Create a simple mock DOM environment
function createMockStyle() {
const properties = new Map<string, string>()
return {
setProperty: vi.fn((key: string, value: string) => {
properties.set(key, value)
}),
getPropertyValue: vi.fn((key: string) => properties.get(key) ?? ''),
removeProperty: vi.fn((key: string) => {
const previous = properties.get(key) ?? ''
properties.delete(key)
return previous
}),
}
}
const mockElement = {
classList: { add: vi.fn() },
tabIndex: -1,
cloneNode: vi.fn(),
innerHTML: '',
textContent: '',
setAttribute: vi.fn(),
style: createMockStyle(),
scrollWidth: 100,
getBoundingClientRect: vi.fn(() => ({
width: 100,
height: 20,
left: 0,
top: 0,
right: 100,
bottom: 20,
})),
remove: vi.fn(),
insertAdjacentElement: vi.fn(),
childNodes: [],
}
// Mock document.createElement to return our mock element
const mockCreateElement = vi.fn(() => {
const element = { ...mockElement }
element.style = createMockStyle()
element.cloneNode = vi.fn(() => ({ ...element }))
// Make textContent and innerHTML reactive like real DOM elements
let _textContent = ''
let _innerHTML = ''
Object.defineProperty(element, 'textContent', {
get: () => _textContent,
set: (value) => {
_textContent = value || ''
// When textContent is set, innerHTML should be the escaped version
_innerHTML = _textContent
},
})
Object.defineProperty(element, 'innerHTML', {
get: () => _innerHTML,
set: (value) => {
_innerHTML = value || ''
_textContent = _innerHTML // Simple approximation
},
})
return element
})
// Mock editor
const mockDocument = {
createElement: mockCreateElement,
}
const mockEditor = {
getContainer: vi.fn(() => ({
appendChild: vi.fn(),
})),
getContainerDocument: vi.fn(() => mockDocument),
} as unknown as Editor
global.Range = vi.fn(function () {
return {
setStart: vi.fn(),
setEnd: vi.fn(),
getClientRects: vi.fn(() => [
{
width: 10,
height: 16,
left: 0,
top: 0,
right: 10,
bottom: 16,
},
]),
}
}) as any
describe('TextManager', () => {
let textManager: TextManager
beforeEach(() => {
vi.clearAllMocks()
textManager = new TextManager(mockEditor)
})
describe('constructor', () => {
it('should create a TextManager instance', () => {
expect(textManager).toBeInstanceOf(TextManager)
})
})
describe('measureText', () => {
const defaultOpts = {
fontStyle: 'normal',
fontWeight: '400',
fontFamily: 'Arial',
fontSize: 16,
lineHeight: 1.2,
maxWidth: 200,
minWidth: null,
padding: '0px',
}
it('should call measureHtml with normalized text', () => {
const spy = vi.spyOn(textManager, 'measureHtml')
textManager.measureText('Hello World', defaultOpts)
expect(spy).toHaveBeenCalledWith('Hello World', defaultOpts)
})
it('should normalize line breaks', () => {
const spy = vi.spyOn(textManager, 'measureHtml')
textManager.measureText('Hello\nWorld\r\nTest', defaultOpts)
// The text should be normalized to use consistent line breaks
expect(spy).toHaveBeenCalled()
})
it('should handle empty text', () => {
const result = textManager.measureText('', { ...defaultOpts, measureScrollWidth: true })
expect(result).toHaveProperty('x', 0)
expect(result).toHaveProperty('y', 0)
expect(result).toHaveProperty('w')
expect(result).toHaveProperty('h')
expect(result).toHaveProperty('scrollWidth')
})
})
describe('measureHtml', () => {
const defaultOpts = {
fontStyle: 'normal',
fontWeight: '400',
fontFamily: 'Arial',
fontSize: 16,
lineHeight: 1.2,
maxWidth: 200,
minWidth: null,
padding: '0px',
}
it('should return measurement object with correct structure', () => {
const result = textManager.measureHtml('<span>Test</span>', defaultOpts)
expect(result).toMatchObject({
x: 0,
y: 0,
w: expect.any(Number),
h: expect.any(Number),
})
})
it('should handle null maxWidth', () => {
const opts = { ...defaultOpts, maxWidth: null }
const result = textManager.measureHtml('Test', opts)
expect(result).toMatchObject({
x: 0,
y: 0,
w: expect.any(Number),
h: expect.any(Number),
})
})
it('should handle overflow wrap breaking', () => {
const opts = { ...defaultOpts, disableOverflowWrapBreaking: true }
const result = textManager.measureHtml('Test', opts)
expect(result).toMatchObject({
x: 0,
y: 0,
w: expect.any(Number),
h: expect.any(Number),
})
})
it('should handle other styles', () => {
const opts = {
...defaultOpts,
otherStyles: {
'text-decoration': 'underline',
color: 'red',
},
}
const result = textManager.measureHtml('Test', opts)
expect(result).toMatchObject({
x: 0,
y: 0,
w: expect.any(Number),
h: expect.any(Number),
})
})
})
describe('measureElementTextNodeSpans', () => {
it('should handle elements with text nodes', () => {
const mockTextNode = {
nodeType: 3, // TEXT_NODE
textContent: 'Hello',
}
const mockElementWithText = {
childNodes: [mockTextNode],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
const result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
expect(result).toHaveProperty('spans')
expect(result).toHaveProperty('didTruncate')
expect(Array.isArray(result.spans)).toBe(true)
expect(typeof result.didTruncate).toBe('boolean')
})
it('should handle empty elements', () => {
const mockEmptyElement = {
childNodes: [],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
const result = textManager.measureElementTextNodeSpans(mockEmptyElement as any)
expect(result.didTruncate).toBe(false)
expect(result.spans).toHaveLength(0)
})
it('should skip characters that produce no layout rectangles', () => {
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
RangeMock.mockImplementationOnce(function () {
return {
setStart: vi.fn(),
setEnd: vi.fn(),
// simulate a browser returning no rects for a measured character,
// which previously crashed with "Cannot read properties of
// undefined (reading 'top')". See issue #9112.
getClientRects: vi.fn(() => []),
}
})
const mockTextNode = {
nodeType: 3, // TEXT_NODE
textContent: 'Hello',
}
const mockElementWithText = {
childNodes: [mockTextNode],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
let result
expect(() => {
result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
}).not.toThrow()
expect(result!.spans).toHaveLength(0)
expect(result!.didTruncate).toBe(false)
})
it('should measure grapheme clusters as a single unit', () => {
const startOffsets: number[] = []
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
RangeMock.mockImplementationOnce(function () {
return {
setStart: vi.fn((_node: any, offset: number) => startOffsets.push(offset)),
setEnd: vi.fn(),
getClientRects: vi.fn(() => [
{ width: 10, height: 16, left: 0, top: 0, right: 10, bottom: 16 },
]),
}
})
// 👨‍👩‍👧 is 5 code points (8 UTF-16 units) joined by zero-width joiners
const family = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
const mockTextNode = { nodeType: 3, textContent: `a${family}b` }
const mockElementWithText = {
childNodes: [mockTextNode],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
const result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
// the emoji is measured as one grapheme, so the index jumps over all 8 units
expect(startOffsets).toEqual([0, 1, 9])
// and its code points are kept together in the span text
expect(result.spans[0].text).toBe(`a${family}b`)
})
it('should measure each text node with its own offsets', () => {
const starts: [unknown, number][] = []
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
RangeMock.mockImplementationOnce(function () {
return {
setStart: vi.fn((node: unknown, offset: number) => starts.push([node, offset])),
setEnd: vi.fn(),
getClientRects: vi.fn(() => [
{ width: 10, height: 16, left: 0, top: 0, right: 10, bottom: 16 },
]),
}
})
const first = { nodeType: 3, textContent: 'ab' }
const second = { nodeType: 3, textContent: 'cd' }
const mockElementWithText = {
childNodes: [first, { nodeType: 1 }, second],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
textManager.measureElementTextNodeSpans(mockElementWithText as any)
// the range must be placed in the node being measured, not the first one
expect(starts).toEqual([
[first, 0],
[first, 1],
[second, 0],
[second, 1],
])
})
it('should stop measuring later text nodes once the first line is truncated', () => {
const starts: [unknown, number][] = []
let current: { textContent: string } | null = null
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
RangeMock.mockImplementationOnce(function () {
return {
setStart: vi.fn((node: { textContent: string }, offset: number) => {
current = node
starts.push([node, offset])
}),
setEnd: vi.fn(),
// the second node sits on a new line
getClientRects: vi.fn(() => {
const top = current?.textContent === 'cd' ? 16 : 0
return [{ width: 10, height: 16, left: 0, top, right: 10, bottom: top + 16 }]
}),
}
})
const first = { nodeType: 3, textContent: 'ab' }
const second = { nodeType: 3, textContent: 'cd' }
const third = { nodeType: 3, textContent: 'ef' }
const mockElementWithText = {
childNodes: [first, second, third],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
const result = textManager.measureElementTextNodeSpans(mockElementWithText as any, {
shouldTruncateToFirstLine: true,
})
expect(result).toEqual({
spans: [{ box: { x: 0, y: 0, w: 10, h: 16 }, text: 'ab' }],
didTruncate: true,
})
// the third node must never be measured
expect(starts).toEqual([
[first, 0],
[first, 1],
[second, 0],
])
})
it('should handle truncation option', () => {
const mockTextNode = {
nodeType: 3, // TEXT_NODE
textContent: 'Hello World',
}
const mockElementWithText = {
childNodes: [mockTextNode],
getBoundingClientRect: () => ({ left: 0, top: 0 }),
}
const result = textManager.measureElementTextNodeSpans(mockElementWithText as any, {
shouldTruncateToFirstLine: true,
})
expect(result).toHaveProperty('spans')
expect(result).toHaveProperty('didTruncate')
})
})
describe('measureTextSpans', () => {
const defaultOpts: TLMeasureTextSpanOpts = {
overflow: 'wrap',
width: 200,
height: 100,
padding: 10,
fontSize: 16,
fontWeight: '400',
fontFamily: 'Arial',
fontStyle: 'normal',
lineHeight: 1.2,
textAlign: 'start',
}
it('should return empty array for empty text', () => {
const result = textManager.measureTextSpans('', defaultOpts)
expect(result).toEqual([])
})
it('should return array of text spans for non-empty text', () => {
// Mock measureElementTextNodeSpans to return some spans
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Hello World',
box: { x: 0, y: 0, w: 100, h: 16 },
},
],
didTruncate: false,
})
const result = textManager.measureTextSpans('Hello World', defaultOpts)
expect(Array.isArray(result)).toBe(true)
expect(result.length).toBeGreaterThan(0)
expect(result[0]).toHaveProperty('text')
expect(result[0]).toHaveProperty('box')
})
it('should handle wrap overflow', () => {
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Hello World',
box: { x: 0, y: 0, w: 100, h: 16 },
},
],
didTruncate: false,
})
const opts = { ...defaultOpts, overflow: 'wrap' as const }
const result = textManager.measureTextSpans('Hello World', opts)
expect(Array.isArray(result)).toBe(true)
})
it('should handle truncate-ellipsis overflow', () => {
// Mock the calls for ellipsis handling
vi.spyOn(textManager, 'measureElementTextNodeSpans')
.mockReturnValueOnce({
spans: [
{
text: 'Hello Wo',
box: { x: 0, y: 0, w: 80, h: 16 },
},
],
didTruncate: true,
})
.mockReturnValueOnce({
spans: [
{
text: '…',
box: { x: 0, y: 0, w: 10, h: 16 },
},
],
didTruncate: false,
})
.mockReturnValueOnce({
spans: [
{
text: 'Hello W',
box: { x: 0, y: 0, w: 70, h: 16 },
},
],
didTruncate: false,
})
const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
const result = textManager.measureTextSpans('Hello World', opts)
expect(Array.isArray(result)).toBe(true)
})
it('should not throw when the truncated remeasure yields no spans', () => {
// The text truncates on the first pass, but once we narrow the width
// to make room for the ellipsis, nothing measurable remains (e.g. the
// measurement element can't be laid out). This must not crash.
vi.spyOn(textManager, 'measureElementTextNodeSpans')
.mockReturnValueOnce({
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
didTruncate: true,
})
.mockReturnValueOnce({
spans: [{ text: '…', box: { x: 0, y: 0, w: 10, h: 16 } }],
didTruncate: false,
})
.mockReturnValueOnce({
spans: [],
didTruncate: false,
})
const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
let result: ReturnType<typeof textManager.measureTextSpans> | undefined
expect(() => (result = textManager.measureTextSpans('Hello World', opts))).not.toThrow()
expect(result).toEqual([])
})
it('should not throw when the ellipsis itself cannot be measured', () => {
// If measuring the ellipsis returns no spans, fall back to a zero width
// rather than dereferencing a missing span.
vi.spyOn(textManager, 'measureElementTextNodeSpans')
.mockReturnValueOnce({
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
didTruncate: true,
})
.mockReturnValueOnce({ spans: [], didTruncate: false })
.mockReturnValueOnce({
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
didTruncate: false,
})
const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
expect(() => textManager.measureTextSpans('Hello World', opts)).not.toThrow()
})
it('should handle truncate-clip overflow', () => {
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Hello Wo',
box: { x: 0, y: 0, w: 80, h: 16 },
},
],
didTruncate: true,
})
const opts = { ...defaultOpts, overflow: 'truncate-clip' as const }
const result = textManager.measureTextSpans('Hello World', opts)
expect(Array.isArray(result)).toBe(true)
})
it('should handle different text alignments', () => {
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Test',
box: { x: 0, y: 0, w: 40, h: 16 },
},
],
didTruncate: false,
})
const alignments: Array<TLMeasureTextSpanOpts['textAlign']> = ['start', 'middle', 'end']
alignments.forEach((textAlign) => {
const opts = { ...defaultOpts, textAlign }
const result = textManager.measureTextSpans('Test', opts)
expect(Array.isArray(result)).toBe(true)
})
})
it('should handle custom font properties', () => {
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Test',
box: { x: 0, y: 0, w: 40, h: 16 },
},
],
didTruncate: false,
})
const opts = {
...defaultOpts,
fontSize: 18,
fontFamily: 'Times',
fontWeight: 'bold',
fontStyle: 'italic',
lineHeight: 1.5,
}
const result = textManager.measureTextSpans('Test', opts)
expect(Array.isArray(result)).toBe(true)
})
it('should handle other styles', () => {
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
spans: [
{
text: 'Test',
box: { x: 0, y: 0, w: 40, h: 16 },
},
],
didTruncate: false,
})
const opts = {
...defaultOpts,
otherStyles: {
'text-shadow': '1px 1px 1px black',
'letter-spacing': '1px',
},
}
const result = textManager.measureTextSpans('Test', opts)
expect(Array.isArray(result)).toBe(true)
})
})
})

View File

@@ -0,0 +1,476 @@
import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
import { iterateGraphemes, objectMapKeys } from '@tldraw/utils'
import type { Editor } from '../../Editor'
import { EditorManager } from '../EditorManager'
/**
* The whole-pixel line-height for a given font size and tldraw's unitless line-height
* multiplier. tldraw's theme stores line-height as a multiplier (e.g. 1.35); resolving it
* to a whole pixel keeps line spacing identical across rendering engines, which otherwise
* disagree on fractional line boxes (WebKit snaps them to whole pixels, Blink keeps the
* fraction) and let multi-line text drift apart. Apply it everywhere line-height is used —
* measurement, on-canvas render, and export — so geometry and rendering agree.
* See https://github.com/tldraw/tldraw/issues/8970.
*
* @public
*/
export function resolveLineHeightPx(fontSize: number, lineHeight: number): number {
return Math.round(fontSize * lineHeight)
}
const fixNewLines = /\r?\n|\r/g
function normalizeTextForDom(text: string) {
return text
.replace(fixNewLines, '\n')
.split('\n')
.map((x) => x || ' ')
.join('\n')
}
const textAlignmentsForLtr = {
start: 'left',
'start-legacy': 'left',
middle: 'center',
'middle-legacy': 'center',
end: 'right',
'end-legacy': 'right',
}
interface PoolItem {
el: HTMLDivElement
html: string
appliedStyleKeys: string[]
}
/** @public */
export interface BatchMeasurementRequest {
html: string
opts: TLMeasureTextOpts
}
/** @public */
export type TLMeasuredTextSize = BoxModel & {
scrollWidth: number
}
/** @public */
export interface TLMeasureTextOpts {
fontStyle: string
fontWeight: string
fontFamily: string
fontSize: number
/** This must be a number, e.g. 1.35, not a pixel value. */
lineHeight: number
/**
* When maxWidth is a number, the text will be wrapped to that maxWidth. When maxWidth
* is null, the text will be measured without wrapping, but explicit line breaks and
* space are preserved.
*/
maxWidth: null | number
minWidth?: null | number
// todo: make this a number so that it is consistent with other TLMeasureTextSpanOpts
padding: string
otherStyles?: Record<string, string>
disableOverflowWrapBreaking?: boolean
measureScrollWidth?: boolean
}
/** @public */
export interface TLMeasureTextSpanOpts {
overflow: 'wrap' | 'truncate-ellipsis' | 'truncate-clip'
width: number
height: number
padding: number
fontSize: number
fontWeight: string
fontFamily: string
fontStyle: string
lineHeight: number
textAlign: TLDefaultHorizontalAlignStyle
otherStyles?: Record<string, string>
measureScrollWidth?: boolean
}
const spaceCharacterRegex = /\s/
const initialDefaultStyles = Object.freeze({
'overflow-wrap': 'break-word',
'word-break': 'auto',
width: null,
height: null,
'max-width': null,
'min-width': null,
})
/** @public */
export class TextManager extends EditorManager {
private elm: HTMLDivElement
private poolElms: PoolItem[] = []
constructor(editor: Editor) {
super(editor)
this.elm = this.createMeasurementEl()
this.editor.getContainer().appendChild(this.elm)
this.register(() => {
this.elm.remove()
for (const { el } of this.poolElms) {
el.remove()
}
this.poolElms.length = 0
})
}
private createMeasurementEl(): HTMLDivElement {
const elm = this.editor.getContainerDocument().createElement('div')
elm.classList.add('tl-text')
elm.classList.add('tl-text-measure')
elm.setAttribute('dir', 'auto')
elm.tabIndex = -1
for (const key of objectMapKeys(initialDefaultStyles)) {
elm.style.setProperty(key, initialDefaultStyles[key])
}
return elm
}
private resetElementStyles(el: HTMLElement, appliedStyleKeys: string[]) {
for (const key of appliedStyleKeys) {
if (key in initialDefaultStyles) {
el.style.setProperty(key, initialDefaultStyles[key as keyof typeof initialDefaultStyles])
} else {
el.style.removeProperty(key)
}
}
}
private setElementStyles(el: HTMLElement, styles: Record<string, string | undefined | null>) {
type StyleValue = string | null
type RestoreEntry = [prop: string, value: StyleValue]
const restore: RestoreEntry[] = []
for (const [key, nextValue] of Object.entries(styles)) {
const oldValue = el.style.getPropertyValue(key)
if (typeof nextValue === 'string') {
if (oldValue === nextValue) continue
restore.push([key, oldValue || null])
el.style.setProperty(key, nextValue)
} else {
if (!oldValue) continue
restore.push([key, oldValue])
el.style.removeProperty(key)
}
}
return () => {
for (const [key, value] of restore) {
if (value === null || value === '') el.style.removeProperty(key)
else el.style.setProperty(key, value)
}
}
}
private getMeasureStyles(opts: TLMeasureTextOpts): Record<string, string | undefined> {
return {
'font-family': opts.fontFamily,
'font-style': opts.fontStyle,
'font-weight': opts.fontWeight,
'font-size': opts.fontSize + 'px',
'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
// Unitless multiplier consumed by the .tl-rich-text h1–h6 rule (see editor.css),
// so heading measurement matches on-canvas rendering.
'--tl-rich-text-heading-line-height': `${opts.lineHeight}`,
padding: opts.padding,
'max-width': opts.maxWidth ? opts.maxWidth + 'px' : undefined,
'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
'overflow-wrap': opts.disableOverflowWrapBreaking ? 'normal' : 'break-word',
...opts.otherStyles,
}
}
private ensurePoolSize(size: number) {
if (this.poolElms.length >= size) return
const fragment = this.editor.getContainerDocument().createDocumentFragment()
while (this.poolElms.length < size) {
const el = this.createMeasurementEl()
this.poolElms.push({ el, html: '', appliedStyleKeys: [] })
fragment.appendChild(el)
}
this.editor.getContainer().appendChild(fragment)
}
private getPoolItem(index: number): PoolItem {
this.ensurePoolSize(index + 1)
return this.poolElms[index]
}
measureHtmlBatch(requests: BatchMeasurementRequest[]): TLMeasuredTextSize[] {
if (requests.length === 0) return []
while (this.poolElms.length > requests.length) {
const { el } = this.poolElms.pop()!
el.remove()
}
for (let i = 0; i < requests.length; i++) {
const { html, opts } = requests[i]
const poolItem = this.getPoolItem(i)
const { el } = poolItem
this.resetElementStyles(el, poolItem.appliedStyleKeys)
const styles = this.getMeasureStyles(opts)
this.setElementStyles(el, styles)
poolItem.appliedStyleKeys = Object.keys(styles)
// Skip innerHTML parsing if the content hasn't changed
if (poolItem.html !== html) {
el.innerHTML = html
poolItem.html = html
}
}
const results: TLMeasuredTextSize[] = []
for (let i = 0; i < requests.length; i++) {
const el = this.getPoolItem(i).el
const scrollWidth = requests[i].opts.measureScrollWidth ? el.scrollWidth : 0
const rect = el.getBoundingClientRect()
results.push({
x: 0,
y: 0,
w: rect.width,
h: rect.height,
scrollWidth,
})
}
return results
}
measureText(textToMeasure: string, opts: TLMeasureTextOpts): TLMeasuredTextSize {
const div = this.editor.getContainerDocument().createElement('div')
div.textContent = normalizeTextForDom(textToMeasure)
return this.measureHtml(div.innerHTML, opts)
}
measureHtml(html: string, opts: TLMeasureTextOpts): TLMeasuredTextSize {
const { elm } = this
const restoreStyles = this.setElementStyles(elm, this.getMeasureStyles(opts))
try {
elm.innerHTML = html
const scrollWidth = opts.measureScrollWidth ? elm.scrollWidth : 0
const rect = elm.getBoundingClientRect()
return {
x: 0,
y: 0,
w: rect.width,
h: rect.height,
scrollWidth,
}
} finally {
restoreStyles()
}
}
/**
* Given an html element, measure the position of each span of unbroken
* word/white-space characters within any text nodes it contains.
*/
measureElementTextNodeSpans(
element: HTMLElement,
{ shouldTruncateToFirstLine = false }: { shouldTruncateToFirstLine?: boolean } = {}
): { spans: { box: BoxModel; text: string }[]; didTruncate: boolean } {
const spans = []
// Measurements of individual spans are relative to the containing element
const elmBounds = element.getBoundingClientRect()
const offsetX = -elmBounds.left
const offsetY = -elmBounds.top
// we measure by creating a range that spans each character in the elements text nodes
const range = new Range()
let currentSpan = null
let prevCharWasSpaceCharacter = null
let prevCharTop = 0
let prevCharLeftForRTLTest = 0
let didTruncate = false
for (const childNode of element.childNodes) {
if (didTruncate) break
if (childNode.nodeType !== Node.TEXT_NODE) continue
// offsets are per text node, or the range would index past the node's end
let idx = 0
for (const segment of iterateGraphemes(childNode.textContent ?? '')) {
// place the range around the grapheme we're interested in
range.setStart(childNode, idx)
range.setEnd(childNode, idx + segment.length)
// measure the range. some browsers return multiple rects for the
// first char in a new line - one for the line break, and one for
// the character itself. we're only interested in the character.
const rects = range.getClientRects()
const rect = rects[rects.length - 1]
// some graphemes produce no layout rectangle (e.g. zero-width
// spaces or newlines). skip them rather than crashing, but keep
// advancing the index so later graphemes stay aligned.
// See https://github.com/tldraw/tldraw/issues/9112.
if (!rect) {
idx += segment.length
continue
}
// calculate the position of the character relative to the element
const top = rect.top + offsetY
const left = rect.left + offsetX
const right = rect.right + offsetX
const isRTL = left < prevCharLeftForRTLTest
const isSpaceCharacter = spaceCharacterRegex.test(segment)
if (
// If we're at a word boundary...
isSpaceCharacter !== prevCharWasSpaceCharacter ||
// ...or we're on a different line...
top !== prevCharTop ||
// ...or we're at the start of the text and haven't created a span yet...
!currentSpan
) {
// ...then we're at a span boundary!
if (currentSpan) {
// if we're truncating to a single line & we just finished the first line, stop there
if (shouldTruncateToFirstLine && top !== prevCharTop) {
didTruncate = true
break
}
// otherwise add the span to the list ready to start a new one
spans.push(currentSpan)
}
// start a new span
currentSpan = {
box: { x: left, y: top, w: rect.width, h: rect.height },
text: segment,
}
prevCharLeftForRTLTest = left
} else {
// Looks like we're in RTL mode, so we need to adjust the left position.
if (isRTL) {
currentSpan.box.x = left
}
// otherwise we just need to extend the current span with the next character
currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
currentSpan.text += segment
}
if (segment === '\n') {
prevCharLeftForRTLTest = 0
}
prevCharWasSpaceCharacter = isSpaceCharacter
prevCharTop = top
idx += segment.length
}
}
// Add the last span
if (currentSpan) {
spans.push(currentSpan)
}
return { spans, didTruncate }
}
/**
* Measure text into individual spans. Spans are created by rendering the
* text, then dividing it up according to line breaks and word boundaries.
*
* It works by having the browser render the text, then measuring the
* position of each character. You can use this to replicate the text-layout
* algorithm of the current browser in e.g. an SVG export.
*/
measureTextSpans(
textToMeasure: string,
opts: TLMeasureTextSpanOpts
): { text: string; box: BoxModel }[] {
if (textToMeasure === '') return []
const { elm } = this
const shouldTruncateToFirstLine =
opts.overflow === 'truncate-ellipsis' || opts.overflow === 'truncate-clip'
const elementWidth = Math.ceil(opts.width - opts.padding * 2)
const newStyles = {
'font-family': opts.fontFamily,
'font-style': opts.fontStyle,
'font-weight': opts.fontWeight,
'font-size': opts.fontSize + 'px',
'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
width: `${elementWidth}px`,
height: 'min-content',
'text-align': textAlignmentsForLtr[opts.textAlign],
'overflow-wrap': shouldTruncateToFirstLine ? 'anywhere' : 'break-word',
'word-break': shouldTruncateToFirstLine ? 'break-all' : 'normal',
...opts.otherStyles,
}
const restoreStyles = this.setElementStyles(elm, newStyles)
try {
const normalizedText = normalizeTextForDom(textToMeasure)
// Render the text into the measurement element:
elm.textContent = normalizedText
// actually measure the text:
const { spans, didTruncate } = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine,
})
if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
// we need to measure the ellipsis to know how much space it takes up
elm.textContent = '…'
const ellipsisSpan = this.measureElementTextNodeSpans(elm).spans[0]
const ellipsisWidth = ellipsisSpan ? Math.ceil(ellipsisSpan.box.w) : 0
// then, we need to subtract that space from the width we have and measure again:
elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
elm.textContent = normalizedText
const truncatedSpans = this.measureElementTextNodeSpans(elm, {
shouldTruncateToFirstLine: true,
}).spans
// Finally, we add in our ellipsis at the end of the last span. We
// have to do this after measuring, not before, because adding the
// ellipsis changes how whitespace might be getting collapsed by the
// browser.
const lastSpan = truncatedSpans[truncatedSpans.length - 1]
// If nothing measurable remained, return the (possibly empty)
// spans as-is rather than dereferencing a missing last span.
if (!lastSpan) {
return truncatedSpans
}
truncatedSpans.push({
text: '…',
box: {
x: Math.min(lastSpan.box.x + lastSpan.box.w, opts.width - opts.padding - ellipsisWidth),
y: lastSpan.box.y,
w: ellipsisWidth,
h: lastSpan.box.h,
},
})
return truncatedSpans
}
return spans
} finally {
restoreStyles()
}
}
}

View File

@@ -0,0 +1,53 @@
import { TLTheme } from '@tldraw/tlschema'
import { MockInstance, vi } from 'vitest'
import { TestEditor } from '../../../test/TestEditor'
import { DEFAULT_THEME } from './defaultThemes'
// Optional so that other tests' theme objects still satisfy TLThemes.
declare module '@tldraw/tlschema' {
interface TLThemes {
ocean?: TLTheme
sunset?: TLTheme
}
}
const oceanTheme: TLTheme = { ...DEFAULT_THEME, id: 'ocean' }
let editor: TestEditor
let warn: MockInstance
beforeEach(() => {
editor = new TestEditor({ themes: { ocean: oceanTheme } })
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
warn.mockRestore()
editor.dispose()
})
describe('setCurrentTheme', () => {
it('switches to a registered theme', () => {
editor.setCurrentTheme('ocean')
expect(editor.getCurrentThemeId()).toBe('ocean')
expect(editor.getCurrentTheme()).toBe(oceanTheme)
expect(warn).not.toHaveBeenCalled()
})
it('warns and keeps the current theme when the id is not registered', () => {
editor.setCurrentTheme('sunset')
expect(warn).toHaveBeenCalledWith("Theme 'sunset' not found. Available themes: default, ocean")
expect(editor.getCurrentThemeId()).toBe('default')
expect(editor.getCurrentTheme()).toBe(DEFAULT_THEME)
})
it('keeps the previous theme rather than falling back to default', () => {
editor.setCurrentTheme('ocean')
editor.setCurrentTheme('sunset')
expect(editor.getCurrentThemeId()).toBe('ocean')
expect(editor.getCurrentTheme()).toBe(oceanTheme)
})
})

View File

@@ -0,0 +1,122 @@
import { Atom, atom, computed } from '@tldraw/state'
import { TLTheme, TLThemeId, TLThemes } from '@tldraw/tlschema'
import { structuredClone } from '@tldraw/utils'
import type { Editor } from '../../Editor'
import { DEFAULT_THEME } from './defaultThemes'
/**
* Resolve a partial set of user-provided themes into a complete `TLThemes`
* record by merging with `DEFAULT_THEME`. The result is suitable for passing to
* `registerColorsFromThemes`, `registerFontsFromThemes`, and the
* `ThemeManager` constructor.
*
* @public
*/
export function resolveThemes(themes?: Partial<TLThemes>): TLThemes {
return { default: DEFAULT_THEME, ...themes } as TLThemes
}
/**
* Manages the editor's color themes.
*
* Stores named theme definitions (each containing light and dark color palettes
* alongside shared properties like font size). The current theme is resolved by
* combining the current theme name with the user's color mode preference.
*
* **Terminology:**
* - **Theme** (`TLTheme`): A named set of colors and typographic values for both light and dark modes.
* - **Color mode** (`'light' | 'dark'`): The resolved appearance mode, derived from the user's
* `colorScheme` preference (`'light' | 'dark' | 'system'`). Access via `getColorMode()`.
*
* @public
*/
export class ThemeManager {
private readonly _themes: Atom<TLThemes>
private readonly _currentThemeId: Atom<TLThemeId>
constructor(
private readonly editor: Editor,
options: {
themes: TLThemes
initial: TLThemeId
}
) {
this._themes = atom('ThemeManager._definitions', options.themes)
this._currentThemeId = atom('ThemeManager._currentThemeName', options.initial)
}
/** Get the current color mode based on the user's dark mode preference. */
@computed getColorMode(): 'light' | 'dark' {
return this.editor.user.getIsDarkMode() ? 'dark' : 'light'
}
/** Get all registered theme definitions. */
getThemes(): TLThemes {
return this._themes.get()
}
/** Get a single theme definition by id. */
getTheme(id: TLThemeId): TLTheme | undefined {
return this._themes.get()[id]
}
/** Get the id of the current theme. */
getCurrentThemeId(): TLThemeId {
return this._currentThemeId.get()
}
getCurrentTheme(): TLTheme {
return this._themes.get()[this.getCurrentThemeId()]!
}
/**
* Set the current theme by id. The theme must have been previously registered; an
* unregistered id is ignored and the current theme is kept.
*/
setCurrentTheme(id: TLThemeId): void {
// Storing an unregistered id would make getCurrentTheme() return
// undefined despite its TLTheme return type.
if (!(id in this._themes.get())) {
if (process.env.NODE_ENV !== 'production') {
console.warn(
`Theme '${id}' not found. Available themes: ${Object.keys(this._themes.get()).join(', ')}`
)
}
return
}
this._currentThemeId.set(id)
}
/** Replace all theme definitions, or update them via a callback that receives a deep copy. */
updateThemes(themes: TLThemes | ((themes: TLThemes) => TLThemes)): void {
this._themes.update((prev) => {
const next = typeof themes === 'function' ? themes(structuredClone(prev)) : themes
if (process.env.NODE_ENV !== 'production') {
if (!('default' in next)) {
console.warn("The 'default' theme cannot be removed.")
return prev
}
}
// If the current theme was removed, fall back to 'default'
if (!(this._currentThemeId.get() in next)) {
this._currentThemeId.set('default')
}
return next
})
}
/** Register or update a named theme definition. */
updateTheme(theme: TLTheme): void {
this._themes.update((prev) => ({
...prev,
[theme.id]: theme,
}))
}
/** Clean up any resources held by the manager. */
dispose() {
// currently no subscriptions to tear down, but here for consistency
// with the manager pattern and for future use
}
}

View File

@@ -0,0 +1,619 @@
import {
DefaultFontFamilies,
TLDefaultColor,
TLDefaultColorStyle,
TLTheme,
TLThemeColors,
} from '@tldraw/tlschema'
/**
* The default theme definition containing color palettes for both light and dark modes.
*
* @public
*/
export const DEFAULT_THEME: TLTheme = {
id: 'default',
fontSize: 16,
lineHeight: 1.35,
strokeWidth: 2,
fonts: {
draw: {
fontFamily: DefaultFontFamilies.draw,
faces: [
{ family: 'tldraw_draw', src: { url: 'tldraw_draw', format: 'woff2' }, weight: 'normal' },
{
family: 'tldraw_draw',
src: { url: 'tldraw_draw_bold', format: 'woff2' },
weight: 'bold',
},
{
family: 'tldraw_draw',
src: { url: 'tldraw_draw_italic', format: 'woff2' },
weight: 'normal',
style: 'italic',
},
{
family: 'tldraw_draw',
src: { url: 'tldraw_draw_italic_bold', format: 'woff2' },
weight: 'bold',
style: 'italic',
},
],
},
sans: {
fontFamily: DefaultFontFamilies.sans,
faces: [
{
family: 'tldraw_sans',
src: { url: 'tldraw_sans', format: 'woff2' },
weight: 'normal',
style: 'normal',
},
{
family: 'tldraw_sans',
src: { url: 'tldraw_sans_bold', format: 'woff2' },
weight: 'bold',
style: 'normal',
},
{
family: 'tldraw_sans',
src: { url: 'tldraw_sans_italic', format: 'woff2' },
weight: 'normal',
style: 'italic',
},
{
family: 'tldraw_sans',
src: { url: 'tldraw_sans_italic_bold', format: 'woff2' },
weight: 'bold',
style: 'italic',
},
],
},
serif: {
fontFamily: DefaultFontFamilies.serif,
faces: [
{
family: 'tldraw_serif',
src: { url: 'tldraw_serif', format: 'woff2' },
weight: 'normal',
style: 'normal',
},
{
family: 'tldraw_serif',
src: { url: 'tldraw_serif_bold', format: 'woff2' },
weight: 'bold',
style: 'normal',
},
{
family: 'tldraw_serif',
src: { url: 'tldraw_serif_italic', format: 'woff2' },
weight: 'normal',
style: 'italic',
},
{
family: 'tldraw_serif',
src: { url: 'tldraw_serif_italic_bold', format: 'woff2' },
weight: 'bold',
style: 'italic',
},
],
},
mono: {
fontFamily: DefaultFontFamilies.mono,
faces: [
{
family: 'tldraw_mono',
src: { url: 'tldraw_mono', format: 'woff2' },
weight: 'normal',
style: 'normal',
},
{
family: 'tldraw_mono',
src: { url: 'tldraw_mono_bold', format: 'woff2' },
weight: 'bold',
style: 'normal',
},
{
family: 'tldraw_mono',
src: { url: 'tldraw_mono_italic', format: 'woff2' },
weight: 'normal',
style: 'italic',
},
{
family: 'tldraw_mono',
src: { url: 'tldraw_mono_italic_bold', format: 'woff2' },
weight: 'bold',
style: 'italic',
},
],
},
},
colors: {
light: {
text: '#000000',
background: '#f9fafb',
negativeSpace: '#f9fafb',
solid: '#fcfffe',
cursor: 'black',
noteBorder: 'rgb(144, 144, 144)',
snap: 'hsl(0, 76%, 60%)',
selectionStroke: 'hsl(214, 84%, 56%)',
selectionFill: 'hsl(210, 100%, 56%, 24%)',
brushFill: 'hsl(0, 0%, 56%, 10.2%)',
brushStroke: 'hsl(0, 0%, 56%, 25.1%)',
selectedContrast: '#ffffff',
laser: 'hsl(0, 100%, 50%)',
black: {
solid: '#1d1d1d',
fill: '#1d1d1d',
linedFill: '#363636',
frameHeadingStroke: '#717171',
frameHeadingFill: '#ffffff',
frameStroke: '#717171',
frameFill: '#ffffff',
frameText: '#000000',
noteFill: '#FCE19C',
noteText: '#000000',
semi: '#e8e8e8',
pattern: '#494949',
highlightSrgb: '#fddd00',
highlightP3: 'color(display-p3 0.972 0.8205 0.05)',
},
blue: {
solid: '#4465e9',
fill: '#4465e9',
linedFill: '#6580ec',
frameHeadingStroke: '#6681ec',
frameHeadingFill: '#f9fafe',
frameStroke: '#6681ec',
frameFill: '#f9fafe',
frameText: '#000000',
noteFill: '#8AA3FF',
noteText: '#000000',
semi: '#dce1f8',
pattern: '#6681ee',
highlightSrgb: '#10acff',
highlightP3: 'color(display-p3 0.308 0.6632 0.9996)',
},
green: {
solid: '#099268',
fill: '#099268',
linedFill: '#0bad7c',
frameHeadingStroke: '#37a684',
frameHeadingFill: '#f8fcfa',
frameStroke: '#37a684',
frameFill: '#f8fcfa',
frameText: '#000000',
noteFill: '#6FC896',
noteText: '#000000',
semi: '#d3e9e3',
pattern: '#39a785',
highlightSrgb: '#00ffc8',
highlightP3: 'color(display-p3 0.2536 0.984 0.7981)',
},
grey: {
solid: '#9fa8b2',
fill: '#9fa8b2',
linedFill: '#bbc1c9',
frameHeadingStroke: '#aaaaab',
frameHeadingFill: '#fbfcfc',
frameStroke: '#aaaaab',
frameFill: '#fcfcfd',
frameText: '#000000',
noteFill: '#C0CAD3',
noteText: '#000000',
semi: '#eceef0',
pattern: '#bcc3c9',
highlightSrgb: '#cbe7f1',
highlightP3: 'color(display-p3 0.8163 0.9023 0.9416)',
},
'light-blue': {
solid: '#4ba1f1',
fill: '#4ba1f1',
linedFill: '#7abaf5',
frameHeadingStroke: '#6cb2f3',
frameHeadingFill: '#f8fbfe',
frameStroke: '#6cb2f3',
frameFill: '#fafcff',
frameText: '#000000',
noteFill: '#9BC4FD',
noteText: '#000000',
semi: '#ddedfa',
pattern: '#6fbbf8',
highlightSrgb: '#00f4ff',
highlightP3: 'color(display-p3 0.1512 0.9414 0.9996)',
},
'light-green': {
solid: '#4cb05e',
fill: '#4cb05e',
linedFill: '#7ec88c',
frameHeadingStroke: '#6dbe7c',
frameHeadingFill: '#f8fcf9',
frameStroke: '#6dbe7c',
frameFill: '#fafdfa',
frameText: '#000000',
noteFill: '#98D08A',
noteText: '#000000',
semi: '#dbf0e0',
pattern: '#65cb78',
highlightSrgb: '#65f641',
highlightP3: 'color(display-p3 0.563 0.9495 0.3857)',
},
'light-red': {
solid: '#f87777',
fill: '#f87777',
linedFill: '#f99a9a',
frameHeadingStroke: '#f89090',
frameHeadingFill: '#fffafa',
frameStroke: '#f89090',
frameFill: '#fffbfb',
frameText: '#000000',
noteFill: '#F7A5A1',
noteText: '#000000',
semi: '#f4dadb',
pattern: '#fe9e9e',
highlightSrgb: '#ff7fa3',
highlightP3: 'color(display-p3 0.9988 0.5301 0.6397)',
},
'light-violet': {
solid: '#e085f4',
fill: '#e085f4',
linedFill: '#e9abf7',
frameHeadingStroke: '#e59bf5',
frameHeadingFill: '#fefaff',
frameStroke: '#e59bf5',
frameFill: '#fefbff',
frameText: '#000000',
noteFill: '#DFB0F9',
noteText: '#000000',
semi: '#f5eafa',
pattern: '#e9acf8',
highlightSrgb: '#ff88ff',
highlightP3: 'color(display-p3 0.9676 0.5652 0.9999)',
},
orange: {
solid: '#e16919',
fill: '#e16919',
linedFill: '#ea8643',
frameHeadingStroke: '#e68544',
frameHeadingFill: '#fef9f6',
frameStroke: '#e68544',
frameFill: '#fef9f6',
frameText: '#000000',
noteFill: '#FAA475',
noteText: '#000000',
semi: '#f8e2d4',
pattern: '#f78438',
highlightSrgb: '#ffa500',
highlightP3: 'color(display-p3 0.9988 0.6905 0.266)',
},
red: {
solid: '#e03131',
fill: '#e03131',
linedFill: '#e75f5f',
frameHeadingStroke: '#e55757',
frameHeadingFill: '#fef7f7',
frameStroke: '#e55757',
frameFill: '#fef9f9',
frameText: '#000000',
noteFill: '#FC8282',
noteText: '#000000',
semi: '#f4dadb',
pattern: '#e55959',
highlightSrgb: '#ff636e',
highlightP3: 'color(display-p3 0.9992 0.4376 0.45)',
},
violet: {
solid: '#ae3ec9',
fill: '#ae3ec9',
linedFill: '#be68d4',
frameHeadingStroke: '#bc62d3',
frameHeadingFill: '#fcf7fd',
frameStroke: '#bc62d3',
frameFill: '#fdf9fd',
frameText: '#000000',
noteFill: '#DB91FD',
noteText: '#000000',
semi: '#ecdcf2',
pattern: '#bd63d3',
highlightSrgb: '#c77cff',
highlightP3: 'color(display-p3 0.7469 0.5089 0.9995)',
},
yellow: {
solid: '#f1ac4b',
fill: '#f1ac4b',
linedFill: '#f5c27a',
frameHeadingStroke: '#f3bb6c',
frameHeadingFill: '#fefcf8',
frameStroke: '#f3bb6c',
frameFill: '#fffdfa',
frameText: '#000000',
noteFill: '#FED49A',
noteText: '#000000',
semi: '#f9f0e6',
pattern: '#fecb92',
highlightSrgb: '#fddd00',
highlightP3: 'color(display-p3 0.972 0.8705 0.05)',
},
white: {
solid: '#FFFFFF',
fill: '#FFFFFF',
linedFill: '#ffffff',
semi: '#f5f5f5',
pattern: '#f9f9f9',
frameHeadingStroke: '#7d7d7d',
frameHeadingFill: '#ffffff',
frameStroke: '#7d7d7d',
frameFill: '#ffffff',
frameText: '#000000',
noteFill: '#FFFFFF',
noteText: '#000000',
highlightSrgb: '#ffffff',
highlightP3: 'color(display-p3 1 1 1)',
},
},
dark: {
text: 'hsl(210, 17%, 98%)',
background: 'hsl(240, 5%, 6.5%)',
negativeSpace: 'hsl(240, 5%, 6.5%)',
solid: '#010403',
cursor: 'white',
snap: 'hsl(0, 76%, 60%)',
selectionStroke: 'hsl(214, 84%, 56%)',
selectionFill: 'hsl(209, 100%, 57%, 20%)',
brushFill: 'hsl(0, 0%, 56%, 10.2%)',
brushStroke: 'hsl(0, 0%, 56%, 25.1%)',
selectedContrast: '#ffffff',
laser: 'hsl(0, 100%, 50%)',
noteBorder: 'rgb(20, 20, 20)',
black: {
solid: '#f2f2f2',
fill: '#f2f2f2',
linedFill: '#ffffff',
frameHeadingStroke: '#5c5c5c',
frameHeadingFill: '#252525',
frameStroke: '#5c5c5c',
frameFill: '#0c0c0c',
frameText: '#f2f2f2',
noteFill: '#2c2c2c',
noteText: '#f2f2f2',
semi: '#2c3036',
pattern: '#989898',
highlightSrgb: '#d2b700',
highlightP3: 'color(display-p3 0.8078 0.6225 0.0312)',
},
blue: {
solid: '#4f72fc', // 3c60f0
fill: '#4f72fc',
linedFill: '#3c5cdd',
frameHeadingStroke: '#384994',
frameHeadingFill: '#1C2036',
frameStroke: '#384994',
frameFill: '#11141f',
frameText: '#f2f2f2',
noteFill: '#2A3F98',
noteText: '#f2f2f2',
semi: '#262d40',
pattern: '#3a4b9e',
highlightSrgb: '#0079d2',
highlightP3: 'color(display-p3 0.0032 0.4655 0.7991)',
},
green: {
solid: '#099268',
fill: '#099268',
linedFill: '#087856',
frameHeadingStroke: '#10513C',
frameHeadingFill: '#14241f',
frameStroke: '#10513C',
frameFill: '#0E1614',
frameText: '#f2f2f2',
noteFill: '#014429',
noteText: '#f2f2f2',
semi: '#253231',
pattern: '#366a53',
highlightSrgb: '#009774',
highlightP3: 'color(display-p3 0.0085 0.582 0.4604)',
},
grey: {
solid: '#9398b0',
fill: '#9398b0',
linedFill: '#8388a5',
frameHeadingStroke: '#42474D',
frameHeadingFill: '#23262A',
frameStroke: '#42474D',
frameFill: '#151719',
frameText: '#f2f2f2',
noteFill: '#56595F',
noteText: '#f2f2f2',
semi: '#33373c',
pattern: '#7c8187',
highlightSrgb: '#9cb4cb',
highlightP3: 'color(display-p3 0.6299 0.7012 0.7856)',
},
'light-blue': {
solid: '#4dabf7',
fill: '#4dabf7',
linedFill: '#2793ec',
frameHeadingStroke: '#075797',
frameHeadingFill: '#142839',
frameStroke: '#075797',
frameFill: '#0B1823',
frameText: '#f2f2f2',
noteFill: '#1F5495',
noteText: '#f2f2f2',
semi: '#2a3642',
pattern: '#4d7aa9',
highlightSrgb: '#00bdc8',
highlightP3: 'color(display-p3 0.0023 0.7259 0.7735)',
},
'light-green': {
solid: '#40c057',
fill: '#40c057',
linedFill: '#37a44b',
frameHeadingStroke: '#1C5427',
frameHeadingFill: '#18251A',
frameStroke: '#1C5427',
frameFill: '#0F1911',
frameText: '#f2f2f2',
noteFill: '#21581D',
noteText: '#f2f2f2',
semi: '#2a3830',
pattern: '#4e874e',
highlightSrgb: '#00a000',
highlightP3: 'color(display-p3 0.2711 0.6172 0.0195)',
},
'light-red': {
solid: '#ff8787',
fill: '#ff8787',
linedFill: '#ff6666',
frameHeadingStroke: '#6f3232',
frameHeadingFill: '#341818',
frameStroke: '#6f3232',
frameFill: '#181212',
frameText: '#f2f2f2',
noteFill: '#7a3333',
noteText: '#f2f2f2',
semi: '#3c2b2b',
pattern: '#a56767',
highlightSrgb: '#db005b',
highlightP3: 'color(display-p3 0.7849 0.0585 0.3589)',
},
'light-violet': {
solid: '#e599f7',
fill: '#e599f7',
linedFill: '#dc71f4',
frameHeadingStroke: '#6c367a',
frameHeadingFill: '#2D2230',
frameStroke: '#6c367a',
frameFill: '#1C151E',
frameText: '#f2f2f2',
noteFill: '#762F8E',
noteText: '#f2f2f2',
semi: '#383442',
pattern: '#9770a9',
highlightSrgb: '#c400c7',
highlightP3: 'color(display-p3 0.7024 0.0403 0.753)',
},
orange: {
solid: '#f76707',
fill: '#f76707',
linedFill: '#f54900',
frameHeadingStroke: '#773a0e',
frameHeadingFill: '#2f1d13',
frameStroke: '#773a0e',
frameFill: '#1c1512',
frameText: '#f2f2f2',
noteFill: '#7c3905',
noteText: '#f2f2f2',
semi: '#3b2e27',
pattern: '#9f552d',
highlightSrgb: '#d07a00',
highlightP3: 'color(display-p3 0.7699 0.4937 0.0085)',
},
red: {
solid: '#e03131',
fill: '#e03131',
linedFill: '#c31d1d',
frameHeadingStroke: '#701e1e',
frameHeadingFill: '#301616',
frameStroke: '#701e1e',
frameFill: '#1b1313',
frameText: '#f2f2f2',
noteFill: '#7e201f',
noteText: '#f2f2f2',
semi: '#382726',
pattern: '#8f3734',
highlightSrgb: '#de002c',
highlightP3: 'color(display-p3 0.7978 0.0509 0.2035)',
},
violet: {
solid: '#ae3ec9',
fill: '#ae3ec9',
linedFill: '#8f2fa7',
frameHeadingStroke: '#6d1583',
frameHeadingFill: '#27152e',
frameStroke: '#6d1583',
frameFill: '#1b0f21',
frameText: '#f2f2f2',
noteFill: '#5f1c70',
noteText: '#f2f2f2',
semi: '#342938',
pattern: '#763a8b',
highlightSrgb: '#9e00ee',
highlightP3: 'color(display-p3 0.5651 0.0079 0.8986)',
},
yellow: {
solid: '#ffc034',
fill: '#ffc034',
linedFill: '#ffae00',
frameHeadingStroke: '#684e12',
frameHeadingFill: '#2a2113',
frameStroke: '#684e12',
frameFill: '#1e1911',
frameText: '#f2f2f2',
noteFill: '#8a5e1c',
noteText: '#f2f2f2',
semi: '#3b352b',
pattern: '#fecb92',
highlightSrgb: '#d2b700',
highlightP3: 'color(display-p3 0.8078 0.7225 0.0312)',
},
white: {
solid: '#f3f3f3',
fill: '#f3f3f3',
linedFill: '#f3f3f3',
semi: '#f5f5f5',
pattern: '#f9f9f9',
frameHeadingStroke: '#ffffff',
frameHeadingFill: '#ffffff',
frameStroke: '#ffffff',
frameFill: '#ffffff',
frameText: '#000000',
noteFill: '#eaeaea',
noteText: '#1d1d1d',
highlightSrgb: '#ffffff',
highlightP3: 'color(display-p3 1 1 1)',
},
},
},
}
/**
* Resolves a color style value to its actual CSS color string for a given theme and variant.
* If the color is not a default theme color, returns the color value as-is.
*
* @param colors - The color palette for the current color mode (e.g. `theme.colors[colorMode]`)
* @param color - The color style value to resolve
* @param variant - Which variant of the color to return (solid, fill, pattern, etc.)
* @returns The CSS color string for the specified color and variant
*
* @example
* ```ts
* import { getColorValue } from 'tldraw'
*
* const colors = editor.getCurrentTheme().colors[editor.getColorMode()]
*
* // Get the solid variant of red
* const redSolid = getColorValue(colors, 'red', 'solid') // '#e03131'
*
* // Get the fill variant of blue
* const blueFill = getColorValue(colors, 'blue', 'fill') // '#4465e9'
*
* // Custom color passes through unchanged
* const customColor = getColorValue(colors, '#ff0000', 'solid') // '#ff0000'
* ```
*
* @public
*/
export function getColorValue(
colors: TLThemeColors,
color: TLDefaultColorStyle | string,
variant: keyof TLDefaultColor
): string {
const colorEntry = colors[color as TLDefaultColorStyle]
if (!colorEntry || typeof colorEntry === 'string') {
return color
}
return colorEntry[variant]
}

View File

@@ -0,0 +1,211 @@
import { Mock, Mocked, vi } from 'vitest'
import { Editor } from '../../Editor'
import { TickManager } from './TickManager'
// Mock the Editor class
vi.mock('../../Editor')
// Mock Date.now to control time
const mockDateNow = vi.fn()
Date.now = mockDateNow
// Mock requestAnimationFrame and cancelAnimationFrame
const mockRequestAnimationFrame = vi.fn()
const mockCancelAnimationFrame = vi.fn()
global.requestAnimationFrame = mockRequestAnimationFrame
global.cancelAnimationFrame = mockCancelAnimationFrame
describe('TickManager', () => {
let editor: Mocked<Editor>
let tickManager: TickManager
let mockEmit: Mock
beforeEach(() => {
vi.clearAllMocks()
// Reset time
mockDateNow.mockReturnValue(1000)
// Mock RAF to execute callback immediately in tests
mockRequestAnimationFrame.mockImplementation((callback) => {
const id = Math.random()
// Execute immediately for tests
setTimeout(callback, 0)
return id
})
mockCancelAnimationFrame.mockImplementation(() => {})
mockEmit = vi.fn()
editor = {
emit: mockEmit,
} as unknown as Mocked<Editor>
tickManager = new TickManager(editor)
})
afterEach(() => {
if (tickManager) {
tickManager.dispose()
}
})
describe('constructor and initialization', () => {
it('should initialize with correct default values', () => {
expect(tickManager.isPaused).toBe(false)
expect(tickManager.now).toBe(1000)
})
it('should start the tick loop on construction', () => {
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
})
describe('start method', () => {
it('should set isPaused to false', () => {
tickManager.isPaused = true
tickManager.start()
expect(tickManager.isPaused).toBe(false)
})
it('should update the now timestamp', () => {
mockDateNow.mockReturnValue(2000)
tickManager.start()
expect(tickManager.now).toBe(2000)
})
it('should cancel existing RAF before starting new one', () => {
const mockCancel = vi.fn()
tickManager.cancelRaf = mockCancel
tickManager.start()
expect(mockCancel).toHaveBeenCalled()
})
it('should schedule new RAF', () => {
mockRequestAnimationFrame.mockClear()
tickManager.start()
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
})
describe('tick method', () => {
beforeEach(() => {
// Reset mocks
mockEmit.mockClear()
mockRequestAnimationFrame.mockClear()
})
it('should return early if paused', () => {
tickManager.isPaused = true
tickManager.tick()
expect(mockEmit).not.toHaveBeenCalled()
expect(mockRequestAnimationFrame).not.toHaveBeenCalled()
})
it('should calculate elapsed time correctly', () => {
tickManager.now = 1000
mockDateNow.mockReturnValue(1050)
tickManager.tick()
expect(tickManager.now).toBe(1050)
expect(mockEmit).toHaveBeenCalledWith('frame', 50)
expect(mockEmit).toHaveBeenCalledWith('tick', 50)
})
it('should emit frame and tick events with elapsed time', () => {
tickManager.now = 1000
mockDateNow.mockReturnValue(1100)
tickManager.tick()
expect(mockEmit).toHaveBeenCalledWith('frame', 100)
expect(mockEmit).toHaveBeenCalledWith('tick', 100)
expect(mockEmit).toHaveBeenCalledTimes(2)
})
it('should schedule next RAF', () => {
tickManager.tick()
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
it('should handle zero elapsed time', () => {
tickManager.now = 1000
mockDateNow.mockReturnValue(1000)
tickManager.tick()
expect(mockEmit).toHaveBeenCalledWith('frame', 0)
expect(mockEmit).toHaveBeenCalledWith('tick', 0)
})
it('should schedule the next frame even when a listener throws', () => {
const staleCancel = vi.fn()
tickManager.cancelRaf = staleCancel
mockEmit.mockImplementation(() => {
throw new Error('listener blew up')
})
// the error still reaches the caller, but the loop lives on
expect(() => tickManager.tick()).toThrow('listener blew up')
expect(mockRequestAnimationFrame).toHaveBeenCalled()
expect(tickManager.cancelRaf).not.toBe(staleCancel)
})
})
describe('dispose method', () => {
it('should set isPaused to true', () => {
tickManager.isPaused = false
tickManager.dispose()
expect(tickManager.isPaused).toBe(true)
})
it('should cancel RAF if exists', () => {
const mockCancel = vi.fn()
tickManager.cancelRaf = mockCancel
tickManager.dispose()
expect(mockCancel).toHaveBeenCalled()
})
it('should handle dispose when cancelRaf is null', () => {
tickManager.cancelRaf = null
expect(() => tickManager.dispose()).not.toThrow()
})
})
describe('RAF throttling behavior', () => {
it('should use test-specific RAF behavior in test environment', () => {
// The TickManager should detect test environment and use requestAnimationFrame directly
// This is already handled by the mock, but we can verify the behavior
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
})
describe('integration with tick loop', () => {
it('should schedule RAF on start', () => {
mockRequestAnimationFrame.mockClear()
tickManager.start()
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
it('should schedule RAF after each tick', () => {
mockRequestAnimationFrame.mockClear()
tickManager.tick()
expect(mockRequestAnimationFrame).toHaveBeenCalled()
})
it('should not schedule RAF when paused', () => {
tickManager.isPaused = true
mockRequestAnimationFrame.mockClear()
tickManager.tick()
expect(mockRequestAnimationFrame).not.toHaveBeenCalled()
})
})
})

View File

@@ -0,0 +1,60 @@
import { throttleToNextFrame as _throttleToNextFrame, bind } from '@tldraw/utils'
import type { Editor } from '../../Editor'
import { EditorManager } from '../EditorManager'
const throttleToNextFrame =
typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
? // At test time we should use actual raf and not throttle, because throttle was set up to evaluate immediately during tests, which causes stack overflow
// for the tick manager since it sets up a raf loop.
function mockThrottle(cb: any) {
// eslint-disable-next-line no-restricted-globals
const frame = requestAnimationFrame(cb)
return () => cancelAnimationFrame(frame)
}
: _throttleToNextFrame
/** @internal */
export class TickManager extends EditorManager {
constructor(editor: Editor) {
super(editor)
this.start()
}
cancelRaf?: null | (() => void)
isPaused = true
now = 0
start() {
this.isPaused = false
this.cancelRaf?.()
this.cancelRaf = throttleToNextFrame(this.tick)
this.now = Date.now()
}
@bind
tick() {
if (this.isPaused) {
return
}
const now = Date.now()
const elapsed = now - this.now
this.now = now
// Schedule the next frame even if a listener throws. Otherwise a single throwing listener
// ends the loop for good, which silently stops every frame-driven behavior — camera
// animations, edge scrolling, pointer velocity, following — instead of failing once.
try {
this.editor.emit('frame', elapsed)
this.editor.emit('tick', elapsed)
} finally {
this.cancelRaf = throttleToNextFrame(this.tick)
}
}
dispose() {
this.isPaused = true
this.cancelRaf?.()
super.dispose()
}
}

View File

@@ -0,0 +1,731 @@
import { atom } from '@tldraw/state'
import { createUserId } from '@tldraw/tlschema'
import { Mocked, vi } from 'vitest'
import { TLCurrentUser } from '../../../config/createTLCurrentUser'
import { TLUserPreferences, defaultUserPreferences } from '../../../config/TLUserPreferences'
import { UserPreferencesManager } from './UserPreferencesManager'
// Mock window.matchMedia
const mockMatchMedia = vi.fn()
window.matchMedia = mockMatchMedia
describe('UserPreferencesManager', () => {
let mockUser: Mocked<TLCurrentUser>
let mockUserPreferences: TLUserPreferences
let userPreferencesAtom: any
let userPreferencesManager: UserPreferencesManager
const createMockUserPreferences = (
overrides: Partial<TLUserPreferences> = {}
): TLUserPreferences => ({
id: 'test-user-id',
name: 'Test User',
color: '#FF802B',
locale: 'en',
animationSpeed: 1,
areKeyboardShortcutsEnabled: true,
enhancedA11yMode: false,
edgeScrollSpeed: 1,
colorScheme: 'light',
isSnapMode: false,
isWrapMode: false,
isDynamicSizeMode: false,
isPasteAtCursorMode: false,
inputMode: null,
isZoomDirectionInverted: false,
...overrides,
})
beforeEach(() => {
vi.clearAllMocks()
mockUserPreferences = createMockUserPreferences()
userPreferencesAtom = atom('userPreferences', mockUserPreferences)
mockUser = {
userPreferences: userPreferencesAtom,
setUserPreferences: vi.fn((prefs) => {
userPreferencesAtom.set(prefs)
}),
}
// Default matchMedia mock - no dark mode preference
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})
})
describe('constructor', () => {
it('should initialize with light system color scheme when matchMedia not available', () => {
// Test when window.matchMedia is not available
delete (window as any).matchMedia
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
// Restore matchMedia
window.matchMedia = mockMatchMedia
})
it('should initialize with light system color scheme when dark mode not preferred', () => {
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should initialize with dark system color scheme when dark mode preferred', () => {
mockMatchMedia.mockReturnValue({
matches: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'system')
expect(userPreferencesManager.systemColorScheme.get()).toBe('dark')
})
it('should set up media query listener for color scheme changes', () => {
const mockAddEventListener = vi.fn()
const mockRemoveEventListener = vi.fn()
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'system')
expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
it('should handle media query change events', () => {
const mockAddEventListener = vi.fn()
let changeHandler: (e: MediaQueryListEvent) => void
mockMatchMedia.mockImplementation((query: string) => ({
matches: false,
addEventListener: (event: string, handler: any) => {
if (event === 'change' && query === '(prefers-color-scheme: dark)') {
changeHandler = handler
}
mockAddEventListener(event, handler)
},
removeEventListener: vi.fn(),
}))
userPreferencesManager = new UserPreferencesManager(mockUser, 'system')
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
// Simulate dark mode change
changeHandler!({ matches: true } as MediaQueryListEvent)
expect(userPreferencesManager.systemColorScheme.get()).toBe('dark')
// Simulate light mode change
changeHandler!({ matches: false } as MediaQueryListEvent)
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should work in server environment (no window)', () => {
const originalWindow = global.window
delete (global as any).window
expect(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
}).not.toThrow()
global.window = originalWindow
})
})
describe('dispose', () => {
it('should remove media query listener on dispose', () => {
const mockRemoveEventListener = vi.fn()
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener: mockRemoveEventListener,
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'system')
userPreferencesManager.dispose()
expect(mockRemoveEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
it('should call all disposables', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
const mockDisposable1 = vi.fn()
const mockDisposable2 = vi.fn()
userPreferencesManager.disposables.add(mockDisposable1)
userPreferencesManager.disposables.add(mockDisposable2)
userPreferencesManager.dispose()
expect(mockDisposable1).toHaveBeenCalled()
expect(mockDisposable2).toHaveBeenCalled()
})
})
describe('updateUserPreferences', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
it('should update user preferences with partial data', () => {
const updates = { name: 'Updated Name', color: '#EC5E41' }
userPreferencesManager.updateUserPreferences(updates)
expect(mockUser.setUserPreferences).toHaveBeenCalledWith({
...mockUserPreferences,
...updates,
})
})
it('should preserve existing preferences when updating', () => {
const updates = { animationSpeed: 0.5 }
userPreferencesManager.updateUserPreferences(updates)
expect(mockUser.setUserPreferences).toHaveBeenCalledWith({
...mockUserPreferences,
animationSpeed: 0.5,
})
})
it('should handle empty updates', () => {
userPreferencesManager.updateUserPreferences({})
expect(mockUser.setUserPreferences).toHaveBeenCalledWith(mockUserPreferences)
})
})
describe('getUserPreferences', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
it('should return complete user preferences with computed values', () => {
const result = userPreferencesManager.getUserPreferences()
expect(result).toEqual({
id: mockUserPreferences.id,
name: mockUserPreferences.name,
locale: mockUserPreferences.locale,
color: mockUserPreferences.color,
animationSpeed: mockUserPreferences.animationSpeed,
areKeyboardShortcutsEnabled: mockUserPreferences.areKeyboardShortcutsEnabled,
enhancedA11yMode: mockUserPreferences.enhancedA11yMode,
isSnapMode: mockUserPreferences.isSnapMode,
colorScheme: mockUserPreferences.colorScheme,
isDarkMode: false, // light mode
isWrapMode: mockUserPreferences.isWrapMode,
isDynamicResizeMode: mockUserPreferences.isDynamicSizeMode,
inputMode: mockUserPreferences.inputMode,
isZoomDirectionInverted: mockUserPreferences.isZoomDirectionInverted,
})
})
it('should use default values for missing properties', () => {
const minimalPrefs: TLUserPreferences = { id: 'test-id' }
userPreferencesAtom.set(minimalPrefs)
const result = userPreferencesManager.getUserPreferences()
expect(result.name).toBe(defaultUserPreferences.name)
expect(result.color).toBe(defaultUserPreferences.color)
expect(result.locale).toBe(defaultUserPreferences.locale)
expect(result.animationSpeed).toBe(defaultUserPreferences.animationSpeed)
})
})
describe('getIsDarkMode', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
it('should return true when colorScheme is dark', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'dark' })
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should return false when colorScheme is light', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'light' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
})
it('should follow system preference when colorScheme is system', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'system' })
// System is light
userPreferencesManager.systemColorScheme.set('light')
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
// System is dark
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should default to light when colorScheme is undefined', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: undefined })
const manager = new UserPreferencesManager(mockUser, 'light')
manager.systemColorScheme.set('dark')
expect(manager.getIsDarkMode()).toBe(false)
})
})
describe('individual preference getters', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
describe('getExternalId / getRecordId', () => {
it('should return the raw external user id', () => {
expect(userPreferencesManager.getExternalId()).toBe(mockUserPreferences.id)
})
it('should return the prefixed record id', () => {
expect(userPreferencesManager.getRecordId()).toBe(createUserId(mockUserPreferences.id))
})
it('getId() still returns the external id', () => {
// eslint-disable-next-line @typescript-eslint/no-deprecated
expect(userPreferencesManager.getId()).toBe(mockUserPreferences.id)
})
})
describe('getName', () => {
it('should return trimmed user name', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: ' Test User ' })
expect(userPreferencesManager.getName()).toBe('Test User')
})
it('should return default name when name is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: null })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
it('should return default name when name is undefined', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: undefined })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
it('should return default name when name is empty after trimming', () => {
userPreferencesAtom.set({ ...mockUserPreferences, name: ' ' })
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
})
})
describe('getLocale', () => {
it('should return user locale', () => {
expect(userPreferencesManager.getLocale()).toBe(mockUserPreferences.locale)
})
it('should return default locale when locale is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, locale: null })
expect(userPreferencesManager.getLocale()).toBe(defaultUserPreferences.locale)
})
})
describe('getColor', () => {
it('should return user color', () => {
expect(userPreferencesManager.getColor()).toBe(mockUserPreferences.color)
})
it('should return default color when color is null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, color: null })
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
})
})
describe('getAnimationSpeed', () => {
it('should return user animation speed', () => {
expect(userPreferencesManager.getAnimationSpeed()).toBe(mockUserPreferences.animationSpeed)
})
it('should return default animation speed when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
expect(userPreferencesManager.getAnimationSpeed()).toBe(
defaultUserPreferences.animationSpeed
)
})
describe('following the OS reduce-motion setting', () => {
let reducedMotionChangeHandler: ((e: MediaQueryListEvent) => void) | undefined
let osPrefersReducedMotion: boolean
const mountWithOs = (prefersReducedMotion: boolean) => {
osPrefersReducedMotion = prefersReducedMotion
reducedMotionChangeHandler = undefined
mockMatchMedia.mockImplementation((query: string) => {
const isReducedMotion = query === '(prefers-reduced-motion: reduce)'
return {
matches: isReducedMotion ? osPrefersReducedMotion : false,
addEventListener: (event: string, handler: any) => {
if (isReducedMotion && event === 'change') reducedMotionChangeHandler = handler
},
removeEventListener: vi.fn(),
}
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
}
it('resolves an unset speed to 0 when the OS prefers reduced motion', () => {
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
mountWithOs(true)
expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
})
it('tracks a live OS change while the speed is unset', () => {
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: null })
mountWithOs(false)
expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
reducedMotionChangeHandler!({ matches: true } as MediaQueryListEvent)
expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
reducedMotionChangeHandler!({ matches: false } as MediaQueryListEvent)
expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
})
it('ignores the OS once the user has set a speed explicitly', () => {
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: 1 })
mountWithOs(true)
expect(userPreferencesManager.getAnimationSpeed()).toBe(1)
userPreferencesAtom.set({ ...mockUserPreferences, animationSpeed: 0 })
reducedMotionChangeHandler!({ matches: false } as MediaQueryListEvent)
expect(userPreferencesManager.getAnimationSpeed()).toBe(0)
})
it('removes the reduce-motion listener on dispose', () => {
const removeEventListener = vi.fn()
mockMatchMedia.mockReturnValue({
matches: false,
addEventListener: vi.fn(),
removeEventListener,
})
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
userPreferencesManager.dispose()
expect(removeEventListener).toHaveBeenCalledTimes(2)
})
})
})
describe('getAreKeyboardShortcutsEnabled', () => {
it('should return user keyboard shortcuts', () => {
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
mockUserPreferences.areKeyboardShortcutsEnabled
)
})
it('should return default keyboard shortcuts when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, areKeyboardShortcutsEnabled: null })
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
defaultUserPreferences.areKeyboardShortcutsEnabled
)
})
})
describe('getEnhancedA11yMode', () => {
it('should return user enhanced a11y mode setting', () => {
expect(userPreferencesManager.getEnhancedA11yMode()).toBe(
mockUserPreferences.enhancedA11yMode
)
})
it('should return default enhanced a11y mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, enhancedA11yMode: null })
expect(userPreferencesManager.getEnhancedA11yMode()).toBe(
defaultUserPreferences.enhancedA11yMode
)
})
})
describe('getEdgeScrollSpeed', () => {
it('should return user edge scroll speed', () => {
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
mockUserPreferences.edgeScrollSpeed
)
})
it('should return default edge scroll speed when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, edgeScrollSpeed: null })
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
defaultUserPreferences.edgeScrollSpeed
)
})
})
describe('getIsSnapMode', () => {
it('should return user snap mode setting', () => {
expect(userPreferencesManager.getIsSnapMode()).toBe(mockUserPreferences.isSnapMode)
})
it('should return default snap mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isSnapMode: null })
expect(userPreferencesManager.getIsSnapMode()).toBe(defaultUserPreferences.isSnapMode)
})
})
describe('getIsWrapMode', () => {
it('should return user wrap mode setting', () => {
expect(userPreferencesManager.getIsWrapMode()).toBe(mockUserPreferences.isWrapMode)
})
it('should return default wrap mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isWrapMode: null })
expect(userPreferencesManager.getIsWrapMode()).toBe(defaultUserPreferences.isWrapMode)
})
})
describe('getIsDynamicResizeMode', () => {
it('should return user dynamic resize mode setting', () => {
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
mockUserPreferences.isDynamicSizeMode
)
})
it('should return default dynamic resize mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isDynamicSizeMode: null })
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
defaultUserPreferences.isDynamicSizeMode
)
})
})
describe('getIsPasteAtCursorMode', () => {
it('should return user paste at cursor mode setting', () => {
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
mockUserPreferences.isPasteAtCursorMode
)
})
it('should return default paste at cursor mode when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isPasteAtCursorMode: null })
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
defaultUserPreferences.isPasteAtCursorMode
)
})
})
describe('getInputMode', () => {
it('should return user input mode setting', () => {
expect(userPreferencesManager.getInputMode()).toBe(null)
})
it('should return trackpad if input mode is trackpad', () => {
userPreferencesAtom.set({ ...mockUserPreferences, inputMode: 'trackpad' })
expect(userPreferencesManager.getInputMode()).toBe('trackpad')
})
it('should return mouse if input mode is mouse', () => {
userPreferencesAtom.set({ ...mockUserPreferences, inputMode: 'mouse' })
expect(userPreferencesManager.getInputMode()).toBe('mouse')
})
})
describe('getIsZoomDirectionInverted', () => {
it('should return user zoom direction inverted setting', () => {
expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(false)
})
it('should return default when null', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isZoomDirectionInverted: null })
expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(
defaultUserPreferences.isZoomDirectionInverted
)
})
it('should return true when inverted', () => {
userPreferencesAtom.set({ ...mockUserPreferences, isZoomDirectionInverted: true })
expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(true)
})
})
})
describe('reactive behavior', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
it('should react to user preferences changes', () => {
expect(userPreferencesManager.getName()).toBe('Test User')
userPreferencesManager.updateUserPreferences({ name: 'Updated User' })
expect(userPreferencesManager.getName()).toBe('Updated User')
})
it('should react to system color scheme changes', () => {
userPreferencesAtom.set({ ...mockUserPreferences, colorScheme: 'system' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
})
it('should update getUserPreferences when individual preferences change', () => {
const initialPrefs = userPreferencesManager.getUserPreferences()
expect(initialPrefs.name).toBe('Test User')
userPreferencesManager.updateUserPreferences({ name: 'Changed Name' })
const updatedPrefs = userPreferencesManager.getUserPreferences()
expect(updatedPrefs.name).toBe('Changed Name')
})
})
describe('edge cases and error handling', () => {
beforeEach(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
})
it('should handle undefined user preferences gracefully', () => {
userPreferencesAtom.set({} as TLUserPreferences)
expect(() => userPreferencesManager.getUserPreferences()).not.toThrow()
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
})
it('should handle null values in preferences', () => {
const nullPrefs = createMockUserPreferences({
name: null,
color: null,
locale: null,
animationSpeed: null,
areKeyboardShortcutsEnabled: null,
edgeScrollSpeed: null,
isSnapMode: null,
isWrapMode: null,
isDynamicSizeMode: null,
isPasteAtCursorMode: null,
isZoomDirectionInverted: null,
})
userPreferencesAtom.set(nullPrefs)
expect(userPreferencesManager.getName()).toBe(defaultUserPreferences.name)
expect(userPreferencesManager.getColor()).toBe(defaultUserPreferences.color)
expect(userPreferencesManager.getLocale()).toBe(defaultUserPreferences.locale)
expect(userPreferencesManager.getAnimationSpeed()).toBe(defaultUserPreferences.animationSpeed)
expect(userPreferencesManager.getAreKeyboardShortcutsEnabled()).toBe(
defaultUserPreferences.areKeyboardShortcutsEnabled
)
expect(userPreferencesManager.getEdgeScrollSpeed()).toBe(
defaultUserPreferences.edgeScrollSpeed
)
expect(userPreferencesManager.getIsSnapMode()).toBe(defaultUserPreferences.isSnapMode)
expect(userPreferencesManager.getIsWrapMode()).toBe(defaultUserPreferences.isWrapMode)
expect(userPreferencesManager.getIsDynamicResizeMode()).toBe(
defaultUserPreferences.isDynamicSizeMode
)
expect(userPreferencesManager.getIsPasteAtCursorMode()).toBe(
defaultUserPreferences.isPasteAtCursorMode
)
expect(userPreferencesManager.getIsZoomDirectionInverted()).toBe(
defaultUserPreferences.isZoomDirectionInverted
)
})
it('should handle matchMedia with null response', () => {
// Mock matchMedia returning null (like in some environments)
mockMatchMedia.mockReturnValue(null)
expect(() => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
}).not.toThrow()
expect(userPreferencesManager.systemColorScheme.get()).toBe('light')
})
it('should handle dispose gracefully in all cases', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
// Should not throw even if dispose is called multiple times
expect(() => userPreferencesManager.dispose()).not.toThrow()
expect(() => userPreferencesManager.dispose()).not.toThrow()
})
it('should handle empty disposables set', () => {
// Test in server environment where no event listeners are added
const originalWindow = global.window
delete (global as any).window
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
expect(() => userPreferencesManager.dispose()).not.toThrow()
expect(userPreferencesManager.disposables.size).toBe(0)
global.window = originalWindow
})
})
describe('integration scenarios', () => {
it('should work with real-world preference scenarios', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
// User starts with system preference
userPreferencesManager.updateUserPreferences({ colorScheme: 'system' })
userPreferencesManager.systemColorScheme.set('dark')
expect(userPreferencesManager.getIsDarkMode()).toBe(true)
expect(userPreferencesManager.getUserPreferences().isDarkMode).toBe(true)
// User switches to light mode explicitly
userPreferencesManager.updateUserPreferences({ colorScheme: 'light' })
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
expect(userPreferencesManager.getUserPreferences().isDarkMode).toBe(false)
// System changes but user preference is respected
userPreferencesManager.systemColorScheme.set('light')
expect(userPreferencesManager.getIsDarkMode()).toBe(false)
})
it('should handle preference updates with multiple fields', () => {
userPreferencesManager = new UserPreferencesManager(mockUser, 'light')
const updates = {
name: 'New User',
color: '#F2555A',
animationSpeed: 0.5,
isSnapMode: true,
colorScheme: 'dark' as const,
}
userPreferencesManager.updateUserPreferences(updates)
const prefs = userPreferencesManager.getUserPreferences()
expect(prefs.name).toBe('New User')
expect(prefs.color).toBe('#F2555A')
expect(prefs.animationSpeed).toBe(0.5)
expect(prefs.isSnapMode).toBe(true)
expect(prefs.colorScheme).toBe('dark')
expect(prefs.isDarkMode).toBe(true)
})
})
})

View File

@@ -0,0 +1,187 @@
import { atom, computed } from '@tldraw/state'
import { createUserId, TLUserId } from '@tldraw/tlschema'
import { TLCurrentUser } from '../../../config/createTLCurrentUser'
import { TLUserPreferences, defaultUserPreferences } from '../../../config/TLUserPreferences'
import { getGlobalWindow } from '../../../utils/dom'
/** @public */
export class UserPreferencesManager {
systemColorScheme = atom<'dark' | 'light'>('systemColorScheme', 'light')
private readonly systemPrefersReducedMotion = atom('systemPrefersReducedMotion', false)
disposables = new Set<() => void>()
dispose() {
this.disposables.forEach((d) => d())
this.disposables.clear()
}
constructor(
private readonly user: TLCurrentUser,
private readonly colorScheme: 'light' | 'dark' | 'system'
) {
if (typeof window === 'undefined' || !getGlobalWindow().matchMedia) return
const darkModeMediaQuery = getGlobalWindow().matchMedia('(prefers-color-scheme: dark)')
if (darkModeMediaQuery?.matches) {
this.systemColorScheme.set('dark')
}
const handleChange = (e: MediaQueryListEvent) => {
if (e.matches) {
this.systemColorScheme.set('dark')
} else {
this.systemColorScheme.set('light')
}
}
darkModeMediaQuery?.addEventListener('change', handleChange)
this.disposables.add(() => darkModeMediaQuery?.removeEventListener('change', handleChange))
const reducedMotionMediaQuery = getGlobalWindow().matchMedia('(prefers-reduced-motion: reduce)')
this.systemPrefersReducedMotion.set(!!reducedMotionMediaQuery?.matches)
const handleReducedMotionChange = (e: MediaQueryListEvent) => {
this.systemPrefersReducedMotion.set(e.matches)
}
reducedMotionMediaQuery?.addEventListener('change', handleReducedMotionChange)
this.disposables.add(() =>
reducedMotionMediaQuery?.removeEventListener('change', handleReducedMotionChange)
)
}
updateUserPreferences(userPreferences: Partial<TLUserPreferences>) {
this.user.setUserPreferences({
...this.user.userPreferences.get(),
...userPreferences,
})
}
@computed getUserPreferences() {
return {
id: this.getExternalId(),
name: this.getName(),
locale: this.getLocale(),
color: this.getColor(),
animationSpeed: this.getAnimationSpeed(),
areKeyboardShortcutsEnabled: this.getAreKeyboardShortcutsEnabled(),
isSnapMode: this.getIsSnapMode(),
colorScheme: this.user.userPreferences.get().colorScheme,
isDarkMode: this.getIsDarkMode(),
isWrapMode: this.getIsWrapMode(),
isDynamicResizeMode: this.getIsDynamicResizeMode(),
enhancedA11yMode: this.getEnhancedA11yMode(),
inputMode: this.getInputMode(),
isZoomDirectionInverted: this.getIsZoomDirectionInverted(),
}
}
@computed getIsDarkMode() {
const userColorScheme = this.user.userPreferences.get().colorScheme
const scheme = userColorScheme ?? this.colorScheme
switch (scheme) {
case 'dark':
return true
case 'light':
return false
case 'system':
return this.systemColorScheme.get() === 'dark'
default:
return false
}
}
/**
* The speed at which the user can scroll by dragging toward the edge of the screen.
*/
@computed getEdgeScrollSpeed() {
return this.user.userPreferences.get().edgeScrollSpeed ?? defaultUserPreferences.edgeScrollSpeed
}
@computed getAnimationSpeed() {
// Until the user explicitly sets a speed, follow the OS reduce-motion setting live rather
// than the value `defaultUserPreferences` captured at module load.
return (
this.user.userPreferences.get().animationSpeed ??
(this.systemPrefersReducedMotion.get() ? 0 : 1)
)
}
@computed getAreKeyboardShortcutsEnabled() {
return (
this.user.userPreferences.get().areKeyboardShortcutsEnabled ??
defaultUserPreferences.areKeyboardShortcutsEnabled
)
}
/**
* The current user's raw, app-provided id — the value set in the user's
* {@link @tldraw/editor#TLUserPreferences}. Use this when you need the id your application
* assigned to the user. To compare against or look up store records, use
* {@link UserPreferencesManager.getRecordId} instead.
*/
@computed getExternalId(): string {
return this.user.userPreferences.get().id
}
/**
* @deprecated Use {@link UserPreferencesManager.getExternalId} for the raw app-provided id, or
* {@link UserPreferencesManager.getRecordId} for the prefixed `TLUserId` record id.
*/
@computed getId() {
return this.getExternalId()
}
/**
* The current user's id as a tldraw {@link @tldraw/tlschema#TLUserId} record id (prefixed
* with `user:`). Use this when comparing against or looking up store records, such as a
* presence record's `userId` or `followingUserId`. For the raw, app-provided id, use
* {@link UserPreferencesManager.getExternalId}.
*/
@computed getRecordId(): TLUserId {
return createUserId(this.getExternalId())
}
@computed getName() {
return this.user.userPreferences.get().name?.trim() ?? defaultUserPreferences.name
}
@computed getLocale() {
return this.user.userPreferences.get().locale ?? defaultUserPreferences.locale
}
@computed getColor() {
return this.user.userPreferences.get().color ?? defaultUserPreferences.color
}
@computed getIsSnapMode() {
return this.user.userPreferences.get().isSnapMode ?? defaultUserPreferences.isSnapMode
}
@computed getIsWrapMode() {
return this.user.userPreferences.get().isWrapMode ?? defaultUserPreferences.isWrapMode
}
@computed getIsDynamicResizeMode() {
return (
this.user.userPreferences.get().isDynamicSizeMode ?? defaultUserPreferences.isDynamicSizeMode
)
}
@computed getIsPasteAtCursorMode() {
return (
this.user.userPreferences.get().isPasteAtCursorMode ??
defaultUserPreferences.isPasteAtCursorMode
)
}
@computed getEnhancedA11yMode() {
return (
this.user.userPreferences.get().enhancedA11yMode ?? defaultUserPreferences.enhancedA11yMode
)
}
@computed getInputMode() {
return this.user.userPreferences.get().inputMode ?? defaultUserPreferences.inputMode
}
@computed getIsZoomDirectionInverted() {
return (
this.user.userPreferences.get().isZoomDirectionInverted ??
defaultUserPreferences.isZoomDirectionInverted
)
}
}

View File

@@ -0,0 +1,185 @@
import { atom, computed } from '@tldraw/state'
import { WeakCache } from '@tldraw/utils'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import { VecLike } from '../../primitives/Vec'
import type { Editor } from '../Editor'
import { OverlayUtil, TLOverlay } from './OverlayUtil'
/**
* An active overlay util paired with the overlays it produced for the current
* editor state. Returned by {@link OverlayManager.getActiveOverlayEntries} so
* hit-test, render, and debug paths share a single scan per reactive tick.
*
* @public
*/
export interface TLOverlayEntry {
util: OverlayUtil
overlays: TLOverlay[]
}
/** @public */
export class OverlayManager {
constructor(public readonly editor: Editor) {}
/** @internal */
readonly _overlayUtils = new Map<string, OverlayUtil>()
/**
* Register an overlay util instance. Called during editor construction.
* @internal
*/
registerUtil(util: OverlayUtil) {
const type = (util.constructor as typeof OverlayUtil).type
if (!type) {
throw new Error(`Overlay util ${util.constructor.name} is missing a static 'type' property.`)
}
if (this._overlayUtils.has(type)) {
throw new Error(`Duplicate overlay util type: "${type}"`)
}
this._overlayUtils.set(type, util)
}
dispose() {
for (const util of this._overlayUtils.values()) {
util.dispose()
}
}
/**
* Get an overlay util by type string, overlay instance, or by passing
* a util class as a generic parameter for type-safe lookup.
*
* @example
* ```ts
* const util = editor.overlays.getOverlayUtil('brush')
* const util = editor.overlays.getOverlayUtil<BrushOverlayUtil>('brush')
* const util = editor.overlays.getOverlayUtil(myOverlay)
* ```
*
* @public
*/
getOverlayUtil<T extends OverlayUtil>(
type: T extends OverlayUtil<infer O> ? O['type'] : string
): T
getOverlayUtil<O extends TLOverlay>(overlay: O): OverlayUtil<O>
getOverlayUtil(arg: string | TLOverlay): OverlayUtil {
const type = typeof arg === 'string' ? arg : arg.type
const util = this._overlayUtils.get(type)
if (!util) throw new Error(`No overlay util found for type: "${type}"`)
return util
}
/**
* Returns all registered overlay utils in paint order (ascending zIndex).
* Utils with the same zIndex preserve their registration order.
*
* @public
*/
@computed getOverlayUtilsInZOrder(): OverlayUtil[] {
const utils = Array.from(this._overlayUtils.values())
// Stable sort by zIndex (registration order breaks ties).
return utils
.map((util, i) => ({ util, i, z: util.options.zIndex ?? 0 }))
.sort((a, b) => a.z - b.z || a.i - b.i)
.map((entry) => entry.util)
}
/**
* Reactive list of active overlay utils paired with the overlays they
* produced for the current editor state, in paint order (ascending
* zIndex). Both the hit-test and render paths read from this single
* cached scan instead of each re-deriving the active set. Active utils
* are included even when their `getOverlays()` returns an empty array,
* since `render()` may still draw non-interactive UI (e.g. the selection
* bounding box during brushing).
*
* @public
*/
@computed getActiveOverlayEntries(): TLOverlayEntry[] {
const entries: TLOverlayEntry[] = []
for (const util of this.getOverlayUtilsInZOrder()) {
if (!util.isActive()) continue
entries.push({ util, overlays: util.getOverlays() })
}
return entries
}
/**
* Reactively computed list of all currently active overlays, in paint order.
* @public
*/
@computed getCurrentOverlays(): TLOverlay[] {
const all: TLOverlay[] = []
for (const { overlays } of this.getActiveOverlayEntries()) {
all.push(...overlays)
}
return all
}
// Hit-test geometry cache keyed by overlay identity. Entries remain valid
// while getActiveOverlayEntries() keeps returning the same overlay
// instances; when its reactive deps change, getOverlays() emits fresh
// objects and stale entries fall out by GC.
private _geometryCache = new WeakCache<TLOverlay, Geometry2d | null>()
/**
* Get hit-test geometry for an overlay, cached by overlay identity. Lets
* hit-testing on a pointermove storm skip the per-overlay geometry
* allocation that {@link OverlayUtil.getGeometry} would otherwise do on
* every call.
*
* @public
*/
getOverlayGeometry(overlay: TLOverlay): Geometry2d | null {
return this._geometryCache.get(overlay, (o) => this.getOverlayUtil(o).getGeometry(o))
}
/**
* The currently hovered overlay id.
* @public
*/
private _hoveredOverlayId = atom<string | null>('hoveredOverlayId', null)
getHoveredOverlayId(): string | null {
return this._hoveredOverlayId.get()
}
getHoveredOverlay(): TLOverlay | null {
const id = this._hoveredOverlayId.get()
if (!id) return null
return this.getCurrentOverlays().find((o) => o.id === id) ?? null
}
setHoveredOverlay(id: string | null) {
if (id === this._hoveredOverlayId.get()) return
this._hoveredOverlayId.set(id)
}
/**
* Hit test all active overlays at a given page point.
* Returns the topmost overlay whose geometry contains the point, or null.
* Utils are walked from highest zIndex to lowest so the overlay painted on
* top also wins the hit test. Within a util, overlays are walked in
* array order: the first overlay whose geometry contains the point wins,
* so utils should place highest-priority overlays first in `getOverlays`.
* Interactive overlays (those with geometry) are checked; non-interactive are skipped.
*
* @param point - Point in page coordinates
* @param margin - Hit test margin
* @public
*/
getOverlayAtPoint(point: VecLike, margin = 0): TLOverlay | null {
const entries = this.getActiveOverlayEntries()
for (let i = entries.length - 1; i >= 0; i--) {
const { overlays } = entries[i]
for (const overlay of overlays) {
const geometry = this.getOverlayGeometry(overlay)
if (!geometry) continue
if (geometry.hitTestPoint(point, geometry.isFilled ? 0 : margin, true)) {
return overlay
}
}
}
return null
}
}

View File

@@ -0,0 +1,148 @@
import { TLCursorType } from '@tldraw/tlschema'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import type { Editor } from '../Editor'
import { TLPointerEventInfo } from '../types/event-types'
/** @public */
export interface TLOverlay<Props = Record<string, unknown>> {
/**
* Globally unique id for this overlay instance across all overlay utils.
* Hit-test and hover lookup key on `id` alone, so utils must namespace their
* ids (e.g. `'selection_fg:top_left'`, `'handle:<shapeId>:<handleId>'`) to
* avoid colliding with overlays from other utils.
*/
id: string
/** The overlay util type that owns this instance */
type: string
/** Arbitrary props for the overlay (handle id, corner name, etc.) */
props: Props
}
/** @public */
export interface TLOverlayUtilConstructor<U extends OverlayUtil = OverlayUtil> {
new (editor: Editor): U
type: string
configure<T extends TLOverlayUtilConstructor<any>>(
this: T,
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
): T
}
/** @public */
export type TLAnyOverlayUtilConstructor = TLOverlayUtilConstructor<any>
/**
* Base class for overlay utilities. Overlays are ephemeral UI elements rendered
* on top of the canvas (selection handles, rotation corners, shape handles, etc.).
*
* Each OverlayUtil defines a type of overlay and knows how to:
* - Determine when its overlays should be active (predicate)
* - Produce overlay instances from current editor state
* - Provide hit-test geometry for interactive overlays
* - Provide cursor style on hover
* - Render into a canvas 2D context
*
* @public
*/
export abstract class OverlayUtil<T extends TLOverlay = TLOverlay> {
constructor(public editor: Editor) {}
static type: string
/**
* Options for this overlay util. Override this to provide customization options.
* Use {@link OverlayUtil.configure} to customize existing overlay utils.
*
* `zIndex` controls paint and hit-test order across utils — higher numbers
* paint on top and are hit-tested first. Ties resolve by registration order.
* Defaults to `0`; built-in utils use larger integers (100, 200, …) with
* gaps so custom utils can slot between.
*
* @public
*/
options: { zIndex?: number } = {}
/**
* Create a new overlay util class with the given options merged in.
*
* @example
* ```ts
* const MyBrush = BrushOverlayUtil.configure({ fill: 'rgba(0,0,255,0.1)' })
* ```
*
* @public
*/
static configure<T extends TLOverlayUtilConstructor<any>>(
this: T,
options: T extends new (...args: any[]) => { options: infer Options } ? Partial<Options> : never
): T {
// @ts-expect-error -- typescript has no idea what's going on here but it's fine
return class extends this {
// @ts-expect-error
options = { ...this.options, ...options }
}
}
/**
* Whether this overlay util's overlays should currently be active.
* Checked reactively to determine which overlays exist at any given time.
*/
abstract isActive(): boolean
/**
* Returns the overlay instances that currently exist.
* Called only when `isActive()` returns true.
*/
abstract getOverlays(): T[]
/**
* Returns hit-test geometry for an overlay instance, in page coordinates.
* Return null for non-interactive overlays (e.g. snap indicators, scribbles).
*/
getGeometry(_overlay: T): Geometry2d | null {
return null
}
/**
* Returns the cursor type to show when hovering this overlay.
*/
getCursor(_overlay: T): TLCursorType | undefined {
return undefined
}
/**
* Called when the user points down on this overlay, before the default
* routing runs. Acts as an interrupt: define it to take over the event.
*
* Return `false` to continue with the default behavior (e.g. the
* built-in rotate/resize handle transitions or shape-handle dispatch).
* Return `true` — or nothing at all — to skip the default. In other
* words, once you override this method you own the event unless you
* explicitly opt back in by returning `false`.
*/
onPointerDown?(overlay: T, info: TLPointerEventInfo): boolean | void
/**
* Render all active overlays into the canvas context.
* The context is already transformed to page space (camera transform applied).
* Called reactively when overlays or editor state changes.
*/
render(_ctx: CanvasRenderingContext2D, _overlays: T[]): void {}
/**
* Optional: render all active overlays into the minimap canvas.
* The context is already transformed to page space (minimap camera applied),
* so overlays can use the same page-space coordinates as in {@link OverlayUtil.render}.
*
* `zoom` is the minimap's screen-pixels-per-page-unit, analogous to
* `editor.getCamera().z`; use `1 / zoom` for one-minimap-pixel line widths.
*
* Most overlays should leave this blank — only overlays that are meaningful
* at minimap scale (e.g. brushes, collaborator cursors) should opt in.
*/
renderMinimap(_ctx: CanvasRenderingContext2D, _overlays: T[], _zoom: number): void {}
/**
* Clean up any resources held by this overlay util.
*/
dispose(): void {}
}

View File

@@ -0,0 +1,51 @@
import { TLTheme } from '@tldraw/tlschema'
import type { Editor } from '../Editor'
import { TLOverlay } from './OverlayUtil'
/** @public */
export interface OverlayOptionsWithDisplayValues<
Overlay extends TLOverlay,
DisplayValues extends object,
> {
getDefaultDisplayValues(
editor: Editor,
overlay: Overlay,
theme: TLTheme,
colorMode: 'light' | 'dark'
): DisplayValues
getCustomDisplayValues(
editor: Editor,
overlay: Overlay,
theme: TLTheme,
colorMode: 'light' | 'dark'
): Partial<DisplayValues>
}
const dvCache = new WeakMap<
TLOverlay,
{ theme: TLTheme; colorMode: 'light' | 'dark'; values: object }
>()
/**
* Get the resolved display values for an overlay, merging the base values with any overrides.
*
* @public
*/
export function getOverlayDisplayValues<Overlay extends TLOverlay, DisplayValues extends object>(
util: { editor: Editor; options: OverlayOptionsWithDisplayValues<Overlay, DisplayValues> },
overlay: Overlay,
colorMode?: 'light' | 'dark'
): DisplayValues {
const theme = util.editor.getCurrentTheme()
const resolvedColorMode = colorMode ?? util.editor.getColorMode()
const cached = dvCache.get(overlay)
if (cached && cached.theme === theme && cached.colorMode === resolvedColorMode) {
return cached.values as DisplayValues
}
const values = {
...util.options.getDefaultDisplayValues(util.editor, overlay, theme, resolvedColorMode),
...util.options.getCustomDisplayValues(util.editor, overlay, theme, resolvedColorMode),
}
dvCache.set(overlay, { theme, colorMode: resolvedColorMode, values })
return values
}

View File

@@ -0,0 +1,85 @@
import { createComputedCache } from '@tldraw/store'
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import type { Editor } from '../Editor'
const indicatorPathCache = createComputedCache(
'shapeIndicatorPath',
(editor: Editor, shape: TLShape) => {
const util = editor.getShapeUtil(shape)
return util.getIndicatorPath(shape)
},
{
areRecordsEqual(a, b) {
return a.props === b.props
},
}
)
/**
* Combine every batchable shape indicator into a single page-space `Path2D` and
* emit one stroke call. Shapes whose indicator needs an evenodd clip (e.g.
* arrows with labels or complex arrowheads) can't be batched — they still
* stroke individually inside a save/restore with `ctx.clip` applied.
*
* Shared by any overlay util that paints shape indicators (e.g. collaborator
* selections).
*
* @public
*/
export function strokeShapeIndicators(
editor: Editor,
ctx: CanvasRenderingContext2D,
shapeIds: TLShapeId[]
): void {
if (shapeIds.length === 0) return
const batched = new Path2D()
for (const shapeId of shapeIds) {
const shape = editor.getShape(shapeId)
if (!shape || shape.isLocked) continue
const pageTransform = editor.getShapePageTransform(shape)
const indicatorPath = indicatorPathCache.get(editor, shape.id)
if (!indicatorPath) continue
if (indicatorPath instanceof Path2D) {
batched.addPath(indicatorPath, pageTransform)
continue
}
const { path, clipPath, additionalPaths } = indicatorPath
if (!clipPath) {
batched.addPath(path, pageTransform)
if (additionalPaths) {
for (const p of additionalPaths) batched.addPath(p, pageTransform)
}
continue
}
// Clipped case: fall back to an individual stroke. Rare (arrows with
// labels / complex arrowheads), so the extra save/restore/stroke
// pair per such shape isn't worth batching away.
ctx.save()
ctx.transform(
pageTransform.a,
pageTransform.b,
pageTransform.c,
pageTransform.d,
pageTransform.e,
pageTransform.f
)
ctx.save()
ctx.clip(clipPath, 'evenodd')
ctx.stroke(path)
ctx.restore()
if (additionalPaths) {
for (const p of additionalPaths) ctx.stroke(p)
}
ctx.restore()
}
ctx.stroke(batched)
}

View File

@@ -0,0 +1,39 @@
import { ExtractShapeByProps } from '@tldraw/tlschema'
import { lerp } from '@tldraw/utils'
import { Geometry2d } from '../../primitives/geometry/Geometry2d'
import { Rectangle2d } from '../../primitives/geometry/Rectangle2d'
import { HandleSnapGeometry } from '../managers/SnapManager/HandleSnaps'
import { ShapeUtil, TLResizeInfo } from './ShapeUtil'
import { resizeBox } from './shared/resizeBox'
/** @public */
export type TLBaseBoxShape = ExtractShapeByProps<{ w: number; h: number }>
/** @public */
export abstract class BaseBoxShapeUtil<Shape extends TLBaseBoxShape> extends ShapeUtil<Shape> {
getGeometry(shape: Shape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
})
}
override onResize(shape: any, info: TLResizeInfo<any>) {
return resizeBox(shape, info)
}
override getHandleSnapGeometry(shape: Shape): HandleSnapGeometry {
return {
points: this.getGeometry(shape).bounds.cornersAndCenter,
}
}
override getInterpolatedProps(startShape: Shape, endShape: Shape, t: number): Shape['props'] {
return {
...endShape.props,
w: lerp(startShape.props.w, endShape.props.w, t),
h: lerp(startShape.props.h, endShape.props.h, t),
}
}
}

View File

@@ -0,0 +1,137 @@
import { TLShape, TLShapeId } from '@tldraw/tlschema'
import { IndexKey, compact } from '@tldraw/utils'
import { Vec } from '../../primitives/Vec'
import { BaseBoxShapeUtil, TLBaseBoxShape } from './BaseBoxShapeUtil'
import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
/**
* A base class for frame-like shapes — containers that clip their children except arrows,
* require full-brush selection, block erasure from inside, and support
* drag-and-drop reparenting.
*
* Extending this class is the easiest way to create a custom frame-like shape.
* It provides sensible defaults for all frame-like behaviors:
*
* - `isFrameLike()` returns `true`
* - `providesBackgroundForChildren()` returns `true`
* - `canReceiveNewChildrenOfType()` returns `true` unless the container is locked
* - `canRemoveChildrenOfType()` returns `true` unless the container is locked
* - `getClipPath()` returns the shape geometry's vertices
* - `shouldClipChild()` clips all children except arrows
* - `onDragShapesIn()` reparents shapes into the frame (with index restoration)
* - `onDragShapesOut()` reparents shapes back to the page
*
* All methods can be overridden for custom behavior.
*
* @example
* ```ts
* class MyContainerUtil extends BaseFrameLikeShapeUtil<MyContainerShape> {
* static override type = 'my-container' as const
* static override props = myContainerShapeProps
*
* override getDefaultProps() {
* return { w: 300, h: 200 }
* }
*
* override component(shape: MyContainerShape) {
* return <SVGContainer>...</SVGContainer>
* }
*
* override getIndicatorPath(shape: MyContainerShape) {
* const path = new Path2D()
* path.rect(0, 0, shape.props.w, shape.props.h)
* return path
* }
* }
* ```
*
* @public
*/
export abstract class BaseFrameLikeShapeUtil<
Shape extends TLBaseBoxShape,
> extends BaseBoxShapeUtil<Shape> {
override isFrameLike(_shape: Shape): boolean {
return true
}
override providesBackgroundForChildren(): boolean {
return true
}
override canReceiveNewChildrenOfType(shape: Shape, _type: TLShape['type']): boolean {
return !shape.isLocked
}
override canRemoveChildrenOfType(shape: Shape, _type: TLShape['type']): boolean {
return !shape.isLocked
}
override getClipPath(shape: Shape): Vec[] | undefined {
return this.editor.getShapeGeometry(shape.id).vertices
}
override shouldClipChild(child: TLShape): boolean {
return child.type !== 'arrow'
}
override onDragShapesIn(
shape: Shape,
draggingShapes: TLShape[],
{ initialParentIds, initialIndices }: TLDragShapesInInfo
): void {
const { editor } = this
if (draggingShapes.every((s) => s.parentId === shape.id)) return
// Check to see whether any of the shapes can have their old index restored
let canRestoreOriginalIndices = false
const previousChildren = draggingShapes.filter(
(s) => shape.id === (initialParentIds.get(s.id) as TLShapeId)
)
if (previousChildren.length > 0) {
const currentChildren = compact(
editor.getSortedChildIdsForParent(shape).map((id) => editor.getShape(id))
)
if (
previousChildren.every(
(s) => !currentChildren.find((c) => c.index === initialIndices.get(s.id))
)
) {
canRestoreOriginalIndices = true
}
}
// If any of the children are the ancestor of the frame, quit here
if (draggingShapes.some((s) => editor.hasAncestor(shape, s.id))) return
editor.reparentShapes(draggingShapes, shape.id)
if (canRestoreOriginalIndices) {
for (const s of previousChildren) {
editor.updateShape({
id: s.id,
type: s.type,
index: initialIndices.get(s.id) as IndexKey,
})
}
}
}
override onDragShapesOut(
shape: Shape,
draggingShapes: TLShape[],
info: TLDragShapesOutInfo
): void {
const { editor } = this
// When a user drags shapes out and we're not dragging into a new shape,
// reparent the dragging shapes onto the current page instead
if (!info.nextDraggingOverShapeId) {
// Locked shapes are already filtered out upstream by DragAndDropManager.
editor.reparentShapes(
draggingShapes.filter((s) => s.parentId === shape.id),
editor.getCurrentPageId()
)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
import { useValue } from '@tldraw/state-react'
import { useEditor } from '../../../hooks/useEditor'
import { Box } from '../../../primitives/Box'
import { getPerfectDashProps } from '../shared/getPerfectDashProps'
export function DashedOutlineBox({ bounds, className }: { bounds: Box; className: string }) {
const editor = useEditor()
const zoomLevel = useValue('zoom level', () => editor.getEfficientZoomLevel(), [editor])
return (
<g className={className} pointerEvents="none" strokeLinecap="round" strokeLinejoin="round">
{bounds.sides.map((side, i) => {
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(
side[0].dist(side[1]),
1 / zoomLevel,
{
style: 'dashed',
lengthRatio: 4,
}
)
return (
<line
key={i}
x1={side[0].x}
y1={side[0].y}
x2={side[1].x}
y2={side[1].y}
strokeDasharray={strokeDasharray}
strokeDashoffset={strokeDashoffset}
/>
)
})}
</g>
)
}

View File

@@ -0,0 +1,156 @@
import { TLGroupShape, groupShapeMigrations, groupShapeProps } from '@tldraw/tlschema'
import { SVGContainer } from '../../../components/SVGContainer'
import { Geometry2d } from '../../../primitives/geometry/Geometry2d'
import { Group2d } from '../../../primitives/geometry/Group2d'
import { Rectangle2d } from '../../../primitives/geometry/Rectangle2d'
import { ShapeUtil } from '../ShapeUtil'
import { getPerfectDashProps } from '../shared/getPerfectDashProps'
import { DashedOutlineBox } from './DashedOutlineBox'
/** @public */
export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
static override type = 'group' as const
static override props = groupShapeProps
static override migrations = groupShapeMigrations
override hideSelectionBoundsFg(shape: TLGroupShape) {
return true
}
override canBind() {
return false
}
canResize() {
return true
}
canResizeChildren() {
return true
}
getDefaultProps(): TLGroupShape['props'] {
return {}
}
getGeometry(shape: TLGroupShape): Geometry2d {
const children = this.editor.getSortedChildIdsForParent(shape.id)
if (children.length === 0) {
return new Rectangle2d({ width: 1, height: 1, isFilled: false })
}
// A child id can outlive its record. The children index and the geometry cache are separate
// derivations, so inside a transaction that removes a child the index can still name it while
// `getShapeGeometry` already answers nothing — its `!` hides that it returns `undefined` for a
// record the store no longer holds. Asserting instead of checking threw a `TypeError` out of a
// reactive recompute, where no caller could catch it.
const geometries: Geometry2d[] = []
for (const childId of children) {
const child = this.editor.getShape(childId)
if (!child) continue
const geometry = this.editor.getShapeGeometry(childId)
if (!geometry) continue
const transform = this.editor.getShapeLocalTransform(child)
if (!transform) continue
geometries.push(geometry.transform(transform, { isLabel: false }))
}
// Every child resolved to nothing, so there is no meaningful extent to report. Matches the
// no-children case above rather than building an empty `Group2d`, whose bounds are `NaN`.
if (geometries.length === 0) {
return new Rectangle2d({ width: 1, height: 1, isFilled: false })
}
return new Group2d({ children: geometries })
}
component(shape: TLGroupShape) {
const isErasing = this.editor.getErasingShapeIds().includes(shape.id)
const { hintingShapeIds } = this.editor.getCurrentPageState()
const isHintingOtherGroup =
hintingShapeIds.length > 0 &&
hintingShapeIds.some(
(id) => id !== shape.id && this.editor.isShapeOfType(this.editor.getShape(id)!, 'group')
)
const isFocused = this.editor.getCurrentPageState().focusedGroupId !== shape.id
if (
!isErasing && // always show the outline while we're erasing the group
// show the outline while the group is focused unless something outside of the group is being hinted
// this happens dropping shapes from a group onto some outside group
(isFocused || isHintingOtherGroup)
) {
return null
}
const bounds = this.editor.getShapeGeometry(shape).bounds
return (
<SVGContainer>
<DashedOutlineBox className="tl-group" bounds={bounds} />
</SVGContainer>
)
}
override getIndicatorPath(shape: TLGroupShape): Path2D {
const bounds = this.editor.getShapeGeometry(shape).bounds
const zoomLevel = this.editor.getEfficientZoomLevel()
const path = new Path2D()
for (const side of bounds.sides) {
const [start, end] = side
const length = start.dist(end)
if (length <= 0) continue
const { strokeDasharray, strokeDashoffset } = getPerfectDashProps(length, 1 / zoomLevel, {
style: 'dashed',
lengthRatio: 4,
})
if (strokeDasharray === 'none') {
path.moveTo(start.x, start.y)
path.lineTo(end.x, end.y)
continue
}
const [dashLength, gapLength] = strokeDasharray.split(' ').map(Number)
const dashOffset = Number(strokeDashoffset)
const period = dashLength + gapLength
if (!Number.isFinite(period) || period <= 0) continue
const dx = (end.x - start.x) / length
const dy = (end.y - start.y) / length
for (let dashStart = -dashOffset; dashStart < length; dashStart += period) {
const dashEnd = Math.min(length, dashStart + dashLength)
const clippedDashStart = Math.max(0, dashStart)
if (dashEnd <= clippedDashStart) continue
path.moveTo(start.x + dx * clippedDashStart, start.y + dy * clippedDashStart)
path.lineTo(start.x + dx * dashEnd, start.y + dy * dashEnd)
}
}
return path
}
override onChildrenChange(group: TLGroupShape) {
const children = this.editor.getSortedChildIdsForParent(group.id)
if (children.length === 0) {
if (this.editor.getCurrentPageState().focusedGroupId === group.id) {
this.editor.popFocusedGroupId()
}
this.editor.deleteShapes([group.id])
return
} else if (children.length === 1) {
if (this.editor.getCurrentPageState().focusedGroupId === group.id) {
this.editor.popFocusedGroupId()
}
this.editor.reparentShapes(children, group.parentId, group.index)
this.editor.deleteShapes([group.id])
return
}
}
}

View File

@@ -0,0 +1,118 @@
import { TLDefaultDashStyle } from '@tldraw/tlschema'
/** @public */
export type PerfectDashTerminal = 'skip' | 'outset' | 'none'
/** @public */
export function getPerfectDashProps(
totalLength: number,
strokeWidth: number,
opts: {
style?: TLDefaultDashStyle
snap?: number
end?: PerfectDashTerminal
start?: PerfectDashTerminal
lengthRatio?: number
closed?: boolean
forceSolid?: boolean
} = {}
): {
strokeDasharray: string
strokeDashoffset: string
} {
const {
closed = false,
snap = 1,
start = 'outset',
end = 'outset',
lengthRatio = 2,
style = 'dashed',
forceSolid = false,
} = opts
let dashLength = 0
let dashCount = 0
let ratio = 1
let gapLength = 0
let strokeDashoffset = 0
if (forceSolid) {
return {
strokeDasharray: 'none',
strokeDashoffset: 'none',
}
}
if (style === 'none') {
return {
strokeDasharray: 'none',
strokeDashoffset: 'none',
}
}
switch (style) {
case 'dashed': {
ratio = 1
dashLength = Math.min(strokeWidth * lengthRatio, totalLength / 4)
break
}
case 'dotted': {
ratio = 100
dashLength = strokeWidth / ratio
break
}
default: {
return {
strokeDasharray: 'none',
strokeDashoffset: 'none',
}
}
}
if (!closed) {
if (start === 'outset') {
totalLength += dashLength / 2
strokeDashoffset += dashLength / 2
} else if (start === 'skip') {
totalLength -= dashLength
strokeDashoffset -= dashLength
}
if (end === 'outset') {
totalLength += dashLength / 2
} else if (end === 'skip') {
totalLength -= dashLength
}
}
dashCount = Math.floor(totalLength / dashLength / (2 * ratio))
dashCount -= dashCount % snap
// A path shorter than one dash period (or of zero length, where the count is NaN) would
// otherwise divide by zero below and produce an invalid dasharray; draw a single dash instead
if (!(dashCount >= 1)) dashCount = 1
if (dashCount < 3 && style === 'dashed') {
if (totalLength / strokeWidth < 4) {
dashLength = totalLength
dashCount = 1
gapLength = 0
} else {
dashLength = totalLength * (1 / 3)
gapLength = totalLength * (1 / 3)
}
} else {
dashLength = totalLength / dashCount / (2 * ratio)
if (closed) {
strokeDashoffset = dashLength / 2
gapLength = (totalLength - dashCount * dashLength) / dashCount
} else {
gapLength = (totalLength - dashCount * dashLength) / Math.max(1, dashCount - 1)
}
}
return {
strokeDasharray: [dashLength, gapLength].join(' '),
strokeDashoffset: strokeDashoffset.toString(),
}
}

View File

@@ -0,0 +1,129 @@
import { VecModel } from '@tldraw/tlschema'
import { Box } from '../../../primitives/Box'
import { Vec } from '../../../primitives/Vec'
import { TLResizeHandle } from '../../types/selection-types'
import type { TLBaseBoxShape } from '../BaseBoxShapeUtil'
import { TLResizeMode } from '../ShapeUtil'
/** @public */
export interface ResizeBoxOptions {
minWidth?: number
maxWidth?: number
minHeight?: number
maxHeight?: number
}
/** @public */
export function resizeBox<T extends TLBaseBoxShape>(
shape: T,
info: {
newPoint: VecModel
handle: TLResizeHandle
mode: TLResizeMode
scaleX: number
scaleY: number
initialBounds: Box
initialShape: T
},
opts = {} as ResizeBoxOptions
): T {
const { newPoint, handle, scaleX, scaleY } = info
const { minWidth = 1, maxWidth = Infinity, minHeight = 1, maxHeight = Infinity } = opts
let w = shape.props.w * scaleX
let h = shape.props.h * scaleY
const offset = new Vec(0, 0)
if (w > 0) {
if (w < minWidth) {
switch (handle) {
case 'top_left':
case 'left':
case 'bottom_left': {
offset.x = w - minWidth
break
}
case 'top':
case 'bottom': {
offset.x = (w - minWidth) / 2
break
}
default: {
offset.x = 0
}
}
w = minWidth
}
} else {
offset.x = w
w = -w
if (w < minWidth) {
switch (handle) {
case 'top_left':
case 'left':
case 'bottom_left': {
offset.x = -w
break
}
default: {
offset.x = -minWidth
}
}
w = minWidth
}
}
if (h > 0) {
if (h < minHeight) {
switch (handle) {
case 'top_left':
case 'top':
case 'top_right': {
offset.y = h - minHeight
break
}
case 'right':
case 'left': {
offset.y = (h - minHeight) / 2
break
}
default: {
offset.y = 0
}
}
h = minHeight
}
} else {
offset.y = h
h = -h
if (h < minHeight) {
switch (handle) {
case 'top_left':
case 'top':
case 'top_right': {
offset.y = -h
break
}
default: {
offset.y = -minHeight
}
}
h = minHeight
}
}
const { x, y } = offset.rot(shape.rotation).add(newPoint)
return {
...shape,
x,
y,
props: {
w: Math.min(maxWidth, w),
h: Math.min(maxHeight, h),
},
}
}

View File

@@ -0,0 +1,61 @@
import { TLBaseShape } from '@tldraw/tlschema'
import { exhaustiveSwitchError } from '@tldraw/utils'
import { Vec } from '../../../primitives/Vec'
import { TLResizeInfo } from '../ShapeUtil'
/**
* Resize a shape that has a scale prop.
*
* @param shape - The shape to resize
* @param info - The resize info
*
* @public */
export function resizeScaled(
shape: TLBaseShape<any, { scale: number }>,
{ initialBounds, scaleX, scaleY, newPoint, handle }: TLResizeInfo<any>
) {
let scaleDelta: number
switch (handle) {
case 'bottom_left':
case 'bottom_right':
case 'top_left':
case 'top_right': {
scaleDelta = Math.max(0.01, Math.max(Math.abs(scaleX), Math.abs(scaleY)))
break
}
case 'left':
case 'right': {
scaleDelta = Math.max(0.01, Math.abs(scaleX))
break
}
case 'bottom':
case 'top': {
scaleDelta = Math.max(0.01, Math.abs(scaleY))
break
}
default: {
throw exhaustiveSwitchError(handle)
}
}
// Compute the offset (if flipped X or flipped Y)
const offset = new Vec(0, 0)
if (scaleX < 0) {
offset.x = -(initialBounds.width * scaleDelta)
}
if (scaleY < 0) {
offset.y = -(initialBounds.height * scaleDelta)
}
// Apply the offset to the new point
const { x, y } = Vec.Add(newPoint, offset.rot(shape.rotation))
return {
x,
y,
props: {
scale: scaleDelta * shape.props.scale,
},
}
}

View File

@@ -0,0 +1,18 @@
import { TLShape } from '@tldraw/tlschema'
import { TLBaseBoxShape } from '../../shapes/BaseBoxShapeUtil'
import { StateNode, TLStateNodeConstructor } from '../StateNode'
import { Idle } from './children/Idle'
import { Pointing } from './children/Pointing'
/** @public */
export abstract class BaseBoxShapeTool extends StateNode {
static override id = 'box'
static override initial = 'idle'
static override children(): TLStateNodeConstructor[] {
return [Idle, Pointing]
}
abstract override shapeType: TLBaseBoxShape['type']
onCreate?(_shape: TLShape | null): void | null
}

View File

@@ -0,0 +1,18 @@
import { TLPointerEventInfo } from '../../../types/event-types'
import { StateNode } from '../../StateNode'
export class Idle extends StateNode {
static override id = 'idle'
override onPointerDown(info: TLPointerEventInfo) {
this.parent.transition('pointing', info)
}
override onEnter() {
this.editor.setCursor({ type: 'cross', rotation: 0 })
}
override onCancel() {
this.editor.setCurrentTool('select')
}
}

View File

@@ -0,0 +1,164 @@
import { TLShape, createShapeId } from '@tldraw/tlschema'
import { structuredClone } from '@tldraw/utils'
import { Vec } from '../../../../primitives/Vec'
import type { Editor } from '../../../Editor'
import { TLBaseBoxShape } from '../../../shapes/BaseBoxShapeUtil'
import { TLPointerEventInfo } from '../../../types/event-types'
import { StateNode } from '../../StateNode'
import type { BaseBoxShapeTool } from '../BaseBoxShapeTool'
export class Pointing extends StateNode {
static override id = 'pointing'
override onPointerMove(info: TLPointerEventInfo) {
const { editor } = this
if (editor.inputs.getIsDragging()) {
const originPagePoint = editor.inputs.getOriginPagePoint()
const shapeType = (this.parent as BaseBoxShapeTool)!.shapeType
const id = createShapeId()
const creatingMarkId = editor.markHistoryStoppingPoint(`creating_box:${id}`)
const newPoint = maybeSnapToGrid(originPagePoint, editor)
// Allow this to trigger the max shapes reached alert
this.editor.createShapes([
{
id,
type: shapeType,
x: newPoint.x,
y: newPoint.y,
props: {
w: 1,
h: 1,
},
},
])
const shape = editor.getShape(id)
if (!shape) {
this.cancel()
return
}
editor.select(id)
const parent = this.parent as BaseBoxShapeTool
this.editor.setCurrentTool(
'select.resizing',
{
...info,
target: 'selection',
handle: 'bottom_right',
isCreating: true,
creatingMarkId,
creationCursorOffset: { x: 1, y: 1 },
onInteractionEnd: this.parent.id,
onCreate: parent.onCreate
? (shape: TLShape | null) => parent.onCreate?.(shape)
: undefined,
} /** satisfies ResizingInfo, defined in main tldraw package 😧 */
)
}
}
override onPointerUp() {
this.complete()
}
override onLongPress() {
// On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
}
override onCancel() {
this.cancel()
}
override onComplete() {
// `complete` arrives mid-press from undo, menus opening, page changes, and focus loss,
// none of which is a release; committing here would drop a default-size shape the user
// never asked for. Match the text and arrow tools and leave nothing behind.
this.cancel()
}
override onInterrupt() {
this.cancel()
}
complete() {
const originPagePoint = this.editor.inputs.getOriginPagePoint()
const shapeType = (this.parent as BaseBoxShapeTool)!.shapeType as TLBaseBoxShape['type']
const id = createShapeId()
this.editor.markHistoryStoppingPoint(`creating_box:${id}`)
// Allow this to trigger the max shapes reached alert
// todo: add scale here when dynamic size is enabled (is this still needed?)
this.editor.createShapes([
{
id,
type: shapeType,
x: originPagePoint.x,
y: originPagePoint.y,
},
])
const shape = this.editor.getShape<TLBaseBoxShape>(id)!
if (!shape) {
this.cancel()
return
}
let { w, h } = shape.props
const delta = new Vec(w / 2, h / 2)
const parentTransform = this.editor.getShapeParentTransform(shape)
if (parentTransform) delta.rot(-parentTransform.rotation())
const scale = this.editor.getResizeScaleFactor()
// A scale factor of 1 means dynamic sizing is not affecting this shape.
if (scale !== 1) {
w *= scale
h *= scale
delta.mul(scale)
}
const next = structuredClone(shape)
const newPoint = maybeSnapToGrid(new Vec(shape.x - delta.x, shape.y - delta.y), this.editor)
next.x = newPoint.x
next.y = newPoint.y
next.props.w = w
next.props.h = h
if ('scale' in shape.props) {
;(next as TLBaseBoxShape & { props: { scale: number } }).props.scale = scale
}
this.editor.updateShape(next)
this.editor.setSelectedShapes([id])
if (this.editor.getInstanceState().isToolLocked) {
this.parent.transition('idle')
} else {
this.editor.setCurrentTool('select.idle')
}
}
cancel() {
this.parent.transition('idle')
}
}
/**
* Checks if grid mode is enabled and snaps a point to the grid if so
*
* @public
*/
export function maybeSnapToGrid(point: Vec, editor: Editor): Vec {
const isGridMode = editor.getInstanceState().isGridMode
const gridSize = editor.getDocumentSettings().gridSize
if (isGridMode) return point.clone().snapToGrid(gridSize)
return point.clone()
}

View File

@@ -0,0 +1,9 @@
import { StateNode } from './StateNode'
export class RootState extends StateNode {
static override id = 'root'
static override initial = ''
static override children() {
return []
}
}

View File

@@ -0,0 +1,349 @@
import { createTLStore } from '../../config/createTLStore'
import { Editor } from '../Editor'
import { StateNode } from './StateNode'
describe('StateNode.addChild', () => {
// Test state node classes for addChild tests
class ParentState extends StateNode {
static override id = 'parent'
static override initial = 'child1'
static override children() {
return [ChildState1]
}
}
class ChildState1 extends StateNode {
static override id = 'child1'
}
class ChildState2 extends StateNode {
static override id = 'child2'
}
class ChildState3 extends StateNode {
static override id = 'child3'
}
class LeafState extends StateNode {
static override id = 'leaf'
}
class RootState extends StateNode {
static override id = 'root'
static override initial = 'child1'
static override children() {
return [ChildState1]
}
}
class RootStateWithoutChildren extends StateNode {
static override id = 'rootWithoutChildren'
}
let editor: Editor
beforeEach(() => {
editor = new Editor({
initialState: 'parent',
shapeUtils: [],
bindingUtils: [],
tools: [
ParentState,
ChildState1,
ChildState2,
ChildState3,
LeafState,
RootState,
RootStateWithoutChildren,
],
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
getContainer: () => document.body,
})
})
it('should add a child to a branch state node', () => {
const parentState = editor.root.children!['parent'] as ParentState
// Initially should have one child
expect(Object.keys(parentState.children!)).toHaveLength(1)
expect(parentState.children!['child1']).toBeDefined()
// Add a new child
parentState.addChild(ChildState2)
// Should now have two children
expect(Object.keys(parentState.children!)).toHaveLength(2)
expect(parentState.children!['child1']).toBeDefined()
expect(parentState.children!['child2']).toBeDefined()
expect(parentState.children!['child2']).toBeInstanceOf(ChildState2)
})
it('should add a child to a root state node', () => {
const rootState = editor.root.children!['root'] as RootState
// Initially should have one child
expect(Object.keys(rootState.children!)).toHaveLength(1)
expect(rootState.children!['child1']).toBeDefined()
// Add a new child
rootState.addChild(ChildState2)
// Should now have two children
expect(Object.keys(rootState.children!)).toHaveLength(2)
expect(rootState.children!['child1']).toBeDefined()
expect(rootState.children!['child2']).toBeDefined()
expect(rootState.children!['child2']).toBeInstanceOf(ChildState2)
})
it('should throw an error when trying to add a child to a leaf state node', () => {
const leafState = editor.root.children!['leaf'] as LeafState
// Leaf state should not have children
expect(leafState.children).toBeUndefined()
// Should throw an error when trying to add a child
expect(() => {
leafState.addChild(ChildState2)
}).toThrow('StateNode.addChild: cannot add child to a leaf node')
})
it('should return the parent state node for chaining', () => {
const parentState = editor.root.children!['parent'] as ParentState
const result = parentState.addChild(ChildState2)
expect(result).toBe(parentState)
})
it('should create the child with the correct editor and parent', () => {
const parentState = editor.root.children!['parent'] as ParentState
parentState.addChild(ChildState2)
const childState = parentState.children!['child2'] as ChildState2
expect(childState.editor).toBe(editor)
expect(childState.parent).toBe(parentState)
})
it('should allow adding multiple children', () => {
const parentState = editor.root.children!['parent'] as ParentState
// Add multiple children
parentState.addChild(ChildState2).addChild(ChildState3)
// Should have three children
expect(Object.keys(parentState.children!)).toHaveLength(3)
expect(parentState.children!['child1']).toBeDefined()
expect(parentState.children!['child2']).toBeDefined()
expect(parentState.children!['child3']).toBeDefined()
expect(parentState.children!['child2']).toBeInstanceOf(ChildState2)
expect(parentState.children!['child3']).toBeInstanceOf(ChildState3)
})
it('should allow transitioning to added children', () => {
const parentState = editor.root.children!['parent'] as ParentState
// Add a new child
parentState.addChild(ChildState2)
// Should be able to transition to the new child
expect(() => {
parentState.transition('child2')
}).not.toThrow()
// The current state should be the new child
expect(parentState.getCurrent()?.id).toBe('child2')
})
it('should maintain existing children when adding new ones', () => {
const parentState = editor.root.children!['parent'] as ParentState
const originalChild = parentState.children!['child1']
// Add a new child
parentState.addChild(ChildState2)
// Original child should still exist and be the same instance
expect(parentState.children!['child1']).toBe(originalChild)
expect(parentState.children!['child1']).toBeInstanceOf(ChildState1)
})
it('should initialize children object for root nodes without static children', () => {
// Create a StateNode directly as a root node (no parent)
const mockEditor = {} as Editor
const rootStateWithoutChildren = new RootStateWithoutChildren(mockEditor, undefined)
// Root state without static children should not have children initially
expect(rootStateWithoutChildren.children).toBeUndefined()
// Adding a child should initialize the children object
rootStateWithoutChildren.addChild(ChildState2)
// Should now have children object with the added child
expect(rootStateWithoutChildren.children).toBeDefined()
expect(Object.keys(rootStateWithoutChildren.children!)).toHaveLength(1)
expect(rootStateWithoutChildren.children!['child2']).toBeDefined()
expect(rootStateWithoutChildren.children!['child2']).toBeInstanceOf(ChildState2)
})
it('should throw an error when trying to add a child with a duplicate ID', () => {
const parentState = editor.root.children!['parent'] as ParentState
// Initially should have one child
expect(Object.keys(parentState.children!)).toHaveLength(1)
expect(parentState.children!['child1']).toBeDefined()
// Should throw an error when trying to add a child with the same ID
expect(() => {
parentState.addChild(ChildState1)
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
// Should still have only one child
expect(Object.keys(parentState.children!)).toHaveLength(1)
expect(parentState.children!['child1']).toBeDefined()
})
it('should throw an error when trying to add a child with a duplicate ID to a root state', () => {
const rootState = editor.root.children!['root'] as RootState
// Initially should have one child
expect(Object.keys(rootState.children!)).toHaveLength(1)
expect(rootState.children!['child1']).toBeDefined()
// Should throw an error when trying to add a child with the same ID
expect(() => {
rootState.addChild(ChildState1)
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
// Should still have only one child
expect(Object.keys(rootState.children!)).toHaveLength(1)
expect(rootState.children!['child1']).toBeDefined()
})
it('should throw an error when trying to add a child with a duplicate ID to a root state without static children', () => {
// Create a StateNode directly as a root node (no parent)
const mockEditor = {} as Editor
const rootStateWithoutChildren = new RootStateWithoutChildren(mockEditor, undefined)
// Add a child first
rootStateWithoutChildren.addChild(ChildState1)
// Should throw an error when trying to add a child with the same ID
expect(() => {
rootStateWithoutChildren.addChild(ChildState1)
}).toThrow("StateNode.addChild: a child with id 'child1' already exists")
// Should still have only one child
expect(Object.keys(rootStateWithoutChildren.children!)).toHaveLength(1)
expect(rootStateWithoutChildren.children!['child1']).toBeDefined()
})
})
describe('StateNode.enter', () => {
let idleExits = 0
class Idle extends StateNode {
static override id = 'idle'
override onExit() {
idleExits++
}
}
class Pointing extends StateNode {
static override id = 'pointing'
}
class ToolWithEnterTransition extends StateNode {
static override id = 'enterTransition'
static override initial = 'idle'
static override children() {
return [Idle, Pointing]
}
override onEnter(info: { startPointing?: boolean }) {
if (info?.startPointing) this.transition('pointing', info)
}
}
class OtherTool extends StateNode {
static override id = 'other'
}
let editor: Editor
beforeEach(() => {
idleExits = 0
editor = new Editor({
initialState: 'other',
shapeUtils: [],
bindingUtils: [],
tools: [OtherTool, ToolWithEnterTransition],
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
getContainer: () => document.body,
})
})
it('enters the initial child when onEnter does not transition', () => {
editor.setCurrentTool('enterTransition')
expect(editor.root.getPath()).toBe('root.enterTransition.idle')
expect(editor.root.children!['enterTransition'].children!['idle'].getIsActive()).toBe(true)
})
it('does not enter the initial child on top of a child that onEnter transitioned to', () => {
editor.setCurrentTool('enterTransition', { startPointing: true })
const tool = editor.root.children!['enterTransition']
expect(editor.root.getPath()).toBe('root.enterTransition.pointing')
expect(tool.children!['pointing'].getIsActive()).toBe(true)
expect(tool.children!['idle'].getIsActive()).toBe(false)
// The stale (never re-entered) idle child must not get a spurious onExit
expect(idleExits).toBe(0)
editor.setCurrentTool('other')
expect(tool.children!['pointing'].getIsActive()).toBe(false)
})
})
describe('current tool id mask', () => {
// Tool mask test classes
class ToolA extends StateNode {
static override id = 'A'
}
class ToolB extends StateNode {
static override id = 'B'
}
class ToolC extends StateNode {
static override id = 'C'
override onEnter() {
this.setCurrentToolIdMask('A')
}
}
let toolMaskEditor: Editor
beforeEach(() => {
toolMaskEditor = new Editor({
initialState: 'A',
shapeUtils: [],
bindingUtils: [],
tools: [ToolA, ToolB, ToolC],
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
getContainer: () => document.body,
})
})
it('starts with the correct tool id', () => {
expect(toolMaskEditor.getCurrentToolId()).toBe('A')
})
it('updates the current tool id', () => {
toolMaskEditor.setCurrentTool('B')
expect(toolMaskEditor.getCurrentToolId()).toBe('B')
})
it('masks the current tool id', () => {
toolMaskEditor.setCurrentTool('C')
expect(toolMaskEditor.getCurrentToolId()).toBe('A')
})
})

View File

@@ -0,0 +1,293 @@
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,
TLCancelEventInfo,
TLClickEventInfo,
TLCompleteEventInfo,
TLEventHandlers,
TLEventInfo,
TLInterruptEventInfo,
TLKeyboardEventInfo,
TLPinchEventInfo,
TLPointerEventInfo,
TLTickEventInfo,
TLWheelEventInfo,
} from '../types/event-types'
/** @public */
export interface TLStateNodeConstructor {
new (editor: Editor, parent?: StateNode): StateNode
id: string
initial?: string
children?(): TLStateNodeConstructor[]
isLockable: boolean
useCoalescedEvents: boolean
trackPerformance: boolean
}
/** @public */
export abstract class StateNode implements Partial<TLEventHandlers> {
performanceTracker: PerformanceTracker
constructor(
public editor: Editor,
parent?: StateNode
) {
const { id, children, initial, isLockable, useCoalescedEvents } = this
.constructor as TLStateNodeConstructor
this.id = id
this._isActive = atom<boolean>('toolIsActive' + this.id, false)
this._current = atom<StateNode | undefined>('toolState' + this.id, undefined)
this._path = computed('toolPath' + this.id, () => {
const current = this.getCurrent()
return this.id + (current ? `.${current.getPath()}` : '')
})
this.parent = parent ?? ({} as any)
if (parent) {
if (children && initial) {
this.type = 'branch'
this.initial = initial
this.children = Object.fromEntries(
children().map((Ctor) => [Ctor.id, new Ctor(this.editor, this)])
)
this._current.set(this.children[this.initial])
} else {
this.type = 'leaf'
}
} else {
this.type = 'root'
if (children && initial) {
this.initial = initial
this.children = Object.fromEntries(
children().map((Ctor) => [Ctor.id, new Ctor(this.editor, this)])
)
this._current.set(this.children[this.initial])
}
}
this.isLockable = isLockable
this.useCoalescedEvents = useCoalescedEvents
this.performanceTracker = new PerformanceTracker()
}
static id: string
static initial?: string
static children?: () => TLStateNodeConstructor[]
static isLockable = true
static useCoalescedEvents = false
/** Set to `true` in subclasses to emit interaction-start/end performance events when this state is entered/exited. */
static trackPerformance = false
id: string
type: 'branch' | 'leaf' | 'root'
shapeType?: string
initial?: string
children?: Record<string, StateNode>
isLockable: boolean
useCoalescedEvents: boolean
parent: StateNode
/**
* This node's path of active state nodes
*
* @public
*/
getPath() {
return this._path.get()
}
_path: Computed<string>
/**
* This node's current active child node, if any.
*
* @public
*/
getCurrent() {
return this._current.get()
}
private _current: Atom<StateNode | undefined>
/**
* Whether this node is active.
*
* @public
*/
getIsActive() {
return this._isActive.get()
}
private _isActive: Atom<boolean>
/**
* Transition to a new active child state node.
*
* @example
* ```ts
* parentState.transition('childStateA')
* parentState.transition('childStateB', { myData: 4 })
*```
*
* @param id - The id of the child state node to transition to.
* @param info - Any data to pass to the `onEnter` and `onExit` handlers.
*
* @public
*/
transition(id: string, info: any = {}) {
const path = id.split('.')
let currState = this as StateNode
for (let i = 0; i < path.length; i++) {
const id = path[i]
const prevChildState = currState.getCurrent()
const nextChildState = currState.children?.[id]
if (!nextChildState) {
throw Error(`${currState.id} - no child state exists with the id ${id}.`)
}
if (prevChildState?.id !== nextChildState.id) {
// The previous child is inactive when transitioning from a parent's onEnter (or from a
// node that was already exited); exiting it again would fire a spurious onExit
if (prevChildState?.getIsActive()) prevChildState.exit(info, id)
currState._current.set(nextChildState)
nextChildState.enter(info, prevChildState?.id || 'initial')
if (!nextChildState.getIsActive()) break
}
currState = nextChildState
}
return this
}
handleEvent(info: Exclude<TLEventInfo, TLPinchEventInfo>) {
const cbName = EVENT_NAME_MAP[info.name]
const currentActiveChild = this._current.__unsafe__getWithoutCapture()
this[cbName]?.(info as any)
if (
this._isActive.__unsafe__getWithoutCapture() &&
currentActiveChild &&
currentActiveChild === this._current.__unsafe__getWithoutCapture()
) {
currentActiveChild.handleEvent(info)
}
}
// todo: move this logic into transition
enter(info: any, from: string) {
const track = (this.constructor as TLStateNodeConstructor).trackPerformance
if (track) {
if (debugFlags.measurePerformance.get()) {
this.performanceTracker.start(this.id)
}
this.editor.performance._notifyInteractionStart(this.id, this.getPath())
}
const currentBeforeEnter = this.getCurrent()
this._isActive.set(true)
this.onEnter?.(info, from)
// If onEnter already transitioned to a child, entering the initial child on top of it would
// leave that child active but orphaned
if (
this.children &&
this.initial &&
this.getIsActive() &&
this.getCurrent() === currentBeforeEnter
) {
const initial = this.children[this.initial]
this._current.set(initial)
initial.enter(info, from)
}
}
// todo: move this logic into transition
exit(info: any, to: string) {
const track = (this.constructor as TLStateNodeConstructor).trackPerformance
if (track) {
if (debugFlags.measurePerformance.get() && this.performanceTracker.isStarted()) {
this.performanceTracker.stop()
}
this.editor.performance._notifyInteractionEnd()
}
this._isActive.set(false)
this.onExit?.(info, to)
if (!this.getIsActive()) {
this.getCurrent()?.exit(info, to)
}
}
/**
* This is a hack / escape hatch that will tell the editor to
* report a different state as active (in `getCurrentToolId()`) when
* this state is active. This is usually used when a tool transitions
* to a child of a different state for a certain interaction and then
* returns to the original tool when that interaction completes; and
* where we would want to show the original tool as active in the UI.
*
* @public
*/
_currentToolIdMask = atom('curent tool id mask', undefined as string | undefined)
getCurrentToolIdMask() {
return this._currentToolIdMask.get()
}
setCurrentToolIdMask(id: string | undefined) {
this._currentToolIdMask.set(id)
}
/**
* Add a child node to this state node.
*
* @public
*/
addChild(childConstructor: TLStateNodeConstructor): this {
if (this.type === 'leaf') {
throw new Error('StateNode.addChild: cannot add child to a leaf node')
}
// Initialize children if it's undefined (for root nodes without static children)
if (!this.children) {
this.children = {}
}
const child = new childConstructor(this.editor, this)
// Check if a child with this ID already exists
if (this.children[child.id]) {
throw new Error(`StateNode.addChild: a child with id '${child.id}' already exists`)
}
this.children[child.id] = child
return this
}
onWheel?(info: TLWheelEventInfo): void
onPointerDown?(info: TLPointerEventInfo): void
onPointerMove?(info: TLPointerEventInfo): void
onLongPress?(info: TLPointerEventInfo): void
onPointerUp?(info: TLPointerEventInfo): void
onDoubleClick?(info: TLClickEventInfo): void
onRightClick?(info: TLPointerEventInfo): void
onMiddleClick?(info: TLPointerEventInfo): void
onKeyDown?(info: TLKeyboardEventInfo): void
onKeyUp?(info: TLKeyboardEventInfo): void
onKeyRepeat?(info: TLKeyboardEventInfo): void
onCancel?(info: TLCancelEventInfo): void
onComplete?(info: TLCompleteEventInfo): void
onInterrupt?(info: TLInterruptEventInfo): void
onTick?(info: TLTickEventInfo): void
onEnter?(info: any, from: string): void
onExit?(info: any, to: string): void
}

Some files were not shown because too many files have changed in this diff Show More