@tldraw/editor 5.4.2 → 5.5.0-canary.003bf5951274

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 (200) hide show
  1. package/dist-cjs/index.d.ts +37 -14
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/constants.js +2 -0
  14. package/dist-cjs/lib/constants.js.map +2 -2
  15. package/dist-cjs/lib/editor/Editor.js +147 -157
  16. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  17. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  18. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  20. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  22. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  26. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  27. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  28. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  29. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  30. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  31. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  32. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  33. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  34. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  35. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  36. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  37. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  38. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  39. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  40. package/dist-cjs/lib/exports/parseCss.js +1 -1
  41. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  42. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  44. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  46. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  47. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  50. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  51. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  52. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  53. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  54. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  55. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  56. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  57. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  58. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  59. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  60. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  61. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  62. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  63. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  64. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +22 -24
  66. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  67. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  68. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  69. package/dist-cjs/version.js +3 -3
  70. package/dist-cjs/version.js.map +1 -1
  71. package/dist-esm/index.d.mts +37 -14
  72. package/dist-esm/index.mjs +4 -2
  73. package/dist-esm/index.mjs.map +2 -2
  74. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  75. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  76. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  77. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  79. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  80. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  81. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  82. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  83. package/dist-esm/lib/constants.mjs +2 -0
  84. package/dist-esm/lib/constants.mjs.map +2 -2
  85. package/dist-esm/lib/editor/Editor.mjs +148 -157
  86. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  87. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  88. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  90. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  92. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  94. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  95. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  96. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  97. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  98. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  99. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  100. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  101. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  102. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  103. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  104. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  105. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  106. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  107. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  108. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  109. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  110. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  111. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  112. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  113. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  114. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  115. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  116. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  117. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  118. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  119. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  120. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  121. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  122. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  123. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  124. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  125. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  126. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  127. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  128. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  129. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  130. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  131. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  132. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  133. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  134. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +22 -24
  135. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  136. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  137. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  138. package/dist-esm/version.mjs +3 -3
  139. package/dist-esm/version.mjs.map +1 -1
  140. package/editor.css +9 -1
  141. package/package.json +8 -11
  142. package/src/index.ts +5 -1
  143. package/src/lib/TldrawEditor.tsx +35 -21
  144. package/src/lib/components/MenuClickCapture.tsx +55 -4
  145. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  146. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  147. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  148. package/src/lib/constants.ts +8 -0
  149. package/src/lib/editor/Editor.test.ts +20 -0
  150. package/src/lib/editor/Editor.ts +228 -262
  151. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  152. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  153. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  154. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  155. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  156. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  157. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  158. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  159. package/src/lib/editor/types/external-content.ts +5 -0
  160. package/src/lib/exports/ExportDelay.tsx +16 -4
  161. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  162. package/src/lib/exports/FontEmbedder.ts +32 -11
  163. package/src/lib/exports/StyleEmbedder.ts +13 -4
  164. package/src/lib/exports/fetchCache.test.ts +22 -0
  165. package/src/lib/exports/fetchCache.ts +4 -0
  166. package/src/lib/exports/getSvgAsImage.ts +13 -5
  167. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  168. package/src/lib/exports/getSvgJsx.tsx +28 -16
  169. package/src/lib/exports/parseCss.test.ts +4 -0
  170. package/src/lib/exports/parseCss.ts +2 -2
  171. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  172. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  173. package/src/lib/hooks/useIdentity.tsx +12 -1
  174. package/src/lib/license/LicenseManager.ts +13 -1
  175. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  176. package/src/lib/license/LicenseProvider.tsx +19 -5
  177. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  178. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  179. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  180. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  181. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  182. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  183. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  184. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  185. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  186. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  187. package/src/lib/test/preserveFocus.test.tsx +81 -0
  188. package/src/lib/utils/debug-flags.ts +0 -6
  189. package/src/lib/utils/deepLinks.test.ts +15 -0
  190. package/src/lib/utils/deepLinks.ts +8 -3
  191. package/src/lib/utils/sync/LocalIndexedDb.test.ts +95 -0
  192. package/src/lib/utils/sync/LocalIndexedDb.ts +31 -27
  193. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  194. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  195. package/src/version.ts +3 -3
  196. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  197. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  198. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  199. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  200. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -18,6 +18,8 @@ function fromScratch(bindingsQuery: Computed<TLBinding[], unknown>) {
18
18
  } else {
19
19
  bindingsForFromShape.push(binding)
20
20
  }
