@tldraw/editor 5.4.2 → 5.5.0-canary.0241f11f2e99

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 (135) hide show
  1. package/dist-cjs/index.d.ts +7 -2
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/constants.js +2 -0
  4. package/dist-cjs/lib/constants.js.map +2 -2
  5. package/dist-cjs/lib/editor/Editor.js +131 -151
  6. package/dist-cjs/lib/editor/Editor.js.map +3 -3
  7. package/dist-cjs/lib/editor/derivations/bindingsIndex.js +2 -0
  8. package/dist-cjs/lib/editor/derivations/bindingsIndex.js.map +2 -2
  9. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +10 -6
  10. package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
  11. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
  12. package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.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/license/LicenseManager.js +9 -1
  30. package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
  31. package/dist-cjs/lib/license/LicenseProvider.js +12 -4
  32. package/dist-cjs/lib/license/LicenseProvider.js.map +2 -2
  33. package/dist-cjs/lib/primitives/geometry/Arc2d.js +1 -1
  34. package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
  35. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
  36. package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
  37. package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
  38. package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
  39. package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
  40. package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
  41. package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
  42. package/dist-cjs/lib/primitives/geometry/Point2d.js.map +2 -2
  43. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js +13 -17
  44. package/dist-cjs/lib/utils/sync/LocalIndexedDb.js.map +2 -2
  45. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +78 -40
  46. package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js.map +2 -2
  47. package/dist-cjs/version.js +3 -3
  48. package/dist-cjs/version.js.map +1 -1
  49. package/dist-esm/index.d.mts +7 -2
  50. package/dist-esm/index.mjs +1 -1
  51. package/dist-esm/lib/constants.mjs +2 -0
  52. package/dist-esm/lib/constants.mjs.map +2 -2
  53. package/dist-esm/lib/editor/Editor.mjs +132 -151
  54. package/dist-esm/lib/editor/Editor.mjs.map +3 -3
  55. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs +2 -0
  56. package/dist-esm/lib/editor/derivations/bindingsIndex.mjs.map +2 -2
  57. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
  58. package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
  59. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
  60. package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs.map +2 -2
  61. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs +14 -6
  62. package/dist-esm/lib/editor/shapes/group/GroupShapeUtil.mjs.map +3 -3
  63. package/dist-esm/lib/exports/ExportDelay.mjs +11 -3
  64. package/dist-esm/lib/exports/ExportDelay.mjs.map +2 -2
  65. package/dist-esm/lib/exports/FontEmbedder.mjs +25 -11
  66. package/dist-esm/lib/exports/FontEmbedder.mjs.map +2 -2
  67. package/dist-esm/lib/exports/StyleEmbedder.mjs +9 -4
  68. package/dist-esm/lib/exports/StyleEmbedder.mjs.map +2 -2
  69. package/dist-esm/lib/exports/fetchCache.mjs +3 -0
  70. package/dist-esm/lib/exports/fetchCache.mjs.map +2 -2
  71. package/dist-esm/lib/exports/getSvgAsImage.mjs +10 -4
  72. package/dist-esm/lib/exports/getSvgAsImage.mjs.map +2 -2
  73. package/dist-esm/lib/exports/getSvgJsx.mjs +18 -9
  74. package/dist-esm/lib/exports/getSvgJsx.mjs.map +2 -2
  75. package/dist-esm/lib/exports/parseCss.mjs +1 -1
  76. package/dist-esm/lib/exports/parseCss.mjs.map +1 -1
  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/primitives/geometry/Arc2d.mjs +1 -1
  82. package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
  83. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
  84. package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
  85. package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
  86. package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
  87. package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
  88. package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
  89. package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
  90. package/dist-esm/lib/primitives/geometry/Point2d.mjs.map +2 -2
  91. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs +13 -17
  92. package/dist-esm/lib/utils/sync/LocalIndexedDb.mjs.map +2 -2
  93. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +78 -40
  94. package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs.map +2 -2
  95. package/dist-esm/version.mjs +3 -3
  96. package/dist-esm/version.mjs.map +1 -1
  97. package/package.json +7 -7
  98. package/src/lib/constants.ts +8 -0
  99. package/src/lib/editor/Editor.test.ts +20 -0
  100. package/src/lib/editor/Editor.ts +202 -255
  101. package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
  102. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
  103. package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
  104. package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
  105. package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
  106. package/src/lib/editor/shapes/group/GroupShapeUtil.tsx +23 -8
  107. package/src/lib/exports/ExportDelay.tsx +16 -4
  108. package/src/lib/exports/FontEmbedder.test.ts +99 -0
  109. package/src/lib/exports/FontEmbedder.ts +32 -11
  110. package/src/lib/exports/StyleEmbedder.ts +13 -4
  111. package/src/lib/exports/fetchCache.test.ts +22 -0
  112. package/src/lib/exports/fetchCache.ts +4 -0
  113. package/src/lib/exports/getSvgAsImage.ts +13 -5
  114. package/src/lib/exports/getSvgJsx.test.ts +43 -1
  115. package/src/lib/exports/getSvgJsx.tsx +28 -16
  116. package/src/lib/exports/parseCss.test.ts +4 -0
  117. package/src/lib/exports/parseCss.ts +2 -2
  118. package/src/lib/license/LicenseManager.ts +13 -1
  119. package/src/lib/license/LicenseProvider.test.tsx +76 -0
  120. package/src/lib/license/LicenseProvider.tsx +19 -5
  121. package/src/lib/primitives/geometry/Arc2d.test.ts +9 -0
  122. package/src/lib/primitives/geometry/Arc2d.ts +1 -1
  123. package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
  124. package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
  125. package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
  126. package/src/lib/primitives/geometry/Edge2d.ts +1 -1
  127. package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
  128. package/src/lib/primitives/geometry/Group2d.ts +18 -7
  129. package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
  130. package/src/lib/primitives/geometry/Point2d.ts +1 -1
  131. package/src/lib/utils/sync/LocalIndexedDb.test.ts +28 -0
  132. package/src/lib/utils/sync/LocalIndexedDb.ts +18 -20
  133. package/src/lib/utils/sync/TLLocalSyncClient.test.ts +236 -0
  134. package/src/lib/utils/sync/TLLocalSyncClient.ts +106 -54
  135. package/src/version.ts +3 -3
