@tldraw/editor 5.3.0 → 5.4.0-canary.1b54b5062691

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 (43) hide show
  1. package/dist-cjs/index.d.ts +15 -6
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/editor/Editor.js +82 -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 +5 -1
  9. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  10. package/dist-cjs/lib/hooks/useViewportHeight.js.map +2 -2
  11. package/dist-cjs/lib/primitives/geometry/Group2d.js +63 -21
  12. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  13. package/dist-cjs/lib/utils/normalizeWheel.js +2 -0
  14. package/dist-cjs/lib/utils/normalizeWheel.js.map +2 -2
  15. package/dist-cjs/version.js +3 -3
  16. package/dist-cjs/version.js.map +1 -1
  17. package/dist-esm/index.d.mts +15 -6
  18. package/dist-esm/index.mjs +1 -1
  19. package/dist-esm/index.mjs.map +2 -2
  20. package/dist-esm/lib/editor/Editor.mjs +82 -26
  21. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  22. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +7 -3
  23. package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
  24. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +5 -1
  25. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  26. package/dist-esm/lib/hooks/useViewportHeight.mjs.map +2 -2
  27. package/dist-esm/lib/primitives/geometry/Group2d.mjs +63 -21
  28. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  29. package/dist-esm/lib/utils/normalizeWheel.mjs +2 -0
  30. package/dist-esm/lib/utils/normalizeWheel.mjs.map +2 -2
  31. package/dist-esm/version.mjs +3 -3
  32. package/dist-esm/version.mjs.map +1 -1
  33. package/package.json +7 -7
  34. package/src/index.ts +1 -0
  35. package/src/lib/editor/Editor.ts +120 -37
  36. package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +7 -3
  37. package/src/lib/editor/managers/TextManager/TextManager.test.ts +42 -0
  38. package/src/lib/editor/managers/TextManager/TextManager.ts +8 -2
  39. package/src/lib/hooks/useViewportHeight.ts +9 -1
  40. package/src/lib/primitives/geometry/Group2d.ts +63 -27
  41. package/src/lib/utils/normalizeWheel.test.ts +39 -0
  42. package/src/lib/utils/normalizeWheel.ts +6 -0
  43. package/src/version.ts +3 -3
@@ -5862,6 +5862,7 @@ export class Editor extends EventEmitter<TLEventMap> {
5862
5862
  const margin = this.options.hitTestMargin / this.getZoomLevel()
5863
5863
  const sortedShapes = this.getCurrentPageShapesSorted()
5864
5864
 
5865
+ // iterate from the top (highest z-index) to find the top-most matching shape
5865
5866
  for (let i = sortedShapes.length - 1; i >= 0; i--) {
5866
5867
  const shape = sortedShapes[i]
5867
5868
  if (shape.type === 'group') continue
@@ -5911,28 +5912,25 @@ export class Editor extends EventEmitter<TLEventMap> {
5911
5912
  const searchMargin = Math.max(innerMargin, outerMargin, this.getHitTestMargin())
5912
5913
  const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, searchMargin)
5913
5914
 
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
5915
+ const shapesToCheck = opts.renderingOnly
5916
+ ? this.getCurrentPageRenderingShapesSorted()
5917
+ : this.getCurrentPageShapesSorted()
5921
5918
 
5919
+ for (let i = shapesToCheck.length - 1; i >= 0; i--) {
5920
+ const shape = shapesToCheck[i]
5921
+ // Frame-like shapes have labels positioned above the shape (outside bounds), so always include them
5922
+ if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) continue
5922
5923
  if (
5923
5924
  (shape.isLocked && !hitLocked) ||
5924
5925
  this.isShapeHidden(shape) ||
5925
5926
  this.isShapeOfType(shape, 'group')
5926
- )
5927
- return false
5927
+ ) {
5928
+ continue
5929
+ }
5928
5930
  const pageMask = this.getShapeMask(shape)
5929
- if (pageMask && !pointInPolygon(point, pageMask)) return false
5930
- if (filter && !filter(shape)) return false
5931
- return true
5932
- })
5931
+ if (pageMask && !pointInPolygon(point, pageMask)) continue
5932
+ if (filter && !filter(shape)) continue
5933
5933
 
