@tldraw/editor 5.5.0-next.f9308d324e1c → 5.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-cjs/index.d.ts +49 -23
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +16 -12
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
- package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
- package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
- package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +168 -181
- package/dist-cjs/lib/editor/Editor.js.map +3 -3
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
- package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js +3 -2
- package/dist-cjs/lib/editor/managers/ClickManager/ClickManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js +11 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js +49 -63
- package/dist-cjs/lib/editor/managers/PerformanceManager/PerformanceManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +1 -1
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js +24 -24
- package/dist-cjs/lib/editor/managers/SnapManager/BoundsSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js +7 -1
- package/dist-cjs/lib/editor/managers/SnapManager/HandleSnaps.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js +4 -3
- package/dist-cjs/lib/editor/managers/SnapManager/SnapManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
- package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
- package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
- package/dist-cjs/lib/exports/ExportDelay.js +10 -2
- package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
- package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
- package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
- package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
- package/dist-cjs/lib/exports/fetchCache.js +3 -0
- package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
- package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
- package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
- package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
- package/dist-cjs/lib/exports/parseCss.js +1 -1
- package/dist-cjs/lib/exports/parseCss.js.map +1 -1
- package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
- package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
- package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
- package/dist-cjs/lib/hooks/useIdentity.js +6 -2
- package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
- package/dist-cjs/lib/license/LicenseManager.js +9 -1
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/license/LicenseProvider.js +12 -4
- package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
- package/dist-cjs/lib/utils/debug-flags.js +0 -6
- package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
- package/dist-cjs/lib/utils/deepLinks.js +2 -1
- package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +32 -25
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +49 -23
- package/dist-esm/index.mjs +4 -2
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +17 -13
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
- package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
- package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
- package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +168 -181
- package/dist-esm/lib/editor/Editor.mjs.map +3 -3
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
- package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs +3 -2
- package/dist-esm/lib/editor/managers/ClickManager/ClickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs +11 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs +49 -63
- package/dist-esm/lib/editor/managers/PerformanceManager/PerformanceManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs +24 -24
- package/dist-esm/lib/editor/managers/SnapManager/BoundsSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs +7 -1
- package/dist-esm/lib/editor/managers/SnapManager/HandleSnaps.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs +4 -3
- package/dist-esm/lib/editor/managers/SnapManager/SnapManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
- package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
- package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
- package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
- package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
- package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
- package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
- package/dist-esm/lib/exports/fetchCache.mjs +3 -0
- package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
- package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
- package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
- package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
- package/dist-esm/lib/exports/parseCss.mjs +1 -1
- package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
- package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
- package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
- package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
- package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
- package/dist-esm/lib/license/LicenseManager.mjs +9 -1
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
- package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
- package/dist-esm/lib/utils/debug-flags.mjs +0 -6
- package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
- package/dist-esm/lib/utils/deepLinks.mjs +2 -1
- package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +32 -25
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +9 -1
- package/package.json +8 -11
- package/src/index.ts +5 -1
- package/src/lib/TldrawEditor.tsx +35 -21
- package/src/lib/components/MenuClickCapture.tsx +55 -4
- package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
- package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
- package/src/lib/editor/Editor.test.ts +65 -0
- package/src/lib/editor/Editor.ts +261 -294
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.test.ts +1 -0
- package/src/lib/editor/managers/ClickManager/ClickManager.ts +3 -2
- package/src/lib/editor/managers/EditorManager.test.ts +32 -1
- package/src/lib/editor/managers/EditorManager.ts +15 -3
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.test.ts +80 -0
- package/src/lib/editor/managers/PerformanceManager/PerformanceManager.ts +54 -81
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +1 -1
- package/src/lib/editor/managers/SnapManager/BoundsSnaps.ts +32 -36
- package/src/lib/editor/managers/SnapManager/HandleSnaps.ts +13 -2
- package/src/lib/editor/managers/SnapManager/SnapManager.test.ts +13 -0
- package/src/lib/editor/managers/SnapManager/SnapManager.ts +6 -4
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
- package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
- package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
- package/src/lib/editor/types/external-content.ts +5 -0
- package/src/lib/exports/ExportDelay.tsx +16 -4
- package/src/lib/exports/FontEmbedder.test.ts +99 -0
- package/src/lib/exports/FontEmbedder.ts +32 -11
- package/src/lib/exports/StyleEmbedder.ts +13 -4
- package/src/lib/exports/fetchCache.test.ts +22 -0
- package/src/lib/exports/fetchCache.ts +4 -0
- package/src/lib/exports/getSvgAsImage.ts +13 -5
- package/src/lib/exports/getSvgJsx.test.ts +43 -1
- package/src/lib/exports/getSvgJsx.tsx +28 -16
- package/src/lib/exports/parseCss.test.ts +4 -0
- package/src/lib/exports/parseCss.ts +2 -2
- package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
- package/src/lib/hooks/useCanvasEvents.ts +42 -0
- package/src/lib/hooks/useIdentity.tsx +12 -1
- package/src/lib/license/LicenseManager.ts +13 -1
- package/src/lib/license/LicenseProvider.test.tsx +76 -0
- package/src/lib/license/LicenseProvider.tsx +19 -5
- package/src/lib/test/preserveFocus.test.tsx +81 -0
- package/src/lib/utils/debug-flags.ts +0 -6
- package/src/lib/utils/deepLinks.test.ts +15 -0
- package/src/lib/utils/deepLinks.ts +8 -3
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +121 -1
- package/src/lib/utils/sync/LocalIndexedDb.ts +41 -28
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
- package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
- package/src/version.ts +3 -3
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
- package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
- package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
- package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
|
@@ -2,7 +2,7 @@ import { safeParseUrl } from '@tldraw/utils'
|
|
|
2
2
|
|
|
3
3
|
export interface ParsedFontFace {
|
|
4
4
|
fontFace: string
|
|
5
|
-
urls: { original: string; resolved: string | null
|
|
5
|
+
urls: { original: string; resolved: string | null }[]
|
|
6
6
|
fontFamilies: Set<string>
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ export interface ParsedFontFace {
|
|
|
18
18
|
// Parse @import declarations:
|
|
19
19
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/@import#formal_syntax
|
|
20
20
|
const importsRegex =
|
|
21
|
-
/@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]
|
|
21
|
+
/@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]*);/gi
|
|
22
22
|
|
|
23
23
|
// Locate @font-face declarations:
|
|
24
24
|
const fontFaceRegex = /@font-face\s*{([^}]+)}/gi
|
|
@@ -5,6 +5,7 @@ import type { TLErrorFallbackComponent } from '../components/default-components/
|
|
|
5
5
|
import type { TLGridProps } from '../components/default-components/DefaultGrid'
|
|
6
6
|
import type { TLShapeErrorFallbackComponent } from '../components/default-components/DefaultShapeErrorFallback'
|
|
7
7
|
import type { TLShapeWrapperProps } from '../components/default-components/DefaultShapeWrapper'
|
|
8
|
+
import type { TLSpinnerProps } from '../components/default-components/DefaultSpinner'
|
|
8
9
|
|
|
9
10
|
/** @public */
|
|
10
11
|
export interface TLEditorComponents {
|
|
@@ -16,7 +17,7 @@ export interface TLEditorComponents {
|
|
|
16
17
|
LoadingScreen?: ComponentType | null
|
|
17
18
|
OnTheCanvas?: ComponentType | null
|
|
18
19
|
ShapeWrapper?: ComponentType<TLShapeWrapperProps & RefAttributes<HTMLDivElement>> | null
|
|
19
|
-
Spinner?: ComponentType<
|
|
20
|
+
Spinner?: ComponentType<TLSpinnerProps> | null
|
|
20
21
|
SvgDefs?: ComponentType | null
|
|
21
22
|
|
|
22
23
|
// These will always have defaults
|
|
@@ -19,6 +19,9 @@ export function useCanvasEvents() {
|
|
|
19
19
|
const events = useMemo(
|
|
20
20
|
function canvasEvents() {
|
|
21
21
|
let isSecondaryClickPointerDown = false
|
|
22
|
+
// The button each accepted press started with, by pointer id. pointercancel
|
|
23
|
+
// reports button -1, so the synthetic pointer_up needs the original.
|
|
24
|
+
const pointerDownButtons = new Map<number, number>()
|
|
22
25
|
|
|
23
26
|
function onPointerDown(e: React.PointerEvent) {
|
|
24
27
|
if (editor.wasEventAlreadyHandled(e)) return
|
|
@@ -45,6 +48,15 @@ export function useCanvasEvents() {
|
|
|
45
48
|
|
|
46
49
|
setPointerCapture(e.currentTarget, e)
|
|
47
50
|
|
|
51
|
+
// Only remember presses the editor will act on. A rejected palm in pen mode
|
|
52
|
+
// must not overwrite the button of the pen press it lands on top of, or the
|
|
53
|
+
// pen's pointercancel would release the wrong button and skip the
|
|
54
|
+
// stylus-eraser tool restore.
|
|
55
|
+
const isPenMode = editor.getInstanceState().isPenMode
|
|
56
|
+
if (!editor.inputs.getIsPinching() && !(isPenMode && e.pointerType !== 'pen')) {
|
|
57
|
+
pointerDownButtons.set(e.pointerId, button)
|
|
58
|
+
}
|
|
59
|
+
|
|
48
60
|
editor.dispatch({
|
|
49
61
|
type: 'pointer',
|
|
50
62
|
target: 'canvas',
|
|
@@ -56,6 +68,7 @@ export function useCanvasEvents() {
|
|
|
56
68
|
|
|
57
69
|
function onPointerUp(e: React.PointerEvent) {
|
|
58
70
|
if (editor.wasEventAlreadyHandled(e)) return
|
|
71
|
+
pointerDownButtons.delete(e.pointerId)
|
|
59
72
|
const button = isSecondaryClickPointerDown ? 2 : getPointerEventButton(e)
|
|
60
73
|
if (button !== 0 && button !== 1 && button !== 2 && button !== 5) return
|
|
61
74
|
|
|
@@ -91,6 +104,34 @@ export function useCanvasEvents() {
|
|
|
91
104
|
isSecondaryClickPointerDown = false
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
function onPointerCancel(e: React.PointerEvent) {
|
|
108
|
+
if (editor.wasEventAlreadyHandled(e)) return
|
|
109
|
+
const pointerDownButton = pointerDownButtons.get(e.pointerId)
|
|
110
|
+
pointerDownButtons.delete(e.pointerId)
|
|
111
|
+
// In pen mode a cancelled touch is usually a rejected palm, and the pen's own
|
|
112
|
+
// interaction must survive it.
|
|
113
|
+
if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
|
|
114
|
+
|
|
115
|
+
releasePointerCapture(e.currentTarget, e)
|
|
116
|
+
|
|
117
|
+
// Nothing to end if the press itself was filtered out (pen mode, unsupported
|
|
118
|
+
// button), or if this pointer never started an accepted press.
|
|
119
|
+
if (!editor.inputs.getIsPointing() || pointerDownButton === undefined) return
|
|
120
|
+
|
|
121
|
+
// The browser has taken the pointer (edge swipe, incoming call) and will never send
|
|
122
|
+
// pointerup, so end the gesture here or the tool stays stuck in its pointing or
|
|
123
|
+
// dragging state and the next touch continues it.
|
|
124
|
+
editor.interrupt()
|
|
125
|
+
editor.dispatch({
|
|
126
|
+
type: 'pointer',
|
|
127
|
+
target: 'canvas',
|
|
128
|
+
name: 'pointer_up',
|
|
129
|
+
...getPointerInfo(editor, e),
|
|
130
|
+
button: pointerDownButton,
|
|
131
|
+
})
|
|
132
|
+
isSecondaryClickPointerDown = false
|
|
133
|
+
}
|
|
134
|
+
|
|
94
135
|
function onPointerEnter(e: React.PointerEvent) {
|
|
95
136
|
if (editor.wasEventAlreadyHandled(e)) return
|
|
96
137
|
if (editor.getInstanceState().isPenMode && e.pointerType !== 'pen') return
|
|
@@ -201,6 +242,7 @@ export function useCanvasEvents() {
|
|
|
201
242
|
return {
|
|
202
243
|
onPointerDown,
|
|
203
244
|
onPointerUp,
|
|
245
|
+
onPointerCancel,
|
|
204
246
|
onPointerEnter,
|
|
205
247
|
onPointerLeave,
|
|
206
248
|
onDragOver,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { areArraysShallowEqual, areObjectsShallowEqual } from '@tldraw/utils'
|
|
1
|
+
import { areArraysShallowEqual, areObjectsShallowEqual, isEqual } from '@tldraw/utils'
|
|
2
2
|
import { useRef } from 'react'
|
|
3
3
|
|
|
4
4
|
function useIdentity<T>(value: T, isEqual: (a: T, b: T) => boolean): T {
|
|
@@ -49,3 +49,14 @@ const areNullableObjectsShallowEqual = (
|
|
|
49
49
|
export function useShallowObjectIdentity<T extends object | null | undefined>(obj: T): T {
|
|
50
50
|
return useIdentity(obj, areNullableObjectsShallowEqual)
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* For small option objects that may nest (e.g. camera constraints), where a shallow comparison
|
|
55
|
+
* would still see a fresh identity on every render. Functions and class instances inside still
|
|
56
|
+
* compare by reference, so an inline callback or a fresh `Extension.configure()` counts as a change.
|
|
57
|
+
*
|
|
58
|
+
* @internal
|
|
59
|
+
*/
|
|
60
|
+
export function useDeepObjectIdentity<T extends object | null | undefined>(obj: T): T {
|
|
61
|
+
return useIdentity(obj, isEqual)
|
|
62
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { atom, transact } from '@tldraw/state'
|
|
2
|
+
import { noop } from '@tldraw/utils'
|
|
2
3
|
import { publishDates, version } from '../../version'
|
|
3
4
|
import { getDefaultCdnBaseUrl } from '../utils/assets'
|
|
4
5
|
import { importPublicKey, str2ab } from '../utils/licensing'
|
|
@@ -127,6 +128,7 @@ export class LicenseManager {
|
|
|
127
128
|
...NO_FEATURES,
|
|
128
129
|
})
|
|
129
130
|
public verbose = true
|
|
131
|
+
private isDisposed = false
|
|
130
132
|
|
|
131
133
|
constructor(licenseKey: string | undefined, testPublicKey?: string) {
|
|
132
134
|
this.isTest = process.env.NODE_ENV === 'test'
|
|
@@ -144,6 +146,9 @@ export class LicenseManager {
|
|
|
144
146
|
|
|
145
147
|
this.getLicenseFromKey(licenseKey)
|
|
146
148
|
.then((result) => {
|
|
149
|
+
// replaced by a manager for a newer key: this one's messages, tracking ping and state
|
|
150
|
+
// would describe a key the app no longer uses
|
|
151
|
+
if (this.isDisposed) return
|
|
147
152
|
const licenseState = getLicenseState(
|
|
148
153
|
result,
|
|
149
154
|
(messages: string[]) => this.outputMessages(messages),
|
|
@@ -160,11 +165,17 @@ export class LicenseManager {
|
|
|
160
165
|
})
|
|
161
166
|
})
|
|
162
167
|
.catch((error) => {
|
|
168
|
+
if (this.isDisposed) return
|
|
163
169
|
console.error('License validation failed:', error)
|
|
164
170
|
this.state.set('unlicensed')
|
|
165
171
|
})
|
|
166
172
|
}
|
|
167
173
|
|
|
174
|
+
/** Ignore the pending validation: nothing is logged, tracked or set once the manager is replaced. */
|
|
175
|
+
dispose() {
|
|
176
|
+
this.isDisposed = true
|
|
177
|
+
}
|
|
178
|
+
|
|
168
179
|
/**
|
|
169
180
|
* Returns whether a given licensable feature is enabled. Reactive: reading this inside a signal
|
|
170
181
|
* recomputes when license validation resolves.
|
|
@@ -247,8 +258,9 @@ export class LicenseManager {
|
|
|
247
258
|
url.searchParams.set('environment', process.env.NODE_ENV)
|
|
248
259
|
}
|
|
249
260
|
|
|
261
|
+
// best-effort: a blocked or offline request must not surface as an unhandled rejection
|
|
250
262
|
// eslint-disable-next-line no-restricted-globals
|
|
251
|
-
fetch(url.toString())
|
|
263
|
+
fetch(url.toString()).catch(noop)
|
|
252
264
|
}
|
|
253
265
|
|
|
254
266
|
private async extractLicenseKey(licenseKey: string): Promise<LicenseInfo> {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
|
+
import { vi } from 'vitest'
|
|
3
|
+
import { LicenseFromKeyResult, LicenseManager } from './LicenseManager'
|
|
4
|
+
import { LICENSE_TIMEOUT, LicenseProvider, useLicenseContext } from './LicenseProvider'
|
|
5
|
+
|
|
6
|
+
const NO_KEY: LicenseFromKeyResult = { isLicenseParseable: false, reason: 'no-key-provided' }
|
|
7
|
+
|
|
8
|
+
describe('LicenseProvider', () => {
|
|
9
|
+
const managers: LicenseManager[] = []
|
|
10
|
+
function Probe() {
|
|
11
|
+
managers.push(useLicenseContext())
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
const renderWithKey = (licenseKey: string | undefined) => (
|
|
15
|
+
<LicenseProvider licenseKey={licenseKey}>
|
|
16
|
+
<Probe />
|
|
17
|
+
</LicenseProvider>
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
managers.length = 0
|
|
22
|
+
})
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.restoreAllMocks()
|
|
25
|
+
vi.useRealTimers()
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('creates a new license manager when the license key changes', async () => {
|
|
29
|
+
const rendered = await act(async () => render(renderWithKey(undefined)))
|
|
30
|
+
const initial = managers.at(-1)!
|
|
31
|
+
|
|
32
|
+
await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
|
|
33
|
+
const changed = managers.at(-1)!
|
|
34
|
+
expect(changed).not.toBe(initial)
|
|
35
|
+
|
|
36
|
+
// same key again: the manager is stable
|
|
37
|
+
await act(async () => rendered.rerender(renderWithKey('not-a-real-key')))
|
|
38
|
+
expect(managers.at(-1)).toBe(changed)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('shows the editor again when a new key arrives after the gate closed', async () => {
|
|
42
|
+
// validation never settles so the test drives the state by hand
|
|
43
|
+
vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockReturnValue(new Promise(() => {}))
|
|
44
|
+
vi.useFakeTimers()
|
|
45
|
+
|
|
46
|
+
const rendered = await act(async () => render(renderWithKey('expired-key')))
|
|
47
|
+
const expired = managers.at(-1)!
|
|
48
|
+
act(() => expired.state.set('expired'))
|
|
49
|
+
act(() => vi.advanceTimersByTime(LICENSE_TIMEOUT))
|
|
50
|
+
expect(rendered.queryByTestId('tl-license-expired')).not.toBeNull()
|
|
51
|
+
|
|
52
|
+
await act(async () => rendered.rerender(renderWithKey('fresh-key')))
|
|
53
|
+
expect(rendered.queryByTestId('tl-license-expired')).toBeNull()
|
|
54
|
+
expect(managers.at(-1)).not.toBe(expired)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('ignores the validation result of a manager replaced by a new key', async () => {
|
|
58
|
+
const resolvers: ((result: LicenseFromKeyResult) => void)[] = []
|
|
59
|
+
vi.spyOn(LicenseManager.prototype, 'getLicenseFromKey').mockImplementation(
|
|
60
|
+
() => new Promise((resolve) => resolvers.push(resolve))
|
|
61
|
+
)
|
|
62
|
+
const track = vi.spyOn(LicenseManager.prototype as any, 'maybeTrack')
|
|
63
|
+
|
|
64
|
+
const rendered = await act(async () => render(renderWithKey(undefined)))
|
|
65
|
+
const orphan = managers.at(-1)!
|
|
66
|
+
await act(async () => rendered.rerender(renderWithKey('real-key')))
|
|
67
|
+
expect(managers.at(-1)).not.toBe(orphan)
|
|
68
|
+
|
|
69
|
+
await act(async () => resolvers[0](NO_KEY))
|
|
70
|
+
expect(orphan.state.get()).toBe('pending')
|
|
71
|
+
expect(track).not.toHaveBeenCalled()
|
|
72
|
+
|
|
73
|
+
await act(async () => resolvers[1](NO_KEY))
|
|
74
|
+
expect(managers.at(-1)!.state.get()).toBe('unlicensed')
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useValue } from '@tldraw/state-react'
|
|
2
|
-
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
|
|
2
|
+
import { createContext, ReactNode, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
3
|
import { useMaybeEditor } from '../hooks/useEditor'
|
|
4
4
|
import { LicenseManager } from './LicenseManager'
|
|
5
5
|
|
|
@@ -46,21 +46,35 @@ export function LicenseProvider({
|
|
|
46
46
|
licenseKey?: string
|
|
47
47
|
children: ReactNode
|
|
48
48
|
}) {
|
|
49
|
-
|
|
49
|
+
// Keyed on the license key: the editor is recreated when the key changes, and must not be
|
|
50
|
+
// handed a manager that validated the old key.
|
|
51
|
+
const licenseManager = useMemo(() => new LicenseManager(licenseKey), [licenseKey])
|
|
50
52
|
const licenseState = useValue(licenseManager.state)
|
|
51
|
-
|
|
53
|
+
// The manager whose LICENSE_TIMEOUT elapsed; compared by identity so a new key un-gates the editor.
|
|
54
|
+
const [gatedManager, setGatedManager] = useState<LicenseManager | null>(null)
|
|
55
|
+
const showEditor = gatedManager !== licenseManager
|
|
56
|
+
|
|
57
|
+
// Dispose only the replaced manager, never on cleanup: strict mode re-runs effects with the
|
|
58
|
+
// same manager, and disposing it there would silence the live one.
|
|
59
|
+
const previousManager = useRef<LicenseManager | null>(null)
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (previousManager.current && previousManager.current !== licenseManager) {
|
|
62
|
+
previousManager.current.dispose()
|
|
63
|
+
}
|
|
64
|
+
previousManager.current = licenseManager
|
|
65
|
+
}, [licenseManager])
|
|
52
66
|
|
|
53
67
|
// When license expires or no license in production, show for 5 seconds then hide
|
|
54
68
|
useEffect(() => {
|
|
55
69
|
if (shouldHideEditorAfterDelay(licenseState) && showEditor) {
|
|
56
70
|
// eslint-disable-next-line no-restricted-globals
|
|
57
71
|
const timer = setTimeout(() => {
|
|
58
|
-
|
|
72
|
+
setGatedManager(licenseManager)
|
|
59
73
|
}, LICENSE_TIMEOUT)
|
|
60
74
|
|
|
61
75
|
return () => clearTimeout(timer)
|
|
62
76
|
}
|
|
63
|
-
}, [licenseState, showEditor])
|
|
77
|
+
}, [licenseManager, licenseState, showEditor])
|
|
64
78
|
|
|
65
79
|
// If license is expired or no license in production and 5 seconds have passed, don't render anything (blank screen)
|
|
66
80
|
if (shouldHideEditorAfterDelay(licenseState) && !showEditor) {
|
|
@@ -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 {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createTLSchema } from '@tldraw/tlschema'
|
|
2
|
-
import { openDB } from 'idb'
|
|
2
|
+
import { deleteDB, openDB } from 'idb'
|
|
3
3
|
import { vi } from 'vitest'
|
|
4
4
|
import { hardReset } from './hardReset'
|
|
5
5
|
import { getAllIndexDbNames, LocalIndexedDb } from './LocalIndexedDb'
|
|
@@ -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) {
|
|
@@ -239,4 +334,29 @@ describe('LocalIndexedDb', () => {
|
|
|
239
334
|
(await window.indexedDB.databases()).find((db) => db.name === 'TLDRAW_ASSET_STORE_v1test-0')
|
|
240
335
|
).toBeUndefined()
|
|
241
336
|
})
|
|
337
|
+
|
|
338
|
+
it('closes its connection so another tab can delete the database', async () => {
|
|
339
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
340
|
+
const db = new LocalIndexedDb('test-0')
|
|
341
|
+
await db.storeSnapshot({ schema, snapshot: {} })
|
|
342
|
+
|
|
343
|
+
// Simulates a hard reset from another tab. Without the blocking handler this never
|
|
344
|
+
// resolves because our open connection blocks the delete.
|
|
345
|
+
let timeout: ReturnType<typeof setTimeout>
|
|
346
|
+
const result = await Promise.race([
|
|
347
|
+
deleteDB('TLDRAW_DOCUMENT_v2test-0').then(() => 'deleted'),
|
|
348
|
+
new Promise<string>((resolve) => {
|
|
349
|
+
timeout = setTimeout(() => resolve('blocked'), 1000)
|
|
350
|
+
}),
|
|
351
|
+
])
|
|
352
|
+
clearTimeout(timeout!)
|
|
353
|
+
expect(result).toBe('deleted')
|
|
354
|
+
expect(warn).toHaveBeenCalledWith(
|
|
355
|
+
'Closing TLDRAW_DOCUMENT_v2test-0 so another tab can delete or upgrade it'
|
|
356
|
+
)
|
|
357
|
+
warn.mockRestore()
|
|
358
|
+
|
|
359
|
+
// the next write on the closed connection surfaces as an error rather than hanging
|
|
360
|
+
await expect(db.storeSnapshot({ schema, snapshot: {} })).rejects.toThrow()
|
|
361
|
+
})
|
|
242
362
|
})
|