@@ -1,5 +1,5 @@
1
1
  import { useAtom, useValue } from '@tldraw/state-react'
2
- import { TLFrameShape, TLShape, TLShapeId } from '@tldraw/tlschema'
2
+ import { DefaultColorStyle, TLShape, TLShapeId } from '@tldraw/tlschema'
3
3
  import { TLFontFace } from '@tldraw/tlschema'
4
4
  import { hasOwnProperty, promiseWithResolve, uniqueId } from '@tldraw/utils'
5
5
  import {
@@ -117,6 +117,7 @@ export function getSvgJsx(editor: Editor, ids: TLShapeId[], opts: TLImageExportO
117
117
  colorMode={colorMode}
118
118
  renderingShapes={renderingShapes}
119
119
  onMount={initialEffectPromise.resolve}
120
+ onError={exportDelay.fail}
120
121
  waitUntil={exportDelay.waitUntil}
121
122
  >
122
123
  {}
@@ -208,6 +209,7 @@ function SvgExport({
208
209
  colorMode,
209
210
  renderingShapes,
210
211
  onMount,
212
+ onError,
211
213
  waitUntil,
212
214
  }: {
213
215
  editor: Editor
@@ -221,6 +223,7 @@ function SvgExport({
221
223
  colorMode: 'light' | 'dark'
222
224
  renderingShapes: TLRenderingShape[]
223
225
  onMount(): void
226
+ onError(error: unknown): void
224
227
  waitUntil(promise: Promise<void>): void
225
228
  }) {
226
229
  const masksId = useUniqueSafeId()
@@ -283,6 +286,8 @@ function SvgExport({
283
286
  throw new Error('SvgExport should only render once - do not use with react strict mode')
284
287
  }
285
288
  didRenderRef.current = true
289
+ // the promise is caught at the end: a throwing `toSvg` would otherwise leave `onMount`
290
+ // pending forever, so the export would wait out its max delay and snapshot an empty svg
286
291
  ;(async () => {
287
292
  const shapeDefs: Record<string, { pending: false; element: ReactElement }> = {}
288
293
 
@@ -418,8 +423,17 @@ function SvgExport({
418
423
  defsById: { ...state.defsById, ...shapeDefs },
419
424
  }))
420
425
  })
421
- })()
422
- }, [bbox, editor, exportContext, masksId, renderingShapes, singleFrameShapeId, stateAtom])
426
+ })().catch(onError)
427
+ }, [
428
+ bbox,
429
+ editor,
430
+ exportContext,
431
+ masksId,
432
+ onError,
433
+ renderingShapes,
434
+ singleFrameShapeId,
435
+ stateAtom,
436
+ ])
423
437
 
