@tldraw/editor 5.2.4 → 5.3.0-canary.00f14de1f81d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +76 -10
- package/dist-cjs/index.js +3 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +6 -1
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +67 -15
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js +52 -0
- package/dist-cjs/lib/editor/managers/EditorManager.js.map +7 -0
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +9 -11
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +5 -9
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +27 -19
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +4 -7
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +1 -1
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -2
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
- package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
- package/dist-cjs/lib/license/LicenseManager.js +14 -5
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/options.js +2 -1
- package/dist-cjs/lib/options.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +76 -10
- package/dist-esm/index.mjs +3 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +6 -1
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +67 -15
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs +32 -0
- package/dist-esm/lib/editor/managers/EditorManager.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +9 -11
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +5 -9
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +27 -19
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +4 -7
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +1 -1
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -2
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +14 -5
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +2 -1
- package/dist-esm/lib/options.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +10 -0
- package/package.json +7 -7
- package/src/index.ts +1 -1
- package/src/lib/TldrawEditor.tsx +10 -1
- package/src/lib/editor/Editor.ts +101 -20
- package/src/lib/editor/managers/EditorManager.test.ts +51 -0
- package/src/lib/editor/managers/EditorManager.ts +60 -0
- package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +2 -1
- package/src/lib/editor/managers/FocusManager/FocusManager.ts +10 -14
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
- package/src/lib/editor/managers/InputsManager/InputsManager.ts +6 -9
- package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +61 -1
- package/src/lib/editor/managers/TextManager/TextManager.ts +36 -19
- package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -10
- package/src/lib/editor/managers/TickManager/TickManager.ts +5 -6
- package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
- package/src/lib/editor/shapes/ShapeUtil.ts +1 -0
- package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +2 -2
- package/src/lib/editor/types/emit-types.ts +1 -0
- package/src/lib/license/LicenseManager.test.ts +99 -29
- package/src/lib/license/LicenseManager.ts +16 -4
- package/src/lib/options.ts +3 -1
- package/src/version.ts +3 -3
- package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js +0 -28
- package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +0 -7
- package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +0 -8
- package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +0 -7
- package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +0 -31
package/src/index.ts
CHANGED
|
@@ -102,6 +102,7 @@ export { ClickManager, type TLClickState } from './lib/editor/managers/ClickMana
|
|
|
102
102
|
export { EdgeScrollManager } from './lib/editor/managers/EdgeScrollManager/EdgeScrollManager'
|
|
103
103
|
export { FontManager } from './lib/editor/managers/FontManager/FontManager'
|
|
104
104
|
export { HistoryManager } from './lib/editor/managers/HistoryManager/HistoryManager'
|
|
105
|
+
export { EditorManager } from './lib/editor/managers/EditorManager'
|
|
105
106
|
export { InputsManager } from './lib/editor/managers/InputsManager/InputsManager'
|
|
106
107
|
export {
|
|
107
108
|
ScribbleManager,
|
|
@@ -191,7 +192,6 @@ export { resizeBox, type ResizeBoxOptions } from './lib/editor/shapes/shared/res
|
|
|
191
192
|
export { resizeScaled } from './lib/editor/shapes/shared/resizeScaled'
|
|
192
193
|
export { BaseBoxShapeTool } from './lib/editor/tools/BaseBoxShapeTool/BaseBoxShapeTool'
|
|
193
194
|
export { maybeSnapToGrid } from './lib/editor/tools/BaseBoxShapeTool/children/Pointing'
|
|
194
|
-
export { cancelShapeCreationOnLongPress } from './lib/editor/tools/cancelShapeCreationOnLongPress'
|
|
195
195
|
export { StateNode, type TLStateNodeConstructor } from './lib/editor/tools/StateNode'
|
|
196
196
|
export { type TLContent } from './lib/editor/types/clipboard-types'
|
|
197
197
|
export { type TLEventMap, type TLEventMapHandler } from './lib/editor/types/emit-types'
|
package/src/lib/TldrawEditor.tsx
CHANGED
|
@@ -801,7 +801,16 @@ export function useOnMount(onMount?: TLOnMountHandler) {
|
|
|
801
801
|
{ history: 'ignore' }
|
|
802
802
|
)
|
|
803
803
|
window.tldrawReady = true
|
|
804
|
-
return
|
|
804
|
+
return () => {
|
|
805
|
+
teardown?.()
|
|
806
|
+
// The editor's component can unmount without the editor being disposed (for example when
|
|
807
|
+
// the canvas is swapped for an error fallback), so emit `unmount` here rather than relying
|
|
808
|
+
// on disposal. If the editor is being disposed, `dispose()` emits `unmount` itself, so we
|
|
809
|
+
// only emit here when it's still mounted and alive.
|
|
810
|
+
if (editor.getIsMounted() && !editor.isDisposed) {
|
|
811
|
+
editor.run(() => editor.emit('unmount'), { history: 'ignore' })
|
|
812
|
+
}
|
|
813
|
+
}
|
|
805
814
|
})
|
|
806
815
|
|
|
807
816
|
React.useLayoutEffect(() => {
|
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -563,16 +563,34 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
563
563
|
let deletedBindings = new Map<TLBindingId, BindingOnDeleteOptions<any>>()
|
|
564
564
|
const deletedShapeIds = new Set<TLShapeId>()
|
|
565
565
|
const invalidParents = new Set<TLShapeId>()
|
|
566
|
+
const createdShapes = new Set<TLShapeId>()
|
|
566
567
|
let invalidBindingTypes = new Set<TLBinding['type']>()
|
|
567
568
|
|
|
568
569
|
this.disposables.add(
|
|
569
570
|
this.sideEffects.registerOperationCompleteHandler(() => {
|
|
570
571
|
// this needs to be cleared here because further effects may delete more shapes
|
|
571
572
|
// and we want the next invocation of this handler to handle those separately
|
|
573
|
+
const deletedIds = deletedShapeIds.size ? new Set(deletedShapeIds) : null
|
|
572
574
|
deletedShapeIds.clear()
|
|
573
575
|
|
|
576
|
+
if (deletedIds) {
|
|
577
|
+
const updates = compact(
|
|
578
|
+
this.getPageStates().map((pageState) => {
|
|
579
|
+
return cleanupInstancePageState(pageState, deletedIds)
|
|
580
|
+
})
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
if (updates.length) {
|
|
584
|
+
this.store.put(updates)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const justCreatedShapeIds = new Set(createdShapes)
|
|
589
|
+
createdShapes.clear()
|
|
590
|
+
|
|
574
591
|
for (const parentId of invalidParents) {
|
|
575
592
|
invalidParents.delete(parentId)
|
|
593
|
+
if (justCreatedShapeIds.has(parentId)) continue
|
|
576
594
|
const parent = this.getShape(parentId)
|
|
577
595
|
if (!parent) continue
|
|
578
596
|
|
|
@@ -608,6 +626,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
608
626
|
this.disposables.add(
|
|
609
627
|
this.sideEffects.register({
|
|
610
628
|
shape: {
|
|
629
|
+
afterCreate: (shape) => {
|
|
630
|
+
createdShapes.add(shape.id)
|
|
631
|
+
if (shape.parentId && isShapeId(shape.parentId)) {
|
|
632
|
+
invalidParents.add(shape.parentId)
|
|
633
|
+
}
|
|
634
|
+
},
|
|
611
635
|
afterChange: (shapeBefore, shapeAfter) => {
|
|
612
636
|
for (const binding of this.getBindingsInvolvingShape(shapeAfter)) {
|
|
613
637
|
invalidBindingTypes.add(binding.type)
|
|
@@ -712,17 +736,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
712
736
|
if (deleteBindingIds.length) {
|
|
713
737
|
this.deleteBindings(deleteBindingIds)
|
|
714
738
|
}
|
|
715
|
-
|
|
716
|
-
const deletedIds = new Set([shape.id])
|
|
717
|
-
const updates = compact(
|
|
718
|
-
this.getPageStates().map((pageState) => {
|
|
719
|
-
return cleanupInstancePageState(pageState, deletedIds)
|
|
720
|
-
})
|
|
721
|
-
)
|
|
722
|
-
|
|
723
|
-
if (updates.length) {
|
|
724
|
-
this.store.put(updates)
|
|
725
|
-
}
|
|
726
739
|
},
|
|
727
740
|
},
|
|
728
741
|
binding: {
|
|
@@ -809,6 +822,10 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
809
822
|
},
|
|
810
823
|
instance_page_state: {
|
|
811
824
|
afterChange: (prev, next) => {
|
|
825
|
+
if (prev?.focusedGroupId !== next?.focusedGroupId) {
|
|
826
|
+
this.cancelDoubleClick()
|
|
827
|
+
}
|
|
828
|
+
|
|
812
829
|
if (prev?.selectedShapeIds !== next?.selectedShapeIds) {
|
|
813
830
|
// ensure that descendants and ancestors are not selected at the same time
|
|
814
831
|
const filtered = next.selectedShapeIds.filter((id) => {
|
|
@@ -891,7 +908,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
891
908
|
|
|
892
909
|
this.edgeScrollManager = new EdgeScrollManager(this)
|
|
893
910
|
this.focusManager = new FocusManager(this, autoFocus)
|
|
894
|
-
this.disposables.add(this.focusManager.dispose
|
|
911
|
+
this.disposables.add(() => this.focusManager.dispose())
|
|
895
912
|
|
|
896
913
|
if (this.getInstanceState().followingUserId) {
|
|
897
914
|
this.stopFollowingUser()
|
|
@@ -899,6 +916,14 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
899
916
|
|
|
900
917
|
this.on('tick', this._flushEventsForTick)
|
|
901
918
|
|
|
919
|
+
this.on('mount', () => {
|
|
920
|
+
this._isMounted.set(true)
|
|
921
|
+
})
|
|
922
|
+
|
|
923
|
+
this.on('unmount', () => {
|
|
924
|
+
this._isMounted.set(false)
|
|
925
|
+
})
|
|
926
|
+
|
|
902
927
|
this.timers.requestAnimationFrame(() => {
|
|
903
928
|
this._tickManager.start()
|
|
904
929
|
})
|
|
@@ -1011,6 +1036,23 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
1011
1036
|
*/
|
|
1012
1037
|
isDisposed = false
|
|
1013
1038
|
|
|
1039
|
+
private readonly _isMounted = atom('isMounted', false)
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Whether the editor is currently mounted. This is `true` while the editor's component is
|
|
1043
|
+
* mounted in the DOM (after the `mount` event) and `false` before mount and after `unmount`.
|
|
1044
|
+
*
|
|
1045
|
+
* Unlike disposal, mounting is not terminal: the editor's component can unmount and remount
|
|
1046
|
+
* (for example when the canvas is replaced by an error fallback and restored) without the
|
|
1047
|
+
* editor itself being disposed. To react to the transitions, listen to the editor's `mount`
|
|
1048
|
+
* and `unmount` events.
|
|
1049
|
+
*
|
|
1050
|
+
* @public
|
|
1051
|
+
*/
|
|
1052
|
+
@computed getIsMounted(): boolean {
|
|
1053
|
+
return this._isMounted.get()
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1014
1056
|
/**
|
|
1015
1057
|
* A manager for the editor's tick events.
|
|
1016
1058
|
*
|
|
@@ -1161,6 +1203,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
1161
1203
|
* @public
|
|
1162
1204
|
*/
|
|
1163
1205
|
dispose() {
|
|
1206
|
+
// If the editor is disposed while still mounted (for example when its component tree is
|
|
1207
|
+
// unmounted all at once), emit `unmount` first — while listeners are still attached — so
|
|
1208
|
+
// that `mount` is always balanced by an `unmount` and `getIsMounted()` reads `false`.
|
|
1209
|
+
if (this._isMounted.get()) {
|
|
1210
|
+
this.emit('unmount')
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1164
1213
|
// Stop any in-progress camera animations and following before
|
|
1165
1214
|
// running disposables, so their cleanup listeners fire first
|
|
1166
1215
|
this.stopCameraAnimation()
|
|
@@ -5785,6 +5834,22 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5785
5834
|
return commonBounds
|
|
5786
5835
|
}
|
|
5787
5836
|
|
|
5837
|
+
/**
|
|
5838
|
+
* Get the hit-test margin in page space—the distance in page units within which a pointer is
|
|
5839
|
+
* considered to be touching a shape. This resolves to {@link TldrawOptions.hitTestMargin} (or
|
|
5840
|
+
* {@link TldrawOptions.coarseHitTestMargin} when using a coarse pointer) divided by the current
|
|
5841
|
+
* zoom level, so it stays a constant distance in screen space.
|
|
5842
|
+
*
|
|
5843
|
+
* @returns The hit-test margin in page space.
|
|
5844
|
+
*
|
|
5845
|
+
* @public
|
|
5846
|
+
*/
|
|
5847
|
+
@computed getHitTestMargin(): number {
|
|
5848
|
+
const { hitTestMargin, coarseHitTestMargin } = this.options
|
|
5849
|
+
const margin = this.getInstanceState().isCoarsePointer ? coarseHitTestMargin : hitTestMargin
|
|
5850
|
+
return margin / this.getZoomLevel()
|
|
5851
|
+
}
|
|
5852
|
+
|
|
5788
5853
|
/**
|
|
5789
5854
|
* Get the top-most selected shape at the given point, ignoring groups.
|
|
5790
5855
|
*
|
|
@@ -5794,10 +5859,25 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5794
5859
|
*/
|
|
5795
5860
|
getSelectedShapeAtPoint(point: VecLike): TLShape | undefined {
|
|
5796
5861
|
const selectedShapeIds = this.getSelectedShapeIds()
|
|
5797
|
-
|
|
5798
|
-
|
|
5799
|
-
|
|
5800
|
-
|
|
5862
|
+
const margin = this.options.hitTestMargin / this.getZoomLevel()
|
|
5863
|
+
const sortedShapes = this.getCurrentPageShapesSorted()
|
|
5864
|
+
|
|
5865
|
+
for (let i = sortedShapes.length - 1; i >= 0; i--) {
|
|
5866
|
+
const shape = sortedShapes[i]
|
|
5867
|
+
if (shape.type === 'group') continue
|
|
5868
|
+
if (!selectedShapeIds.includes(shape.id)) continue
|
|
5869
|
+
if (
|
|
5870
|
+
this.getShapeGeometry(shape).hitTestPoint(
|
|
5871
|
+
this.getPointInShapeSpace(shape, point),
|
|
5872
|
+
margin,
|
|
5873
|
+
true
|
|
5874
|
+
)
|
|
5875
|
+
) {
|
|
5876
|
+
return shape
|
|
5877
|
+
}
|
|
5878
|
+
}
|
|
5879
|
+
|
|
5880
|
+
return undefined
|
|
5801
5881
|
}
|
|
5802
5882
|
|
|
5803
5883
|
/**
|
|
@@ -5809,7 +5889,6 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5809
5889
|
* @returns The shape at the given point, or undefined if there is no shape at the point.
|
|
5810
5890
|
*/
|
|
5811
5891
|
getShapeAtPoint(point: VecLike, opts: TLGetShapeAtPointOptions = {}): TLShape | undefined {
|
|
5812
|
-
const zoomLevel = this.getZoomLevel()
|
|
5813
5892
|
const viewportPageBounds = this.getViewportPageBounds()
|
|
5814
5893
|
const {
|
|
5815
5894
|
filter,
|
|
@@ -5829,7 +5908,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5829
5908
|
let inMarginClosestToEdgeHit: TLShape | null = null
|
|
5830
5909
|
|
|
5831
5910
|
// Use larger margin for spatial search to account for edge distance checks
|
|
5832
|
-
const searchMargin = Math.max(innerMargin, outerMargin, this.
|
|
5911
|
+
const searchMargin = Math.max(innerMargin, outerMargin, this.getHitTestMargin())
|
|
5833
5912
|
const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
|
|
5834
5913
|
|
|
5835
5914
|
const shapesToCheck = (
|
|
@@ -5998,7 +6077,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5998
6077
|
// For open shapes (e.g. lines or draw shapes) always use the margin.
|
|
5999
6078
|
// If the distance is less than the margin, return the shape as the hit.
|
|
6000
6079
|
// Use the editor's configurable hit test margin.
|
|
6001
|
-
if (distance < this.
|
|
6080
|
+
if (distance < this.getHitTestMargin()) {
|
|
6002
6081
|
return shape
|
|
6003
6082
|
}
|
|
6004
6083
|
}
|
|
@@ -10983,7 +11062,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10983
11062
|
this._ctrlKeyTimeout = this.timers.setTimeout(this._setCtrlKeyTimeout, 150)
|
|
10984
11063
|
}
|
|
10985
11064
|
|
|
10986
|
-
if (info.metaKey) {
|
|
11065
|
+
if (info.metaKey && info.name !== 'key_up') {
|
|
11066
|
+
// Unlike the other modifiers, the native metaKey property is still true on keyup.
|
|
11067
|
+
// If we don't have this guard, then the metakey will be left true without the timeout.
|
|
10987
11068
|
clearTimeout(this._metaKeyTimeout)
|
|
10988
11069
|
this._metaKeyTimeout = -1
|
|
10989
11070
|
inputs.setMetaKey(true)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { vi } from 'vitest'
|
|
2
|
+
import type { Editor } from '../Editor'
|
|
3
|
+
import { EditorManager } from './EditorManager'
|
|
4
|
+
|
|
5
|
+
class TestManager extends EditorManager {
|
|
6
|
+
onTestEvent = vi.fn()
|
|
7
|
+
|
|
8
|
+
constructor(editor: Editor) {
|
|
9
|
+
super(editor)
|
|
10
|
+
this.addEditorEvent('frame', this.onTestEvent)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
cancelRaf = vi.fn()
|
|
14
|
+
startRaf() {
|
|
15
|
+
this.register(this.cancelRaf)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('EditorManager', () => {
|
|
20
|
+
let on: ReturnType<typeof vi.fn>
|
|
21
|
+
let off: ReturnType<typeof vi.fn>
|
|
22
|
+
let editor: Editor
|
|
23
|
+
let manager: TestManager
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
on = vi.fn()
|
|
27
|
+
off = vi.fn()
|
|
28
|
+
editor = { on, off } as unknown as Editor
|
|
29
|
+
manager = new TestManager(editor)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('addEditorEvent registers a listener on the editor', () => {
|
|
33
|
+
expect(on).toHaveBeenCalledWith('frame', manager.onTestEvent)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('dispose removes registered editor listeners', () => {
|
|
37
|
+
manager.dispose()
|
|
38
|
+
expect(off).toHaveBeenCalledWith('frame', manager.onTestEvent)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('register runs custom disposables on dispose', () => {
|
|
42
|
+
manager.startRaf()
|
|
43
|
+
manager.dispose()
|
|
44
|
+
expect(manager.cancelRaf).toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('dispose is safe to call twice', () => {
|
|
48
|
+
manager.dispose()
|
|
49
|
+
expect(() => manager.dispose()).not.toThrow()
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Editor } from '../Editor'
|
|
2
|
+
import type { TLEventMap } from '../types/emit-types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Base class for editor-attached managers that need to clean up after themselves.
|
|
6
|
+
*
|
|
7
|
+
* Extending `EditorManager` gives a manager one consistent teardown contract: register
|
|
8
|
+
* anything that needs undoing, and it runs automatically on `dispose()`. This keeps
|
|
9
|
+
* subscription lifecycle symmetric — whatever sets a thing up is what tears it down — so
|
|
10
|
+
* it can't be forgotten (the failure mode behind the strict-mode camera bug, #8892).
|
|
11
|
+
*
|
|
12
|
+
* Register cleanup by what you're creating:
|
|
13
|
+
*
|
|
14
|
+
* - Editor bus event (`tick`, `frame`, `change`, …): {@link EditorManager.addEditorEvent}
|
|
15
|
+
* - Store side effect (`editor.sideEffects.register…`): `this.register(disposer)`
|
|
16
|
+
* - Reaction (`react(...)` from `@tldraw/state`): `this.register(react(...))`
|
|
17
|
+
* - DOM listener: `this.register(() => el.removeEventListener(...))`
|
|
18
|
+
* - Other resource (cache, index, child manager): `this.register(() => x.dispose())`
|
|
19
|
+
*
|
|
20
|
+
* For timeouts, intervals, and animation frames prefer `editor.timers` (context-grouped
|
|
21
|
+
* and auto-disposed) over raw `setTimeout`. For cleanup on the editor itself rather than a
|
|
22
|
+
* manager, use `editor.disposables`.
|
|
23
|
+
*
|
|
24
|
+
* If a manager needs ordered teardown (e.g. pause a loop before cancelling it), override
|
|
25
|
+
* `dispose()` and call `super.dispose()` last — see `TickManager`.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
export abstract class EditorManager {
|
|
30
|
+
protected readonly disposables = new Set<() => void>()
|
|
31
|
+
|
|
32
|
+
constructor(protected readonly editor: Editor) {}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Register a teardown function to run on `dispose()`. The universal way for a manager to
|
|
36
|
+
* make a resource "see itself out". Returns the same function for convenience.
|
|
37
|
+
*/
|
|
38
|
+
protected register(dispose: () => void): () => void {
|
|
39
|
+
this.disposables.add(dispose)
|
|
40
|
+
return dispose
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Subscribe to an editor bus event and register the matching unsubscribe, so the listener
|
|
45
|
+
* is removed automatically on `dispose()`.
|
|
46
|
+
*/
|
|
47
|
+
protected addEditorEvent<E extends keyof TLEventMap>(
|
|
48
|
+
event: E,
|
|
49
|
+
fn: (...args: TLEventMap[E]) => void
|
|
50
|
+
): void {
|
|
51
|
+
this.editor.on(event, fn as any)
|
|
52
|
+
this.register(() => this.editor.off(event, fn as any))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** @internal */
|
|
56
|
+
dispose(): void {
|
|
57
|
+
this.disposables.forEach((d) => d())
|
|
58
|
+
this.disposables.clear()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -73,7 +73,8 @@ describe('FocusManager', () => {
|
|
|
73
73
|
describe('constructor', () => {
|
|
74
74
|
it('should initialize with editor reference', () => {
|
|
75
75
|
focusManager = new FocusManager(editor)
|
|
76
|
-
expect(focusManager
|
|
76
|
+
expect(focusManager).toBeInstanceOf(FocusManager)
|
|
77
|
+
expect(editor.sideEffects.registerAfterChangeHandler).toHaveBeenCalled()
|
|
77
78
|
})
|
|
78
79
|
|
|
79
80
|
it('should register side effect listener for instance state changes', () => {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bind } from '@tldraw/utils'
|
|
2
2
|
import type { Editor } from '../../Editor'
|
|
3
|
+
import { EditorManager } from '../EditorManager'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* A manager for ensuring correct focus across the editor.
|
|
@@ -10,14 +11,11 @@ import type { Editor } from '../../Editor'
|
|
|
10
11
|
*
|
|
11
12
|
* @internal
|
|
12
13
|
*/
|
|
13
|
-
export class FocusManager {
|
|
14
|
-
|
|
14
|
+
export class FocusManager extends EditorManager {
|
|
15
|
+
constructor(editor: Editor, autoFocus?: boolean) {
|
|
16
|
+
super(editor)
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
public editor: Editor,
|
|
18
|
-
autoFocus?: boolean
|
|
19
|
-
) {
|
|
20
|
-
this.disposeSideEffectListener = editor.sideEffects.registerAfterChangeHandler(
|
|
18
|
+
const disposeSideEffectListener = editor.sideEffects.registerAfterChangeHandler(
|
|
21
19
|
'instance',
|
|
22
20
|
(prev, next) => {
|
|
23
21
|
if (prev.isFocused !== next.isFocused) {
|
|
@@ -25,6 +23,7 @@ export class FocusManager {
|
|
|
25
23
|
}
|
|
26
24
|
}
|
|
27
25
|
)
|
|
26
|
+
this.register(() => disposeSideEffectListener?.())
|
|
28
27
|
|
|
29
28
|
const currentFocusState = editor.getInstanceState().isFocused
|
|
30
29
|
if (autoFocus !== currentFocusState) {
|
|
@@ -35,6 +34,10 @@ export class FocusManager {
|
|
|
35
34
|
const body = editor.getContainerDocument().body
|
|
36
35
|
body.addEventListener('keydown', this.handleKeyDown)
|
|
37
36
|
body.addEventListener('mousedown', this.handleMouseDown)
|
|
37
|
+
this.register(() => {
|
|
38
|
+
body.removeEventListener('keydown', this.handleKeyDown)
|
|
39
|
+
body.removeEventListener('mousedown', this.handleMouseDown)
|
|
40
|
+
})
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
/**
|
|
@@ -83,11 +86,4 @@ export class FocusManager {
|
|
|
83
86
|
this.editor.complete() // stop any interaction
|
|
84
87
|
if (blurContainer) this.editor.getContainer().blur() // blur the container
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
dispose() {
|
|
88
|
-
const body = this.editor.getContainerDocument().body
|
|
89
|
-
body.removeEventListener('keydown', this.handleKeyDown)
|
|
90
|
-
body.removeEventListener('mousedown', this.handleMouseDown)
|
|
91
|
-
this.disposeSideEffectListener?.()
|
|
92
|
-
}
|
|
93
89
|
}
|
|
@@ -142,6 +142,72 @@ describe('FontManager', () => {
|
|
|
142
142
|
|
|
143
143
|
await secondPromise
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
it('ignores font load failures after disposal', async () => {
|
|
147
|
+
const font = createMockFont()
|
|
148
|
+
const error = new Error('Font load failed')
|
|
149
|
+
let rejectLoad: (err: Error) => void = () => {}
|
|
150
|
+
;(global.FontFace as Mock).mockImplementationOnce(function (family, src, descriptors) {
|
|
151
|
+
return {
|
|
152
|
+
family,
|
|
153
|
+
src,
|
|
154
|
+
...descriptors,
|
|
155
|
+
load: vi.fn(
|
|
156
|
+
() =>
|
|
157
|
+
new Promise<void>((_, reject) => {
|
|
158
|
+
rejectLoad = reject
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
}
|
|
162
|
+
})
|
|
163
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
164
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
165
|
+
|
|
166
|
+
const promise = fontManager.ensureFontIsLoaded(font)
|
|
167
|
+
fontManager.dispose()
|
|
168
|
+
rejectLoad(error)
|
|
169
|
+
|
|
170
|
+
await expect(promise).resolves.toBeUndefined()
|
|
171
|
+
expect(errorSpy).not.toHaveBeenCalled()
|
|
172
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
173
|
+
`Font "${font.family}" load interrupted by editor dispose`,
|
|
174
|
+
error
|
|
175
|
+
)
|
|
176
|
+
errorSpy.mockRestore()
|
|
177
|
+
debugSpy.mockRestore()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('ignores font loads that finish after disposal', async () => {
|
|
181
|
+
const font = createMockFont()
|
|
182
|
+
let resolveLoad: () => void = () => {}
|
|
183
|
+
;(global.FontFace as Mock).mockImplementationOnce(function (family, src, descriptors) {
|
|
184
|
+
return {
|
|
185
|
+
family,
|
|
186
|
+
src,
|
|
187
|
+
...descriptors,
|
|
188
|
+
load: vi.fn(
|
|
189
|
+
() =>
|
|
190
|
+
new Promise<void>((resolve) => {
|
|
191
|
+
resolveLoad = resolve
|
|
192
|
+
})
|
|
193
|
+
),
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
|
|
197
|
+
|
|
198
|
+
const promise = fontManager.ensureFontIsLoaded(font)
|
|
199
|
+
fontManager.dispose()
|
|
200
|
+
resolveLoad()
|
|
201
|
+
|
|
202
|
+
await expect(promise).resolves.toBeUndefined()
|
|
203
|
+
// only the creation-time add from findOrCreateFontFace, not a second
|
|
204
|
+
// post-load add after dispose
|
|
205
|
+
expect(document.fonts.add).toHaveBeenCalledTimes(1)
|
|
206
|
+
expect(debugSpy).toHaveBeenCalledWith(
|
|
207
|
+
`Font "${font.family}" load interrupted by editor dispose`
|
|
208
|
+
)
|
|
209
|
+
debugSpy.mockRestore()
|
|
210
|
+
})
|
|
145
211
|
})
|
|
146
212
|
|
|
147
213
|
describe('getShapeFontFaces', () => {
|
|
@@ -106,16 +106,30 @@ export class FontManager {
|
|
|
106
106
|
if (existingState) return existingState.loadingPromise
|
|
107
107
|
|
|
108
108
|
const instance = this.findOrCreateFontFace(font)
|
|
109
|
+
// dispose() clears fontStates while loads may still be in flight, so a late
|
|
110
|
+
// callback must only touch the exact entry it was created for - not a fresh
|
|
111
|
+
// entry from a later request. Check identity, not just presence.
|
|
112
|
+
const isStale = () => this.fontStates.__unsafe__getWithoutCapture(font) !== state
|
|
109
113
|
const state: FontState = {
|
|
110
114
|
state: 'loading',
|
|
111
115
|
instance,
|
|
112
116
|
loadingPromise: instance
|
|
113
117
|
.load()
|
|
114
118
|
.then(() => {
|
|
119
|
+
if (isStale()) {
|
|
120
|
+
// eslint-disable-next-line no-console
|
|
121
|
+
console.debug(`Font "${font.family}" load interrupted by editor dispose`)
|
|
122
|
+
return
|
|
123
|
+
}
|
|
115
124
|
this.editor.getContainerDocument().fonts.add(instance)
|
|
116
125
|
this.fontStates.update(font, (s) => ({ ...s, state: 'ready' }))
|
|
117
126
|
})
|
|
118
127
|
.catch((err) => {
|
|
128
|
+
if (isStale()) {
|
|
129
|
+
// eslint-disable-next-line no-console
|
|
130
|
+
console.debug(`Font "${font.family}" load interrupted by editor dispose`, err)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
119
133
|
console.error(err)
|
|
120
134
|
this.fontStates.update(font, (s) => ({ ...s, state: 'error' }))
|
|
121
135
|
}),
|
|
@@ -7,19 +7,16 @@ import { Vec } from '../../../primitives/Vec'
|
|
|
7
7
|
import { isAccelKey } from '../../../utils/keyboard'
|
|
8
8
|
import type { Editor } from '../../Editor'
|
|
9
9
|
import { TLPinchEventInfo, TLPointerEventInfo, TLWheelEventInfo } from '../../types/event-types'
|
|
10
|
+
import { EditorManager } from '../EditorManager'
|
|
10
11
|
|
|
11
12
|
const POINTER_VELOCITY_REFERENCE_INTERVAL_MS = 16
|
|
12
13
|
const POINTER_VELOCITY_REFERENCE_SMOOTHING = 0.5
|
|
13
14
|
|
|
14
15
|
/** @public */
|
|
15
|
-
export class InputsManager {
|
|
16
|
-
constructor(
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** @internal */
|
|
21
|
-
dispose() {
|
|
22
|
-
this.editor.off('frame', this._onFrame)
|
|
16
|
+
export class InputsManager extends EditorManager {
|
|
17
|
+
constructor(editor: Editor) {
|
|
18
|
+
super(editor)
|
|
19
|
+
this.addEditorEvent('frame', this._onFrame)
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
@bind
|
|
@@ -472,7 +469,7 @@ export class InputsManager {
|
|
|
472
469
|
private _velocityPrevPoint = new Vec()
|
|
473
470
|
|
|
474
471
|
/**
|
|
475
|
-
* Update the pointer velocity based on elapsed time. Called
|
|
472
|
+
* Update the pointer velocity based on elapsed time. Called by the tick manager.
|
|
476
473
|
* @param elapsed - The time elapsed since the last tick in milliseconds.
|
|
477
474
|
* @internal
|
|
478
475
|
*/
|
|
@@ -46,28 +46,6 @@ export class RBushIndex {
|
|
|
46
46
|
return new Set(results.map((e: SpatialElement) => e.id))
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
* Insert or update a shape in the spatial index.
|
|
51
|
-
* If the shape already exists, it will be removed first to prevent duplicates.
|
|
52
|
-
*/
|
|
53
|
-
upsert(id: TLShapeId, bounds: Box): void {
|
|
54
|
-
// Remove existing entry to prevent map-tree desync
|
|
55
|
-
const existing = this.elementsInTree.get(id)
|
|
56
|
-
if (existing) {
|
|
57
|
-
this.rBush.remove(existing)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const element: SpatialElement = {
|
|
61
|
-
minX: bounds.minX,
|
|
62
|
-
minY: bounds.minY,
|
|
63
|
-
maxX: bounds.maxX,
|
|
64
|
-
maxY: bounds.maxY,
|
|
65
|
-
id,
|
|
66
|
-
}
|
|
67
|
-
this.rBush.insert(element)
|
|
68
|
-
this.elementsInTree.set(id, element)
|
|
69
|
-
}
|
|
70
|
-
|
|
71
49
|
/**
|
|
72
50
|
* Remove a shape from the spatial index.
|
|
73
51
|
*/
|
|
@@ -79,6 +57,25 @@ export class RBushIndex {
|
|
|
79
57
|
}
|
|
80
58
|
}
|
|
81
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Apply the removals and upserts collected during one incremental update.
|
|
62
|
+
* Mutations are applied per item: bulk-rebuilding the whole tree instead
|
|
63
|
+
* was measured slower even with ~10% of the index in the batch.
|
|
64
|
+
*/
|
|
65
|
+
applyBatch(removes: Set<TLShapeId>, upserts: SpatialElement[]): void {
|
|
66
|
+
for (const id of removes) {
|
|
67
|
+
this.remove(id)
|
|
68
|
+
}
|
|
69
|
+
for (const element of upserts) {
|
|
70
|
+
const existing = this.elementsInTree.get(element.id)
|
|
71
|
+
if (existing) {
|
|
72
|
+
this.rBush.remove(existing)
|
|
73
|
+
}
|
|
74
|
+
this.rBush.insert(element)
|
|
75
|
+
this.elementsInTree.set(element.id, element)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
82
79
|
/**
|
|
83
80
|
* Bulk load elements into the spatial index.
|
|
84
81
|
* More efficient than individual inserts for initial loading.
|
|
@@ -122,17 +119,6 @@ export class RBushIndex {
|
|
|
122
119
|
return this.elementsInTree.get(id)
|
|
123
120
|
}
|
|
124
121
|
|
|
125
|
-
/**
|
|
126
|
-
* Iterate the entries currently in the index. Callers may upsert existing
|
|
127
|
-
* keys or remove keys during iteration; current callers do not insert new
|
|
128
|
-
* keys.
|
|
129
|
-
*
|
|
130
|
-
* @internal
|
|
131
|
-
*/
|
|
132
|
-
entries(): IterableIterator<[TLShapeId, SpatialElement]> {
|
|
133
|
-
return this.elementsInTree.entries()
|
|
134
|
-
}
|
|
135
|
-
|
|
136
122
|
/**
|
|
137
123
|
* Dispose of the spatial index.
|
|
138
124
|
* Clears all data structures to prevent memory leaks.
|