@tldraw/editor 5.3.2 → 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.
Files changed (55) hide show
  1. package/dist-cjs/index.d.ts +94 -52
  2. package/dist-cjs/index.js +3 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +86 -26
  5. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  6. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +7 -3
  7. package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
  8. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +6 -3
  9. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  10. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +6 -3
  11. package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
  12. package/dist-cjs/lib/globals/editors.js +57 -0
  13. package/dist-cjs/lib/globals/editors.js.map +7 -0
  14. package/dist-cjs/lib/hooks/useViewportHeight.js.map +2 -2
  15. package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
  16. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  17. package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
  18. package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
  19. package/dist-cjs/version.js +3 -3
  20. package/dist-cjs/version.js.map +1 -1
  21. package/dist-esm/index.d.mts +94 -52
  22. package/dist-esm/index.mjs +3 -1
  23. package/dist-esm/index.mjs.map +2 -2
  24. package/dist-esm/lib/editor/Editor.mjs +86 -26
  25. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  26. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
  27. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  28. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +7 -4
  29. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  30. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +6 -3
  31. package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
  32. package/dist-esm/lib/globals/editors.mjs +37 -0
  33. package/dist-esm/lib/globals/editors.mjs.map +7 -0
  34. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +2 -2
  35. package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
  36. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  37. package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
  38. package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
  39. package/dist-esm/version.mjs +3 -3
  40. package/dist-esm/version.mjs.map +1 -1
  41. package/package.json +7 -7
  42. package/src/index.ts +2 -0
  43. package/src/lib/editor/Editor.ts +126 -37
  44. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
  45. package/src/lib/editor/managers/TextManager/TextManager.test.ts +42 -0
  46. package/src/lib/editor/managers/TextManager/TextManager.ts +10 -8
  47. package/src/lib/editor/managers/TickManager/TickManager.test.ts +13 -0
  48. package/src/lib/editor/managers/TickManager/TickManager.ts +9 -3
  49. package/src/lib/globals/editors.test.ts +76 -0
  50. package/src/lib/globals/editors.ts +53 -0
  51. package/src/lib/hooks/useViewportHeight.ts +9 -1
  52. package/src/lib/primitives/geometry/Group2d.ts +63 -27
  53. package/src/lib/utils/normalizeWheel.test.ts +39 -0
  54. package/src/lib/utils/normalizeWheel.ts +6 -0
  55. package/src/version.ts +3 -3
@@ -118,6 +118,7 @@ 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'
123
124
  import { LicenseManager } from '../license/LicenseManager'
