@tldraw/editor 5.4.2 → 5.5.0-canary.0241f11f2e99

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 (135) hide show
  1. package/dist-cjs/index.d.ts +7 -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 +131 -151
  6. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  7. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  8. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  10. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  12. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  14. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  15. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  16. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  17. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  18. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  19. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  20. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  21. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  22. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  23. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  24. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  25. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  26. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  27. package/dist-cjs/lib/exports/parseCss.js +1 -1
  28. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  29. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  30. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  31. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  32. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  33. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  34. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  35. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  36. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  37. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  38. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  39. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  40. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  41. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  42. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  43. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
  44. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  45. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -40
  46. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  47. package/dist-cjs/version.js +3 -3
  48. package/dist-cjs/version.js.map +1 -1
  49. package/dist-esm/index.d.mts +7 -2
  50. package/dist-esm/index.mjs +1 -1
  51. package/dist-esm/lib/constants.mjs +2 -0
  52. package/dist-esm/lib/constants.mjs.map +2 -2
  53. package/dist-esm/lib/editor/Editor.mjs +132 -151
  54. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  55. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  56. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  58. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  60. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  62. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  63. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  64. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  65. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  66. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  67. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  68. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  69. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  70. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  71. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  72. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  73. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  74. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  75. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  76. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  77. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  78. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  79. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  80. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  81. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  82. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  83. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  84. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  85. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  86. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  87. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  88. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  89. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  90. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  91. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
  92. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  93. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -40
  94. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  95. package/dist-esm/version.mjs +3 -3
  96. package/dist-esm/version.mjs.map +1 -1
  97. package/package.json +7 -7
  98. package/src/lib/constants.ts +8 -0
  99. package/src/lib/editor/Editor.test.ts +20 -0
  100. package/src/lib/editor/Editor.ts +202 -255
  101. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  102. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  103. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  104. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  105. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  106. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  107. package/src/lib/exports/ExportDelay.tsx +16 -4
  108. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  109. package/src/lib/exports/FontEmbedder.ts +32 -11
  110. package/src/lib/exports/StyleEmbedder.ts +13 -4
  111. package/src/lib/exports/fetchCache.test.ts +22 -0
  112. package/src/lib/exports/fetchCache.ts +4 -0
  113. package/src/lib/exports/getSvgAsImage.ts +13 -5
  114. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  115. package/src/lib/exports/getSvgJsx.tsx +28 -16
  116. package/src/lib/exports/parseCss.test.ts +4 -0
  117. package/src/lib/exports/parseCss.ts +2 -2
  118. package/src/lib/license/LicenseManager.ts +13 -1
  119. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  120. package/src/lib/license/LicenseProvider.tsx +19 -5
  121. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  122. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  123. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  124. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  125. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  126. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  127. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  128. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  129. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  130. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  131. package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
  132. package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
  133. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
  134. package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
  135. package/src/version.ts +3 -3
@@ -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
 
@@ -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) {
@@ -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
+ })