@tldraw/editor 5.4.0 → 5.5.0-canary.04d1e6c4daff

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 (67) hide show
  1. package/dist-cjs/index.d.ts +5 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/constants.js +2 -0
  4. package/dist-cjs/lib/constants.js.map +2 -2
  5. package/dist-cjs/lib/editor/Editor.js +9 -6
  6. package/dist-cjs/lib/editor/Editor.js.map +2 -2
  7. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  8. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  10. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  12. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  13. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  14. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  15. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  16. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  17. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  18. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  19. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  20. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  21. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  22. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  23. package/dist-cjs/version.js +3 -3
  24. package/dist-cjs/version.js.map +1 -1
  25. package/dist-esm/index.d.mts +5 -2
  26. package/dist-esm/index.mjs +1 -1
  27. package/dist-esm/lib/constants.mjs +2 -0
  28. package/dist-esm/lib/constants.mjs.map +2 -2
  29. package/dist-esm/lib/editor/Editor.mjs +10 -6
  30. package/dist-esm/lib/editor/Editor.mjs.map +2 -2
  31. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  32. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  33. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  34. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  35. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  36. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  37. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  38. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  39. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  40. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  41. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  42. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  43. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  44. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  45. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  46. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  47. package/dist-esm/version.mjs +3 -3
  48. package/dist-esm/version.mjs.map +1 -1
  49. package/package.json +7 -7
  50. package/src/lib/constants.ts +8 -0
  51. package/src/lib/editor/Editor.ts +19 -9
  52. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  53. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  54. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  55. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  56. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  57. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  58. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  59. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  60. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  61. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  62. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  63. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  64. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  65. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  66. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  67. package/src/version.ts +3 -3
