@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0

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 (192) hide show
  1. package/dist-cjs/index.d.ts +49 -23
  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/editor/Editor.js +168 -181
  14. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  15. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  16. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  17. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
  18. package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
  20. package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
  22. package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
  24. package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
  26. package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
  27. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
  28. package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
  29. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
  30. package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
  31. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  32. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  33. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  34. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  35. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  36. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  37. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  38. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  39. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  40. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  41. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  42. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  43. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  44. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  45. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  46. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  47. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  48. package/dist-cjs/lib/exports/parseCss.js +1 -1
  49. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  50. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  51. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  52. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  53. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  54. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  55. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  56. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  57. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  58. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  59. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  60. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  61. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  62. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  63. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
  64. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  66. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  67. package/dist-cjs/version.js +3 -3
  68. package/dist-cjs/version.js.map +1 -1
  69. package/dist-esm/index.d.mts +49 -23
  70. package/dist-esm/index.mjs +4 -2
  71. package/dist-esm/index.mjs.map +2 -2
  72. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  73. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  74. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  75. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  76. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  77. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  79. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  80. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  81. package/dist-esm/lib/editor/Editor.mjs +168 -181
  82. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  83. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  84. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  85. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
  86. package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
  87. package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
  88. package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
  90. package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
  92. package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
  94. package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
  95. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
  96. package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
  97. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
  98. package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
  99. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  100. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  101. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  102. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  103. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  104. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  105. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  106. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  107. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  108. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  109. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  110. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  111. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  112. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  113. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  114. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  115. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  116. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  117. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  118. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  119. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  120. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  121. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  122. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  123. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  124. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  125. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  126. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  127. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  128. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  129. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  130. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
  131. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  132. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  133. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  134. package/dist-esm/version.mjs +3 -3
  135. package/dist-esm/version.mjs.map +1 -1
  136. package/editor.css +9 -1
  137. package/package.json +8 -11
  138. package/src/index.ts +5 -1
  139. package/src/lib/TldrawEditor.tsx +35 -21
  140. package/src/lib/components/MenuClickCapture.tsx +55 -4
  141. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  142. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  143. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  144. package/src/lib/editor/Editor.test.ts +65 -0
  145. package/src/lib/editor/Editor.ts +261 -294
  146. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  147. package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
  148. package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
  149. package/src/lib/editor/managers/EditorManager.test.ts +32 -1
  150. package/src/lib/editor/managers/EditorManager.ts +15 -3
  151. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
  152. package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
  153. package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
  154. package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
  155. package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
  156. package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
  157. package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
  158. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  159. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  160. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  161. package/src/lib/editor/types/external-content.ts +5 -0
  162. package/src/lib/exports/ExportDelay.tsx +16 -4
  163. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  164. package/src/lib/exports/FontEmbedder.ts +32 -11
  165. package/src/lib/exports/StyleEmbedder.ts +13 -4
  166. package/src/lib/exports/fetchCache.test.ts +22 -0
  167. package/src/lib/exports/fetchCache.ts +4 -0
  168. package/src/lib/exports/getSvgAsImage.ts +13 -5
  169. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  170. package/src/lib/exports/getSvgJsx.tsx +28 -16
  171. package/src/lib/exports/parseCss.test.ts +4 -0
  172. package/src/lib/exports/parseCss.ts +2 -2
  173. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  174. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  175. package/src/lib/hooks/useIdentity.tsx +12 -1
  176. package/src/lib/license/LicenseManager.ts +13 -1
  177. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  178. package/src/lib/license/LicenseProvider.tsx +19 -5
  179. package/src/lib/test/preserveFocus.test.tsx +81 -0
  180. package/src/lib/utils/debug-flags.ts +0 -6
  181. package/src/lib/utils/deepLinks.test.ts +15 -0
  182. package/src/lib/utils/deepLinks.ts +8 -3
  183. package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
  184. package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
  185. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  186. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  187. package/src/version.ts +3 -3
  188. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  189. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  190. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  191. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  192. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -65,21 +65,21 @@ export class SnapManager {
65
65
  @computed getSnappableShapes(): Set<TLShapeId> {
66
66
  const { editor } = this
67
67
  const renderingBounds = editor.getViewportPageBounds()
68
- const selectedShapeIds = editor.getSelectedShapeIds()
68
+ const selectedShapeIds = new Set(editor.getSelectedShapeIds())
69
69
 
70
70
  const snappableShapes: Set<TLShapeId> = new Set()
71
71
 
72
72
  const collectSnappableShapesFromParent = (parentId: TLParentId) => {
73
73
  if (isShapeId(parentId)) {
74
74
  const parent = editor.getShape(parentId)
75
- if (parent && editor.isShapeFrameLike(parent)) {
75
+ if (parent && editor.isShapeFrameLike(parent) && !editor.isShapeHidden(parent)) {
76
76
  snappableShapes.add(parentId)
77
77
  }
78
78
  }
79
79
  const sortedChildIds = editor.getSortedChildIdsForParent(parentId)
80
80
  for (const childId of sortedChildIds) {
81
81
  // Skip any selected ids
82
- if (selectedShapeIds.includes(childId)) continue
82
+ if (selectedShapeIds.has(childId)) continue
83
83
  const childShape = editor.getShape(childId)
84
84
  if (!childShape) continue
85
85
  const util = editor.getShapeUtil(childShape)
@@ -88,11 +88,13 @@ export class SnapManager {
88
88
  // Only consider shapes if they're inside of the viewport page bounds
89
89
  const pageBounds = editor.getShapePageBounds(childId)
90
90
  if (!(pageBounds && renderingBounds.includes(pageBounds))) continue
91
- // Snap to children of groups but not group itself
91
+ // Snap to children of groups but not group itself. A hidden group's
92
+ // children may still override to visible, so recurse before the check.
92
93
  if (editor.isShapeOfType(childShape, 'group')) {
93
94
  collectSnappableShapesFromParent(childId)
94
95
  continue
95
96
  }
97
+ if (editor.isShapeHidden(childShape)) continue
96
98
  snappableShapes.add(childId)
97
99
  }
98
100
  }
@@ -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.
@@ -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
  }
@@ -145,7 +145,13 @@ function measureContentBounds(
145
145
  // (extraPx * 2 >= w means declaredRight <= declaredLeft, producing zero/negative crop)
146
146
  if (extraPx <= 0 || extraPx * 2 >= w || extraPx * 2 >= h) return null
147
147
 
148
- const imageData = ctx.getImageData(0, 0, w, h)
148
+ let imageData: ImageData
149
+ try {
150
+ imageData = ctx.getImageData(0, 0, w, h)
151
+ } catch {
152
+ // a tainted or unallocatable canvas can't be measured; export untrimmed rather than fail
153
+ return null
154
+ }
149
155
  const data = imageData.data
150
156
 
151
157
  // Determine how to detect "empty" pixels.
@@ -302,9 +308,11 @@ export async function trimSvgToContent(
302
308
 
303
309
  if (trimPadding <= 0) return null
304
310
 
305
- // Render SVG to a temporary canvas at 1:1 pixel ratio
306
- const canvasWidth = Math.floor(width)
307
- const canvasHeight = Math.floor(height)
311
+ // Render SVG to a temporary canvas at 1:1 pixel ratio, clamped like the raster path so a
312
+ // large export doesn't exceed the browser's canvas limits
313
+ let [canvasWidth, canvasHeight] = clampToBrowserMaxCanvasSize(width, height)
314
+ canvasWidth = Math.floor(canvasWidth)
315
+ canvasHeight = Math.floor(canvasHeight)
308
316
 
309
317
  if (canvasWidth <= 0 || canvasHeight <= 0) return null
310
318
 
@@ -313,7 +321,7 @@ export async function trimSvgToContent(
313
321
  if (!canvas) return null
314
322
 
315
323
  // Measure content bounds on the canvas
316
- const trimPaddingPx = trimPadding * scale
324
+ const trimPaddingPx = trimPadding * scale * (canvasWidth / width)
317
325
  const bounds = measureContentBounds(canvas, trimPaddingPx)
318
326
  if (!bounds) return null
319
327
 
@@ -1,4 +1,13 @@
1
- import { Geometry2d, RecordProps, Rectangle2d, ShapeUtil, T, TLShape, createShapeId } from '../..'
1
+ import {
2
+ BaseFrameLikeShapeUtil,
3
+ Geometry2d,
4
+ RecordProps,
5
+ Rectangle2d,
6
+ ShapeUtil,
7
+ T,
8
+ TLShape,
9
+ createShapeId,
10
+ } from '../..'
2
11
  import { createTLStore } from '../config/createTLStore'
3
12
  import { Editor } from '../editor/Editor'
4
13
  import { Box } from '../primitives/Box'
@@ -861,3 +870,36 @@ describe('getExportDefaultBounds', () => {
861
870
  })
862
871
  })
863
872
  })
873
+
874
+ // Deliberately not registered in TLGlobalShapePropsMap (test-only types leak into the api report)
875
+ const FRAME_LIKE_TYPE = 'test-frame-like'
876
+
877
+ class FrameLikeShape extends BaseFrameLikeShapeUtil<any> {
878
+ static override type = FRAME_LIKE_TYPE
879
+ static override props = { w: T.number, h: T.number }
880
+ getDefaultProps() {
881
+ return { w: 100, h: 100 }
882
+ }
883
+ getIndicatorPath() {
884
+ return undefined
885
+ }
886
+ component() {}
887
+ }
888
+
889
+ describe('exporting a single frame-like shape', () => {
890
+ it('does not need the default frame shape util to be registered', async () => {
891
+ const editor = new Editor({
892
+ shapeUtils: [FrameLikeShape],
893
+ bindingUtils: [],
894
+ tools: [],
895
+ store: createTLStore({ shapeUtils: [FrameLikeShape], bindingUtils: [] }),
896
+ getContainer: () => document.body,
897
+ })
898
+ const id = createShapeId('frame-like')
899
+ editor.createShape({ id, type: FRAME_LIKE_TYPE, x: 0, y: 0, props: { w: 100, h: 100 } } as any)
900
+
901
+ const result = await editor.getSvgElement([id], { background: true })
902
+ expect(result?.svg.style.backgroundColor).not.toBe('')
903
+ expect(result?.svg.style.backgroundColor).not.toBe('transparent')
904
+ })
905
+ })
@@ -1,5 +1,5 @@
1
1
  import { useAtom, useValue } from '@tldraw/state-react'
2
- import { TLFrameShape, TLShape, TLShapeId } from '@tldraw/tlschema'
2
+ import { DefaultColorStyle, TLShape, TLShapeId } from '@tldraw/tlschema'
3
3
  import { TLFontFace } from '@tldraw/tlschema'
4
4
  import { hasOwnProperty, promiseWithResolve, uniqueId } from '@tldraw/utils'
5
5
  import {
@@ -117,6 +117,7 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
117
117
  colorMode={colorMode}
118
118
  renderingShapes={renderingShapes}
119
119
  onMount={initialEffectPromise.resolve}
120
+ onError={exportDelay.fail}
120
121
  waitUntil={exportDelay.waitUntil}
121
122
  >
122
123
  {}
@@ -208,6 +209,7 @@ function SvgExport({
208
209
  colorMode,
209
210
  renderingShapes,
210
211
  onMount,
212
+ onError,
211
213
  waitUntil,
212
214
  }: {
213
215
  editor: Editor
@@ -221,6 +223,7 @@ function SvgExport({
221
223
  colorMode: 'light' | 'dark'
222
224
  renderingShapes: TLRenderingShape[]
223
225
  onMount(): void
226
+ onError(error: unknown): void
224
227
  waitUntil(promise: Promise<void>): void
225
228
  }) {
226
229
  const masksId = useUniqueSafeId()
@@ -283,6 +286,8 @@ function SvgExport({
283
286
  throw new Error('SvgExport should only render once - do not use with react strict mode')
284
287
  }
285
288
  didRenderRef.current = true
289
+ // the promise is caught at the end: a throwing `toSvg` would otherwise leave `onMount`
290
+ // pending forever, so the export would wait out its max delay and snapshot an empty svg
286
291
  ;(async () => {
287
292
  const shapeDefs: Record<string, { pending: false; element: ReactElement }> = {}
288
293
 
@@ -418,8 +423,17 @@ function SvgExport({
418
423
  defsById: { ...state.defsById, ...shapeDefs },
419
424
  }))
420
425
  })
421
- })()
422
- }, [bbox, editor, exportContext, masksId, renderingShapes, singleFrameShapeId, stateAtom])
426
+ })().catch(onError)
427
+ }, [
428
+ bbox,
429
+ editor,
430
+ exportContext,
431
+ masksId,
432
+ onError,
433
+ renderingShapes,
434
+ singleFrameShapeId,
435
+ stateAtom,
436
+ ])
423
437
 
