@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.
Files changed (127) hide show
  1. package/dist-cjs/index.d.ts +2 -0
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/TldrawEditor.js +16 -12
  4. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  5. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  6. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  7. package/dist-cjs/lib/editor/Editor.js +144 -155
  8. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  9. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  10. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  12. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  13. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  14. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  15. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  16. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  17. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  18. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  19. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  20. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  21. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  22. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  23. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  24. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  25. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  26. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  27. package/dist-cjs/lib/exports/parseCss.js +1 -1
  28. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  29. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  30. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  31. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  32. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  33. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  34. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  35. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  36. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  37. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  38. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  39. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
  40. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  41. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -40
  42. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  43. package/dist-cjs/version.js +3 -3
  44. package/dist-cjs/version.js.map +1 -1
  45. package/dist-esm/index.d.mts +2 -0
  46. package/dist-esm/index.mjs +1 -1
  47. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  48. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  49. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  50. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  51. package/dist-esm/lib/editor/Editor.mjs +144 -155
  52. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  53. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  54. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  55. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  56. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  57. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  58. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  59. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  60. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  61. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  62. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  63. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  64. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  65. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  66. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  67. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  68. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  69. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  70. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  71. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  72. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  73. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  74. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  75. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  76. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  77. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  78. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  79. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  80. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  81. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  82. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  83. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
  84. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  85. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -40
  86. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  87. package/dist-esm/version.mjs +3 -3
  88. package/dist-esm/version.mjs.map +1 -1
  89. package/editor.css +9 -1
  90. package/package.json +8 -11
  91. package/src/lib/TldrawEditor.tsx +27 -15
  92. package/src/lib/components/MenuClickCapture.tsx +55 -4
  93. package/src/lib/editor/Editor.test.ts +20 -0
  94. package/src/lib/editor/Editor.ts +221 -259
  95. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  96. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  97. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  98. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  99. package/src/lib/exports/ExportDelay.tsx +16 -4
  100. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  101. package/src/lib/exports/FontEmbedder.ts +32 -11
  102. package/src/lib/exports/StyleEmbedder.ts +13 -4
  103. package/src/lib/exports/fetchCache.test.ts +22 -0
  104. package/src/lib/exports/fetchCache.ts +4 -0
  105. package/src/lib/exports/getSvgAsImage.ts +13 -5
  106. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  107. package/src/lib/exports/getSvgJsx.tsx +28 -16
  108. package/src/lib/exports/parseCss.test.ts +4 -0
  109. package/src/lib/exports/parseCss.ts +2 -2
  110. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  111. package/src/lib/hooks/useIdentity.tsx +12 -1
  112. package/src/lib/license/LicenseManager.ts +13 -1
  113. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  114. package/src/lib/license/LicenseProvider.tsx +19 -5
  115. package/src/lib/test/preserveFocus.test.tsx +81 -0
  116. package/src/lib/utils/deepLinks.test.ts +15 -0
  117. package/src/lib/utils/deepLinks.ts +8 -3
  118. package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
  119. package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
  120. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
  121. package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
  122. package/src/version.ts +3 -3
  123. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  124. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  125. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  126. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  127. 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
- const [licenseManager] = useState(() => new LicenseManager(licenseKey))
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
- const [showEditor, setShowEditor] = useState(true)
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
- setShowEditor(false)
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, so without this check a malformed
78
- // viewport link would bypass the caller's fallback and park the camera at the origin
79
- if (![bounds.x, bounds.y, bounds.w, bounds.h].every(Number.isFinite)) {
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
- // need to add a catch here early to prevent unhandled promise rejection
145
- // during react-strict-mode where this tx.done promise can be rejected
146
- // before we have a chance to await on it
147
- const done = tx.done.catch((e: unknown) => {
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 (!this.isClosed) {
156
- await done
157
- } else {
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
- txPromise.finally(() => this.pendingTransactionSet.delete(txPromise))
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
- await recordsStore.put(record, id)
209
+ requests.push(recordsStore.put(record, id))
213
210
  }
214
211
 
215
212
  for (const [_prev, updated] of Object.values(changes.updated)) {
216
- await recordsStore.put(updated, updated.id)
213
+ requests.push(recordsStore.put(updated, updated.id))
217
214
  }
218
215
 
219
216
  for (const id of Object.keys(changes.removed)) {
220
- await recordsStore.delete(id)
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
- for (const [id, record] of Object.entries(snapshot)) {
258
- await recordsStore.put(record, id)
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