@tldraw/editor 5.4.1 → 5.5.0-canary.003bf5951274
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +37 -14
- 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/constants.js +2 -0
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +147 -157
- 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/TextManager/TextManager.js +4 -4
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.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/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/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/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 +22 -24
- 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 +37 -14
- 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/constants.mjs +2 -0
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +148 -157
- 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/TextManager/TextManager.mjs +4 -4
- package/dist-esm/lib/editor/managers/TextManager/TextManager.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/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/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/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 +22 -24
- 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/constants.ts +8 -0
- package/src/lib/editor/Editor.test.ts +20 -0
- package/src/lib/editor/Editor.ts +228 -262
- 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/TextManager/TextManager.test.ts +74 -0
- package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
- 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/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/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/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 +95 -0
- package/src/lib/utils/sync/LocalIndexedDb.ts +31 -27
- 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
|
@@ -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
|
|
|
@@ -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.
|
|
@@ -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) {
|
|
@@ -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
|
}
|