@@ -919,10 +920,12 @@ export class Editor extends EventEmitter<TLEventMap> {
919
920
 
920
921
  this.on('mount', () => {
921
922
  this._isMounted.set(true)
923
+ registerMountedEditor(this)
922
924
  })
923
925
 
924
926
  this.on('unmount', () => {
925
927
  this._isMounted.set(false)
928
+ unregisterMountedEditor(this)
926
929
  })
927
930
 
928
931
  this.timers.requestAnimationFrame(() => {
@@ -1219,6 +1222,9 @@ export class Editor extends EventEmitter<TLEventMap> {
1219
1222
  if (this._isMounted.get()) {
1220
1223
  this.emit('unmount')
1221
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)
1222
1228
 
1223
1229
  // Stop any in-progress camera animations and following before
1224
1230
  // running disposables, so their cleanup listeners fire first
@@ -5872,6 +5878,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5872
5878
  const margin = this.options.hitTestMargin / this.getZoomLevel()
5873
5879
  const sortedShapes = this.getCurrentPageShapesSorted()
5874
5880
 
5881
+ // iterate from the top (highest z-index) to find the top-most matching shape
5875
5882
  for (let i = sortedShapes.length - 1; i >= 0; i--) {
5876
5883
  const shape = sortedShapes[i]
5877
5884
  if (shape.type === 'group') continue
@@ -5921,28 +5928,25 @@ export class Editor extends EventEmitter<TLEventMap> {
5921
5928
  const searchMargin = Math.max(innerMargin, outerMargin, this.getHitTestMargin())
5922
5929
  const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
5923
5930
 
5924
- const shapesToCheck = (
5925
- opts.renderingOnly
5926
- ? this.getCurrentPageRenderingShapesSorted()
5927
- : this.getCurrentPageShapesSorted()
5928
- ).filter((shape) => {
5929
- // Frame-like shapes have labels positioned above the shape (outside bounds), so always include them
5930
- if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) return false
5931
+ const shapesToCheck = opts.renderingOnly
5932
+ ? this.getCurrentPageRenderingShapesSorted()
5933
+ : this.getCurrentPageShapesSorted()
5931
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
5932
5939
  if (
5933
5940
  (shape.isLocked && !hitLocked) ||
5934
5941
  this.isShapeHidden(shape) ||
5935
5942
  this.isShapeOfType(shape, 'group')
5936
- )
5937
- return false
5943
+ ) {
5944
+ continue
5945
+ }
5938
5946
  const pageMask = this.getShapeMask(shape)
5939
- if (pageMask && !pointInPolygon(point, pageMask)) return false
5940
- if (filter && !filter(shape)) return false
5941
- return true
5942
- })
5947
+ if (pageMask && !pointInPolygon(point, pageMask)) continue
5948
+ if (filter && !filter(shape)) continue
5943
5949
 
5944
- for (let i = shapesToCheck.length - 1; i >= 0; i--) {
5945
- const shape = shapesToCheck[i]
5946
5950
  const geometry = this.getShapeGeometry(shape)
5947
5951
  const isGroup = geometry instanceof Group2d
5948
5952
 
@@ -6124,15 +6128,18 @@ export class Editor extends EventEmitter<TLEventMap> {
6124
6128
  const margin = opts.margin ?? 0
6125
6129
  const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, margin)
6126
6130
 
6127
- // Get all page shapes in z-index order and filter to candidates that pass isPointInShape
6128
- // Frame-like shapes are always checked because their labels can be outside their bounds
6129
- return this.getCurrentPageShapesSorted()
6130
- .filter((shape) => {
6131
- if (this.isShapeHidden(shape)) return false
6132
- if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) return false
6133
- return this.isPointInShape(shape, point, opts)
6134
- })
6135
- .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
6136
6143
  }
6137
6144
 
6138
6145
  /**
@@ -7887,9 +7894,19 @@ export class Editor extends EventEmitter<TLEventMap> {
7887
7894
  */
7888
7895
  alignShapes(
7889
7896
  shapes: TLShapeId[] | TLShape[],
7890
- 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'
7891
7905
  ): this {
7892
7906
  if (this.getIsReadonly()) return this
7907
+ if (operation === 'center') {
7908
+ return this.alignShapes(shapes, 'center-horizontal').alignShapes(shapes, 'center-vertical')
7909
+ }
7893
7910
 
7894
7911
  const { clusters: shapeClustersToAlign, allBounds } = this.getShapeClusters(shapes, 'align')
7895
7912
 
@@ -10850,9 +10867,15 @@ export class Editor extends EventEmitter<TLEventMap> {
10850
10867
  /** @internal */
10851
10868
  private _shiftKeyTimeout = -1 as any
10852
10869
 
10853
- /** @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
+ */
10854
10876
  @bind
10855
- _setShiftKeyTimeout() {
10877
+ _releaseShiftKey() {
10878
+ this._shiftKeyTimeout = -1
10856
10879
  this.inputs.setShiftKey(false)
10857
10880
  this.dispatch({
10858
10881
  type: 'keyboard',
@@ -10870,9 +10893,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10870
10893
  /** @internal */
10871
10894
  private _altKeyTimeout = -1 as any
10872
10895
 
10873
- /** @internal */
10896
+ /**
10897
+ * Release the alt modifier. See {@link Editor._releaseShiftKey}.
10898
+ * @internal
10899
+ */
10874
10900
  @bind
10875
- _setAltKeyTimeout() {
10901
+ _releaseAltKey() {
10902
+ this._altKeyTimeout = -1
10876
10903
  this.inputs.setAltKey(false)
10877
10904
  this.dispatch({
10878
10905
  type: 'keyboard',
@@ -10890,9 +10917,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10890
10917
  /** @internal */
10891
10918
  private _ctrlKeyTimeout = -1 as any
10892
10919
 
10893
- /** @internal */
10920
+ /**
10921
+ * Release the ctrl modifier. See {@link Editor._releaseShiftKey}.
10922
+ * @internal
10923
+ */
10894
10924
  @bind
10895
- _setCtrlKeyTimeout() {
10925
+ _releaseCtrlKey() {
10926
+ this._ctrlKeyTimeout = -1
10896
10927
  this.inputs.setCtrlKey(false)
10897
10928
  this.dispatch({
10898
10929
  type: 'keyboard',
@@ -10910,9 +10941,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10910
10941
  /** @internal */
10911
10942
  private _metaKeyTimeout = -1 as any
10912
10943
 
10913
- /** @internal */
10944
+ /**
10945
+ * Release the meta modifier. See {@link Editor._releaseShiftKey}.
10946
+ * @internal
10947
+ */
10914
10948
  @bind
10915
- _setMetaKeyTimeout() {
10949
+ _releaseMetaKey() {
10950
+ this._metaKeyTimeout = -1
10916
10951
  this.inputs.setMetaKey(false)
10917
10952
  this.dispatch({
10918
10953
  type: 'keyboard',
@@ -10927,6 +10962,56 @@ export class Editor extends EventEmitter<TLEventMap> {
10927
10962
  })
10928
10963
  }
10929
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
+
10930
11015
  /** @internal */
10931
11016
  private _restoreToolId = 'select'
10932
11017
 
@@ -11065,7 +11150,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11065
11150
  this._shiftKeyTimeout = -1
11066
11151
  inputs.setShiftKey(true)
11067
11152
  } else if (!info.shiftKey && inputs.getShiftKey() && this._shiftKeyTimeout === -1) {
11068
- this._shiftKeyTimeout = this.timers.setTimeout(this._setShiftKeyTimeout, 150)
11153
+ this._shiftKeyTimeout = this.timers.setTimeout(this._releaseShiftKey, 150)
11069
11154
  }
11070
11155
 
11071
11156
  if (info.altKey) {
@@ -11073,7 +11158,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11073
11158
  this._altKeyTimeout = -1
11074
11159
  inputs.setAltKey(true)
11075
11160
  } else if (!info.altKey && inputs.getAltKey() && this._altKeyTimeout === -1) {
11076
- this._altKeyTimeout = this.timers.setTimeout(this._setAltKeyTimeout, 150)
11161
+ this._altKeyTimeout = this.timers.setTimeout(this._releaseAltKey, 150)
11077
11162
  }
11078
11163
 
11079
11164
  if (info.ctrlKey) {
@@ -11081,7 +11166,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11081
11166
  this._ctrlKeyTimeout = -1
11082
11167
  inputs.setCtrlKey(true)
11083
11168
  } else if (!info.ctrlKey && inputs.getCtrlKey() && this._ctrlKeyTimeout === -1) {
11084
- this._ctrlKeyTimeout = this.timers.setTimeout(this._setCtrlKeyTimeout, 150)
11169
+ this._ctrlKeyTimeout = this.timers.setTimeout(this._releaseCtrlKey, 150)
11085
11170
  }
11086
11171
 
11087
11172
  if (info.metaKey && info.name !== 'key_up') {
@@ -11091,7 +11176,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11091
11176
  this._metaKeyTimeout = -1
11092
11177
  inputs.setMetaKey(true)
11093
11178
  } else if (!info.metaKey && inputs.getMetaKey() && this._metaKeyTimeout === -1) {
11094
- this._metaKeyTimeout = this.timers.setTimeout(this._setMetaKeyTimeout, 150)
11179
+ this._metaKeyTimeout = this.timers.setTimeout(this._releaseMetaKey, 150)
11095
11180
  }
11096
11181
 
11097
11182
  if (!inputs.getIsPointing()) {
@@ -11299,6 +11384,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11299
11384
  // If we're in pen mode and the input is not a pen type, then stop here
11300
11385
  if (isPenMode && !isPen) return
11301
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
+
11302
11391
  if (!this.inputs.getIsPanning()) {
11303
11392
  // Start a long press timeout
11304
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