@tldraw/editor 5.4.2 → 5.5.0-canary.003bf5951274

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/dist-cjs/index.d.ts +37 -14
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/index.js.map +2 -2
  4. package/dist-cjs/lib/TldrawEditor.js +16 -12
  5. package/dist-cjs/lib/TldrawEditor.js.map +2 -2
  6. package/dist-cjs/lib/components/MenuClickCapture.js +42 -3
  7. package/dist-cjs/lib/components/MenuClickCapture.js.map +2 -2
  8. package/dist-cjs/lib/components/default-components/DefaultCanvas.js +5 -1
  9. package/dist-cjs/lib/components/default-components/DefaultCanvas.js.map +2 -2
  10. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +2 -5
  11. package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +3 -3
  12. package/dist-cjs/lib/components/default-components/DefaultSpinner.js.map +2 -2
  13. package/dist-cjs/lib/constants.js +2 -0
  14. package/dist-cjs/lib/constants.js.map +2 -2
  15. package/dist-cjs/lib/editor/Editor.js +147 -157
  16. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  17. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  18. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  19. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  20. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  21. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +4 -4
  22. package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
  23. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  24. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js.map +2 -2
  25. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js +14 -6
  26. package/dist-cjs/lib/editor/shapes/group/GroupShapeUtil.js.map +3 -3
  27. package/dist-cjs/lib/editor/types/external-content.js.map +1 -1
  28. package/dist-cjs/lib/exports/ExportDelay.js +10 -2
  29. package/dist-cjs/lib/exports/ExportDelay.js.map +2 -2
  30. package/dist-cjs/lib/exports/FontEmbedder.js +25 -11
  31. package/dist-cjs/lib/exports/FontEmbedder.js.map +2 -2
  32. package/dist-cjs/lib/exports/StyleEmbedder.js +9 -4
  33. package/dist-cjs/lib/exports/StyleEmbedder.js.map +2 -2
  34. package/dist-cjs/lib/exports/fetchCache.js +3 -0
  35. package/dist-cjs/lib/exports/fetchCache.js.map +2 -2
  36. package/dist-cjs/lib/exports/getSvgAsImage.js +10 -4
  37. package/dist-cjs/lib/exports/getSvgAsImage.js.map +2 -2
  38. package/dist-cjs/lib/exports/getSvgJsx.js +18 -9
  39. package/dist-cjs/lib/exports/getSvgJsx.js.map +2 -2
  40. package/dist-cjs/lib/exports/parseCss.js +1 -1
  41. package/dist-cjs/lib/exports/parseCss.js.map +1 -1
  42. package/dist-cjs/lib/hooks/EditorComponentsContext.js.map +2 -2
  43. package/dist-cjs/lib/hooks/useCanvasEvents.js +24 -0
  44. package/dist-cjs/lib/hooks/useCanvasEvents.js.map +2 -2
  45. package/dist-cjs/lib/hooks/useIdentity.js +6 -2
  46. package/dist-cjs/lib/hooks/useIdentity.js.map +3 -3
  47. package/dist-cjs/lib/license/LicenseManager.js +9 -1
  48. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  49. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  50. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  51. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  52. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  53. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  54. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  55. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  56. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  57. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  58. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  59. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  60. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  61. package/dist-cjs/lib/utils/debug-flags.js +0 -6
  62. package/dist-cjs/lib/utils/debug-flags.js.map +2 -2
  63. package/dist-cjs/lib/utils/deepLinks.js +2 -1
  64. package/dist-cjs/lib/utils/deepLinks.js.map +2 -2
  65. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +22 -24
  66. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  67. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +81 -40
  68. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  69. package/dist-cjs/version.js +3 -3
  70. package/dist-cjs/version.js.map +1 -1
  71. package/dist-esm/index.d.mts +37 -14
  72. package/dist-esm/index.mjs +4 -2
  73. package/dist-esm/index.mjs.map +2 -2
  74. package/dist-esm/lib/TldrawEditor.mjs +17 -13
  75. package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
  76. package/dist-esm/lib/components/MenuClickCapture.mjs +42 -3
  77. package/dist-esm/lib/components/MenuClickCapture.mjs.map +2 -2
  78. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs +5 -1
  79. package/dist-esm/lib/components/default-components/DefaultCanvas.mjs.map +2 -2
  80. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +2 -5
  81. package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +3 -3
  82. package/dist-esm/lib/components/default-components/DefaultSpinner.mjs.map +2 -2
  83. package/dist-esm/lib/constants.mjs +2 -0
  84. package/dist-esm/lib/constants.mjs.map +2 -2
  85. package/dist-esm/lib/editor/Editor.mjs +148 -157
  86. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  87. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  88. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  89. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  90. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  91. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +4 -4
  92. package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
  93. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  94. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  95. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  96. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  97. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  98. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  99. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  100. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  101. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  102. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  103. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  104. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  105. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  106. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  107. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  108. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  109. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  110. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  111. package/dist-esm/lib/hooks/EditorComponentsContext.mjs.map +2 -2
  112. package/dist-esm/lib/hooks/useCanvasEvents.mjs +24 -0
  113. package/dist-esm/lib/hooks/useCanvasEvents.mjs.map +2 -2
  114. package/dist-esm/lib/hooks/useIdentity.mjs +7 -3
  115. package/dist-esm/lib/hooks/useIdentity.mjs.map +3 -3
  116. package/dist-esm/lib/license/LicenseManager.mjs +9 -1
  117. package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
  118. package/dist-esm/lib/license/LicenseProvider.mjs +13 -5
  119. package/dist-esm/lib/license/LicenseProvider.mjs.map +2 -2
  120. package/dist-esm/lib/primitives/geometry/Arc2d.mjs +1 -1
  121. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  122. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  123. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  124. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  125. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  126. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  127. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  128. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  129. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  130. package/dist-esm/lib/utils/debug-flags.mjs +0 -6
  131. package/dist-esm/lib/utils/debug-flags.mjs.map +2 -2
  132. package/dist-esm/lib/utils/deepLinks.mjs +2 -1
  133. package/dist-esm/lib/utils/deepLinks.mjs.map +2 -2
  134. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +22 -24
  135. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  136. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +81 -40
  137. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  138. package/dist-esm/version.mjs +3 -3
  139. package/dist-esm/version.mjs.map +1 -1
  140. package/editor.css +9 -1
  141. package/package.json +8 -11
  142. package/src/index.ts +5 -1
  143. package/src/lib/TldrawEditor.tsx +35 -21
  144. package/src/lib/components/MenuClickCapture.tsx +55 -4
  145. package/src/lib/components/default-components/DefaultCanvas.tsx +7 -1
  146. package/src/lib/components/default-components/DefaultErrorFallback.tsx +13 -5
  147. package/src/lib/components/default-components/DefaultSpinner.tsx +4 -1
  148. package/src/lib/constants.ts +8 -0
  149. package/src/lib/editor/Editor.test.ts +20 -0
  150. package/src/lib/editor/Editor.ts +228 -262
  151. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  152. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  153. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  154. package/src/lib/editor/managers/TextManager/TextManager.test.ts +74 -0
  155. package/src/lib/editor/managers/TextManager/TextManager.ts +6 -5
  156. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  157. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  158. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  159. package/src/lib/editor/types/external-content.ts +5 -0
  160. package/src/lib/exports/ExportDelay.tsx +16 -4
  161. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  162. package/src/lib/exports/FontEmbedder.ts +32 -11
  163. package/src/lib/exports/StyleEmbedder.ts +13 -4
  164. package/src/lib/exports/fetchCache.test.ts +22 -0
  165. package/src/lib/exports/fetchCache.ts +4 -0
  166. package/src/lib/exports/getSvgAsImage.ts +13 -5
  167. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  168. package/src/lib/exports/getSvgJsx.tsx +28 -16
  169. package/src/lib/exports/parseCss.test.ts +4 -0
  170. package/src/lib/exports/parseCss.ts +2 -2
  171. package/src/lib/hooks/EditorComponentsContext.tsx +2 -1
  172. package/src/lib/hooks/useCanvasEvents.ts +42 -0
  173. package/src/lib/hooks/useIdentity.tsx +12 -1
  174. package/src/lib/license/LicenseManager.ts +13 -1
  175. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  176. package/src/lib/license/LicenseProvider.tsx +19 -5
  177. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  178. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  179. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  180. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  181. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  182. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  183. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  184. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  185. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  186. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  187. package/src/lib/test/preserveFocus.test.tsx +81 -0
  188. package/src/lib/utils/debug-flags.ts +0 -6
  189. package/src/lib/utils/deepLinks.test.ts +15 -0
  190. package/src/lib/utils/deepLinks.ts +8 -3
  191. package/src/lib/utils/sync/LocalIndexedDb.test.ts +95 -0
  192. package/src/lib/utils/sync/LocalIndexedDb.ts +31 -27
  193. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +257 -1
  194. package/src/lib/utils/sync/TLLocalSyncClient.ts +112 -54
  195. package/src/version.ts +3 -3
  196. package/dist-cjs/lib/primitives/geometry/geometry.bench.js +0 -133
  197. package/dist-cjs/lib/primitives/geometry/geometry.bench.js.map +0 -7
  198. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs +0 -132
  199. package/dist-esm/lib/primitives/geometry/geometry.bench.mjs.map +0 -7
  200. package/src/lib/primitives/geometry/geometry.bench.ts +0 -179
