@tldraw/editor 5.3.1 → 5.4.0-canary.042da660fa66
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 +97 -52
- package/dist-cjs/index.js +4 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +3 -0
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +94 -26
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +6 -3
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
- package/dist-cjs/lib/globals/editors.js +57 -0
- package/dist-cjs/lib/globals/editors.js.map +7 -0
- package/dist-cjs/lib/hooks/useViewportHeight.js.map +2 -2
- package/dist-cjs/lib/license/LicenseManager.js +9 -2
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/LicenseProvider.js +16 -3
- package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
- package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
- package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
- package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
- package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
- package/dist-cjs/lib/utils/normalizeWheel.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 +97 -52
- package/dist-esm/index.mjs +9 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +4 -1
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +94 -26
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -4
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
- package/dist-esm/lib/globals/editors.mjs +37 -0
- package/dist-esm/lib/globals/editors.mjs.map +7 -0
- package/dist-esm/lib/hooks/useViewportHeight.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +9 -2
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
- package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
- package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
- package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
- package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
- package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
- package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/package.json +13 -7
- package/src/index.ts +7 -1
- package/src/lib/TldrawEditor.tsx +4 -1
- package/src/lib/editor/Editor.ts +136 -37
- package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +42 -0
- package/src/lib/editor/managers/TextManager/TextManager.ts +10 -8
- package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
- package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
- package/src/lib/globals/editors.test.ts +76 -0
- package/src/lib/globals/editors.ts +53 -0
- package/src/lib/hooks/useViewportHeight.ts +9 -1
- package/src/lib/license/LicenseManager.test.ts +160 -0
- package/src/lib/license/LicenseManager.ts +13 -4
- package/src/lib/license/LicenseProvider.tsx +24 -3
- package/src/lib/license/useLicenseManagerState.ts +8 -6
- package/src/lib/primitives/geometry/Group2d.ts +63 -27
- package/src/lib/utils/normalizeWheel.test.ts +39 -0
- package/src/lib/utils/normalizeWheel.ts +6 -0
- package/src/version.ts +3 -3
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -118,8 +118,10 @@ import {
|
|
|
118
118
|
import { getOwnerWindow } from '../exports/domUtils'
|
|
119
119
|
import { exportToSvg } from '../exports/exportToSvg'
|
|
120
120
|
import { getSvgAsImageWithOptions, trimSvgToContent } from '../exports/getSvgAsImage'
|
|
121
|
+
import { registerMountedEditor, unregisterMountedEditor } from '../globals/editors'
|
|
121
122
|
import { tlmenus } from '../globals/menus'
|
|
122
123
|
import { tltime } from '../globals/time'
|
|
124
|
+
import { LicenseManager } from '../license/LicenseManager'
|
|
123
125
|
import { TldrawOptions, defaultTldrawOptions } from '../options'
|
|
124
126
|
import { Box, BoxLike } from '../primitives/Box'
|
|
125
127
|
import { EASINGS } from '../primitives/easings'
|
|
@@ -918,10 +920,12 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
918
920
|
|
|
919
921
|
this.on('mount', () => {
|
|
920
922
|
this._isMounted.set(true)
|
|
923
|
+
registerMountedEditor(this)
|
|
921
924
|
})
|
|
922
925
|
|
|
923
926
|
this.on('unmount', () => {
|
|
924
927
|
this._isMounted.set(false)
|
|
928
|
+
unregisterMountedEditor(this)
|
|
925
929
|
})
|
|
926
930
|
|
|
927
931
|
this.timers.requestAnimationFrame(() => {
|
|
@@ -973,6 +977,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
973
977
|
|
|
974
978
|
readonly options: TldrawOptions
|
|
975
979
|
|
|
980
|
+
/**
|
|
981
|
+
* The license manager whose feature flags apply to this editor. Assigned by `<TldrawEditor />`
|
|
982
|
+
* when it creates the editor, so that UI rendered outside the editor tree can resolve the
|
|
983
|
+
* editor's license through the editor instance. Undefined for editors created directly.
|
|
984
|
+
*
|
|
985
|
+
* @internal
|
|
986
|
+
*/
|
|
987
|
+
licenseManager?: LicenseManager
|
|
988
|
+
|
|
976
989
|
readonly contextId = uniqueId()
|
|
977
990
|
|
|
978
991
|
/**
|
|
@@ -1209,6 +1222,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
1209
1222
|
if (this._isMounted.get()) {
|
|
1210
1223
|
this.emit('unmount')
|
|
1211
1224
|
}
|
|
1225
|
+
// The unmount listener above normally handles this; unregister again in case a listener
|
|
1226
|
+
// threw or was removed, so a disposed editor never lingers in `tleditors`.
|
|
1227
|
+
unregisterMountedEditor(this)
|
|
1212
1228
|
|
|
1213
1229
|
// Stop any in-progress camera animations and following before
|
|
1214
1230
|
// running disposables, so their cleanup listeners fire first
|
|
@@ -5862,6 +5878,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5862
5878
|
const margin = this.options.hitTestMargin / this.getZoomLevel()
|
|
5863
5879
|
const sortedShapes = this.getCurrentPageShapesSorted()
|
|
5864
5880
|
|
|
5881
|
+
// iterate from the top (highest z-index) to find the top-most matching shape
|
|
5865
5882
|
for (let i = sortedShapes.length - 1; i >= 0; i--) {
|
|
5866
5883
|
const shape = sortedShapes[i]
|
|
5867
5884
|
if (shape.type === 'group') continue
|
|
@@ -5911,28 +5928,25 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5911
5928
|
const searchMargin = Math.max(innerMargin, outerMargin, this.getHitTestMargin())
|
|
5912
5929
|
const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
|
|
5913
5930
|
|
|
5914
|
-
const shapesToCheck =
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
: this.getCurrentPageShapesSorted()
|
|
5918
|
-
).filter((shape) => {
|
|
5919
|
-
// Frame-like shapes have labels positioned above the shape (outside bounds), so always include them
|
|
5920
|
-
if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) return false
|
|
5931
|
+
const shapesToCheck = opts.renderingOnly
|
|
5932
|
+
? this.getCurrentPageRenderingShapesSorted()
|
|
5933
|
+
: this.getCurrentPageShapesSorted()
|
|
5921
5934
|
|
|
5935
|
+
for (let i = shapesToCheck.length - 1; i >= 0; i--) {
|
|
5936
|
+
const shape = shapesToCheck[i]
|
|
5937
|
+
// Frame-like shapes have labels positioned above the shape (outside bounds), so always include them
|
|
5938
|
+
if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) continue
|
|
5922
5939
|
if (
|
|
5923
5940
|
(shape.isLocked && !hitLocked) ||
|
|
5924
5941
|
this.isShapeHidden(shape) ||
|
|
5925
5942
|
this.isShapeOfType(shape, 'group')
|
|
5926
|
-
)
|
|
5927
|
-
|
|
5943
|
+
) {
|
|
5944
|
+
continue
|
|
5945
|
+
}
|
|
5928
5946
|
const pageMask = this.getShapeMask(shape)
|
|
5929
|
-
if (pageMask && !pointInPolygon(point, pageMask))
|
|
5930
|
-
if (filter && !filter(shape))
|
|
5931
|
-
return true
|
|
5932
|
-
})
|
|
5947
|
+
if (pageMask && !pointInPolygon(point, pageMask)) continue
|
|
5948
|
+
if (filter && !filter(shape)) continue
|
|
5933
5949
|
|
|
5934
|
-
for (let i = shapesToCheck.length - 1; i >= 0; i--) {
|
|
5935
|
-
const shape = shapesToCheck[i]
|
|
5936
5950
|
const geometry = this.getShapeGeometry(shape)
|
|
5937
5951
|
const isGroup = geometry instanceof Group2d
|
|
5938
5952
|
|
|
@@ -6114,15 +6128,18 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
6114
6128
|
const margin = opts.margin ?? 0
|
|
6115
6129
|
const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, margin)
|
|
6116
6130
|
|
|
6117
|
-
// Get all page shapes in z-index order and filter to candidates that pass isPointInShape
|
|
6118
|
-
// Frame-like shapes are always checked because their labels can be outside their bounds
|
|
6119
|
-
|
|
6120
|
-
|
|
6121
|
-
|
|
6122
|
-
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
.
|
|
6131
|
+
// Get all page shapes in z-index order and filter to candidates that pass isPointInShape.
|
|
6132
|
+
// Frame-like shapes are always checked because their labels can be outside their bounds.
|
|
6133
|
+
// Iterate backwards so the result is pre-sorted in reverse z-index order (top-most first).
|
|
6134
|
+
const sorted = this.getCurrentPageShapesSorted()
|
|
6135
|
+
const result: TLShape[] = []
|
|
6136
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
6137
|
+
const shape = sorted[i]
|
|
6138
|
+
if (this.isShapeHidden(shape)) continue
|
|
6139
|
+
if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) continue
|
|
6140
|
+
if (this.isPointInShape(shape, point, opts)) result.push(shape)
|
|
6141
|
+
}
|
|
6142
|
+
return result
|
|
6126
6143
|
}
|
|
6127
6144
|
|
|
6128
6145
|
/**
|
|
@@ -7877,9 +7894,19 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
7877
7894
|
*/
|
|
7878
7895
|
alignShapes(
|
|
7879
7896
|
shapes: TLShapeId[] | TLShape[],
|
|
7880
|
-
operation:
|
|
7897
|
+
operation:
|
|
7898
|
+
| 'left'
|
|
7899
|
+
| 'center-horizontal'
|
|
7900
|
+
| 'right'
|
|
7901
|
+
| 'top'
|
|
7902
|
+
| 'center-vertical'
|
|
7903
|
+
| 'bottom'
|
|
7904
|
+
| 'center'
|
|
7881
7905
|
): this {
|
|
7882
7906
|
if (this.getIsReadonly()) return this
|
|
7907
|
+
if (operation === 'center') {
|
|
7908
|
+
return this.alignShapes(shapes, 'center-horizontal').alignShapes(shapes, 'center-vertical')
|
|
7909
|
+
}
|
|
7883
7910
|
|
|
7884
7911
|
const { clusters: shapeClustersToAlign, allBounds } = this.getShapeClusters(shapes, 'align')
|
|
7885
7912
|
|
|
@@ -10840,9 +10867,15 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10840
10867
|
/** @internal */
|
|
10841
10868
|
private _shiftKeyTimeout = -1 as any
|
|
10842
10869
|
|
|
10843
|
-
/**
|
|
10870
|
+
/**
|
|
10871
|
+
* Release the shift modifier: clear its debounce timer id, drop the atom, and
|
|
10872
|
+
* dispatch a synthetic `key_up` so `inputs.keys` and tool `onKeyUp` handlers update.
|
|
10873
|
+
* Runs both as the 150ms debounce timer callback and when a pointer down flushes it.
|
|
10874
|
+
* @internal
|
|
10875
|
+
*/
|
|
10844
10876
|
@bind
|
|
10845
|
-
|
|
10877
|
+
_releaseShiftKey() {
|
|
10878
|
+
this._shiftKeyTimeout = -1
|
|
10846
10879
|
this.inputs.setShiftKey(false)
|
|
10847
10880
|
this.dispatch({
|
|
10848
10881
|
type: 'keyboard',
|
|
@@ -10860,9 +10893,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10860
10893
|
/** @internal */
|
|
10861
10894
|
private _altKeyTimeout = -1 as any
|
|
10862
10895
|
|
|
10863
|
-
/**
|
|
10896
|
+
/**
|
|
10897
|
+
* Release the alt modifier. See {@link Editor._releaseShiftKey}.
|
|
10898
|
+
* @internal
|
|
10899
|
+
*/
|
|
10864
10900
|
@bind
|
|
10865
|
-
|
|
10901
|
+
_releaseAltKey() {
|
|
10902
|
+
this._altKeyTimeout = -1
|
|
10866
10903
|
this.inputs.setAltKey(false)
|
|
10867
10904
|
this.dispatch({
|
|
10868
10905
|
type: 'keyboard',
|
|
@@ -10880,9 +10917,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10880
10917
|
/** @internal */
|
|
10881
10918
|
private _ctrlKeyTimeout = -1 as any
|
|
10882
10919
|
|
|
10883
|
-
/**
|
|
10920
|
+
/**
|
|
10921
|
+
* Release the ctrl modifier. See {@link Editor._releaseShiftKey}.
|
|
10922
|
+
* @internal
|
|
10923
|
+
*/
|
|
10884
10924
|
@bind
|
|
10885
|
-
|
|
10925
|
+
_releaseCtrlKey() {
|
|
10926
|
+
this._ctrlKeyTimeout = -1
|
|
10886
10927
|
this.inputs.setCtrlKey(false)
|
|
10887
10928
|
this.dispatch({
|
|
10888
10929
|
type: 'keyboard',
|
|
@@ -10900,9 +10941,13 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10900
10941
|
/** @internal */
|
|
10901
10942
|
private _metaKeyTimeout = -1 as any
|
|
10902
10943
|
|
|
10903
|
-
/**
|
|
10944
|
+
/**
|
|
10945
|
+
* Release the meta modifier. See {@link Editor._releaseShiftKey}.
|
|
10946
|
+
* @internal
|
|
10947
|
+
*/
|
|
10904
10948
|
@bind
|
|
10905
|
-
|
|
10949
|
+
_releaseMetaKey() {
|
|
10950
|
+
this._metaKeyTimeout = -1
|
|
10906
10951
|
this.inputs.setMetaKey(false)
|
|
10907
10952
|
this.dispatch({
|
|
10908
10953
|
type: 'keyboard',
|
|
@@ -10917,6 +10962,56 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
10917
10962
|
})
|
|
10918
10963
|
}
|
|
10919
10964
|
|
|
10965
|
+
/**
|
|
10966
|
+
* Flush any modifier still sitting in its 150ms release-debounce window, so a new
|
|
10967
|
+
* interaction (a pointer down) starts with correct modifier state instead of a
|
|
10968
|
+
* just-released key still being counted as held. A pending timer is exactly
|
|
10969
|
+
* "released but still counted as held"; a genuinely-held modifier has no timer
|
|
10970
|
+
* (-1) and is left alone.
|
|
10971
|
+
*
|
|
10972
|
+
* Each modifier is released through the same path its timer would have taken
|
|
10973
|
+
* (`_releaseShiftKey` and friends), which dispatches the synthetic `key_up` so
|
|
10974
|
+
* stale codes (e.g. `ShiftLeft`) leave `inputs.keys` and tool `onKeyUp` handlers run.
|
|
10975
|
+
* We clear every pending modifier atom and timer *first*, then dispatch: a synthetic
|
|
10976
|
+
* `key_up` reports all currently-held modifiers, so releasing them one at a time
|
|
10977
|
+
* would make each event re-confirm the not-yet-released ones and cancel their flush.
|
|
10978
|
+
* @internal
|
|
10979
|
+
*/
|
|
10980
|
+
private _releaseDebouncedModifiers() {
|
|
10981
|
+
const releaseShift = this._shiftKeyTimeout !== -1
|
|
10982
|
+
const releaseAlt = this._altKeyTimeout !== -1
|
|
10983
|
+
const releaseCtrl = this._ctrlKeyTimeout !== -1
|
|
10984
|
+
const releaseMeta = this._metaKeyTimeout !== -1
|
|
10985
|
+
|
|
10986
|
+
if (releaseShift) {
|
|
10987
|
+
clearTimeout(this._shiftKeyTimeout)
|
|
10988
|
+
this._shiftKeyTimeout = -1
|
|
10989
|
+
this.inputs.setShiftKey(false)
|
|
10990
|
+
}
|
|
10991
|
+
if (releaseAlt) {
|
|
10992
|
+
clearTimeout(this._altKeyTimeout)
|
|
10993
|
+
this._altKeyTimeout = -1
|
|
10994
|
+
this.inputs.setAltKey(false)
|
|
10995
|
+
}
|
|
10996
|
+
if (releaseCtrl) {
|
|
10997
|
+
clearTimeout(this._ctrlKeyTimeout)
|
|
10998
|
+
this._ctrlKeyTimeout = -1
|
|
10999
|
+
this.inputs.setCtrlKey(false)
|
|
11000
|
+
}
|
|
11001
|
+
if (releaseMeta) {
|
|
11002
|
+
clearTimeout(this._metaKeyTimeout)
|
|
11003
|
+
this._metaKeyTimeout = -1
|
|
11004
|
+
this.inputs.setMetaKey(false)
|
|
11005
|
+
}
|
|
11006
|
+
|
|
11007
|
+
// Dispatch the synthetic key_up for each flushed modifier (same path its 150ms
|
|
11008
|
+
// timer would run): clears stale codes from inputs.keys and fires tool onKeyUp.
|
|
11009
|
+
if (releaseShift) this._releaseShiftKey()
|
|
11010
|
+
if (releaseAlt) this._releaseAltKey()
|
|
11011
|
+
if (releaseCtrl) this._releaseCtrlKey()
|
|
11012
|
+
if (releaseMeta) this._releaseMetaKey()
|
|
11013
|
+
}
|
|
11014
|
+
|
|
10920
11015
|
/** @internal */
|
|
10921
11016
|
private _restoreToolId = 'select'
|
|
10922
11017
|
|
|
@@ -11055,7 +11150,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11055
11150
|
this._shiftKeyTimeout = -1
|
|
11056
11151
|
inputs.setShiftKey(true)
|
|
11057
11152
|
} else if (!info.shiftKey && inputs.getShiftKey() && this._shiftKeyTimeout === -1) {
|
|
11058
|
-
this._shiftKeyTimeout = this.timers.setTimeout(this.
|
|
11153
|
+
this._shiftKeyTimeout = this.timers.setTimeout(this._releaseShiftKey, 150)
|
|
11059
11154
|
}
|
|
11060
11155
|
|
|
11061
11156
|
if (info.altKey) {
|
|
@@ -11063,7 +11158,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11063
11158
|
this._altKeyTimeout = -1
|
|
11064
11159
|
inputs.setAltKey(true)
|
|
11065
11160
|
} else if (!info.altKey && inputs.getAltKey() && this._altKeyTimeout === -1) {
|
|
11066
|
-
this._altKeyTimeout = this.timers.setTimeout(this.
|
|
11161
|
+
this._altKeyTimeout = this.timers.setTimeout(this._releaseAltKey, 150)
|
|
11067
11162
|
}
|
|
11068
11163
|
|
|
11069
11164
|
if (info.ctrlKey) {
|
|
@@ -11071,7 +11166,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11071
11166
|
this._ctrlKeyTimeout = -1
|
|
11072
11167
|
inputs.setCtrlKey(true)
|
|
11073
11168
|
} else if (!info.ctrlKey && inputs.getCtrlKey() && this._ctrlKeyTimeout === -1) {
|
|
11074
|
-
this._ctrlKeyTimeout = this.timers.setTimeout(this.
|
|
11169
|
+
this._ctrlKeyTimeout = this.timers.setTimeout(this._releaseCtrlKey, 150)
|
|
11075
11170
|
}
|
|
11076
11171
|
|
|
11077
11172
|
if (info.metaKey && info.name !== 'key_up') {
|
|
@@ -11081,7 +11176,7 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11081
11176
|
this._metaKeyTimeout = -1
|
|
11082
11177
|
inputs.setMetaKey(true)
|
|
11083
11178
|
} else if (!info.metaKey && inputs.getMetaKey() && this._metaKeyTimeout === -1) {
|
|
11084
|
-
this._metaKeyTimeout = this.timers.setTimeout(this.
|
|
11179
|
+
this._metaKeyTimeout = this.timers.setTimeout(this._releaseMetaKey, 150)
|
|
11085
11180
|
}
|
|
11086
11181
|
|
|
11087
11182
|
if (!inputs.getIsPointing()) {
|
|
@@ -11289,6 +11384,10 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
11289
11384
|
// If we're in pen mode and the input is not a pen type, then stop here
|
|
11290
11385
|
if (isPenMode && !isPen) return
|
|
11291
11386
|
|
|
11387
|
+
// A pointer down starts a new interaction, so flush any modifier that's
|
|
11388
|
+
// still lingering in its release-debounce window: treat it as released now.
|
|
11389
|
+
this._releaseDebouncedModifiers()
|
|
11390
|
+
|
|
11292
11391
|
if (!this.inputs.getIsPanning()) {
|
|
11293
11392
|
// Start a long press timeout
|
|
11294
11393
|
this._longPressTimeout = this.timers.setTimeout(() => {
|
|
@@ -39,9 +39,13 @@ export function deriveShapeIdsInCurrentPage(store: TLStore, getCurrentPageId: ()
|
|
|
39
39
|
function fromScratch() {
|
|
40
40
|
const currentPageId = getCurrentPageId()
|
|
41
41
|
lastPageId = currentPageId
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const result = new Set<TLShapeId>()
|
|
43
|
+
for (const id of shapesIndex.get()) {
|
|
44
|
+
if (isShapeInPage(store, currentPageId, store.get(id)!)) {
|
|
45
|
+
result.add(id)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result
|
|
45
49
|
}
|
|
46
50
|
return computed<Set<TLShapeId>>('_shapeIdsInCurrentPage', (prevValue, lastComputedEpoch) => {
|
|
47
51
|
if (isUninitialized(prevValue)) {
|
|
@@ -420,6 +420,48 @@ describe('TextManager', () => {
|
|
|
420
420
|
expect(Array.isArray(result)).toBe(true)
|
|
421
421
|
})
|
|
422
422
|
|
|
423
|
+
it('should not throw when the truncated remeasure yields no spans', () => {
|
|
424
|
+
// The text truncates on the first pass, but once we narrow the width
|
|
425
|
+
// to make room for the ellipsis, nothing measurable remains (e.g. the
|
|
426
|
+
// measurement element can't be laid out). This must not crash.
|
|
427
|
+
vi.spyOn(textManager, 'measureElementTextNodeSpans')
|
|
428
|
+
.mockReturnValueOnce({
|
|
429
|
+
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
|
|
430
|
+
didTruncate: true,
|
|
431
|
+
})
|
|
432
|
+
.mockReturnValueOnce({
|
|
433
|
+
spans: [{ text: '…', box: { x: 0, y: 0, w: 10, h: 16 } }],
|
|
434
|
+
didTruncate: false,
|
|
435
|
+
})
|
|
436
|
+
.mockReturnValueOnce({
|
|
437
|
+
spans: [],
|
|
438
|
+
didTruncate: false,
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
|
|
442
|
+
let result: ReturnType<typeof textManager.measureTextSpans> | undefined
|
|
443
|
+
expect(() => (result = textManager.measureTextSpans('Hello World', opts))).not.toThrow()
|
|
444
|
+
expect(result).toEqual([])
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
it('should not throw when the ellipsis itself cannot be measured', () => {
|
|
448
|
+
// If measuring the ellipsis returns no spans, fall back to a zero width
|
|
449
|
+
// rather than dereferencing a missing span.
|
|
450
|
+
vi.spyOn(textManager, 'measureElementTextNodeSpans')
|
|
451
|
+
.mockReturnValueOnce({
|
|
452
|
+
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
|
|
453
|
+
didTruncate: true,
|
|
454
|
+
})
|
|
455
|
+
.mockReturnValueOnce({ spans: [], didTruncate: false })
|
|
456
|
+
.mockReturnValueOnce({
|
|
457
|
+
spans: [{ text: 'Hello Wo', box: { x: 0, y: 0, w: 80, h: 16 } }],
|
|
458
|
+
didTruncate: false,
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const opts = { ...defaultOpts, overflow: 'truncate-ellipsis' as const }
|
|
462
|
+
expect(() => textManager.measureTextSpans('Hello World', opts)).not.toThrow()
|
|
463
|
+
})
|
|
464
|
+
|
|
423
465
|
it('should handle truncate-clip overflow', () => {
|
|
424
466
|
vi.spyOn(textManager, 'measureElementTextNodeSpans').mockReturnValue({
|
|
425
467
|
spans: [
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
|
|
2
|
-
import { objectMapKeys } from '@tldraw/utils'
|
|
2
|
+
import { iterateGraphemes, objectMapKeys } from '@tldraw/utils'
|
|
3
3
|
import type { Editor } from '../../Editor'
|
|
4
4
|
import { EditorManager } from '../EditorManager'
|
|
5
5
|
|
|
@@ -94,10 +94,6 @@ export interface TLMeasureTextSpanOpts {
|
|
|
94
94
|
|
|
95
95
|
const spaceCharacterRegex = /\s/
|
|
96
96
|
|
|
97
|
-
// Iterate by grapheme cluster (e.g. emoji sequences, flags, accented letters)
|
|
98
|
-
// rather than by code point, so a single glyph is measured as one unit.
|
|
99
|
-
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
100
|
-
|
|
101
97
|
const initialDefaultStyles = Object.freeze({
|
|
102
98
|
'overflow-wrap': 'break-word',
|
|
103
99
|
'word-break': 'auto',
|
|
@@ -309,7 +305,7 @@ export class TextManager extends EditorManager {
|
|
|
309
305
|
for (const childNode of element.childNodes) {
|
|
310
306
|
if (childNode.nodeType !== Node.TEXT_NODE) continue
|
|
311
307
|
|
|
312
|
-
for (const
|
|
308
|
+
for (const segment of iterateGraphemes(childNode.textContent ?? '')) {
|
|
313
309
|
// place the range around the grapheme we're interested in
|
|
314
310
|
range.setStart(textNode, idx)
|
|
315
311
|
range.setEnd(textNode, idx + segment.length)
|
|
@@ -438,7 +434,8 @@ export class TextManager extends EditorManager {
|
|
|
438
434
|
if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
|
|
439
435
|
// we need to measure the ellipsis to know how much space it takes up
|
|
440
436
|
elm.textContent = '…'
|
|
441
|
-
const
|
|
437
|
+
const ellipsisSpan = this.measureElementTextNodeSpans(elm).spans[0]
|
|
438
|
+
const ellipsisWidth = ellipsisSpan ? Math.ceil(ellipsisSpan.box.w) : 0
|
|
442
439
|
|
|
443
440
|
// then, we need to subtract that space from the width we have and measure again:
|
|
444
441
|
elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
|
|
@@ -451,7 +448,12 @@ export class TextManager extends EditorManager {
|
|
|
451
448
|
// have to do this after measuring, not before, because adding the
|
|
452
449
|
// ellipsis changes how whitespace might be getting collapsed by the
|
|
453
450
|
// browser.
|
|
454
|
-
const lastSpan = truncatedSpans[truncatedSpans.length - 1]
|
|
451
|
+
const lastSpan = truncatedSpans[truncatedSpans.length - 1]
|
|
452
|
+
// If nothing measurable remained, return the (possibly empty)
|
|
453
|
+
// spans as-is rather than dereferencing a missing last span.
|
|
454
|
+
if (!lastSpan) {
|
|
455
|
+
return truncatedSpans
|
|
456
|
+
}
|
|
455
457
|
truncatedSpans.push({
|
|
456
458
|
text: '…',
|
|
457
459
|
box: {
|
|
@@ -142,6 +142,19 @@ describe('TickManager', () => {
|
|
|
142
142
|
expect(mockEmit).toHaveBeenCalledWith('frame', 0)
|
|
143
143
|
expect(mockEmit).toHaveBeenCalledWith('tick', 0)
|
|
144
144
|
})
|
|
145
|
+
|
|
146
|
+
it('should schedule the next frame even when a listener throws', () => {
|
|
147
|
+
const staleCancel = vi.fn()
|
|
148
|
+
tickManager.cancelRaf = staleCancel
|
|
149
|
+
mockEmit.mockImplementation(() => {
|
|
150
|
+
throw new Error('listener blew up')
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
// the error still reaches the caller, but the loop lives on
|
|
154
|
+
expect(() => tickManager.tick()).toThrow('listener blew up')
|
|
155
|
+
expect(mockRequestAnimationFrame).toHaveBeenCalled()
|
|
156
|
+
expect(tickManager.cancelRaf).not.toBe(staleCancel)
|
|
157
|
+
})
|
|
145
158
|
})
|
|
146
159
|
|
|
147
160
|
describe('dispose method', () => {
|
|
@@ -41,9 +41,15 @@ export class TickManager extends EditorManager {
|
|
|
41
41
|
const elapsed = now - this.now
|
|
42
42
|
this.now = now
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
// Schedule the next frame even if a listener throws. Otherwise a single throwing listener
|
|
45
|
+
// ends the loop for good, which silently stops every frame-driven behavior — camera
|
|
46
|
+
// animations, edge scrolling, pointer velocity, following — instead of failing once.
|
|
47
|
+
try {
|
|
48
|
+
this.editor.emit('frame', elapsed)
|
|
49
|
+
this.editor.emit('tick', elapsed)
|
|
50
|
+
} finally {
|
|
51
|
+
this.cancelRaf = throttleToNextFrame(this.tick)
|
|
52
|
+
}
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
dispose() {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createTLStore } from '../..'
|
|
2
|
+
import { Editor } from '../editor/Editor'
|
|
3
|
+
import { tleditors } from './editors'
|
|
4
|
+
|
|
5
|
+
const editors: Editor[] = []
|
|
6
|
+
|
|
7
|
+
function createTestEditor() {
|
|
8
|
+
const editor = new Editor({
|
|
9
|
+
shapeUtils: [],
|
|
10
|
+
bindingUtils: [],
|
|
11
|
+
tools: [],
|
|
12
|
+
store: createTLStore({ shapeUtils: [], bindingUtils: [] }),
|
|
13
|
+
getContainer: () => document.body,
|
|
14
|
+
})
|
|
15
|
+
editors.push(editor)
|
|
16
|
+
return editor
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const editor of editors) {
|
|
21
|
+
editor.dispose()
|
|
22
|
+
}
|
|
23
|
+
editors.length = 0
|
|
24
|
+
expect(tleditors.getMounted()).toEqual([])
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('tleditors', () => {
|
|
28
|
+
it('tracks editors as they mount and unmount', () => {
|
|
29
|
+
const editor = createTestEditor()
|
|
30
|
+
expect(tleditors.getMounted()).toEqual([])
|
|
31
|
+
|
|
32
|
+
editor.emit('mount')
|
|
33
|
+
expect(tleditors.getMounted()).toEqual([editor])
|
|
34
|
+
|
|
35
|
+
editor.emit('unmount')
|
|
36
|
+
expect(tleditors.getMounted()).toEqual([])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('does not add the same editor twice', () => {
|
|
40
|
+
const editor = createTestEditor()
|
|
41
|
+
editor.emit('mount')
|
|
42
|
+
editor.emit('mount')
|
|
43
|
+
expect(tleditors.getMounted()).toEqual([editor])
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('removes an editor when it is disposed while mounted', () => {
|
|
47
|
+
const editor = createTestEditor()
|
|
48
|
+
editor.emit('mount')
|
|
49
|
+
expect(tleditors.getMounted()).toEqual([editor])
|
|
50
|
+
|
|
51
|
+
editor.dispose()
|
|
52
|
+
expect(tleditors.getMounted()).toEqual([])
|
|
53
|
+
expect(editor.getIsMounted()).toBe(false)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('removes an editor on dispose even if the unmount listener was removed', () => {
|
|
57
|
+
const editor = createTestEditor()
|
|
58
|
+
editor.emit('mount')
|
|
59
|
+
editor.removeAllListeners('unmount')
|
|
60
|
+
|
|
61
|
+
editor.dispose()
|
|
62
|
+
expect(tleditors.getMounted()).toEqual([])
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('tracks multiple mounted editors', () => {
|
|
66
|
+
const editorA = createTestEditor()
|
|
67
|
+
const editorB = createTestEditor()
|
|
68
|
+
|
|
69
|
+
editorA.emit('mount')
|
|
70
|
+
editorB.emit('mount')
|
|
71
|
+
expect(tleditors.getMounted()).toEqual([editorA, editorB])
|
|
72
|
+
|
|
73
|
+
editorA.emit('unmount')
|
|
74
|
+
expect(tleditors.getMounted()).toEqual([editorB])
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Signal, atom } from '@tldraw/state'
|
|
2
|
+
import type { Editor } from '../editor/Editor'
|
|
3
|
+
|
|
4
|
+
const mounted = atom<readonly Editor[]>('mounted editors', [])
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A global registry of currently mounted editor instances. Use it to discover live editors from
|
|
8
|
+
* outside the React tree, for example from sidebar chrome, keyboard shortcuts, or multi-editor
|
|
9
|
+
* layouts.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* tleditors.getMounted() // readonly Editor[]
|
|
14
|
+
*
|
|
15
|
+
* const editors = useValue('mounted editors', () => tleditors.getMounted(), [])
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @public
|
|
19
|
+
*/
|
|
20
|
+
export const tleditors = {
|
|
21
|
+
/**
|
|
22
|
+
* A reactive list of currently mounted editor instances.
|
|
23
|
+
*
|
|
24
|
+
* An editor is added when it emits the `mount` event and removed when it emits `unmount`
|
|
25
|
+
* (including when the editor is disposed while still mounted).
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
mounted: mounted as Signal<readonly Editor[]>,
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get the currently mounted editor instances.
|
|
33
|
+
*
|
|
34
|
+
* @public
|
|
35
|
+
*/
|
|
36
|
+
getMounted(): readonly Editor[] {
|
|
37
|
+
return mounted.get()
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @internal */
|
|
42
|
+
export function registerMountedEditor(editor: Editor) {
|
|
43
|
+
const current = mounted.get()
|
|
44
|
+
if (current.includes(editor)) return
|
|
45
|
+
mounted.set([...current, editor])
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** @internal */
|
|
49
|
+
export function unregisterMountedEditor(editor: Editor) {
|
|
50
|
+
const current = mounted.get()
|
|
51
|
+
if (!current.includes(editor)) return
|
|
52
|
+
mounted.set(current.filter((e) => e !== editor))
|
|
53
|
+
}
|
|
@@ -11,7 +11,15 @@ import { useMaybeEditor } from './useEditor'
|
|
|
11
11
|
* N.B. On iOS, you have to take into account the offsetTop as well so that you get an accurate position
|
|
12
12
|
* while using the virtual keyboard.
|
|
13
13
|
*/
|
|
14
|
-
/**
|
|
14
|
+
/**
|
|
15
|
+
* The height of the visible viewport, including the visual viewport's own offset.
|
|
16
|
+
*
|
|
17
|
+
* @deprecated Read `window.visualViewport` directly: this flattens the visible rectangle to a
|
|
18
|
+
* single scalar, and keeping UI clear of the software keyboard usually needs the other edges too.
|
|
19
|
+
* Unused since v3.14; will be removed in a future release.
|
|
20
|
+
*
|
|
21
|
+
* @public
|
|
22
|
+
*/
|
|
15
23
|
export function useViewportHeight(): number {
|
|
16
24
|
const editor = useMaybeEditor()
|
|
17
25
|
const win = editor?.getContainerWindow() ?? window
|