21
+ // A binding from a shape to itself belongs in that shape's array once, not twice
22
+ if (toId === fromId) continue
21
23
  const bindingsForToShape = shapesToBindings.get(toId)
22
24
  if (!bindingsForToShape) {
23
25
  shapesToBindings.set(toId, [binding])
@@ -83,6 +85,7 @@ export function bindingsIndex(editor: Editor): Computed<TLBindingsIndex> {
83
85
 
84
86
  function addBinding(binding: TLBinding) {
85
87
  ensureNewArray(binding.fromId).push(binding)
88
+ if (binding.toId === binding.fromId) return
86
89
  ensureNewArray(binding.toId).push(binding)
87
90
  }
88
91
 
@@ -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
 
@@ -307,6 +307,80 @@ describe('TextManager', () => {
307
307
  expect(result.spans[0].text).toBe(`a${family}b`)
308
308
  })
309
309
 
310
+ it('should measure each text node with its own offsets', () => {
311
+ const starts: [unknown, number][] = []
312
+ const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
313
+ RangeMock.mockImplementationOnce(function () {
314
+ return {
315
+ setStart: vi.fn((node: unknown, offset: number) => starts.push([node, offset])),
316
+ setEnd: vi.fn(),
317
+ getClientRects: vi.fn(() => [
318
+ { width: 10, height: 16, left: 0, top: 0, right: 10, bottom: 16 },
319
+ ]),
320
+ }
321
+ })
322
+
323
+ const first = { nodeType: 3, textContent: 'ab' }
324
+ const second = { nodeType: 3, textContent: 'cd' }
325
+ const mockElementWithText = {
326
+ childNodes: [first, { nodeType: 1 }, second],
327
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
328
+ }
329
+
330
+ textManager.measureElementTextNodeSpans(mockElementWithText as any)
331
+
332
+ // the range must be placed in the node being measured, not the first one
333
+ expect(starts).toEqual([
334
+ [first, 0],
335
+ [first, 1],
336
+ [second, 0],
337
+ [second, 1],
338
+ ])
339
+ })
340
+
341
+ it('should stop measuring later text nodes once the first line is truncated', () => {
342
+ const starts: [unknown, number][] = []
343
+ let current: { textContent: string } | null = null
344
+ const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
345
+ RangeMock.mockImplementationOnce(function () {
346
+ return {
347
+ setStart: vi.fn((node: { textContent: string }, offset: number) => {
348
+ current = node
349
+ starts.push([node, offset])
350
+ }),
351
+ setEnd: vi.fn(),
352
+ // the second node sits on a new line
353
+ getClientRects: vi.fn(() => {
354
+ const top = current?.textContent === 'cd' ? 16 : 0
355
+ return [{ width: 10, height: 16, left: 0, top, right: 10, bottom: top + 16 }]
356
+ }),
357
+ }
358
+ })
359
+
360
+ const first = { nodeType: 3, textContent: 'ab' }
361
+ const second = { nodeType: 3, textContent: 'cd' }
362
+ const third = { nodeType: 3, textContent: 'ef' }
363
+ const mockElementWithText = {
364
+ childNodes: [first, second, third],
365
+ getBoundingClientRect: () => ({ left: 0, top: 0 }),
366
+ }
367
+
368
+ const result = textManager.measureElementTextNodeSpans(mockElementWithText as any, {
369
+ shouldTruncateToFirstLine: true,
370
+ })
371
+
372
+ expect(result).toEqual({
373
+ spans: [{ box: { x: 0, y: 0, w: 10, h: 16 }, text: 'ab' }],
374
+ didTruncate: true,
375
+ })
376
+ // the third node must never be measured
377
+ expect(starts).toEqual([
378
+ [first, 0],
379
+ [first, 1],
380
+ [second, 0],
381
+ ])
382
+ })
383
+
310
384
  it('should handle truncation option', () => {
311
385
  const mockTextNode = {
312
386
  nodeType: 3, // TEXT_NODE
@@ -292,10 +292,8 @@ export class TextManager extends EditorManager {
292
292
  const offsetX = -elmBounds.left
293
293
  const offsetY = -elmBounds.top
294
294
 
295
- // we measure by creating a range that spans each character in the elements text node
295
+ // we measure by creating a range that spans each character in the elements text nodes
296
296
  const range = new Range()
297
- const textNode = element.childNodes[0]
298
- let idx = 0
299
297
 
300
298
  let currentSpan = null
301
299
  let prevCharWasSpaceCharacter = null
@@ -303,12 +301,15 @@ export class TextManager extends EditorManager {
303
301
  let prevCharLeftForRTLTest = 0
304
302
  let didTruncate = false
305
303
  for (const childNode of element.childNodes) {
304
+ if (didTruncate) break
306
305
  if (childNode.nodeType !== Node.TEXT_NODE) continue
307
306
 
307
+ // offsets are per text node, or the range would index past the node's end
308
+ let idx = 0
308
309
  for (const segment of iterateGraphemes(childNode.textContent ?? '')) {
309
310
  // place the range around the grapheme we're interested in
310
- range.setStart(textNode, idx)
311
- range.setEnd(textNode, idx + segment.length)
311
+ range.setStart(childNode, idx)
312
+ range.setEnd(childNode, idx + segment.length)
312
313
  // measure the range. some browsers return multiple rects for the
313
314
  // first char in a new line - one for the line break, and one for
314
315
  // the character itself. we're only interested in the character.
@@ -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) {
@@ -73,6 +73,11 @@ export interface TLFileReplaceExternalContent extends TLBaseExternalContent {
73
73
  export interface TLUrlExternalContent extends TLBaseExternalContent {
74
74
  type: 'url'
75
75
  url: string
76
+ /**
77
+ * A shape to set the url on, instead of creating a bookmark or an embed. The shape must have a
78
+ * `url` prop; the clipboard sets this when a link is pasted with one such shape selected.
79
+ */
80
+ shapeId?: TLShapeId
76
81
  }
77
82
 
78
83
  /** @public */
@@ -1,4 +1,4 @@
1
- import { bind, sleep } from '@tldraw/utils'
1
+ import { bind, noop, promiseWithResolve, sleep } from '@tldraw/utils'
2
2
 
3
3
  /**
4
4
  * Export delay is a helper class that allows you to wait for a set of promises to resolve before
@@ -11,8 +11,20 @@ import { bind, sleep } from '@tldraw/utils'
11
11
  export class ExportDelay {
12
12
  private isResolved = false
13
13
  private readonly promisesToWaitFor: Promise<void>[] = []
14
+ private readonly failure = promiseWithResolve<never>()
14
15
 
15
- constructor(private readonly maxDelayTimeMs: number) {}
16
+ constructor(private readonly maxDelayTimeMs: number) {
17
+ // only observed inside `resolve`, so a `fail` before then would otherwise be an unhandled rejection
18
+ this.failure.catch(noop)
19
+ }
20
+
21
+ /**
22
+ * Abort the export: `resolve` rejects with this error instead of waiting out the delay and
23
+ * snapshotting a broken svg.
24
+ */
25
+ @bind fail(error: unknown): void {
26
+ this.failure.reject(error)
27
+ }
16
28
 
17
29
  @bind waitUntil(promise: Promise<void>): void {
18
30
  if (this.isResolved) {
@@ -40,9 +52,9 @@ export class ExportDelay {
40
52
  const timeoutPromise = sleep(this.maxDelayTimeMs).then(() => 'timeout' as const)
41
53
  const resolvePromise = this.resolvePromises().then(() => 'resolved' as const)
42
54
 
43
- const result = await Promise.race([timeoutPromise, resolvePromise])
55
+ const result = await Promise.race([timeoutPromise, resolvePromise, this.failure])
44
56
  if (result === 'timeout') {
45
- console.warn('[tldraw] Export delay timed out after ${this.maxDelayTimeMs}ms')
57
+ console.warn(`[tldraw] Export delay timed out after ${this.maxDelayTimeMs}ms`)
46
58
  }
47
59
 
48
60
  this.isResolved = true
@@ -0,0 +1,99 @@
1
+ import { vi } from 'vitest'
2
+ import { fetchCssFontFaces, FontEmbedder } from './FontEmbedder'
3
+ import { parseCssFontFaces } from './parseCss'
4
+
5
+ describe('FontEmbedder', () => {
6
+ let style: HTMLStyleElement
7
+
8
+ beforeEach(() => {
9
+ style = document.createElement('style')
10
+ style.textContent = `
11
+ @font-face { font-family: 'Font A'; src: url('/font-a.woff2'); }
12
+ @font-face { font-family: 'Font B'; src: url('/font-b.woff2'); }
13
+ `
14
+ document.head.appendChild(style)
15
+ })
16
+
17
+ afterEach(() => {
18
+ style.remove()
19
+ })
20
+
21
+ it('embeds every family in a font-family list, even when an earlier one was already seen', async () => {
22
+ const embedder = new FontEmbedder()
23
+ embedder.startFindingDocumentFontFaces(document)
24
+
25
+ embedder.onFontFamilyValue(`'Font A'`)
26
+ embedder.onFontFamilyValue(`'Font A', 'Font B'`)
27
+
28
+ const css = await embedder.createCss()
29
+ expect(css).toContain('Font A')
30
+ expect(css).toContain('Font B')
31
+ })
32
+
33
+ it('retries a failed font fetch on the next export', async () => {
34
+ vi.spyOn(console, 'error').mockImplementation(() => {})
35
+ // the parsed sheet is shared across exports through the fetch cache
36
+ const fontFaces = parseCssFontFaces(
37
+ `@font-face { font-family: 'Font A'; src: url(a.woff2); }`,
38
+ 'https://example.com/fonts.css'
39
+ )
40
+ const blob = new Blob(['font'], { type: 'font/woff2' })
41
+ vi.spyOn(window, 'fetch')
42
+ .mockResolvedValueOnce({ ok: false } as Response)
43
+ .mockResolvedValue({ ok: true, blob: async () => blob } as Response)
44
+
45
+ const exportCss = async () => {
46
+ const embedder = new FontEmbedder()
47
+ embedder.startFindingDocumentFontFaces(document)
48
+ ;(embedder as any).fontFacesPromise = Promise.resolve(fontFaces)
49
+ embedder.onFontFamilyValue(`'Font A'`)
50
+ return embedder.createCss()
51
+ }
52
+
53
+ expect(await exportCss()).toContain('url(a.woff2)')
54
+ expect(await exportCss()).toContain('data:font/woff2;base64,')
55
+ })
56
+ })
57
+
58
+ describe('fetchCssFontFaces', () => {
59
+ afterEach(() => {
60
+ vi.restoreAllMocks()
61
+ })
62
+
63
+ it('resolves when stylesheets @import each other', async () => {
64
+ const sheets: Record<string, string> = {
65
+ 'https://example.com/a.css': `@import url(b.css); @font-face { font-family: 'Font A'; src: url(a.woff2); }`,
66
+ 'https://example.com/b.css': `@import url(a.css); @font-face { font-family: 'Font B'; src: url(b.woff2); }`,
67
+ }
68
+ vi.spyOn(window, 'fetch').mockImplementation(async (input) => {
69
+ const url = String(input)
70
+ return { ok: true, url, text: async () => sheets[url] } as Response
71
+ })
72
+
73
+ const fontFaces = await fetchCssFontFaces('https://example.com/a.css')
74
+ expect(fontFaces.map((f) => [...f.fontFamilies])).toEqual([['font a'], ['font b']])
75
+ })
76
+
77
+ it('keeps font order in source order when siblings import the same sheet', async () => {
78
+ const sheets: Record<string, string> = {
79
+ 'https://example.com/root.css': `@import url(c.css); @import url(d.css);`,
80
+ 'https://example.com/c.css': `@import url(e.css); @font-face { font-family: 'Font C'; src: url(c.woff2); }`,
81
+ 'https://example.com/d.css': `@import url(e.css); @font-face { font-family: 'Font D'; src: url(d.woff2); }`,
82
+ 'https://example.com/e.css': `@font-face { font-family: 'Font E'; src: url(e.woff2); }`,
83
+ }
84
+ vi.spyOn(window, 'fetch').mockImplementation(async (input) => {
85
+ const url = String(input)
86
+ // the first sibling resolves last, so the shared sheet is reached through the second first
87
+ if (url.endsWith('c.css')) await new Promise((resolve) => setTimeout(resolve, 20))
88
+ return { ok: true, url, text: async () => sheets[url] } as Response
89
+ })
90
+
91
+ const fontFaces = await fetchCssFontFaces('https://example.com/root.css')
92
+ expect(fontFaces.map((f) => [...f.fontFamilies][0])).toEqual([
93
+ 'font c',
94
+ 'font e',
95
+ 'font d',
96
+ 'font e',
97
+ ])
98
+ })
99
+ })
@@ -24,6 +24,9 @@ export class FontEmbedder {
24
24
  private fontFacesPromise: Promise<ParsedFontFace[]> | null = null
25
25
  private readonly foundFontNames = new Set<string>()
26
26
  private readonly fontFacesToEmbed = new Set<ParsedFontFace>()
27
+ // per export, keyed by resolved url: parsed font faces are shared across exports through the
28
+ // fetch cache, so a failed fetch stored on them would never be retried
29
+ private readonly embeddedUrls = new Map<string, Promise<string | null>>()
27
30
  private readonly pendingPromises: Promise<void>[] = []
28
31
 
29
32
  startFindingDocumentFontFaces(doc: Document) {
@@ -36,7 +39,7 @@ export class FontEmbedder {
36
39
 
37
40
  const fonts = parseCssFontFamilyValue(fontFamilyValue)
38
41
  for (const font of fonts) {
39
- if (this.foundFontNames.has(font)) return
42
+ if (this.foundFontNames.has(font)) continue
40
43
  this.foundFontNames.add(font)
41
44
 
42
45
  this.pendingPromises.push(
@@ -47,9 +50,9 @@ export class FontEmbedder {
47
50
 
48
51
  this.fontFacesToEmbed.add(fontFace)
49
52
  for (const url of fontFace.urls) {
50
- if (!url.resolved || url.embedded) continue
53
+ if (!url.resolved || this.embeddedUrls.has(url.resolved)) continue
51
54
  // kick off fetching this font
52
- url.embedded = resourceToDataUrl(url.resolved)
55
+ this.embeddedUrls.set(url.resolved, resourceToDataUrl(url.resolved))
53
56
  }
54
57
  }
55
58
  })
@@ -66,11 +69,11 @@ export class FontEmbedder {
66
69
  let fontFaceString = `@font-face {${fontFace.fontFace}}`
67
70
 
68
71
  for (const url of fontFace.urls) {
69
- if (!url.embedded) continue
70
- const dataUrl = await url.embedded
72
+ if (!url.resolved) continue
73
+ const dataUrl = await this.embeddedUrls.get(url.resolved)
71
74
  if (!dataUrl) continue
72
75
 
73
- fontFaceString = fontFaceString.replace(url.original, dataUrl)
76
+ fontFaceString = fontFaceString.replace(url.original, () => dataUrl)
74
77
  }
75
78
 
76
79
  css += fontFaceString
@@ -119,12 +122,30 @@ async function getDocumentFontFaces(doc: Document) {
119
122
  return compact(await Promise.all(fontFaces)).flat()
120
123
  }
121
124
 
122
- const fetchCssFontFaces = fetchCache(async (response: Response): Promise<ParsedFontFace[]> => {
125
+ const fetchParsedCss = fetchCache(async (response: Response) => {
123
126
  const parsed = parseCss(await response.text(), response.url)
127
+ return {
128
+ fontFaces: parsed.fontFaces,
129
+ importUrls: parsed.imports.map(({ url }) => new URL(url, response.url).href),
130
+ }
131
+ })
132
+
133
+ /** @internal */
134
+ export async function fetchCssFontFaces(
135
+ url: string,
136
+ // the chain of sheets that imported this one: sheets that @import each other would otherwise
137
+ // await their own in-flight fetch forever. Each branch gets its own copy so a sheet imported by
138
+ // two siblings lands under both in source order, not under whichever fetch resolved first.
139
+ seen = new Set<string>()
140
+ ): Promise<ParsedFontFace[]> {
141
+ if (seen.has(url)) return []
142
+ seen.add(url)
143
+
144
+ const parsed = await fetchParsedCss(url)
145
+ if (!parsed) return []
124
146
 
125
147
  const importedFontFaces = await Promise.all(
126
- parsed.imports.map(({ url }) => fetchCssFontFaces(new URL(url, response.url).href))
148
+ parsed.importUrls.map((importUrl) => fetchCssFontFaces(importUrl, new Set(seen)))
127
149
  )
128
-
129
- return [...parsed.fontFaces, ...compact(importedFontFaces).flat()]
130
- })
150
+ return [...parsed.fontFaces, ...importedFontFaces.flat()]
151
+ }
@@ -92,10 +92,19 @@ export class StyleEmbedder {
92
92
  if (urlMatches.length === 0) continue
93
93
 
94
94
  promises.push(
95
- ...urlMatches.map(async ({ url, original }) => {
96
- const dataUrl = (await resourceToDataUrl(url)) ?? 'data:'
97
- styles[property] = value.replace(original, `url("${dataUrl}")`)
98
- })
95
+ (async () => {
96
+ // resolve every url first, then rewrite the value in one pass - replacing
97
+ // per-url from the original value would keep only whichever resolved last.
98
+ // The replacer callback keeps `$` in a data url's mime segment literal.
99
+ const dataUrls = await Promise.all(
100
+ urlMatches.map(({ url }) => resourceToDataUrl(url))
101
+ )
102
+ styles[property] = urlMatches.reduce(
103
+ (result, { original }, i) =>
104
+ result.replace(original, () => `url("${dataUrls[i] ?? 'data:'}")`),
105
+ value
106
+ )
107
+ })()
99
108
  )
100
109
  }
101
110
  }
@@ -0,0 +1,22 @@
1
+ import { vi } from 'vitest'
2
+ import { fetchCache } from './fetchCache'
3
+
4
+ describe('fetchCache', () => {
5
+ afterEach(() => {
6
+ vi.restoreAllMocks()
7
+ })
8
+
9
+ it('caches successes but retries after a failure', async () => {
10
+ vi.spyOn(console, 'error').mockImplementation(() => {})
11
+ const fetchSpy = vi
12
+ .spyOn(window, 'fetch')
13
+ .mockResolvedValueOnce({ ok: false } as Response)
14
+ .mockResolvedValue({ ok: true, text: async () => 'hello' } as Response)
15
+ const fetchText = fetchCache((response) => response.text())
16
+
17
+ expect(await fetchText('https://example.com/a')).toBe(null)
18
+ expect(await fetchText('https://example.com/a')).toBe('hello')
19
+ expect(await fetchText('https://example.com/a')).toBe('hello')
20
+ expect(fetchSpy).toHaveBeenCalledTimes(2)
21
+ })
22
+ })
@@ -18,6 +18,10 @@ export function fetchCache<T>(cb: (response: Response) => Promise<T>, init?: Req
18
18
  }
19
19
  })()
20
20
  cache.set(url, promise)
21
+ // only keep successes, otherwise a transient failure poisons this url for the whole session
22
+ promise.then((result) => {
23
+ if (result === null && cache.get(url) === promise) cache.delete(url)
24
+ })
21
25
  return promise
22
26
  }
23
27
  }