@@ -110,6 +110,7 @@ import {
110
110
  import {
111
111
  DEFAULT_ANIMATION_OPTIONS,
112
112
  DEFAULT_CAMERA_OPTIONS,
113
+ FRAME_MS_60HZ,
113
114
  INTERNAL_POINTER_IDS,
114
115
  LEFT_MOUSE_BUTTON,
115
116
  MIDDLE_MOUSE_BUTTON,
@@ -2303,8 +2304,11 @@ export class Editor extends EventEmitter<TLEventMap> {
2303
2304
  const shapeIds = readingOrderShapes.map((shape) => shape.id)
2304
2305
 
2305
2306
  const currentIndex = currentShapeId ? shapeIds.indexOf(currentShapeId) : -1
2307
+ // With no current index, stepping from -1 makes 'prev' land one shape
2308
+ // before the last; seed it from 0 so it wraps to the last shape. See #10559.
2309
+ const startIndex = currentIndex === -1 && direction === 'prev' ? 0 : currentIndex
2306
2310
  const adjacentIndex =
2307
- (currentIndex + (direction === 'next' ? 1 : -1) + shapeIds.length) % shapeIds.length
2311
+ (startIndex + (direction === 'next' ? 1 : -1) + shapeIds.length) % shapeIds.length
2308
2312
  adjacentShapeId = shapeIds[adjacentIndex]
2309
2313
  } else {
2310
2314
  if (!currentShapeId) return
@@ -3591,6 +3595,18 @@ export class Editor extends EventEmitter<TLEventMap> {
3591
3595
  const { isLocked } = this._cameraOptions.__unsafe__getWithoutCapture()
3592
3596
  if (isLocked && !opts?.force) return this
3593
3597
 
3598
+ const _point = Vec.Cast(point)
3599
+
3600
+ // Reject non-finite values before anything else, so the call is a no-op rather than a
3601
+ // partial one. An animated move writes the camera from a 'tick' listener, and a listener
3602
+ // that throws stops TickManager scheduling the next frame, which kills every frame-driven
3603
+ // behavior for the rest of the session instead of surfacing the error to the caller.
3604
+ if (!Number.isFinite(_point.x) || !Number.isFinite(_point.y) || !Number.isFinite(_point.z)) {
3605
+ throw Error(
3606
+ `Editor.setCamera: expected finite values, got (${_point.x}, ${_point.y}, ${_point.z}).`
3607
+ )
3608
+ }
3609
+
3594
3610
  // Stop any camera animations
3595
3611
  this.stopCameraAnimation()
3596
3612
 
@@ -3599,12 +3615,6 @@ export class Editor extends EventEmitter<TLEventMap> {
3599
3615
  this.stopFollowingUser()
3600
3616
  }
3601
3617
 
3602
- const _point = Vec.Cast(point)
3603
-
3604
- if (!Number.isFinite(_point.x)) _point.x = 0
3605
- if (!Number.isFinite(_point.y)) _point.y = 0
3606
- if (_point.z === undefined || !Number.isFinite(_point.z)) point.z = this.getZoomLevel()
3607
-
3608
3618
  const camera = this.getConstrainedCamera(_point, opts)
3609
3619
 
3610
3620
  if (opts?.animation) {
@@ -4086,8 +4096,8 @@ export class Editor extends EventEmitter<TLEventMap> {
4086
4096
  newCy += center.y / newCz - center.y / cz
4087
4097
  }
4088
4098
 
4089
- // Apply friction
4090
- currentSpeed *= 1 - friction
4099
+ // Apply friction per unit of elapsed time, not per tick, or a 120 Hz display decays twice as fast
4100
+ currentSpeed *= (1 - friction) ** (elapsed / FRAME_MS_60HZ)
4091
4101
  if (currentSpeed < speedThreshold) {
4092
4102
  cancel()
4093
4103
  } else {
@@ -262,15 +262,46 @@ describe('EdgeScrollManager', () => {
262
262
  })
263
263
 
264
264
  it('should adjust scroll speed based on zoom level', () => {
265
- editor.getZoomLevel.mockReturnValue(2)
266
265
  mockInputs.setCurrentScreenPoint(new Vec(5, 300))
267
266
 
268
267
  edgeScrollManager.updateEdgeScrolling(300)
268
+ const atZoom1 = (editor.setCamera.mock.calls[0][0] as Vec).x
269
+
270
+ editor.setCamera.mockClear()
271
+ edgeScrollManager = new EdgeScrollManager(editor)
272
+ editor.getZoomLevel.mockReturnValue(2)
273
+ edgeScrollManager.updateEdgeScrolling(300)
274
+ const atZoom2 = (editor.setCamera.mock.calls[0][0] as Vec).x
269
275
 
270
- expect(editor.setCamera).toHaveBeenCalled()
271
- const callArgs = editor.setCamera.mock.calls[0][0] as Vec
272
276
  // Higher zoom should result in smaller camera movement
273
- expect(Math.abs(callArgs.x)).toBeLessThan(25)
277
+ expect(atZoom2).toBeCloseTo(atZoom1 / 2)
278
+ })
279
+
280
+ it('should move the same distance per second regardless of tick rate', () => {
281
+ mockInputs.setCurrentScreenPoint(new Vec(5, 300))
282
+
283
+ // Get past the delay and easing ramp so each tick moves at full speed
284
+ edgeScrollManager.updateEdgeScrolling(1000)
285
+ editor.setCamera.mockClear()
286
+
287
+ // One second of 60 Hz ticks
288
+ for (let i = 0; i < 60; i++) edgeScrollManager.updateEdgeScrolling(1000 / 60)
289
+ const at60Hz = editor.setCamera.mock.calls.reduce(
290
+ (sum, [camera]) => sum + (camera as Vec).x,
291
+ 0
292
+ )
293
+
294
+ editor.setCamera.mockClear()
295
+
296
+ // One second of 120 Hz ticks
297
+ for (let i = 0; i < 120; i++) edgeScrollManager.updateEdgeScrolling(1000 / 120)
298
+ const at120Hz = editor.setCamera.mock.calls.reduce(
299
+ (sum, [camera]) => sum + (camera as Vec).x,
300
+ 0
301
+ )
302
+
303
+ expect(at60Hz).toBeGreaterThan(0)
304
+ expect(at120Hz).toBeCloseTo(at60Hz)
274
305
  })
275
306
 
276
307
  it('should add scroll delta to current camera position', () => {
@@ -1,3 +1,4 @@
1
+ import { FRAME_MS_60HZ } from '../../../constants'
1
2
  import { EASINGS } from '../../../primitives/easings'
2
3
  import { Vec } from '../../../primitives/Vec'
3
4
  import type { Editor } from '../../Editor'
@@ -51,10 +52,13 @@ export class EdgeScrollManager {
51
52
  )
52
53
  )
53
54
  : 1
54
- this.moveCameraWhenCloseToEdge({
55
- x: edgeScrollProximityFactor.x * eased,
56
- y: edgeScrollProximityFactor.y * eased,
57
- })
55
+ this.moveCameraWhenCloseToEdge(
56
+ {
57
+ x: edgeScrollProximityFactor.x * eased,
58
+ y: edgeScrollProximityFactor.y * eased,
59
+ },
60
+ elapsed
61
+ )
58
62
  }
59
63
  }
60
64
  }
@@ -112,7 +116,7 @@ export class EdgeScrollManager {
112
116
  * Moves the camera when the mouse is close to the edge of the screen.
113
117
  * @public
114
118
  */
115
- private moveCameraWhenCloseToEdge(proximityFactor: { x: number; y: number }) {
119
+ private moveCameraWhenCloseToEdge(proximityFactor: { x: number; y: number }, elapsed: number) {
116
120
  if (proximityFactor.x === 0 && proximityFactor.y === 0) return
117
121
  const { editor } = this
118
122
 
@@ -128,9 +132,11 @@ export class EdgeScrollManager {
128
132
  ? EDGE_SCROLL_SMALL_SCREEN_SPEED_FACTOR
129
133
  : 1
130
134
 
131
- // Determines the base speed of the scroll
135
+ // Determines the base speed of the scroll. edgeScrollSpeed is px per 60 Hz frame, so scale
136
+ // by elapsed time or a 120 Hz display scrolls twice as fast.
132
137
  const zoomLevel = editor.getZoomLevel()
133
- const pxSpeed = editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed
138
+ const pxSpeed =
139
+ editor.user.getEdgeScrollSpeed() * editor.options.edgeScrollSpeed * (elapsed / FRAME_MS_60HZ)
134
140
  const scrollDeltaX = (pxSpeed * proximityFactor.x * screenSizeFactorX) / zoomLevel
135
141
  const scrollDeltaY = (pxSpeed * proximityFactor.y * screenSizeFactorY) / zoomLevel
136
142
 
@@ -0,0 +1,53 @@
1
+ import { TLTheme } from '@tldraw/tlschema'
2
+ import { MockInstance, vi } from 'vitest'
3
+ import { TestEditor } from '../../../test/TestEditor'
4
+ import { DEFAULT_THEME } from './defaultThemes'
5
+
6
+ // Optional so that other tests' theme objects still satisfy TLThemes.
7
+ declare module '@tldraw/tlschema' {
8
+ interface TLThemes {
9
+ ocean?: TLTheme
10
+ sunset?: TLTheme
11
+ }
12
+ }
13
+
14
+ const oceanTheme: TLTheme = { ...DEFAULT_THEME, id: 'ocean' }
15
+
16
+ let editor: TestEditor
17
+ let warn: MockInstance
18
+
19
+ beforeEach(() => {
20
+ editor = new TestEditor({ themes: { ocean: oceanTheme } })
21
+ warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
22
+ })
23
+
24
+ afterEach(() => {
25
+ warn.mockRestore()
26
+ editor.dispose()
27
+ })
28
+
29
+ describe('setCurrentTheme', () => {
30
+ it('switches to a registered theme', () => {
31
+ editor.setCurrentTheme('ocean')
32
+
33
+ expect(editor.getCurrentThemeId()).toBe('ocean')
34
+ expect(editor.getCurrentTheme()).toBe(oceanTheme)
35
+ expect(warn).not.toHaveBeenCalled()
36
+ })
37
+
38
+ it('warns and keeps the current theme when the id is not registered', () => {
39
+ editor.setCurrentTheme('sunset')
40
+
41
+ expect(warn).toHaveBeenCalledWith("Theme 'sunset' not found. Available themes: default, ocean")
42
+ expect(editor.getCurrentThemeId()).toBe('default')
43
+ expect(editor.getCurrentTheme()).toBe(DEFAULT_THEME)
44
+ })
45
+
46
+ it('keeps the previous theme rather than falling back to default', () => {
47
+ editor.setCurrentTheme('ocean')
48
+ editor.setCurrentTheme('sunset')
49
+
50
+ expect(editor.getCurrentThemeId()).toBe('ocean')
51
+ expect(editor.getCurrentTheme()).toBe(oceanTheme)
52
+ })
53
+ })
@@ -69,14 +69,20 @@ export class ThemeManager {
69
69
  return this._themes.get()[this.getCurrentThemeId()]!
70
70
  }
71
71
 
72
- /** Set the current theme by id. The theme must have been previously registered. */
72
+ /**
73
+ * Set the current theme by id. The theme must have been previously registered; an
74
+ * unregistered id is ignored and the current theme is kept.
75
+ */
73
76
  setCurrentTheme(id: TLThemeId): void {
74
- if (process.env.NODE_ENV !== 'production') {
75
- if (!(id in this._themes.get())) {
77
+ // Storing an unregistered id would make getCurrentTheme() return
78
+ // undefined despite its TLTheme return type.
79
+ if (!(id in this._themes.get())) {
80
+ if (process.env.NODE_ENV !== 'production') {
76
81
  console.warn(
77
82
  `Theme '${id}' not found. Available themes: ${Object.keys(this._themes.get()).join(', ')}`
78
83
  )
79
84
  }
85
+ return
80
86
  }
81
87
 
82
88
  this._currentThemeId.set(id)
@@ -39,14 +39,29 @@ export class GroupShapeUtil extends ShapeUtil<TLGroupShape> {
39
39
  return new Rectangle2d({ width: 1, height: 1, isFilled: false })
40
40
  }
41
41
 
42
- return new Group2d({
43
- children: children.map((childId) => {
44
- const shape = this.editor.getShape(childId)!
45
- return this.editor
46
- .getShapeGeometry(childId)
47
- .transform(this.editor.getShapeLocalTransform(shape)!, { isLabel: false })
48
- }),
49
- })
42
+ // A child id can outlive its record. The children index and the geometry cache are separate
43
+ // derivations, so inside a transaction that removes a child the index can still name it while
44
+ // `getShapeGeometry` already answers nothing — its `!` hides that it returns `undefined` for a
45
+ // record the store no longer holds. Asserting instead of checking threw a `TypeError` out of a
46
+ // reactive recompute, where no caller could catch it.
47
+ const geometries: Geometry2d[] = []
48
+ for (const childId of children) {
49
+ const child = this.editor.getShape(childId)
50
+ if (!child) continue
51
+ const geometry = this.editor.getShapeGeometry(childId)
52
+ if (!geometry) continue
53
+ const transform = this.editor.getShapeLocalTransform(child)
54
+ if (!transform) continue
55
+ geometries.push(geometry.transform(transform, { isLabel: false }))
56
+ }
57
+
58
+ // Every child resolved to nothing, so there is no meaningful extent to report. Matches the
59
+ // no-children case above rather than building an empty `Group2d`, whose bounds are `NaN`.
60
+ if (geometries.length === 0) {
61
+ return new Rectangle2d({ width: 1, height: 1, isFilled: false })
62
+ }
63
+
64
+ return new Group2d({ children: geometries })
50
65
  }
51
66
 
52
67
  component(shape: TLGroupShape) {
@@ -225,4 +225,13 @@ describe('Arc2d.getSvgPathData', () => {
225
225
  })
226
226
  expect(arc.getSvgPathData()).toBe('M10, 0 A10 10 0 0 1 7.07, 7.07')
227
227
  })
228
+
229
+ it('does not mutate the stored points', () => {
230
+ const start = new Vec(10, 0)
231
+ const end = new Vec(10 / Math.SQRT2, 10 / Math.SQRT2)
232
+ const arc = new Arc2d({ center: new Vec(0, 0), start, end, sweepFlag: 1, largeArcFlag: 0 })
233
+ arc.getSvgPathData()
234
+ expect(start).toMatchObject({ x: 10, y: 0 })
235
+ expect(end).toMatchObject({ x: 10 / Math.SQRT2, y: 10 / Math.SQRT2 })
236
+ })
228
237
  })
@@ -105,7 +105,7 @@ export class Arc2d extends Geometry2d {
105
105
  _largeArcFlag: largeArcFlag,
106
106
  _sweepFlag: sweepFlag,
107
107
  } = this
108
- return `${first ? `M${start.toFixed()}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.toFixed()}`
108
+ return `${first ? `M${start}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end}`
109
109
  }
110
110
 
111
111
  override getLength() {
@@ -172,4 +172,17 @@ describe('CubicBezier2d.getSvgPathData', () => {
172
172
  })
173
173
  expect(b.getSvgPathData()).toBe('M 0.12, 0 C1.01, 2 3, 5 10, 10')
174
174
  })
175
+
176
+ it('does not mutate the stored points', () => {
177
+ const start = new Vec(0.123, 0)
178
+ const cp1 = new Vec(1.006, 2)
179
+ const cp2 = new Vec(3, 4.999)
180
+ const end = new Vec(10, 10.005)
181
+ const b = new CubicBezier2d({ start, cp1, cp2, end })
182
+ b.getSvgPathData()
183
+ expect(start).toMatchObject({ x: 0.123, y: 0 })
184
+ expect(cp1).toMatchObject({ x: 1.006, y: 2 })
185
+ expect(cp2).toMatchObject({ x: 3, y: 4.999 })
186
+ expect(end).toMatchObject({ x: 10, y: 10.005 })
187
+ })
175
188
  })
@@ -81,7 +81,7 @@ export class CubicBezier2d extends Polyline2d {
81
81
 
82
82
  getSvgPathData(first = true) {
83
83
  const { _a: a, _b: b, _c: c, _d: d } = this
84
- return `${first ? `M ${a.toFixed()} ` : ``} C${b.toFixed()} ${c.toFixed()} ${d.toFixed()}`
84
+ return `${first ? `M ${a} ` : ``} C${b} ${c} ${d}`
85
85
  }
86
86
 
87
87
  static GetAtT(segment: CubicBezier2d, t: number) {
@@ -1,5 +1,23 @@
1
- describe('Edge2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Edge2d } from './Edge2d'
3
+
4
+ describe('Edge2d.getSvgPathData', () => {
5
+ it('emits a move and a line command, rounded to two decimals', () => {
6
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
7
+ expect(edge.getSvgPathData()).toBe('M0.12, 0 L10, 10.99')
8
+ })
9
+
10
+ it('omits the move command when not first', () => {
11
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
12
+ expect(edge.getSvgPathData(false)).toBe(' L10, 10.99')
13
+ })
14
+
15
+ it('does not mutate the stored points', () => {
16
+ const start = new Vec(0.123456, 0)
17
+ const end = new Vec(10, 10.987654)
18
+ const edge = new Edge2d({ start, end })
19
+ edge.getSvgPathData()
20
+ expect(start).toMatchObject({ x: 0.123456, y: 0 })
21
+ expect(end).toMatchObject({ x: 10, y: 10.987654 })
4
22
  })
5
23
  })
@@ -68,6 +68,6 @@ export class Edge2d extends Geometry2d {
68
68
 
69
69
  getSvgPathData(first = true) {
70
70
  const { _start: start, _end: end } = this
71
- return `${first ? `M${start.toFixed()}` : ``} L${end.toFixed()}`
71
+ return `${first ? `M${start}` : ``} L${end}`
72
72
  }
73
73
  }
@@ -340,20 +340,38 @@ describe('Group2d.transform', () => {
340
340
  expect(t.vertices.length).toBe(8)
341
341
  })
342
342
 
343
- it('carries the label, debug colour and ignore flags forward', () => {
343
+ it('carries all the flags forward', () => {
344
344
  const g = new Group2d({
345
345
  children: [rectA()],
346
346
  isLabel: true,
347
+ isEmptyLabel: true,
348
+ isInternal: true,
349
+ excludeFromShapeBounds: true,
347
350
  debugColor: 'red',
348
351
  ignore: true,
349
352
  })
350
353
  expect(g.transform(Mat.Identity())).toMatchObject({
351
354
  isLabel: true,
355
+ isEmptyLabel: true,
356
+ isInternal: true,
357
+ excludeFromShapeBounds: true,
352
358
  debugColor: 'red',
353
359
  ignore: true,
354
360
  })
355
361
  })
356
362
 
363
+ it('applies the transform options over the carried flags', () => {
364
+ const g = new Group2d({ children: [rectA()], isLabel: true, isInternal: true })
365
+ expect(
366
+ g.transform(Mat.Identity(), {
367
+ isLabel: false,
368
+ isInternal: false,
369
+ debugColor: 'red',
370
+ ignore: true,
371
+ })
372
+ ).toMatchObject({ isLabel: false, isInternal: false, debugColor: 'red', ignore: true })
373
+ })
374
+
357
375
  it('scales hit testing with the transform', () => {
358
376
  const t = group().transform(Mat.Scale(2, 2))
359
377
  expect(t.hitTestPoint(new Vec(100, 50), 0, false)).toBe(true)
@@ -361,12 +379,15 @@ describe('Group2d.transform', () => {
361
379
  expect(t.distanceToPoint(new Vec(300, 50))).toBe(100)
362
380
  })
363
381
 
364
- // Locks in current behaviour, see #10562.
365
- it('drops the ignored children', () => {
382
+ // #10562: transform used to drop the ignored children.
383
+ it('keeps the transformed ignored children', () => {
366
384
  const ignored = new Rectangle2d({ x: 500, width: 10, height: 10, isFilled: true, ignore: true })
367
385
  const g = new Group2d({ children: [rectA(), ignored] })
368
- expect(g.ignoredChildren.length).toBe(1)
369
- expect((g.transform(Mat.Identity()) as Group2d).ignoredChildren).toEqual([])
386
+ const t = g.transform(Mat.Translate(10, 20)) as Group2d
387
+ expect(t.children.length).toBe(1)
388
+ expect(t.ignoredChildren.length).toBe(1)
389
+ expect(t.ignoredChildren[0].ignore).toBe(true)
390
+ expect(t.ignoredChildren[0].bounds).toMatchObject({ x: 510, y: 20, w: 10, h: 10 })
370
391
  })
371
392
  })
372
393
 
@@ -1,8 +1,13 @@
1
1
  import { assert, invLerp, lerp } from '@tldraw/utils'
2
2
  import { Box } from '../Box'
3
- import { Mat } from '../Mat'
3
+ import { MatModel } from '../Mat'
4
4
  import { Vec, VecLike } from '../Vec'
5
- import { Geometry2d, Geometry2dFilters, Geometry2dOptions } from './Geometry2d'
5
+ import {
6
+ Geometry2d,
7
+ Geometry2dFilters,
8
+ Geometry2dOptions,
9
+ TransformedGeometry2dOptions,
10
+ } from './Geometry2d'
6
11
 
7
12
  /** @public */
8
13
  export class Group2d extends Geometry2d {
@@ -226,12 +231,18 @@ export class Group2d extends Geometry2d {
226
231
  return childTLength / totalLength
227
232
  }
228
233
 
229
- override transform(transform: Mat): Geometry2d {
234
+ override transform(transform: MatModel, opts?: TransformedGeometry2dOptions): Geometry2d {
235
+ // Each child's own transform preserves its `ignore` flag, so the constructor
236
+ // re-partitions ignored children back into `ignoredChildren` instead of dropping
237
+ // them (#10562).
230
238
  return new Group2d({
231
- children: this.children.map((c) => c.transform(transform)),
232
- isLabel: this.isLabel,
233
- debugColor: this.debugColor,
234
- ignore: this.ignore,
239
+ children: [...this.children, ...this.ignoredChildren].map((c) => c.transform(transform)),
240
+ isLabel: opts?.isLabel ?? this.isLabel,
241
+ isEmptyLabel: this.isEmptyLabel,
242
+ isInternal: opts?.isInternal ?? this.isInternal,
243
+ debugColor: opts?.debugColor ?? this.debugColor,
244
+ ignore: opts?.ignore ?? this.ignore,
245
+ excludeFromShapeBounds: this.excludeFromShapeBounds,
235
246
  })
236
247
  }
237
248
 
@@ -1,5 +1,16 @@
1
- describe('Point2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Point2d } from './Point2d'
3
+
4
+ describe('Point2d.getSvgPathData', () => {
5
+ it('emits a move command rounded to two decimals', () => {
6
+ const point = new Point2d({ margin: 0, point: new Vec(0.123456, 7.891234) })
7
+ expect(point.getSvgPathData()).toBe('M0.12, 7.89')
8
+ })
9
+
10
+ it('does not mutate the stored point', () => {
11
+ const vec = new Vec(0.123456, 7.891234)
12
+ const point = new Point2d({ margin: 0, point: vec })
13
+ point.getSvgPathData()
14
+ expect(vec).toMatchObject({ x: 0.123456, y: 7.891234 })
4
15
  })
5
16
  })
@@ -28,6 +28,6 @@ export class Point2d extends Geometry2d {
28
28
 
29
29
  getSvgPathData() {
30
30
  const { _point: point } = this
31
- return `M${point.toFixed()}`
31
+ return `M${point}`
32
32
  }
33
33
  }
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.4.0'
4
+ export const version = '5.5.0-canary.04d1e6c4daff'
5
5
  export const publishDates = {
6
6
  major: '2026-05-06T16:28:18.473Z',
7
- minor: '2026-09-02T11:04:02.901Z',
8
- patch: '2026-09-02T11:04:02.901Z',
7
+ minor: '2026-09-04T14:57:28.023Z',
8
+ patch: '2026-09-04T14:57:28.023Z',
9
9
  }