424
438
  useEffect(() => {
425
439
  const fontsInUse = new Set<TLFontFace>()
@@ -449,19 +463,17 @@ function SvgExport({
449
463
  let backgroundColor = background ? colors.background : 'transparent'
450
464
 
451
465
  if (singleFrameShapeId && background) {
452
- const frameShapeUtil = editor.getShapeUtil('frame') as any as
453
- | undefined
454
- | {
455
- options: {
456
- showColors: boolean
457
- }
458
- }
459
- if (frameShapeUtil?.options.showColors) {
460
- const shape = editor.getShape(singleFrameShapeId)! as TLFrameShape
461
- backgroundColor = getColorValue(colors, shape.props.color, 'frameFill')
462
- } else {
463
- backgroundColor = colors.solid
464
- }
466
+ // the frame-like shape's own util decides this - it may not be the default 'frame' util
467
+ const shape = editor.getShape(singleFrameShapeId)!
468
+ const { showColors } = editor.getShapeUtil(shape).options as { showColors?: boolean }
469
+ // the color style may live under any prop key, so look it up through the style map and
470
+ // fall back to a literal `color` prop for frame-like shapes that don't use the style
471
+ const color = showColors
472
+ ? (editor.getShapeStyleIfExists(shape, DefaultColorStyle) ??
473
+ ('color' in shape.props ? shape.props.color : null))
474
+ : null
475
+ backgroundColor =
476
+ typeof color === 'string' ? getColorValue(colors, color, 'frameFill') : colors.solid
465
477
  }
466
478
 
467
479
  return (
@@ -119,6 +119,10 @@ test('parseCss', () => {
119
119
  },
120
120
  ],
121
121
  "imports": [
122
+ {
123
+ "extras": "",
124
+ "url": "https://example.com/font.css",
125
+ },
122
126
  {
123
127
  "extras": " screen and (min-width: 900px)",
124
128
  "url": "thing.css",
@@ -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; embedded?: Promise<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*\))([^;]+);/gi
21
+ /@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]*);/gi
22
22
 
23
23
  // Locate @font-face declarations:
24
24
  const fontFaceRegex = /@font-face\s*{([^}]+)}/gi
@@ -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) {
@@ -225,4 +225,13 @@ describe('Arc2d.getSvgPathData', () => {
225
225
  })
226
226
  expect(arc.getSvgPathData()).toBe('M10, 0 A10 10 0 0 1 7.07, 7.07')
227
227
  })
228
+
229
+ it('does not mutate the stored points', () => {
230
+ const start = new Vec(10, 0)
231
+ const end = new Vec(10 / Math.SQRT2, 10 / Math.SQRT2)
232
+ const arc = new Arc2d({ center: new Vec(0, 0), start, end, sweepFlag: 1, largeArcFlag: 0 })
233
+ arc.getSvgPathData()
234
+ expect(start).toMatchObject({ x: 10, y: 0 })
235
+ expect(end).toMatchObject({ x: 10 / Math.SQRT2, y: 10 / Math.SQRT2 })
236
+ })
228
237
  })
@@ -105,7 +105,7 @@ export class Arc2d extends Geometry2d {
105
105
  _largeArcFlag: largeArcFlag,
106
106
  _sweepFlag: sweepFlag,
107
107
  } = this