424
438
  useEffect(() => {
425
439
  const fontsInUse = new Set<TLFontFace>()
@@ -449,19 +463,17 @@ function SvgExport({
449
463
  let backgroundColor = background ? colors.background : 'transparent'
450
464
 
451
465
  if (singleFrameShapeId && background) {
452
- const frameShapeUtil = editor.getShapeUtil('frame') as any as
453
- | undefined
454
- | {
455
- options: {
456
- showColors: boolean
457
- }
458
- }
459
- if (frameShapeUtil?.options.showColors) {
460
- const shape = editor.getShape(singleFrameShapeId)! as TLFrameShape
461
- backgroundColor = getColorValue(colors, shape.props.color, 'frameFill')
462
- } else {
463
- backgroundColor = colors.solid
464
- }
466
+ // the frame-like shape's own util decides this - it may not be the default 'frame' util
467
+ const shape = editor.getShape(singleFrameShapeId)!
468
+ const { showColors } = editor.getShapeUtil(shape).options as { showColors?: boolean }
469
+ // the color style may live under any prop key, so look it up through the style map and
470
+ // fall back to a literal `color` prop for frame-like shapes that don't use the style
471
+ const color = showColors
472
+ ? (editor.getShapeStyleIfExists(shape, DefaultColorStyle) ??
473
+ ('color' in shape.props ? shape.props.color : null))
474
+ : null
475
+ backgroundColor =
476
+ typeof color === 'string' ? getColorValue(colors, color, 'frameFill') : colors.solid
465
477
  }
466
478
 
467
479
  return (