@tldraw/editor 5.4.2 → 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
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { assert, invLerp, lerp } from '@tldraw/utils'
|
|
2
2
|
import { Box } from '../Box'
|
|
3
|
-
import {
|
|
3
|
+
import { MatModel } from '../Mat'
|
|
4
4
|
import { Vec, VecLike } from '../Vec'
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
Geometry2d,
|
|
7
|
+
Geometry2dFilters,
|
|
8
|
+
Geometry2dOptions,
|
|
9
|
+
TransformedGeometry2dOptions,
|
|
10
|
+
} from './Geometry2d'
|
|
6
11
|
|
|
7
12
|
/** @public */
|
|
8
13
|
export class Group2d extends Geometry2d {
|
|
@@ -226,12 +231,18 @@ export class Group2d extends Geometry2d {
|
|
|
226
231
|
return childTLength / totalLength
|
|
227
232
|
}
|
|
228
233
|
|
|
229
|
-
override transform(transform:
|
|
234
|
+
override transform(transform: MatModel, opts?: TransformedGeometry2dOptions): Geometry2d {
|
|
235
|
+
// Each child's own transform preserves its `ignore` flag, so the constructor
|
|
236
|
+
// re-partitions ignored children back into `ignoredChildren` instead of dropping
|
|
237
|
+
// them (#10562).
|
|
230
238
|
return new Group2d({
|
|
231
|
-
children: this.children.map((c) => c.transform(transform)),
|
|
232
|
-
isLabel: this.isLabel,
|
|
233
|
-
|
|
234
|
-
|
|
239
|
+
children: [...this.children, ...this.ignoredChildren].map((c) => c.transform(transform)),
|
|
240
|
+
isLabel: opts?.isLabel ?? this.isLabel,
|
|
241
|
+
isEmptyLabel: this.isEmptyLabel,
|
|
242
|
+
isInternal: opts?.isInternal ?? this.isInternal,
|
|
243
|
+
debugColor: opts?.debugColor ?? this.debugColor,
|
|
244
|
+
ignore: opts?.ignore ?? this.ignore,
|
|
245
|
+
excludeFromShapeBounds: this.excludeFromShapeBounds,
|
|
235
246
|
})
|
|
236
247
|
}
|
|
237
248
|
|
|
@@ -1,5 +1,16 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { Vec } from '../Vec'
|
|
2
|
+
import { Point2d } from './Point2d'
|
|
3
|
+
|
|
4
|
+
describe('Point2d.getSvgPathData', () => {
|
|
5
|
+
it('emits a move command rounded to two decimals', () => {
|
|
6
|
+
const point = new Point2d({ margin: 0, point: new Vec(0.123456, 7.891234) })
|
|
7
|
+
expect(point.getSvgPathData()).toBe('M0.12, 7.89')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('does not mutate the stored point', () => {
|
|
11
|
+
const vec = new Vec(0.123456, 7.891234)
|
|
12
|
+
const point = new Point2d({ margin: 0, point: vec })
|
|
13
|
+
point.getSvgPathData()
|
|
14
|
+
expect(vec).toMatchObject({ x: 0.123456, y: 7.891234 })
|
|
4
15
|
})
|
|
5
16
|
})
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
|
+
import { createTLStore } from '../config/createTLStore'
|
|
3
|
+
import { Editor } from '../editor/Editor'
|
|
4
|
+
import { TldrawEditor } from '../TldrawEditor'
|
|
5
|
+
|
|
6
|
+
// The `tldraw_preserve_focus` search param switches the editor into preserve-focus mode, where
|
|
7
|
+
// a pointerdown inside the container focuses the editor and a pointerdown elsewhere blurs it.
|
|
8
|
+
describe('preserve-focus mode', () => {
|
|
9
|
+
const mounted: Element[] = []
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
window.history.replaceState(null, '', '?tldraw_preserve_focus')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
window.history.replaceState(null, '', '/')
|
|
17
|
+
for (const el of mounted.splice(0)) el.remove()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
function pointerDown(target: EventTarget) {
|
|
21
|
+
act(() => {
|
|
22
|
+
target.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, composed: true }))
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function mount(root: Element | ShadowRoot) {
|
|
27
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
28
|
+
const container = document.createElement('div')
|
|
29
|
+
root.appendChild(container)
|
|
30
|
+
mounted.push(container)
|
|
31
|
+
let editor: Editor | undefined
|
|
32
|
+
let unmount = () => {}
|
|
33
|
+
await act(async () => {
|
|
34
|
+
unmount = render(
|
|
35
|
+
<TldrawEditor
|
|
36
|
+
store={store}
|
|
37
|
+
onMount={(e) => {
|
|
38
|
+
editor = e
|
|
39
|
+
}}
|
|
40
|
+
/>,
|
|
41
|
+
{ container }
|
|
42
|
+
).unmount
|
|
43
|
+
})
|
|
44
|
+
return { editor: editor!, unmount }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
it('focuses on a canvas pointerdown and blurs on a pointerdown outside', async () => {
|
|
48
|
+
const { editor } = await mount(document.body)
|
|
49
|
+
expect(editor.getIsFocused()).toBe(false)
|
|
50
|
+
|
|
51
|
+
pointerDown(editor.getContainer().querySelector('.tl-canvas')!)
|
|
52
|
+
expect(editor.getIsFocused()).toBe(true)
|
|
53
|
+
|
|
54
|
+
pointerDown(document.body)
|
|
55
|
+
expect(editor.getIsFocused()).toBe(false)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps focus when the editor is inside an open shadow root', async () => {
|
|
59
|
+
// The body listener sees the event retargeted to the shadow host, so a check on
|
|
60
|
+
// `e.target` alone would blur the editor right after the container focused it.
|
|
61
|
+
const host = document.createElement('div')
|
|
62
|
+
document.body.appendChild(host)
|
|
63
|
+
mounted.push(host)
|
|
64
|
+
const { editor } = await mount(host.attachShadow({ mode: 'open' }))
|
|
65
|
+
|
|
66
|
+
pointerDown(editor.getContainer().querySelector('.tl-canvas')!)
|
|
67
|
+
expect(editor.getIsFocused()).toBe(true)
|
|
68
|
+
|
|
69
|
+
pointerDown(document.body)
|
|
70
|
+
expect(editor.getIsFocused()).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('removes the body listener on unmount', async () => {
|
|
74
|
+
const { editor, unmount } = await mount(document.body)
|
|
75
|
+
const blur = vi.spyOn(editor, 'blur')
|
|
76
|
+
|
|
77
|
+
act(() => unmount())
|
|
78
|
+
pointerDown(document.body)
|
|
79
|
+
expect(blur).not.toHaveBeenCalled()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -43,12 +43,6 @@ export const debugFlags = {
|
|
|
43
43
|
throwToBlob: createDebugValue('throwToBlob', {
|
|
44
44
|
defaults: { all: false },
|
|
45
45
|
}),
|
|
46
|
-
reconnectOnPing: createDebugValue('reconnectOnPing', {
|
|
47
|
-
defaults: { all: false },
|
|
48
|
-
}),
|
|
49
|
-
debugCursors: createDebugValue('debugCursors', {
|
|
50
|
-
defaults: { all: false },
|
|
51
|
-
}),
|
|
52
46
|
forceSrgb: createDebugValue('forceSrgbColors', { defaults: { all: false } }),
|
|
53
47
|
debugGeometry: createDebugValue('debugGeometry', { defaults: { all: false } }),
|
|
54
48
|
hideShapes: createDebugValue('hideShapes', { defaults: { all: false } }),
|
|
@@ -81,6 +81,21 @@ describe('parseDeepLinkString', () => {
|
|
|
81
81
|
expect(() => parseDeepLinkString('v')).toThrow('Invalid deep link string')
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
it('throws on a viewport link with no area', () => {
|
|
85
|
+
expect(() => parseDeepLinkString('v0.0.0.0')).toThrow('Invalid deep link string')
|
|
86
|
+
expect(() => parseDeepLinkString('v100.100.0.200')).toThrow('Invalid deep link string')
|
|
87
|
+
expect(() => parseDeepLinkString('v100.100.200.0')).toThrow('Invalid deep link string')
|
|
88
|
+
expect(() => parseDeepLinkString('v100.100.-200.-100')).toThrow('Invalid deep link string')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('accepts a viewport link positioned at the origin', () => {
|
|
92
|
+
expect(parseDeepLinkString('v0.0.200.100')).toEqual({
|
|
93
|
+
type: 'viewport',
|
|
94
|
+
bounds: new Box(0, 0, 200, 100),
|
|
95
|
+
pageId: undefined,
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
84
99
|
it('throws on an unknown link type', () => {
|
|
85
100
|
expect(() => parseDeepLinkString('xabc')).toThrow('Invalid deep link string')
|
|
86
101
|
})
|
|
@@ -74,9 +74,14 @@ export function parseDeepLinkString(deepLinkString: string): TLDeepLink {
|
|
|
74
74
|
case 'v': {
|
|
75
75
|
const [x, y, w, h, pageId] = deepLinkString.slice(1).split('.')
|
|
76
76
|
const bounds = new Box(Number(x), Number(y), Number(w), Number(h))
|
|
77
|
-
// Number('abc') is NaN rather than an error,
|
|
78
|
-
//
|
|
79
|
-
|
|
77
|
+
// Number('abc') is NaN rather than an error, and a box with no area parses cleanly but
|
|
78
|
+
// can only be zoomed to by clamping to max zoom at its corner. Both bypass the caller's
|
|
79
|
+
// fallback and park the camera at the origin, so both count as an invalid link here.
|
|
80
|
+
const isUsableViewport =
|
|
81
|
+
[bounds.x, bounds.y, bounds.w, bounds.h].every(Number.isFinite) &&
|
|
82
|
+
bounds.w > 0 &&
|
|
83
|
+
bounds.h > 0
|
|
84
|
+
if (!isUsableViewport) {
|
|
80
85
|
throw Error('Invalid deep link string')
|
|
81
86
|
}
|
|
82
87
|
return {
|
|
@@ -10,6 +10,7 @@ describe('LocalIndexedDb', () => {
|
|
|
10
10
|
vi.useRealTimers()
|
|
11
11
|
})
|
|
12
12
|
afterEach(async () => {
|
|
13
|
+
vi.restoreAllMocks()
|
|
13
14
|
await hardReset({ shouldReload: false })
|
|
14
15
|
})
|
|
15
16
|
describe('#storeSnapshot', () => {
|
|
@@ -106,6 +107,34 @@ describe('LocalIndexedDb', () => {
|
|
|
106
107
|
})
|
|
107
108
|
})
|
|
108
109
|
|
|
110
|
+
it('commits a write that is still in flight when the db is closed', async () => {
|
|
111
|
+
const db = new LocalIndexedDb('test-0')
|
|
112
|
+
const write = db.storeSnapshot({ schema, snapshot: { 'shape:1': { id: 'shape:1' } } })
|
|
113
|
+
await db.close()
|
|
114
|
+
await write
|
|
115
|
+
|
|
116
|
+
const reopened = new LocalIndexedDb('test-0')
|
|
117
|
+
expect((await reopened.load())?.records).toEqual([{ id: 'shape:1' }])
|
|
118
|
+
await reopened.close()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('rejects a transaction that aborts after its requests succeeded while closing', async () => {
|
|
122
|
+
const db = new LocalIndexedDb('test-0')
|
|
123
|
+
let closing: Promise<void> | undefined
|
|
124
|
+
// A commit can fail after every request succeeded, so the request promises cannot report it.
|
|
125
|
+
// @ts-expect-error Exercise the transaction boundary directly to inject a commit failure.
|
|
126
|
+
const write = db.tx('readwrite', ['records'], async (tx) => {
|
|
127
|
+
await tx.objectStore('records').put({ id: 'shape:1' }, 'shape:1')
|
|
128
|
+
closing = db.close()
|
|
129
|
+
tx.abort()
|
|
130
|
+
})
|
|
131
|
+
try {
|
|
132
|
+
await expect(write).rejects.toMatchObject({ name: 'AbortError' })
|
|
133
|
+
} finally {
|
|
134
|
+
await closing
|
|
135
|
+
}
|
|
136
|
+
})
|
|
137
|
+
|
|
109
138
|
describe('#storeChanges', () => {
|
|
110
139
|
it('allows merging changes into an existing store', async () => {
|
|
111
140
|
const db = new LocalIndexedDb('test-0')
|
|
@@ -174,6 +203,72 @@ describe('LocalIndexedDb', () => {
|
|
|
174
203
|
})
|
|
175
204
|
})
|
|
176
205
|
|
|
206
|
+
describe('#pruneSessions', () => {
|
|
207
|
+
it('keeps the 10 most recent session rows and the current session', async () => {
|
|
208
|
+
const db = new LocalIndexedDb('test-0')
|
|
209
|
+
let now = 1000
|
|
210
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now++)
|
|
211
|
+
|
|
212
|
+
// the current session is the oldest row of 14
|
|
213
|
+
const sessionIds = ['current', ...Array.from({ length: 13 }, (_, i) => `other-${i}`)]
|
|
214
|
+
for (const sessionId of sessionIds) {
|
|
215
|
+
await db.storeChanges({
|
|
216
|
+
schema,
|
|
217
|
+
changes: { added: {}, updated: {}, removed: {} },
|
|
218
|
+
sessionId,
|
|
219
|
+
sessionStateSnapshot: { sessionId } as any,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
await db.pruneSessions({ keepSessionId: 'current' })
|
|
224
|
+
|
|
225
|
+
const remaining = await Promise.all(
|
|
226
|
+
sessionIds.map(async (sessionId) => [
|
|
227
|
+
sessionId,
|
|
228
|
+
((await db.load({ sessionId })).sessionStateSnapshot as any)?.sessionId === sessionId,
|
|
229
|
+
])
|
|
230
|
+
)
|
|
231
|
+
expect(Object.fromEntries(remaining)).toEqual({
|
|
232
|
+
current: true,
|
|
233
|
+
'other-0': false,
|
|
234
|
+
'other-1': false,
|
|
235
|
+
'other-2': false,
|
|
236
|
+
'other-3': true,
|
|
237
|
+
'other-4': true,
|
|
238
|
+
'other-5': true,
|
|
239
|
+
'other-6': true,
|
|
240
|
+
'other-7': true,
|
|
241
|
+
'other-8': true,
|
|
242
|
+
'other-9': true,
|
|
243
|
+
'other-10': true,
|
|
244
|
+
'other-11': true,
|
|
245
|
+
'other-12': true,
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
await db.close()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
it('is a no-op when there are at most 10 rows', async () => {
|
|
252
|
+
const db = new LocalIndexedDb('test-0')
|
|
253
|
+
for (let i = 0; i < 10; i++) {
|
|
254
|
+
await db.storeChanges({
|
|
255
|
+
schema,
|
|
256
|
+
changes: { added: {}, updated: {}, removed: {} },
|
|
257
|
+
sessionId: `session-${i}`,
|
|
258
|
+
sessionStateSnapshot: { i } as any,
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
await db.pruneSessions()
|
|
263
|
+
|
|
264
|
+
for (let i = 0; i < 10; i++) {
|
|
265
|
+
expect((await db.load({ sessionId: `session-${i}` })).sessionStateSnapshot).toEqual({ i })
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await db.close()
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
177
272
|
it('migrates legacy asset storage into the new format', async () => {
|
|
178
273
|
const legacyAssetsDb = await openDB('TLDRAW_ASSET_STORE_v1test-0', 1, {
|
|
179
274
|
upgrade(database) {
|
|
@@ -9,6 +9,9 @@ const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
|
|
|
9
9
|
const LEGACY_ASSET_STORE_PREFIX = 'TLDRAW_ASSET_STORE_v1'
|
|
10
10
|
const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
|
|
11
11
|
|
|
12
|
+
/** How many session state rows (one per tab) to keep per document when pruning. */
|
|
13
|
+
const MAX_SESSION_STATE_ROWS = 10
|
|
14
|
+
|
|
12
15
|
/** @internal */
|
|
13
16
|
export const Table = {
|
|
14
17
|
Records: 'records',
|
|
@@ -141,26 +144,22 @@ export class LocalIndexedDb {
|
|
|
141
144
|
assert(!this.isClosed, 'db is closed')
|
|
142
145
|
const db = await this.getDb()
|
|
143
146
|
const tx = db.transaction(names, mode)
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!this.isClosed) {
|
|
149
|
-
throw e
|
|
150
|
-
}
|
|
151
|
-
})
|
|
147
|
+
// Commit failures may arrive before the callback finishes; observe them immediately
|
|
148
|
+
// while preserving the rejection for the await below.
|
|
149
|
+
const done = tx.done
|
|
150
|
+
done.catch(noop)
|
|
152
151
|
try {
|
|
153
152
|
return await cb(tx)
|
|
154
153
|
} finally {
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
tx.abort()
|
|
159
|
-
}
|
|
154
|
+
// let the transaction commit even if close() was called meanwhile, otherwise a
|
|
155
|
+
// persist racing an unmount is silently rolled back
|
|
156
|
+
await done
|
|
160
157
|
}
|
|
161
158
|
})()
|
|
162
159
|
this.pendingTransactionSet.add(txPromise)
|
|
163
|
-
|
|
160
|
+
// not `.finally()`: the promise it returns would re-reject with nobody observing it
|
|
161
|
+
const cleanup = () => this.pendingTransactionSet.delete(txPromise)
|
|
162
|
+
txPromise.then(cleanup, cleanup)
|
|
164
163
|
return txPromise
|
|
165
164
|
}
|
|
166
165
|
|
|
@@ -208,17 +207,19 @@ export class LocalIndexedDb {
|
|
|
208
207
|
const schemaStore = tx.objectStore(Table.Schema)
|
|
209
208
|
const sessionStateStore = tx.objectStore(Table.SessionState)
|
|
210
209
|
|
|
210
|
+
const requests: Promise<unknown>[] = []
|
|
211
211
|
for (const [id, record] of Object.entries(changes.added)) {
|
|
212
|
-
|
|
212
|
+
requests.push(recordsStore.put(record, id))
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
for (const [_prev, updated] of Object.values(changes.updated)) {
|
|
216
|
-
|
|
216
|
+
requests.push(recordsStore.put(updated, updated.id))
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
for (const id of Object.keys(changes.removed)) {
|
|
220
|
-
|
|
220
|
+
requests.push(recordsStore.delete(id))
|
|
221
221
|
}
|
|
222
|
+
await Promise.all(requests)
|
|
222
223
|
|
|
223
224
|
schemaStore.put(schema.serialize(), Table.Schema)
|
|
224
225
|
if (sessionStateSnapshot && sessionId) {
|
|
@@ -254,9 +255,9 @@ export class LocalIndexedDb {
|
|
|
254
255
|
|
|
255
256
|
await recordsStore.clear()
|
|
256
257
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
258
|
+
await Promise.all(
|
|
259
|
+
Object.entries(snapshot).map(([id, record]) => recordsStore.put(record, id))
|
|
260
|
+
)
|
|
260
261
|
|
|
261
262
|
schemaStore.put(schema.serialize(), Table.Schema)
|
|
262
263
|
|
|
@@ -275,15 +276,18 @@ export class LocalIndexedDb {
|
|
|
275
276
|
})
|
|
276
277
|
}
|
|
277
278
|
|
|
278
|
-
|
|
279
|
+
/**
|
|
280
|
+
* Drop all but the most recently updated session state rows. The row for `keepSessionId` is never
|
|
281
|
+
* deleted: it may be an old row that this tab restored its state from and has not rewritten yet,
|
|
282
|
+
* and deleting it would make a later reload fall back to another tab's session state.
|
|
283
|
+
*/
|
|
284
|
+
async pruneSessions({ keepSessionId }: { keepSessionId?: string } = {}) {
|
|
279
285
|
await this.tx('readwrite', [Table.SessionState], async (tx) => {
|
|
280
286
|
const sessionStateStore = tx.objectStore(Table.SessionState)
|
|
281
|
-
const all = (await sessionStateStore.getAll())
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
const toDelete = all.slice(0, all.length - 10)
|
|
287
|
+
const all = ((await sessionStateStore.getAll()) as SessionStateSnapshotRow[])
|
|
288
|
+
.filter((row) => row.id !== keepSessionId)
|
|
289
|
+
.sort((a, b) => a.updatedAt - b.updatedAt)
|
|
290
|
+
const toDelete = all.slice(0, Math.max(0, all.length - MAX_SESSION_STATE_ROWS))
|
|
287
291
|
for (const { id } of toDelete) {
|
|
288
292
|
await sessionStateStore.delete(id)
|
|
289
293
|
}
|