@tldraw/editor 5.3.1 → 5.4.0-canary.0063438d1c75

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.
Files changed (76) hide show
  1. package/dist-cjs/index.d.ts +97 -52
  2. package/dist-cjs/index.js +4 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +3 -0
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/editor/Editor.js +94 -26
  7. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  8. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
  9. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +6 -3
  11. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  12. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
  13. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  14. package/dist-cjs/lib/globals/editors.js +57 -0
  15. package/dist-cjs/lib/globals/editors.js.map +7 -0
  16. package/dist-cjs/lib/hooks/useViewportHeight.js.map +2 -2
  17. package/dist-cjs/lib/license/LicenseManager.js +9 -2
  18. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  19. package/dist-cjs/lib/license/LicenseProvider.js +16 -3
  20. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  21. package/dist-cjs/lib/license/useLicenseManagerState.js +5 -4
  22. package/dist-cjs/lib/license/useLicenseManagerState.js.map +2 -2
  23. package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
  24. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  25. package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
  26. package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
  27. package/dist-cjs/version.js +3 -3
  28. package/dist-cjs/version.js.map +1 -1
  29. package/dist-esm/index.d.mts +97 -52
  30. package/dist-esm/index.mjs +9 -2
  31. package/dist-esm/index.mjs.map +2 -2
  32. package/dist-esm/lib/TldrawEditor.mjs +4 -1
  33. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  34. package/dist-esm/lib/editor/Editor.mjs +94 -26
  35. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  36. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
  37. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  38. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -4
  39. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  40. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
  41. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  42. package/dist-esm/lib/globals/editors.mjs +37 -0
  43. package/dist-esm/lib/globals/editors.mjs.map +7 -0
  44. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +2 -2
  45. package/dist-esm/lib/license/LicenseManager.mjs +9 -2
  46. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  47. package/dist-esm/lib/license/LicenseProvider.mjs +16 -3
  48. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  49. package/dist-esm/lib/license/useLicenseManagerState.mjs +5 -4
  50. package/dist-esm/lib/license/useLicenseManagerState.mjs.map +2 -2
  51. package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
  52. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  53. package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
  54. package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
  55. package/dist-esm/version.mjs +3 -3
  56. package/dist-esm/version.mjs.map +1 -1
  57. package/package.json +13 -7
  58. package/src/index.ts +7 -1
  59. package/src/lib/TldrawEditor.tsx +4 -1
  60. package/src/lib/editor/Editor.ts +136 -37
  61. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
  62. package/src/lib/editor/managers/TextManager/TextManager.test.ts +42 -0
  63. package/src/lib/editor/managers/TextManager/TextManager.ts +10 -8
  64. package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
  65. package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
  66. package/src/lib/globals/editors.test.ts +76 -0
  67. package/src/lib/globals/editors.ts +53 -0
  68. package/src/lib/hooks/useViewportHeight.ts +9 -1
  69. package/src/lib/license/LicenseManager.test.ts +160 -0
  70. package/src/lib/license/LicenseManager.ts +13 -4
  71. package/src/lib/license/LicenseProvider.tsx +24 -3
  72. package/src/lib/license/useLicenseManagerState.ts +8 -6
  73. package/src/lib/primitives/geometry/Group2d.ts +63 -27
  74. package/src/lib/utils/normalizeWheel.test.ts +39 -0
  75. package/src/lib/utils/normalizeWheel.ts +6 -0
  76. package/src/version.ts +3 -3
@@ -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
- opts.renderingOnly
5916
- ? this.getCurrentPageRenderingShapesSorted()
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
- return false
5943
+ ) {
5944
+ continue
5945
+ }
5928
5946
  const pageMask = this.getShapeMask(shape)
5929
- if (pageMask && !pointInPolygon(point, pageMask)) return false
5930
- if (filter && !filter(shape)) return false
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
- return this.getCurrentPageShapesSorted()
6120
- .filter((shape) => {
6121
- if (this.isShapeHidden(shape)) return false
6122
- if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) return false
6123
- return this.isPointInShape(shape, point, opts)
6124
- })
6125
- .reverse()
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: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
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
- /** @internal */
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
- _setShiftKeyTimeout() {
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
- /** @internal */
10896
+ /**
10897
+ * Release the alt modifier. See {@link Editor._releaseShiftKey}.
10898
+ * @internal
10899
+ */
10864
10900
  @bind
10865
- _setAltKeyTimeout() {
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
- /** @internal */
10920
+ /**
10921
+ * Release the ctrl modifier. See {@link Editor._releaseShiftKey}.
10922
+ * @internal
10923
+ */
10884
10924
  @bind
10885
- _setCtrlKeyTimeout() {
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
- /** @internal */
10944
+ /**
10945
+ * Release the meta modifier. See {@link Editor._releaseShiftKey}.
10946
+ * @internal
10947
+ */
10904
10948
  @bind
10905
- _setMetaKeyTimeout() {
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._setShiftKeyTimeout, 150)
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._setAltKeyTimeout, 150)
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._setCtrlKeyTimeout, 150)
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._setMetaKeyTimeout, 150)
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
- return new Set(
43
- [...shapesIndex.get()].filter((id) => isShapeInPage(store, currentPageId, store.get(id)!))
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 { segment } of graphemeSegmenter.segment(childNode.textContent ?? '')) {
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 ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)
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
- this.editor.emit('frame', elapsed)
45
- this.editor.emit('tick', elapsed)
46
- this.cancelRaf = throttleToNextFrame(this.tick)
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
- /** @public */
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