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
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:
1
packages/editor/LICENSE.md
Normal file
1
packages/editor/LICENSE.md
Normal 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
37
packages/editor/README.md
Normal 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).
|
||||
4
packages/editor/api-extractor.json
Normal file
4
packages/editor/api-extractor.json
Normal 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
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
4
packages/editor/modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.svg' {
|
||||
const content: any
|
||||
export default content
|
||||
}
|
||||
92
packages/editor/package.json
Normal file
92
packages/editor/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
28
packages/editor/setupTests.js
Normal file
28
packages/editor/setupTests.js
Normal 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 () => [],
|
||||
}
|
||||
}
|
||||
66
packages/editor/setupVitest.js
Normal file
66
packages/editor/setupVitest.js
Normal 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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
526
packages/editor/src/index.ts
Normal file
526
packages/editor/src/index.ts
Normal 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'
|
||||
853
packages/editor/src/lib/TldrawEditor.tsx
Normal file
853
packages/editor/src/lib/TldrawEditor.tsx
Normal 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])
|
||||
}
|
||||
77
packages/editor/src/lib/components/EditorPortal.tsx
Normal file
77
packages/editor/src/lib/components/EditorPortal.tsx
Normal 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)
|
||||
}
|
||||
57
packages/editor/src/lib/components/ErrorBoundary.tsx
Normal file
57
packages/editor/src/lib/components/ErrorBoundary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
packages/editor/src/lib/components/HTMLContainer.tsx
Normal file
14
packages/editor/src/lib/components/HTMLContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
packages/editor/src/lib/components/LiveCollaborators.tsx
Normal file
133
packages/editor/src/lib/components/LiveCollaborators.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
259
packages/editor/src/lib/components/MenuClickCapture.tsx
Normal file
259
packages/editor/src/lib/components/MenuClickCapture.tsx
Normal 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()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
)
|
||||
}
|
||||
14
packages/editor/src/lib/components/SVGContainer.tsx
Normal file
14
packages/editor/src/lib/components/SVGContainer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
packages/editor/src/lib/components/Shape.tsx
Normal file
283
packages/editor/src/lib/components/Shape.tsx
Normal 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
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @public @react */
|
||||
export function DefaultBackground() {
|
||||
return <div className="tl-background" />
|
||||
}
|
||||
@@ -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',
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @public @react */
|
||||
export function DefaultSvgDefs() {
|
||||
return null
|
||||
}
|
||||
244
packages/editor/src/lib/config/TLEditorSnapshot.test.ts
Normal file
244
packages/editor/src/lib/config/TLEditorSnapshot.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
107
packages/editor/src/lib/config/TLEditorSnapshot.ts
Normal file
107
packages/editor/src/lib/config/TLEditorSnapshot.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
332
packages/editor/src/lib/config/TLSessionStateSnapshot.ts
Normal file
332
packages/editor/src/lib/config/TLSessionStateSnapshot.ts
Normal 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
|
||||
}
|
||||
}
|
||||
58
packages/editor/src/lib/config/TLUserPreferences.test.ts
Normal file
58
packages/editor/src/lib/config/TLUserPreferences.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
286
packages/editor/src/lib/config/TLUserPreferences.ts
Normal file
286
packages/editor/src/lib/config/TLUserPreferences.ts
Normal 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
|
||||
}
|
||||
57
packages/editor/src/lib/config/createTLCurrentUser.ts
Normal file
57
packages/editor/src/lib/config/createTLCurrentUser.ts
Normal 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]
|
||||
)
|
||||
}
|
||||
208
packages/editor/src/lib/config/createTLStore.ts
Normal file
208
packages/editor/src/lib/config/createTLStore.ts
Normal 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,
|
||||
},
|
||||
])
|
||||
)
|
||||
}
|
||||
19
packages/editor/src/lib/config/defaultAssets.ts
Normal file
19
packages/editor/src/lib/config/defaultAssets.ts
Normal 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
|
||||
}
|
||||
19
packages/editor/src/lib/config/defaultBindings.ts
Normal file
19
packages/editor/src/lib/config/defaultBindings.ts
Normal 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
|
||||
}
|
||||
33
packages/editor/src/lib/config/defaultShapes.ts
Normal file
33
packages/editor/src/lib/config/defaultShapes.ts
Normal 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
|
||||
}
|
||||
41
packages/editor/src/lib/constants.ts
Normal file
41
packages/editor/src/lib/constants.ts
Normal 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
|
||||
1436
packages/editor/src/lib/editor/Editor.test.ts
Normal file
1436
packages/editor/src/lib/editor/Editor.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
85
packages/editor/src/lib/editor/assets/AssetUtil.ts
Normal file
85
packages/editor/src/lib/editor/assets/AssetUtil.ts
Normal 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
|
||||
}
|
||||
}
|
||||
278
packages/editor/src/lib/editor/bindings/BindingUtil.ts
Normal file
278
packages/editor/src/lib/editor/bindings/BindingUtil.ts
Normal 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
|
||||
}
|
||||
110
packages/editor/src/lib/editor/derivations/bindingsIndex.ts
Normal file
110
packages/editor/src/lib/editor/derivations/bindingsIndex.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
123
packages/editor/src/lib/editor/derivations/parentsToChildren.ts
Normal file
123
packages/editor/src/lib/editor/derivations/parentsToChildren.ts
Normal 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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
72
packages/editor/src/lib/editor/managers/EditorManager.ts
Normal file
72
packages/editor/src/lib/editor/managers/EditorManager.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1264
packages/editor/src/lib/editor/managers/SnapManager/BoundsSnaps.ts
Normal file
1264
packages/editor/src/lib/editor/managers/SnapManager/BoundsSnaps.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
185
packages/editor/src/lib/editor/overlays/OverlayManager.ts
Normal file
185
packages/editor/src/lib/editor/overlays/OverlayManager.ts
Normal 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
|
||||
}
|
||||
}
|
||||
148
packages/editor/src/lib/editor/overlays/OverlayUtil.ts
Normal file
148
packages/editor/src/lib/editor/overlays/OverlayUtil.ts
Normal 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 {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
39
packages/editor/src/lib/editor/shapes/BaseBoxShapeUtil.tsx
Normal file
39
packages/editor/src/lib/editor/shapes/BaseBoxShapeUtil.tsx
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
137
packages/editor/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx
Normal file
137
packages/editor/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1155
packages/editor/src/lib/editor/shapes/ShapeUtil.ts
Normal file
1155
packages/editor/src/lib/editor/shapes/ShapeUtil.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
156
packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx
Normal file
156
packages/editor/src/lib/editor/shapes/group/GroupShapeUtil.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
129
packages/editor/src/lib/editor/shapes/shared/resizeBox.ts
Normal file
129
packages/editor/src/lib/editor/shapes/shared/resizeBox.ts
Normal 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),
|
||||
},
|
||||
}
|
||||
}
|
||||
61
packages/editor/src/lib/editor/shapes/shared/resizeScaled.ts
Normal file
61
packages/editor/src/lib/editor/shapes/shared/resizeScaled.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
9
packages/editor/src/lib/editor/tools/RootState.ts
Normal file
9
packages/editor/src/lib/editor/tools/RootState.ts
Normal 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 []
|
||||
}
|
||||
}
|
||||
349
packages/editor/src/lib/editor/tools/StateNode.test.ts
Normal file
349
packages/editor/src/lib/editor/tools/StateNode.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
293
packages/editor/src/lib/editor/tools/StateNode.ts
Normal file
293
packages/editor/src/lib/editor/tools/StateNode.ts
Normal 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
Reference in New Issue
Block a user