@@ -1,8 +1,13 @@
1
1
  import { assert, invLerp, lerp } from '@tldraw/utils'
2
2
  import { Box } from '../Box'
3
- import { Mat } from '../Mat'
3
+ import { MatModel } from '../Mat'
4
4
  import { Vec, VecLike } from '../Vec'
5
- import { Geometry2d, Geometry2dFilters, Geometry2dOptions } from './Geometry2d'
5
+ import {
6
+ Geometry2d,
7
+ Geometry2dFilters,
8
+ Geometry2dOptions,
9
+ TransformedGeometry2dOptions,
10
+ } from './Geometry2d'
6
11
 
7
12
  /** @public */
8
13
  export class Group2d extends Geometry2d {
@@ -226,12 +231,18 @@ export class Group2d extends Geometry2d {
226
231
  return childTLength / totalLength
227
232
  }
228
233
 
229
- override transform(transform: Mat): Geometry2d {
234
+ override transform(transform: MatModel, opts?: TransformedGeometry2dOptions): Geometry2d {
235
+ // Each child's own transform preserves its `ignore` flag, so the constructor
236
+ // re-partitions ignored children back into `ignoredChildren` instead of dropping
237
+ // them (#10562).
230
238
  return new Group2d({
231
- children: this.children.map((c) => c.transform(transform)),
232
- isLabel: this.isLabel,
233
- debugColor: this.debugColor,
234
- ignore: this.ignore,
239
+ children: [...this.children, ...this.ignoredChildren].map((c) => c.transform(transform)),
240
+ isLabel: opts?.isLabel ?? this.isLabel,
241
+ isEmptyLabel: this.isEmptyLabel,
242
+ isInternal: opts?.isInternal ?? this.isInternal,
243
+ debugColor: opts?.debugColor ?? this.debugColor,
244
+ ignore: opts?.ignore ?? this.ignore,
245
+ excludeFromShapeBounds: this.excludeFromShapeBounds,
235
246
  })
236
247
  }
237
248
 
@@ -1,5 +1,16 @@
1
- describe('Point2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Point2d } from './Point2d'
3
+
4
+ describe('Point2d.getSvgPathData', () => {
5
+ it('emits a move command rounded to two decimals', () => {
6
+ const point = new Point2d({ margin: 0, point: new Vec(0.123456, 7.891234) })
7
+ expect(point.getSvgPathData()).toBe('M0.12, 7.89')
8
+ })
9
+
10
+ it('does not mutate the stored point', () => {
11
+ const vec = new Vec(0.123456, 7.891234)
12
+ const point = new Point2d({ margin: 0, point: vec })
13
+ point.getSvgPathData()
14
+ expect(vec).toMatchObject({ x: 0.123456, y: 7.891234 })
4
15
  })
5
16
  })
@@ -28,6 +28,6 @@ export class Point2d extends Geometry2d {
28
28
 
29
29
  getSvgPathData() {
30
30
  const { _point: point } = this
31
- return `M${point.toFixed()}`
31
+ return `M${point}`
32
32
  }
33
33
  }
@@ -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, 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 {
@@ -10,6 +10,7 @@ describe('LocalIndexedDb', () => {
10
10
  vi.useRealTimers()
11
11
  })
12
12
  afterEach(async () => {
13
+ vi.restoreAllMocks()
13
14
  await hardReset({ shouldReload: false })
14
15
  })
15
16
  describe('#storeSnapshot', () => {
@@ -106,6 +107,34 @@ describe('LocalIndexedDb', () => {
106
107
  })
107
108
  })
108
109
 
110
+ it('commits a write that is still in flight when the db is closed', async () => {
111
+ const db = new LocalIndexedDb('test-0')
112
+ const write = db.storeSnapshot({ schema, snapshot: { 'shape:1': { id: 'shape:1' } } })
113
+ await db.close()
114
+ await write
115
+
116
+ const reopened = new LocalIndexedDb('test-0')
117
+ expect((await reopened.load())?.records).toEqual([{ id: 'shape:1' }])
118
+ await reopened.close()
119
+ })
120
+
121
+ it('rejects a transaction that aborts after its requests succeeded while closing', async () => {
122
+ const db = new LocalIndexedDb('test-0')
123
+ let closing: Promise<void> | undefined
124
+ // A commit can fail after every request succeeded, so the request promises cannot report it.
125
+ // @ts-expect-error Exercise the transaction boundary directly to inject a commit failure.
126
+ const write = db.tx('readwrite', ['records'], async (tx) => {
127
+ await tx.objectStore('records').put({ id: 'shape:1' }, 'shape:1')
128
+ closing = db.close()
129
+ tx.abort()
130
+ })
131
+ try {
132
+ await expect(write).rejects.toMatchObject({ name: 'AbortError' })
133
+ } finally {
134
+ await closing
135
+ }
136
+ })
137
+
109
138
  describe('#storeChanges', () => {
110
139
  it('allows merging changes into an existing store', async () => {
111
140
  const db = new LocalIndexedDb('test-0')
@@ -174,6 +203,72 @@ describe('LocalIndexedDb', () => {
174
203
  })
175
204
  })
176
205
 
206
+ describe('#pruneSessions', () => {
207
+ it('keeps the 10 most recent session rows and the current session', async () => {
208
+ const db = new LocalIndexedDb('test-0')
209
+ let now = 1000
210
+ vi.spyOn(Date, 'now').mockImplementation(() => now++)
211
+
212
+ // the current session is the oldest row of 14
213
+ const sessionIds = ['current', ...Array.from({ length: 13 }, (_, i) => `other-${i}`)]
214
+ for (const sessionId of sessionIds) {
215
+ await db.storeChanges({
216
+ schema,
217
+ changes: { added: {}, updated: {}, removed: {} },
218
+ sessionId,
219
+ sessionStateSnapshot: { sessionId } as any,
220
+ })
221
+ }
222
+
223
+ await db.pruneSessions({ keepSessionId: 'current' })
224
+
225
+ const remaining = await Promise.all(
226
+ sessionIds.map(async (sessionId) => [
227
+ sessionId,
228
+ ((await db.load({ sessionId })).sessionStateSnapshot as any)?.sessionId === sessionId,
229
+ ])
230
+ )
231
+ expect(Object.fromEntries(remaining)).toEqual({
232
+ current: true,
233
+ 'other-0': false,
234
+ 'other-1': false,
235
+ 'other-2': false,
236
+ 'other-3': true,
237
+ 'other-4': true,
238
+ 'other-5': true,
239
+ 'other-6': true,
240
+ 'other-7': true,
241
+ 'other-8': true,
242
+ 'other-9': true,
243
+ 'other-10': true,
244
+ 'other-11': true,
245
+ 'other-12': true,
246
+ })
247
+
248
+ await db.close()
249
+ })
250
+
251
+ it('is a no-op when there are at most 10 rows', async () => {
252
+ const db = new LocalIndexedDb('test-0')
253
+ for (let i = 0; i < 10; i++) {
254
+ await db.storeChanges({
255
+ schema,
256
+ changes: { added: {}, updated: {}, removed: {} },
257
+ sessionId: `session-${i}`,
258
+ sessionStateSnapshot: { i } as any,
259
+ })
260
+ }
261
+
262
+ await db.pruneSessions()
263
+
264
+ for (let i = 0; i < 10; i++) {
265
+ expect((await db.load({ sessionId: `session-${i}` })).sessionStateSnapshot).toEqual({ i })
266
+ }
267
+
268
+ await db.close()
269
+ })
270
+ })
271
+
177
272
  it('migrates legacy asset storage into the new format', async () => {
178
273
  const legacyAssetsDb = await openDB('TLDRAW_ASSET_STORE_v1test-0', 1, {
179
274
  upgrade(database) {
@@ -9,6 +9,9 @@ const STORE_PREFIX = 'TLDRAW_DOCUMENT_v2'
9
9
  const LEGACY_ASSET_STORE_PREFIX = 'TLDRAW_ASSET_STORE_v1'
10
10
  const dbNameIndexKey = 'TLDRAW_DB_NAME_INDEX_v2'
11
11
 
12
+ /** How many session state rows (one per tab) to keep per document when pruning. */
13
+ const MAX_SESSION_STATE_ROWS = 10
14
+
12
15
  /** @internal */
13
16
  export const Table = {
14
17
  Records: 'records',
@@ -141,26 +144,22 @@ export class LocalIndexedDb {
141
144
  assert(!this.isClosed, 'db is closed')
142
145
  const db = await this.getDb()
143
146
  const tx = db.transaction(names, mode)
144
- // 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
- })
147
+ // Commit failures may arrive before the callback finishes; observe them immediately
148
+ // while preserving the rejection for the await below.
149
+ const done = tx.done
150
+ done.catch(noop)
152
151
  try {
153
152
  return await cb(tx)
154
153
  } finally {
155
- if (!this.isClosed) {
156
- await done
157
- } else {
158
- tx.abort()
159
- }
154
+ // let the transaction commit even if close() was called meanwhile, otherwise a
155
+ // persist racing an unmount is silently rolled back
156
+ await done
160
157
  }
161
158
  })()
162
159
  this.pendingTransactionSet.add(txPromise)
163
- txPromise.finally(() => this.pendingTransactionSet.delete(txPromise))
160
+ // not `.finally()`: the promise it returns would re-reject with nobody observing it
161
+ const cleanup = () => this.pendingTransactionSet.delete(txPromise)
162
+ txPromise.then(cleanup, cleanup)
164
163
  return txPromise
165
164
  }
166
165
 
@@ -208,17 +207,19 @@ export class LocalIndexedDb {
208
207
  const schemaStore = tx.objectStore(Table.Schema)
209
208
  const sessionStateStore = tx.objectStore(Table.SessionState)
210
209
 
210
+ const requests: Promise<unknown>[] = []
211
211
  for (const [id, record] of Object.entries(changes.added)) {
212
- await recordsStore.put(record, id)
212
+ requests.push(recordsStore.put(record, id))
213
213
  }
214
214
 
215
215
  for (const [_prev, updated] of Object.values(changes.updated)) {
216
- await recordsStore.put(updated, updated.id)
216
+ requests.push(recordsStore.put(updated, updated.id))
217
217
  }
218
218
 
219
219
  for (const id of Object.keys(changes.removed)) {
220
- await recordsStore.delete(id)
220
+ requests.push(recordsStore.delete(id))
221
221
  }
222
+ await Promise.all(requests)
222
223
 
223
224
  schemaStore.put(schema.serialize(), Table.Schema)
224
225
  if (sessionStateSnapshot && sessionId) {
@@ -254,9 +255,9 @@ export class LocalIndexedDb {
254
255
 
255
256
  await recordsStore.clear()
256
257
 
257
- for (const [id, record] of Object.entries(snapshot)) {
258
- await recordsStore.put(record, id)
259
- }
258
+ await Promise.all(
259
+ Object.entries(snapshot).map(([id, record]) => recordsStore.put(record, id))
260
+ )
260
261
 
261
262
  schemaStore.put(schema.serialize(), Table.Schema)
262
263
 
@@ -275,15 +276,18 @@ export class LocalIndexedDb {
275
276
  })
276
277
  }
277
278
 
278
- async pruneSessions() {
279
+ /**
280
+ * Drop all but the most recently updated session state rows. The row for `keepSessionId` is never
281
+ * deleted: it may be an old row that this tab restored its state from and has not rewritten yet,
282
+ * and deleting it would make a later reload fall back to another tab's session state.
283
+ */
284
+ async pruneSessions({ keepSessionId }: { keepSessionId?: string } = {}) {
279
285
  await this.tx('readwrite', [Table.SessionState], async (tx) => {
280
286
  const sessionStateStore = tx.objectStore(Table.SessionState)
281
- const all = (await sessionStateStore.getAll()).sort((a, b) => a.updatedAt - b.updatedAt)
282
- if (all.length < 10) {
283
- await tx.done
284
- return
285
- }
286
- const toDelete = all.slice(0, all.length - 10)
287
+ const all = ((await sessionStateStore.getAll()) as SessionStateSnapshotRow[])
288
+ .filter((row) => row.id !== keepSessionId)
289
+ .sort((a, b) => a.updatedAt - b.updatedAt)
290
+ const toDelete = all.slice(0, Math.max(0, all.length - MAX_SESSION_STATE_ROWS))
287
291
  for (const { id } of toDelete) {
288
292
  await sessionStateStore.delete(id)
289
293
  }