@tldraw/editor 5.5.0-next.a2698a88ecfb → 5.5.0-next.b6a9f7cd7be2
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 +2 -0
- package/dist-cjs/index.js +1 -1
- 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/editor/Editor.js +144 -155
- 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/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/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/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/deepLinks.js +2 -1
- package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
- package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -40
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +2 -0
- package/dist-esm/index.mjs +1 -1
- 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/editor/Editor.mjs +144 -155
- 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/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/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/deepLinks.mjs +2 -1
- package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
- package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -40
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +9 -1
- package/package.json +8 -11
- package/src/lib/TldrawEditor.tsx +27 -15
- package/src/lib/components/MenuClickCapture.tsx +55 -4
- package/src/lib/editor/Editor.test.ts +20 -0
- package/src/lib/editor/Editor.ts +221 -259
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- 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/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/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/deepLinks.test.ts +15 -0
- package/src/lib/utils/deepLinks.ts +8 -3
- package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
- package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
- package/src/version.ts +3 -3
- 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
|
@@ -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
|
+
})
|
|
@@ -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 {
|
|
@@ -106,6 +106,34 @@ describe('LocalIndexedDb', () => {
|
|
|
106
106
|
})
|
|
107
107
|
})
|
|
108
108
|
|
|
109
|
+
it('commits a write that is still in flight when the db is closed', async () => {
|
|
110
|
+
const db = new LocalIndexedDb('test-0')
|
|
111
|
+
const write = db.storeSnapshot({ schema, snapshot: { 'shape:1': { id: 'shape:1' } } })
|
|
112
|
+
await db.close()
|
|
113
|
+
await write
|
|
114
|
+
|
|
115
|
+
const reopened = new LocalIndexedDb('test-0')
|
|
116
|
+
expect((await reopened.load())?.records).toEqual([{ id: 'shape:1' }])
|
|
117
|
+
await reopened.close()
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('rejects a transaction that aborts after its requests succeeded while closing', async () => {
|
|
121
|
+
const db = new LocalIndexedDb('test-0')
|
|
122
|
+
let closing: Promise<void> | undefined
|
|
123
|
+
// A commit can fail after every request succeeded, so the request promises cannot report it.
|
|
124
|
+
// @ts-expect-error Exercise the transaction boundary directly to inject a commit failure.
|
|
125
|
+
const write = db.tx('readwrite', ['records'], async (tx) => {
|
|
126
|
+
await tx.objectStore('records').put({ id: 'shape:1' }, 'shape:1')
|
|
127
|
+
closing = db.close()
|
|
128
|
+
tx.abort()
|
|
129
|
+
})
|
|
130
|
+
try {
|
|
131
|
+
await expect(write).rejects.toMatchObject({ name: 'AbortError' })
|
|
132
|
+
} finally {
|
|
133
|
+
await closing
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
|
|
109
137
|
describe('#storeChanges', () => {
|
|
110
138
|
it('allows merging changes into an existing store', async () => {
|
|
111
139
|
const db = new LocalIndexedDb('test-0')
|
|
@@ -141,26 +141,22 @@ export class LocalIndexedDb {
|
|
|
141
141
|
assert(!this.isClosed, 'db is closed')
|
|
142
142
|
const db = await this.getDb()
|
|
143
143
|
const tx = db.transaction(names, mode)
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
if (!this.isClosed) {
|
|
149
|
-
throw e
|
|
150
|
-
}
|
|
151
|
-
})
|
|
144
|
+
// Commit failures may arrive before the callback finishes; observe them immediately
|
|
145
|
+
// while preserving the rejection for the await below.
|
|
146
|
+
const done = tx.done
|
|
147
|
+
done.catch(noop)
|
|
152
148
|
try {
|
|
153
149
|
return await cb(tx)
|
|
154
150
|
} finally {
|
|
155
|
-
if (
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
tx.abort()
|
|
159
|
-
}
|
|
151
|
+
// let the transaction commit even if close() was called meanwhile, otherwise a
|
|
152
|
+
// persist racing an unmount is silently rolled back
|
|
153
|
+
await done
|
|
160
154
|
}
|
|
161
155
|
})()
|
|
162
156
|
this.pendingTransactionSet.add(txPromise)
|
|
163
|
-
|
|
157
|
+
// not `.finally()`: the promise it returns would re-reject with nobody observing it
|
|
158
|
+
const cleanup = () => this.pendingTransactionSet.delete(txPromise)
|
|
159
|
+
txPromise.then(cleanup, cleanup)
|
|
164
160
|
return txPromise
|
|
165
161
|
}
|
|
166
162
|
|
|
@@ -208,17 +204,19 @@ export class LocalIndexedDb {
|
|
|
208
204
|
const schemaStore = tx.objectStore(Table.Schema)
|
|
209
205
|
const sessionStateStore = tx.objectStore(Table.SessionState)
|
|
210
206
|
|
|
207
|
+
const requests: Promise<unknown>[] = []
|
|
211
208
|
for (const [id, record] of Object.entries(changes.added)) {
|
|
212
|
-
|
|
209
|
+
requests.push(recordsStore.put(record, id))
|
|
213
210
|
}
|
|
214
211
|
|
|
215
212
|
for (const [_prev, updated] of Object.values(changes.updated)) {
|
|
216
|
-
|
|
213
|
+
requests.push(recordsStore.put(updated, updated.id))
|
|
217
214
|
}
|
|
218
215
|
|
|
219
216
|
for (const id of Object.keys(changes.removed)) {
|
|
220
|
-
|
|
217
|
+
requests.push(recordsStore.delete(id))
|
|
221
218
|
}
|
|
219
|
+
await Promise.all(requests)
|
|
222
220
|
|
|
223
221
|
schemaStore.put(schema.serialize(), Table.Schema)
|
|
224
222
|
if (sessionStateSnapshot && sessionId) {
|
|
@@ -254,9 +252,9 @@ export class LocalIndexedDb {
|
|
|
254
252
|
|
|
255
253
|
await recordsStore.clear()
|
|
256
254
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
255
|
+
await Promise.all(
|
|
256
|
+
Object.entries(snapshot).map(([id, record]) => recordsStore.put(record, id))
|
|
257
|
+
)
|
|
260
258
|
|
|
261
259
|
schemaStore.put(schema.serialize(), Table.Schema)
|
|
262
260
|
|