5934
- for (let i = shapesToCheck.length - 1; i >= 0; i--) {
5935
- const shape = shapesToCheck[i]
5936
5934
  const geometry = this.getShapeGeometry(shape)
5937
5935
  const isGroup = geometry instanceof Group2d
5938
5936
 
@@ -6114,15 +6112,18 @@ export class Editor extends EventEmitter<TLEventMap> {
6114
6112
  const margin = opts.margin ?? 0
6115
6113
  const candidateIds = this._spatialIndex.getShapeIdsAtPoint(point, margin)
6116
6114
 
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()
6115
+ // Get all page shapes in z-index order and filter to candidates that pass isPointInShape.
6116
+ // Frame-like shapes are always checked because their labels can be outside their bounds.
6117
+ // Iterate backwards so the result is pre-sorted in reverse z-index order (top-most first).
6118
+ const sorted = this.getCurrentPageShapesSorted()
6119
+ const result: TLShape[] = []
6120
+ for (let i = sorted.length - 1; i >= 0; i--) {
6121
+ const shape = sorted[i]
6122
+ if (this.isShapeHidden(shape)) continue
6123
+ if (!candidateIds.has(shape.id) && !this.isShapeFrameLike(shape)) continue
6124
+ if (this.isPointInShape(shape, point, opts)) result.push(shape)
6125
+ }
6126
+ return result
6126
6127
  }
6127
6128
 
6128
6129
  /**
@@ -7877,9 +7878,19 @@ export class Editor extends EventEmitter<TLEventMap> {
7877
7878
  */
7878
7879
  alignShapes(
7879
7880
  shapes: TLShapeId[] | TLShape[],
7880
- operation: 'left' | 'center-horizontal' | 'right' | 'top' | 'center-vertical' | 'bottom'
7881
+ operation:
7882
+ | 'left'
7883
+ | 'center-horizontal'
7884
+ | 'right'
7885
+ | 'top'
7886
+ | 'center-vertical'
7887
+ | 'bottom'
7888
+ | 'center'
7881
7889
  ): this {
7882
7890
  if (this.getIsReadonly()) return this
7891
+ if (operation === 'center') {
7892
+ return this.alignShapes(shapes, 'center-horizontal').alignShapes(shapes, 'center-vertical')
7893
+ }
7883
7894
 
7884
7895
  const { clusters: shapeClustersToAlign, allBounds } = this.getShapeClusters(shapes, 'align')
7885
7896
 
@@ -10840,9 +10851,15 @@ export class Editor extends EventEmitter<TLEventMap> {
10840
10851
  /** @internal */
10841
10852
  private _shiftKeyTimeout = -1 as any
10842
10853
 
10843
- /** @internal */
10854
+ /**
10855
+ * Release the shift modifier: clear its debounce timer id, drop the atom, and
10856
+ * dispatch a synthetic `key_up` so `inputs.keys` and tool `onKeyUp` handlers update.
10857
+ * Runs both as the 150ms debounce timer callback and when a pointer down flushes it.
10858
+ * @internal
10859
+ */
10844
10860
  @bind
10845
- _setShiftKeyTimeout() {
10861
+ _releaseShiftKey() {
10862
+ this._shiftKeyTimeout = -1
10846
10863
  this.inputs.setShiftKey(false)
10847
10864
  this.dispatch({
10848
10865
  type: 'keyboard',
@@ -10860,9 +10877,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10860
10877
  /** @internal */
10861
10878
  private _altKeyTimeout = -1 as any
10862
10879
 
10863
- /** @internal */
10880
+ /**
10881
+ * Release the alt modifier. See {@link Editor._releaseShiftKey}.
10882
+ * @internal
10883
+ */
10864
10884
  @bind
10865
- _setAltKeyTimeout() {
10885
+ _releaseAltKey() {
10886
+ this._altKeyTimeout = -1
10866
10887
  this.inputs.setAltKey(false)
10867
10888
  this.dispatch({
10868
10889
  type: 'keyboard',
@@ -10880,9 +10901,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10880
10901
  /** @internal */
10881
10902
  private _ctrlKeyTimeout = -1 as any
10882
10903
 
10883
- /** @internal */
10904
+ /**
10905
+ * Release the ctrl modifier. See {@link Editor._releaseShiftKey}.
10906
+ * @internal
10907
+ */
10884
10908
  @bind
10885
- _setCtrlKeyTimeout() {
10909
+ _releaseCtrlKey() {
10910
+ this._ctrlKeyTimeout = -1
10886
10911
  this.inputs.setCtrlKey(false)
10887
10912
  this.dispatch({
10888
10913
  type: 'keyboard',
@@ -10900,9 +10925,13 @@ export class Editor extends EventEmitter<TLEventMap> {
10900
10925
  /** @internal */
10901
10926
  private _metaKeyTimeout = -1 as any
10902
10927
 
10903
- /** @internal */
10928
+ /**
10929
+ * Release the meta modifier. See {@link Editor._releaseShiftKey}.
10930
+ * @internal
10931
+ */
10904
10932
  @bind
10905
- _setMetaKeyTimeout() {
10933
+ _releaseMetaKey() {
10934
+ this._metaKeyTimeout = -1
10906
10935
  this.inputs.setMetaKey(false)
10907
10936
  this.dispatch({
10908
10937
  type: 'keyboard',
@@ -10917,6 +10946,56 @@ export class Editor extends EventEmitter<TLEventMap> {
10917
10946
  })
10918
10947
  }
10919
10948
 
10949
+ /**
10950
+ * Flush any modifier still sitting in its 150ms release-debounce window, so a new
10951
+ * interaction (a pointer down) starts with correct modifier state instead of a
10952
+ * just-released key still being counted as held. A pending timer is exactly
10953
+ * "released but still counted as held"; a genuinely-held modifier has no timer
10954
+ * (-1) and is left alone.
10955
+ *
10956
+ * Each modifier is released through the same path its timer would have taken
10957
+ * (`_releaseShiftKey` and friends), which dispatches the synthetic `key_up` so
10958
+ * stale codes (e.g. `ShiftLeft`) leave `inputs.keys` and tool `onKeyUp` handlers run.
10959
+ * We clear every pending modifier atom and timer *first*, then dispatch: a synthetic
10960
+ * `key_up` reports all currently-held modifiers, so releasing them one at a time
10961
+ * would make each event re-confirm the not-yet-released ones and cancel their flush.
10962
+ * @internal
10963
+ */
10964
+ private _releaseDebouncedModifiers() {
10965
+ const releaseShift = this._shiftKeyTimeout !== -1
10966
+ const releaseAlt = this._altKeyTimeout !== -1
10967
+ const releaseCtrl = this._ctrlKeyTimeout !== -1
10968
+ const releaseMeta = this._metaKeyTimeout !== -1
10969
+
10970
+ if (releaseShift) {
10971
+ clearTimeout(this._shiftKeyTimeout)
10972
+ this._shiftKeyTimeout = -1
10973
+ this.inputs.setShiftKey(false)
10974
+ }
10975
+ if (releaseAlt) {
10976
+ clearTimeout(this._altKeyTimeout)
10977
+ this._altKeyTimeout = -1
10978
+ this.inputs.setAltKey(false)
10979
+ }
10980
+ if (releaseCtrl) {
10981
+ clearTimeout(this._ctrlKeyTimeout)
10982
+ this._ctrlKeyTimeout = -1
10983
+ this.inputs.setCtrlKey(false)
10984
+ }
10985
+ if (releaseMeta) {
10986
+ clearTimeout(this._metaKeyTimeout)
10987
+ this._metaKeyTimeout = -1
10988
+ this.inputs.setMetaKey(false)
10989
+ }
10990
+
10991
+ // Dispatch the synthetic key_up for each flushed modifier (same path its 150ms
10992
+ // timer would run): clears stale codes from inputs.keys and fires tool onKeyUp.
10993
+ if (releaseShift) this._releaseShiftKey()
10994
+ if (releaseAlt) this._releaseAltKey()
10995
+ if (releaseCtrl) this._releaseCtrlKey()
10996
+ if (releaseMeta) this._releaseMetaKey()
10997
+ }
10998
+
10920
10999
  /** @internal */
10921
11000
  private _restoreToolId = 'select'
10922
11001
 
@@ -11055,7 +11134,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11055
11134
  this._shiftKeyTimeout = -1
11056
11135
  inputs.setShiftKey(true)
11057
11136
  } else if (!info.shiftKey && inputs.getShiftKey() && this._shiftKeyTimeout === -1) {
11058
- this._shiftKeyTimeout = this.timers.setTimeout(this._setShiftKeyTimeout, 150)
11137
+ this._shiftKeyTimeout = this.timers.setTimeout(this._releaseShiftKey, 150)
11059
11138
  }
11060
11139
 
11061
11140
  if (info.altKey) {
@@ -11063,7 +11142,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11063
11142
  this._altKeyTimeout = -1
11064
11143
  inputs.setAltKey(true)
11065
11144
  } else if (!info.altKey && inputs.getAltKey() && this._altKeyTimeout === -1) {
11066
- this._altKeyTimeout = this.timers.setTimeout(this._setAltKeyTimeout, 150)
11145
+ this._altKeyTimeout = this.timers.setTimeout(this._releaseAltKey, 150)
11067
11146
  }
11068
11147
 
11069
11148
  if (info.ctrlKey) {
@@ -11071,7 +11150,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11071
11150
  this._ctrlKeyTimeout = -1
11072
11151
  inputs.setCtrlKey(true)
11073
11152
  } else if (!info.ctrlKey && inputs.getCtrlKey() && this._ctrlKeyTimeout === -1) {
11074
- this._ctrlKeyTimeout = this.timers.setTimeout(this._setCtrlKeyTimeout, 150)
11153
+ this._ctrlKeyTimeout = this.timers.setTimeout(this._releaseCtrlKey, 150)
11075
11154
  }
11076
11155
 
11077
11156
  if (info.metaKey && info.name !== 'key_up') {
@@ -11081,7 +11160,7 @@ export class Editor extends EventEmitter<TLEventMap> {
11081
11160
  this._metaKeyTimeout = -1
11082
11161
  inputs.setMetaKey(true)
11083
11162
  } else if (!info.metaKey && inputs.getMetaKey() && this._metaKeyTimeout === -1) {
11084
- this._metaKeyTimeout = this.timers.setTimeout(this._setMetaKeyTimeout, 150)
11163
+ this._metaKeyTimeout = this.timers.setTimeout(this._releaseMetaKey, 150)
11085
11164
  }
11086
11165
 
11087
11166
  if (!inputs.getIsPointing()) {
@@ -11289,6 +11368,10 @@ export class Editor extends EventEmitter<TLEventMap> {
11289
11368
  // If we're in pen mode and the input is not a pen type, then stop here
11290
11369
  if (isPenMode && !isPen) return
11291
11370
 
11371
+ // A pointer down starts a new interaction, so flush any modifier that's
11372
+ // still lingering in its release-debounce window: treat it as released now.
11373
+ this._releaseDebouncedModifiers()
11374
+
11292
11375
  if (!this.inputs.getIsPanning()) {
11293
11376
  // Start a long press timeout
11294
11377
  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: [
@@ -438,7 +438,8 @@ export class TextManager extends EditorManager {
438
438
  if (opts.overflow === 'truncate-ellipsis' && didTruncate) {
439
439
  // we need to measure the ellipsis to know how much space it takes up
440
440
  elm.textContent = '…'
441
- const ellipsisWidth = Math.ceil(this.measureElementTextNodeSpans(elm).spans[0].box.w)
441
+ const ellipsisSpan = this.measureElementTextNodeSpans(elm).spans[0]
442
+ const ellipsisWidth = ellipsisSpan ? Math.ceil(ellipsisSpan.box.w) : 0
442
443
 
443
444
  // then, we need to subtract that space from the width we have and measure again:
444
445
  elm.style.setProperty('width', `${elementWidth - ellipsisWidth}px`)
@@ -451,7 +452,12 @@ export class TextManager extends EditorManager {
451
452
  // have to do this after measuring, not before, because adding the
452
453
  // ellipsis changes how whitespace might be getting collapsed by the
453
454
  // browser.
454
- const lastSpan = truncatedSpans[truncatedSpans.length - 1]!
455
+ const lastSpan = truncatedSpans[truncatedSpans.length - 1]
456
+ // If nothing measurable remained, return the (possibly empty)
457
+ // spans as-is rather than dereferencing a missing last span.
458
+ if (!lastSpan) {
459
+ return truncatedSpans
460
+ }
455
461
  truncatedSpans.push({
456
462
  text: '…',
457
463
  box: {
@@ -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
@@ -1,4 +1,3 @@
1
- import { EMPTY_ARRAY } from '@tldraw/state'
2
1
  import { assert, invLerp, lerp } from '@tldraw/utils'
3
2
  import { Box } from '../Box'
4
3
  import { Mat } from '../Mat'
@@ -36,9 +35,15 @@ export class Group2d extends Geometry2d {
36
35
 
37
36
  override getVertices(filters: Geometry2dFilters): Vec[] {
38
37
  if (this.isExcludedByFilter(filters)) return []
39
- return this.children
40
- .filter((c) => !c.isExcludedByFilter(filters))
41
- .flatMap((c) => c.getVertices(filters))
38
+ const vertices: Vec[] = []
39
+ for (const child of this.children) {
40
+ if (child.isExcludedByFilter(filters)) continue
41
+ const childVertices = child.getVertices(filters)
42
+ for (let i = 0, n = childVertices.length; i < n; i++) {
43
+ vertices.push(childVertices[i])
44
+ }
45
+ }
46
+ return vertices
42
47
  }
43
48
 
44
49
  override nearestPoint(point: VecLike, filters?: Geometry2dFilters): Vec {
@@ -84,9 +89,11 @@ export class Group2d extends Geometry2d {
84
89
  hitInside: boolean,
85
90
  filters = Geometry2dFilters.EXCLUDE_LABELS
86
91
  ): boolean {
87
- return !!this.children
88
- .filter((c) => !c.isExcludedByFilter(filters))
89
- .find((c) => c.hitTestPoint(point, margin, hitInside))
92
+ for (const child of this.children) {
93
+ if (child.isExcludedByFilter(filters)) continue
94
+ if (child.hitTestPoint(point, margin, hitInside)) return true
95
+ }
96
+ return false
90
97
  }
91
98
 
92
99
  override hitTestLineSegment(
@@ -95,42 +102,71 @@ export class Group2d extends Geometry2d {
95
102
  zoom: number,
96
103
  filters = Geometry2dFilters.EXCLUDE_LABELS
97
104
  ): boolean {
98
- return !!this.children
99
- .filter((c) => !c.isExcludedByFilter(filters))
100
- .find((c) => c.hitTestLineSegment(A, B, zoom))
105
+ for (const child of this.children) {
106
+ if (child.isExcludedByFilter(filters)) continue
107
+ if (child.hitTestLineSegment(A, B, zoom)) return true
108
+ }
109
+ return false
101
110
  }
102
111
 
103
112
  override intersectLineSegment(A: VecLike, B: VecLike, filters?: Geometry2dFilters) {
104
- return this.children.flatMap((child) => {
105
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
106
- return child.intersectLineSegment(A, B, filters)
107
- })
113
+ const result: VecLike[] = []
114
+ for (const child of this.children) {
115
+ if (child.isExcludedByFilter(filters)) continue
116
+ const hits = child.intersectLineSegment(A, B, filters)
117
+ for (let i = 0, n = hits.length; i < n; i++) {
118
+ result.push(hits[i])
119
+ }
120
+ }
121
+ return result
108
122
  }
109
123
 
110
124
  override intersectCircle(center: VecLike, radius: number, filters?: Geometry2dFilters) {
111
- return this.children.flatMap((child) => {
112
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
113
- return child.intersectCircle(center, radius, filters)
114
- })
125
+ const result: VecLike[] = []
126
+ for (const child of this.children) {
127
+ if (child.isExcludedByFilter(filters)) continue
128
+ const hits = child.intersectCircle(center, radius, filters)
129
+ for (let i = 0, n = hits.length; i < n; i++) {
130
+ result.push(hits[i])
131
+ }
132
+ }
133
+ return result
115
134
  }
116
135
 
117
136
  override getBoundsVertices(): Vec[] {
118
137
  if (this.excludeFromShapeBounds) return []
119
- return this.children.flatMap((child) => child.getBoundsVertices())
138
+ const vertices: Vec[] = []
139
+ for (const child of this.children) {
140
+ const childVertices = child.getBoundsVertices()
141
+ for (let i = 0, n = childVertices.length; i < n; i++) {
142
+ vertices.push(childVertices[i])
143
+ }
144
+ }
145
+ return vertices
120
146
  }
121
147
 
122
148
  override intersectPolygon(polygon: VecLike[], filters?: Geometry2dFilters) {
123
- return this.children.flatMap((child) => {
124
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
125
- return child.intersectPolygon(polygon, filters)
126
- })
149
+ const result: VecLike[] = []
150
+ for (const child of this.children) {
151
+ if (child.isExcludedByFilter(filters)) continue
152
+ const hits = child.intersectPolygon(polygon, filters)
153
+ for (let i = 0, n = hits.length; i < n; i++) {
154
+ result.push(hits[i])
155
+ }
156
+ }
157
+ return result
127
158
  }
128
159
 
129
160
  override intersectPolyline(polyline: VecLike[], filters?: Geometry2dFilters) {
130
- return this.children.flatMap((child) => {
131
- if (child.isExcludedByFilter(filters)) return EMPTY_ARRAY
132
- return child.intersectPolyline(polyline, filters)
133
- })
161
+ const result: VecLike[] = []
162
+ for (const child of this.children) {
163
+ if (child.isExcludedByFilter(filters)) continue
164
+ const hits = child.intersectPolyline(polyline, filters)
165
+ for (let i = 0, n = hits.length; i < n; i++) {
166
+ result.push(hits[i])
167
+ }
168
+ }
169
+ return result
134
170
  }
135
171
 
136
172
  override interpolateAlongEdge(t: number, filters?: Geometry2dFilters): Vec {
@@ -0,0 +1,39 @@
1
+ import { normalizeWheel } from './normalizeWheel'
2
+
3
+ function wheelEvent(props: Partial<WheelEvent>): WheelEvent {
4
+ return {
5
+ deltaX: 0,
6
+ deltaY: 0,
7
+ ctrlKey: false,
8
+ altKey: false,
9
+ metaKey: false,
10
+ shiftKey: false,
11
+ ...props,
12
+ } as WheelEvent
13
+ }
14
+
15
+ describe('normalizeWheel', () => {
16
+ it('negates finite deltas for panning', () => {
17
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: 5, deltaY: 10 }))
18
+ expect(x).toBe(-5)
19
+ expect(y).toBe(-10)
20
+ })
21
+
22
+ it('coerces a non-finite deltaX to zero movement', () => {
23
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: NaN, deltaY: 10 }))
24
+ expect(x === 0).toBe(true)
25
+ expect(y).toBe(-10)
26
+ })
27
+
28
+ it('coerces a non-finite deltaY to zero movement', () => {
29
+ const { x, y } = normalizeWheel(wheelEvent({ deltaX: 5, deltaY: Infinity }))
30
+ expect(x).toBe(-5)
31
+ expect(y === 0).toBe(true)
32
+ })
33
+
34
+ it('does not produce a non-finite zoom delta when wheeling with a bad deltaY', () => {
35
+ const { z } = normalizeWheel(wheelEvent({ deltaY: NaN, ctrlKey: true }))
36
+ expect(Number.isFinite(z)).toBe(true)
37
+ expect(z === 0).toBe(true)
38
+ })
39
+ })
@@ -9,6 +9,12 @@ const MAX_ZOOM_STEP = 10
9
9
  * @internal */
10
10
  export function normalizeWheel(event: WheelEvent | React.WheelEvent<HTMLElement>) {
11
11
  let { deltaY, deltaX } = event
12
+
13
+ // Coerce non-finite deltas to no movement so they can't propagate into the camera and
14
+ // crash validation. Also covers deltaZ below, which is derived from deltaY.
15
+ if (!Number.isFinite(deltaX)) deltaX = 0
16
+ if (!Number.isFinite(deltaY)) deltaY = 0
17
+
12
18
  let deltaZ = 0
13
19
 
14
20
  // wheeling
package/src/version.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated by internal/scripts/refresh-assets.ts.
2
2
  // Do not edit manually. Or do, I'm a comment, not a cop.
3
3
 
4
- export const version = '5.3.0'
4
+ export const version = '5.4.0-canary.1b54b5062691'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-08-05T11:39:54.978Z',
8
- patch: '2026-08-05T11:39:54.978Z',
7
+ minor: '2026-08-07T14:56:56.539Z',
8
+ patch: '2026-08-07T14:56:56.539Z',
9
9
  }