@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.
- package/dist-cjs/index.d.ts +7 -2
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/constants.js +2 -0
- package/dist-cjs/lib/constants.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +131 -151
- 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/EdgeScrollManager/EdgeScrollManager.js +10 -6
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.js +7 -3
- package/dist-cjs/lib/editor/managers/ThemeManager/ThemeManager.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/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/primitives/geometry/Arc2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Arc2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/CubicBezier2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Edge2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Edge2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Group2d.js +8 -5
- package/dist-cjs/lib/primitives/geometry/Group2d.js.map +2 -2
- package/dist-cjs/lib/primitives/geometry/Point2d.js +1 -1
- package/dist-cjs/lib/primitives/geometry/Point2d.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 +7 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/constants.mjs +2 -0
- package/dist-esm/lib/constants.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +132 -151
- 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/EdgeScrollManager/EdgeScrollManager.mjs +10 -6
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.mjs +7 -3
- package/dist-esm/lib/editor/managers/ThemeManager/ThemeManager.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/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/primitives/geometry/Arc2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Arc2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/CubicBezier2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Edge2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Group2d.mjs +12 -6
- package/dist-esm/lib/primitives/geometry/Group2d.mjs.map +2 -2
- package/dist-esm/lib/primitives/geometry/Point2d.mjs +1 -1
- package/dist-esm/lib/primitives/geometry/Point2d.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/package.json +7 -7
- package/src/lib/constants.ts +8 -0
- package/src/lib/editor/Editor.test.ts +20 -0
- package/src/lib/editor/Editor.ts +202 -255
- package/src/lib/editor/derivations/bindingsIndex.ts +3 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +35 -4
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +13 -7
- package/src/lib/editor/managers/ThemeManager/ThemeManager.test.ts +53 -0
- package/src/lib/editor/managers/ThemeManager/ThemeManager.ts +9 -3
- 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/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/primitives/geometry/Arc2d.test.ts +9 -0
- package/src/lib/primitives/geometry/Arc2d.ts +1 -1
- package/src/lib/primitives/geometry/CubicBezier2d.test.ts +13 -0
- package/src/lib/primitives/geometry/CubicBezier2d.ts +1 -1
- package/src/lib/primitives/geometry/Edge2d.test.ts +21 -3
- package/src/lib/primitives/geometry/Edge2d.ts +1 -1
- package/src/lib/primitives/geometry/Group2d.test.ts +26 -5
- package/src/lib/primitives/geometry/Group2d.ts +18 -7
- package/src/lib/primitives/geometry/Point2d.test.ts +14 -3
- package/src/lib/primitives/geometry/Point2d.ts +1 -1
- 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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useAtom, useValue } from '@tldraw/state-react'
|
|
2
|
-
import {
|
|
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
|
-
}, [
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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 (
|
|
@@ -2,7 +2,7 @@ import { safeParseUrl } from '@tldraw/utils'
|
|
|
2
2
|
|
|
3
3
|
export interface ParsedFontFace {
|
|
4
4
|
fontFace: string
|
|
5
|
-
urls: { original: string; resolved: string | null
|
|
5
|
+
urls: { original: string; resolved: string | null }[]
|
|
6
6
|
fontFamilies: Set<string>
|
|
7
7
|
}
|
|
8
8
|
|
|
@@ -18,7 +18,7 @@ export interface ParsedFontFace {
|
|
|
18
18
|
// Parse @import declarations:
|
|
19
19
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/@import#formal_syntax
|
|
20
20
|
const importsRegex =
|
|
21
|
-
/@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]
|
|
21
|
+
/@import\s+(?:"([^"]+)"|'([^']+)'|url\s*\(\s*(?:"([^"]+)"|'([^']+)'|([^'")]+))\s*\))([^;]*);/gi
|
|
22
22
|
|
|
23
23
|
// Locate @font-face declarations:
|
|
24
24
|
const fontFaceRegex = /@font-face\s*{([^}]+)}/gi
|
|
@@ -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) {
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
})
|
|
@@ -340,20 +340,38 @@ describe('Group2d.transform', () => {
|
|
|
340
340
|
expect(t.vertices.length).toBe(8)
|
|
341
341
|
})
|
|
342
342
|
|
|
343
|
-
it('carries the
|
|
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
|
-
//
|
|
365
|
-
it('
|
|
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
|
-
|
|
369
|
-
expect(
|
|
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 {
|
|
3
|
+
import { MatModel } from '../Mat'
|
|
4
4
|
import { Vec, VecLike } from '../Vec'
|
|
5
|
-
import {
|
|
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:
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
})
|
|
@@ -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
|
|