108
- return `${first ? `M${start.toFixed()}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end.toFixed()}`
108
+ return `${first ? `M${start}` : ``} A${radius} ${radius} 0 ${largeArcFlag} ${sweepFlag} ${end}`
109
109
  }
110
110
 
111
111
  override getLength() {
@@ -172,4 +172,17 @@ describe('CubicBezier2d.getSvgPathData', () => {
172
172
  })
173
173
  expect(b.getSvgPathData()).toBe('M 0.12, 0 C1.01, 2 3, 5 10, 10')
174
174
  })
175
+
176
+ it('does not mutate the stored points', () => {
177
+ const start = new Vec(0.123, 0)
178
+ const cp1 = new Vec(1.006, 2)
179
+ const cp2 = new Vec(3, 4.999)
180
+ const end = new Vec(10, 10.005)
181
+ const b = new CubicBezier2d({ start, cp1, cp2, end })
182
+ b.getSvgPathData()
183
+ expect(start).toMatchObject({ x: 0.123, y: 0 })
184
+ expect(cp1).toMatchObject({ x: 1.006, y: 2 })
185
+ expect(cp2).toMatchObject({ x: 3, y: 4.999 })
186
+ expect(end).toMatchObject({ x: 10, y: 10.005 })
187
+ })
175
188
  })
@@ -81,7 +81,7 @@ export class CubicBezier2d extends Polyline2d {
81
81
 
82
82
  getSvgPathData(first = true) {
83
83
  const { _a: a, _b: b, _c: c, _d: d } = this
84
- return `${first ? `M ${a.toFixed()} ` : ``} C${b.toFixed()} ${c.toFixed()} ${d.toFixed()}`
84
+ return `${first ? `M ${a} ` : ``} C${b} ${c} ${d}`
85
85
  }
86
86
 
87
87
  static GetAtT(segment: CubicBezier2d, t: number) {
@@ -1,5 +1,23 @@
1
- describe('Edge2d', () => {
2
- it('should be tested', () => {
3
- expect(true).toBe(true)
1
+ import { Vec } from '../Vec'
2
+ import { Edge2d } from './Edge2d'
3
+
4
+ describe('Edge2d.getSvgPathData', () => {
5
+ it('emits a move and a line command, rounded to two decimals', () => {
6
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
7
+ expect(edge.getSvgPathData()).toBe('M0.12, 0 L10, 10.99')
8
+ })
9
+
10
+ it('omits the move command when not first', () => {
11
+ const edge = new Edge2d({ start: new Vec(0.123456, 0), end: new Vec(10, 10.987654) })
12
+ expect(edge.getSvgPathData(false)).toBe(' L10, 10.99')
13
+ })
14
+
15
+ it('does not mutate the stored points', () => {
16
+ const start = new Vec(0.123456, 0)
17
+ const end = new Vec(10, 10.987654)
18
+ const edge = new Edge2d({ start, end })
19
+ edge.getSvgPathData()
20
+ expect(start).toMatchObject({ x: 0.123456, y: 0 })
21
+ expect(end).toMatchObject({ x: 10, y: 10.987654 })
4
22
  })
5
23
  })
@@ -68,6 +68,6 @@ export class Edge2d extends Geometry2d {
68
68
 
69
69
  getSvgPathData(first = true) {
70
70
  const { _start: start, _end: end } = this
71
- return `${first ? `M${start.toFixed()}` : ``} L${end.toFixed()}`
71
+ return `${first ? `M${start}` : ``} L${end}`
72
72
  }
73
73
  }
@@ -340,20 +340,38 @@ describe('Group2d.transform', () => {
340
340
  expect(t.vertices.length).toBe(8)
341
341
  })
342
342
 
343
- it('carries the label, debug colour and ignore flags forward', () => {
343
+ it('carries all the flags forward', () => {
344
344
  const g = new Group2d({
345
345
  children: [rectA()],
346
346
  isLabel: true,
347
+ isEmptyLabel: true,
348
+ isInternal: true,
349
+ excludeFromShapeBounds: true,
347
350
  debugColor: 'red',
348
351
  ignore: true,
349
352
  })
350
353
  expect(g.transform(Mat.Identity())).toMatchObject({
351
354
  isLabel: true,
355
+ isEmptyLabel: true,
356
+ isInternal: true,
357
+ excludeFromShapeBounds: true,
352
358
  debugColor: 'red',
353
359
  ignore: true,
354
360
  })
355
361
  })
356
362
 
363
+ it('applies the transform options over the carried flags', () => {
364
+ const g = new Group2d({ children: [rectA()], isLabel: true, isInternal: true })
365
+ expect(
366
+ g.transform(Mat.Identity(), {
367
+ isLabel: false,
368
+ isInternal: false,
369
+ debugColor: 'red',
370
+ ignore: true,
371
+ })
372
+ ).toMatchObject({ isLabel: false, isInternal: false, debugColor: 'red', ignore: true })
373
+ })
374
+
357
375
  it('scales hit testing with the transform', () => {
358
376
  const t = group().transform(Mat.Scale(2, 2))
359
377
  expect(t.hitTestPoint(new Vec(100, 50), 0, false)).toBe(true)
@@ -361,12 +379,15 @@ describe('Group2d.transform', () => {
361
379
  expect(t.distanceToPoint(new Vec(300, 50))).toBe(100)
362
380
  })
363
381
 
364
- // Locks in current behaviour, see #10562.
365
- it('drops the ignored children', () => {
382
+ // #10562: transform used to drop the ignored children.
383
+ it('keeps the transformed ignored children', () => {
366
384
  const ignored = new Rectangle2d({ x: 500, width: 10, height: 10, isFilled: true, ignore: true })
367
385
  const g = new Group2d({ children: [rectA(), ignored] })
368
- expect(g.ignoredChildren.length).toBe(1)
369
- expect((g.transform(Mat.Identity()) as Group2d).ignoredChildren).toEqual([])
386
+ const t = g.transform(Mat.Translate(10, 20)) as Group2d
387
+ expect(t.children.length).toBe(1)
388
+ expect(t.ignoredChildren.length).toBe(1)
389
+ expect(t.ignoredChildren[0].ignore).toBe(true)
390
+ expect(t.ignoredChildren[0].bounds).toMatchObject({ x: 510, y: 20, w: 10, h: 10 })
370
391
  })
371
392
  })
372
393
 
@@ -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
  }
@@ -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