@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.
- package/dist-cjs/index.d.ts +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/constants.js +2 -0
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +131 -151
- 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/EdgeScrollManager/EdgeScrollManager.js +10 -6
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.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/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/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/primitives/geometry/Arc2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
- package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -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 +7 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/constants.mjs +2 -0
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +132 -151
- 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/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.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/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/primitives/geometry/Arc2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
- package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -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/package.json +7 -7
- package/src/lib/constants.ts +8 -0
- package/src/lib/editor/Editor.test.ts +20 -0
- package/src/lib/editor/Editor.ts +202 -255
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
- package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
- package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
- 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/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/primitives/geometry/Arc2d.test.ts +9 -0
- package/src/lib/primitives/geometry/Arc2d.ts +1 -1
- package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
- package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
- package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
- package/src/lib/primitives/geometry/Edge2d.ts +1 -1
- package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
- package/src/lib/primitives/geometry/Group2d.ts +18 -7
- package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
- package/src/lib/primitives/geometry/Point2d.ts +1 -1
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
- package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
- 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(
|
|
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
|
-
|
|
56
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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
|
+
})
|