@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.
- package/dist-cjs/index.d.ts +49 -23
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +16 -12
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
- package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +168 -181
- package/dist-cjs/lib/editor/Editor.js.map +3 -3
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/exports/ExportDelay.js +10 -2
- package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
- package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
- package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
- package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/fetchCache.js +3 -0
- package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
- package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
- package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
- package/dist-cjs/lib/exports/parseCss.js +1 -1
- package/dist-cjs/lib/exports/parseCss.js.map +1 -1
- package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
- package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
- package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useIdentity.js +6 -2
- package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
- package/dist-cjs/lib/license/LicenseManager.js +9 -1
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/LicenseProvider.js +12 -4
- package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
- package/dist-cjs/lib/utils/debug-flags.js +0 -6
- package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
- package/dist-cjs/lib/utils/deepLinks.js +2 -1
- package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +49 -23
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +17 -13
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
- package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
- package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +168 -181
- package/dist-esm/lib/editor/Editor.mjs.map +3 -3
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
- package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
- package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
- package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
- package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
- package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/fetchCache.mjs +3 -0
- package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
- package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
- package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
- package/dist-esm/lib/exports/parseCss.mjs +1 -1
- package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
- package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
- package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
- package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
- package/dist-esm/lib/license/LicenseManager.mjs +9 -1
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
- package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
- package/dist-esm/lib/utils/debug-flags.mjs +0 -6
- package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
- package/dist-esm/lib/utils/deepLinks.mjs +2 -1
- package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +9 -1
- package/package.json +8 -11
- package/src/index.ts +5 -1
- package/src/lib/TldrawEditor.tsx +35 -21
- package/src/lib/components/MenuClickCapture.tsx +55 -4
- package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
- package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
- package/src/lib/editor/Editor.test.ts +65 -0
- package/src/lib/editor/Editor.ts +261 -294
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
- package/src/lib/editor/managers/EditorManager.test.ts +32 -1
- package/src/lib/editor/managers/EditorManager.ts +15 -3
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
- package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
- package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
- package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
- package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
- package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
- package/src/lib/editor/types/external-content.ts +5 -0
- package/src/lib/exports/ExportDelay.tsx +16 -4
- package/src/lib/exports/FontEmbedder.test.ts +99 -0
- package/src/lib/exports/FontEmbedder.ts +32 -11
- package/src/lib/exports/StyleEmbedder.ts +13 -4
- package/src/lib/exports/fetchCache.test.ts +22 -0
- package/src/lib/exports/fetchCache.ts +4 -0
- package/src/lib/exports/getSvgAsImage.ts +13 -5
- package/src/lib/exports/getSvgJsx.test.ts +43 -1
- package/src/lib/exports/getSvgJsx.tsx +28 -16
- package/src/lib/exports/parseCss.test.ts +4 -0
- package/src/lib/exports/parseCss.ts +2 -2
- package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
- package/src/lib/hooks/useCanvasEvents.ts +42 -0
- package/src/lib/hooks/useIdentity.tsx +12 -1
- package/src/lib/license/LicenseManager.ts +13 -1
- package/src/lib/license/LicenseProvider.test.tsx +76 -0
- package/src/lib/license/LicenseProvider.tsx +19 -5
- package/src/lib/test/preserveFocus.test.tsx +81 -0
- package/src/lib/utils/debug-flags.ts +0 -6
- package/src/lib/utils/deepLinks.test.ts +15 -0
- package/src/lib/utils/deepLinks.ts +8 -3
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
- package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
- package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
- package/src/version.ts +3 -3
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
- 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.
|
|
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
|
|
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(
|
|
311
|
-
range.setEnd(
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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(
|
|
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))
|
|
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.
|
|
53
|
+
if (!url.resolved || this.embeddedUrls.has(url.resolved)) continue
|
|
51
54
|
// kick off fetching this font
|
|
52
|
-
url.
|
|
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.
|
|
70
|
-
const dataUrl = await url.
|
|
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
|
|
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.
|
|
148
|
+
parsed.importUrls.map((importUrl) => fetchCssFontFaces(importUrl, new Set(seen)))
|
|
127
149
|
)
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
}, [
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|