@tldraw/editor 5.6.0-next.c4e9c54a816e → 5.6.0-next.f151f7f3ebd6
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 +157 -2
- package/dist-cjs/index.js +7 -1
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +18 -5
- package/dist-cjs/lib/TldrawEditor.js.map +3 -3
- package/dist-cjs/lib/components/LiveCollaborators.js +3 -3
- package/dist-cjs/lib/components/LiveCollaborators.js.map +2 -2
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js +57 -15
- package/dist-cjs/lib/components/default-components/DefaultErrorFallback.js.map +2 -2
- package/dist-cjs/lib/config/TLI18n.js +64 -0
- package/dist-cjs/lib/config/TLI18n.js.map +7 -0
- package/dist-cjs/lib/editor/Editor.js +29 -12
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js +10 -3
- package/dist-cjs/lib/editor/derivations/shapeIdsInCurrentPage.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js +1 -1
- package/dist-cjs/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +19 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js +3 -5
- package/dist-cjs/lib/editor/managers/ScribbleManager/ScribbleManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +7 -1
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js +17 -0
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +2 -2
- package/dist-cjs/lib/hooks/useDocumentEvents.js +28 -3
- package/dist-cjs/lib/hooks/useDocumentEvents.js.map +2 -2
- package/dist-cjs/lib/license/LicenseManager.js +62 -4
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/options.js +1 -1
- package/dist-cjs/lib/options.js.map +1 -1
- package/dist-cjs/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +157 -2
- package/dist-esm/index.mjs +13 -1
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +18 -5
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/components/LiveCollaborators.mjs +3 -3
- package/dist-esm/lib/components/LiveCollaborators.mjs.map +2 -2
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs +61 -16
- package/dist-esm/lib/components/default-components/DefaultErrorFallback.mjs.map +2 -2
- package/dist-esm/lib/config/TLI18n.mjs +44 -0
- package/dist-esm/lib/config/TLI18n.mjs.map +7 -0
- package/dist-esm/lib/editor/Editor.mjs +29 -12
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs +10 -3
- package/dist-esm/lib/editor/derivations/shapeIdsInCurrentPage.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs +1 -1
- package/dist-esm/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +20 -3
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs +3 -5
- package/dist-esm/lib/editor/managers/ScribbleManager/ScribbleManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +7 -1
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs +17 -0
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/hooks/useDocumentEvents.mjs +28 -3
- package/dist-esm/lib/hooks/useDocumentEvents.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +62 -4
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +1 -1
- package/dist-esm/lib/options.mjs.map +1 -1
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +2 -1
- package/package.json +7 -7
- package/src/index.ts +14 -2
- package/src/lib/TldrawEditor.tsx +44 -4
- package/src/lib/components/LiveCollaborators.tsx +3 -3
- package/src/lib/components/default-components/DefaultErrorFallback.tsx +66 -18
- package/src/lib/config/TLI18n.test.ts +84 -0
- package/src/lib/config/TLI18n.ts +172 -0
- package/src/lib/editor/Editor.ts +52 -24
- package/src/lib/editor/derivations/shapeIdsInCurrentPage.ts +19 -3
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.test.ts +17 -0
- package/src/lib/editor/managers/EdgeScrollManager/EdgeScrollManager.ts +2 -2
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +1 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +26 -3
- package/src/lib/editor/managers/HistoryManager/HistoryManager.test.ts +32 -0
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +16 -7
- package/src/lib/editor/shapes/ShapeUtil.ts +18 -0
- package/src/lib/hooks/useDocumentEvents.ts +51 -3
- package/src/lib/license/LicenseManager.test.ts +204 -0
- package/src/lib/license/LicenseManager.ts +88 -4
- package/src/lib/options.ts +1 -1
- package/src/lib/test/useDocumentEvents.test.tsx +153 -22
- package/src/version.ts +3 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { computed, EMPTY_ARRAY, transact } from '@tldraw/state'
|
|
1
|
+
import { atom, computed, EMPTY_ARRAY, transact } from '@tldraw/state'
|
|
2
2
|
import { AtomMap } from '@tldraw/store'
|
|
3
3
|
import { TLFontFace, TLShape, TLShapeId } from '@tldraw/tlschema'
|
|
4
4
|
import {
|
|
@@ -102,6 +102,29 @@ export class FontManager {
|
|
|
102
102
|
return this.fontStates.get(font) ?? null
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// One signal for consumers that deliberately avoid per-shape font tracking (the
|
|
106
|
+
// spatial index); otherwise their cached text bounds stay at the fallback-font size.
|
|
107
|
+
private readonly fontLoadEpoch = atom('font load epoch', 0)
|
|
108
|
+
|
|
109
|
+
/** @internal */
|
|
110
|
+
getFontLoadEpoch(): number {
|
|
111
|
+
return this.fontLoadEpoch.get()
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private isFontLoadEpochBumpPending = false
|
|
115
|
+
|
|
116
|
+
private setFontState(font: TLFontFace, state: FontState['state']) {
|
|
117
|
+
this.fontStates.update(font, (s) => ({ ...s, state }))
|
|
118
|
+
// Each bump rebuilds the whole spatial index, and fonts finish loading in
|
|
119
|
+
// separate callbacks, so coalesce loads that land in the same frame.
|
|
120
|
+
if (this.isFontLoadEpochBumpPending) return
|
|
121
|
+
this.isFontLoadEpochBumpPending = true
|
|
122
|
+
this.editor.timers.requestAnimationFrame(() => {
|
|
123
|
+
this.isFontLoadEpochBumpPending = false
|
|
124
|
+
this.fontLoadEpoch.update((n) => n + 1)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
ensureFontIsLoaded(font: TLFontFace): Promise<void> {
|
|
106
129
|
const existingState = this.getFontState(font)
|
|
107
130
|
if (existingState) return existingState.loadingPromise
|
|
@@ -123,7 +146,7 @@ export class FontManager {
|
|
|
123
146
|
return
|
|
124
147
|
}
|
|
125
148
|
this.editor.getContainerDocument().fonts.add(instance)
|
|
126
|
-
this.
|
|
149
|
+
this.setFontState(font, 'ready')
|
|
127
150
|
})
|
|
128
151
|
.catch((err) => {
|
|
129
152
|
if (isStale()) {
|
|
@@ -132,7 +155,7 @@ export class FontManager {
|
|
|
132
155
|
return
|
|
133
156
|
}
|
|
134
157
|
console.error(err)
|
|
135
|
-
this.
|
|
158
|
+
this.setFontState(font, 'error')
|
|
136
159
|
}),
|
|
137
160
|
}
|
|
138
161
|
|
|
@@ -177,6 +177,38 @@ describe(HistoryManager, () => {
|
|
|
177
177
|
expect(editor.getCount()).toBe(3)
|
|
178
178
|
})
|
|
179
179
|
|
|
180
|
+
// Callers mark before code that may change nothing, such as a shape util's double-click
|
|
181
|
+
// handler (#11075), so an empty stopping point must not cost an extra undo or redo
|
|
182
|
+
it('treats back-to-back stopping points as one on undo and redo', () => {
|
|
183
|
+
editor.history._mark('stop at 0')
|
|
184
|
+
editor.increment()
|
|
185
|
+
editor.history._mark('nothing follows this mark')
|
|
186
|
+
editor.history._mark('stop at 1')
|
|
187
|
+
editor.increment()
|
|
188
|
+
expect(editor.getCount()).toBe(2)
|
|
189
|
+
|
|
190
|
+
editor.history.undo()
|
|
191
|
+
expect(editor.getCount()).toBe(1)
|
|
192
|
+
editor.history.undo()
|
|
193
|
+
expect(editor.getCount()).toBe(0)
|
|
194
|
+
|
|
195
|
+
editor.history.redo()
|
|
196
|
+
expect(editor.getCount()).toBe(1)
|
|
197
|
+
editor.history.redo()
|
|
198
|
+
expect(editor.getCount()).toBe(2)
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('skips a trailing stopping point with no changes after it on undo', () => {
|
|
202
|
+
editor.history._mark('stop at 0')
|
|
203
|
+
editor.increment()
|
|
204
|
+
editor.history._mark('nothing follows this mark')
|
|
205
|
+
|
|
206
|
+
editor.history.undo()
|
|
207
|
+
expect(editor.getCount()).toBe(0)
|
|
208
|
+
editor.history.redo()
|
|
209
|
+
expect(editor.getCount()).toBe(1)
|
|
210
|
+
})
|
|
211
|
+
|
|
180
212
|
it('allows squashing of commands', () => {
|
|
181
213
|
editor.increment()
|
|
182
214
|
|
|
@@ -444,13 +444,11 @@ export class ScribbleManager {
|
|
|
444
444
|
}
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
-
//
|
|
447
|
+
// Keep starting scribbles with no point yet, or addPoint can't find them (#7681).
|
|
448
448
|
if (session.options.fadeMode === 'individual') {
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
}
|
|
449
|
+
session.items = session.items.filter(
|
|
450
|
+
({ scribble }) => scribble.points.length > 0 || scribble.state === 'starting'
|
|
451
|
+
)
|
|
454
452
|
}
|
|
455
453
|
}
|
|
456
454
|
|
|
@@ -41,6 +41,7 @@ export class SpatialIndexManager {
|
|
|
41
41
|
private rbush: RBushIndex
|
|
42
42
|
private spatialIndexComputed: Computed<number>
|
|
43
43
|
private lastPageId: TLPageId | null = null
|
|
44
|
+
private lastFontLoadEpoch = -1
|
|
44
45
|
|
|
45
46
|
// Bumps only when the rbush content may have changed. Consumers subscribe
|
|
46
47
|
// via the computed; a stable epoch lets prop-only diffs skip downstream
|
|
@@ -66,21 +67,28 @@ export class SpatialIndexManager {
|
|
|
66
67
|
const bindingHistory = this.editor.store.query.filterHistory('binding')
|
|
67
68
|
|
|
68
69
|
return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
|
|
69
|
-
// These
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
70
|
+
// These four reads are the computed's only tracked dependencies: shape
|
|
71
|
+
// and binding record changes, plus font loads (measured text resizes
|
|
72
|
+
// when its font swaps in). Theme changes that resize shapes without a
|
|
73
|
+
// record diff (e.g. `updateTheme({ fontSize })`) are not tracked, so
|
|
74
|
+
// the index stays stale for those until the next rebuild.
|
|
75
|
+
// The rebuild/update paths below run without capture to avoid
|
|
76
|
+
// registering a dependency per checked shape on every update (and
|
|
77
|
+
// ~page-size dependencies on every rebuild).
|
|
75
78
|
const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
|
|
76
79
|
const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
|
|
77
80
|
const currentPageId = this.editor.getCurrentPageId()
|
|
81
|
+
const fontLoadEpoch = this.editor.fonts.getFontLoadEpoch()
|
|
78
82
|
|
|
79
83
|
if (
|
|
80
84
|
isUninitialized(_prevValue) ||
|
|
81
85
|
shapeDiff === RESET_VALUE ||
|
|
82
86
|
bindingDiff === RESET_VALUE ||
|
|
83
|
-
this.lastPageId !== currentPageId
|
|
87
|
+
this.lastPageId !== currentPageId ||
|
|
88
|
+
// Which shapes a font load resizes isn't known without visiting
|
|
89
|
+
// every shape, so rebuild; loads are rare and bounded by the
|
|
90
|
+
// number of distinct fonts.
|
|
91
|
+
this.lastFontLoadEpoch !== fontLoadEpoch
|
|
84
92
|
) {
|
|
85
93
|
return unsafe__withoutCapture(() => this.rebuildAndBumpEpoch())
|
|
86
94
|
}
|
|
@@ -97,6 +105,7 @@ export class SpatialIndexManager {
|
|
|
97
105
|
private buildFromScratch(): void {
|
|
98
106
|
this.rbush.clear()
|
|
99
107
|
this.lastPageId = this.editor.getCurrentPageId()
|
|
108
|
+
this.lastFontLoadEpoch = this.editor.fonts.getFontLoadEpoch()
|
|
100
109
|
|
|
101
110
|
const elements: SpatialElement[] = []
|
|
102
111
|
for (const shape of this.editor.getCurrentPageShapes()) {
|
|
@@ -200,6 +200,24 @@ export abstract class ShapeUtil<Shape extends TLShape = TLShape> {
|
|
|
200
200
|
*/
|
|
201
201
|
createShapeForAsset?(asset: TLAsset, position: VecModel): TLShapePartial | null
|
|
202
202
|
|
|
203
|
+
/**
|
|
204
|
+
* The shape's name, translated, as a screen reader should announce it.
|
|
205
|
+
*
|
|
206
|
+
* Return `undefined` — the default — and the shape is announced by its tool's label, under the
|
|
207
|
+
* `tool.<type>` convention. Override this when the name isn't that: a geo shape is a "Rectangle"
|
|
208
|
+
* or an "Ellipse" rather than a "Shape", so it names itself from its geo style.
|
|
209
|
+
*
|
|
210
|
+
* Resolve the name through {@link translateMessage} and the editor's `i18n` rather than
|
|
211
|
+
* returning English, so it follows the user's locale and still reads as words when an app
|
|
212
|
+
* supplies no translations.
|
|
213
|
+
*
|
|
214
|
+
* @param shape - The shape.
|
|
215
|
+
* @public
|
|
216
|
+
*/
|
|
217
|
+
getShapeName(_shape: Shape): string | undefined {
|
|
218
|
+
return undefined
|
|
219
|
+
}
|
|
220
|
+
|
|
203
221
|
/**
|
|
204
222
|
* Get the shape's geometry.
|
|
205
223
|
*
|
|
@@ -18,6 +18,10 @@ export function useDocumentEvents() {
|
|
|
18
18
|
// blur release below needs the key remembered per code.
|
|
19
19
|
const heldKeysRef = useRef(new Map<string, string>())
|
|
20
20
|
|
|
21
|
+
// Non-modifier keys pressed while Meta was down. macOS never delivers their keyup, so
|
|
22
|
+
// `inputs.keys` would count them as held for good; see the Meta branch in `handleKeyUp`.
|
|
23
|
+
const keysPressedWithMetaRef = useRef(new Set<string>())
|
|
24
|
+
|
|
21
25
|
// Prevent the browser's default drag and drop behavior on our container (UI, etc)
|
|
22
26
|
useEffect(() => {
|
|
23
27
|
if (!container) return
|
|
@@ -217,6 +221,12 @@ export function useDocumentEvents() {
|
|
|
217
221
|
}
|
|
218
222
|
|
|
219
223
|
heldKeysRef.current.set(e.code, e.key)
|
|
224
|
+
// Only a fresh press counts. A key held from before Meta went down keeps auto-
|
|
225
|
+
// repeating, and those repeats carry `metaKey` too, which would make it look like
|
|
226
|
+
// it was pressed during the Meta window.
|
|
227
|
+
if (!e.repeat && e.metaKey && !MODIFIER_CODES.has(e.code)) {
|
|
228
|
+
keysPressedWithMetaRef.current.add(e.code)
|
|
229
|
+
}
|
|
220
230
|
editor.dispatch(info)
|
|
221
231
|
}
|
|
222
232
|
|
|
@@ -232,6 +242,13 @@ export function useDocumentEvents() {
|
|
|
232
242
|
return
|
|
233
243
|
}
|
|
234
244
|
|
|
245
|
+
// The native `metaKey` is still true on Meta's own keyup. Reported as-is it matches
|
|
246
|
+
// neither branch of the editor's modifier handling - which ignores `metaKey` on a
|
|
247
|
+
// key_up, then only arms the 150ms release when it reads false - so a cmd tap left
|
|
248
|
+
// `getMetaKey()` true until some later event happened to say otherwise.
|
|
249
|
+
const isMetaRelease = e.code === 'MetaLeft' || e.code === 'MetaRight'
|
|
250
|
+
const metaKey = isMetaRelease ? false : e.metaKey
|
|
251
|
+
|
|
235
252
|
const info: TLKeyboardEventInfo = {
|
|
236
253
|
type: 'keyboard',
|
|
237
254
|
name: 'key_up',
|
|
@@ -239,13 +256,32 @@ export function useDocumentEvents() {
|
|
|
239
256
|
code: e.code,
|
|
240
257
|
shiftKey: e.shiftKey,
|
|
241
258
|
altKey: e.altKey,
|
|
242
|
-
ctrlKey:
|
|
243
|
-
metaKey
|
|
244
|
-
accelKey: isAccelKey(e),
|
|
259
|
+
ctrlKey: metaKey || e.ctrlKey,
|
|
260
|
+
metaKey,
|
|
261
|
+
accelKey: isAccelKey({ ctrlKey: e.ctrlKey, metaKey }),
|
|
245
262
|
}
|
|
246
263
|
|
|
247
264
|
heldKeysRef.current.delete(e.code)
|
|
265
|
+
keysPressedWithMetaRef.current.delete(e.code)
|
|
248
266
|
editor.dispatch(info)
|
|
267
|
+
|
|
268
|
+
// macOS swallows the keyup of a non-modifier key pressed while Meta is held, so a
|
|
269
|
+
// stuck ArrowUp from Cmd+ArrowUp made every later arrow nudge diagonally: the nudge
|
|
270
|
+
// sums all held arrows. Anything held from before the Meta window still gets its
|
|
271
|
+
// own keyup, so only drop what was pressed inside it. That is still one too many -
|
|
272
|
+
// the key you are *still* holding is not owed anything - and the key_repeat case
|
|
273
|
+
// in `Editor.dispatch` is what takes that one back.
|
|
274
|
+
//
|
|
275
|
+
// Drop them rather than replaying a key_up: a tool's onKeyUp is a real action at
|
|
276
|
+
// the wrong moment, and a synthetic Enter re-entered shape editing and stole the
|
|
277
|
+
// focus that cmd+Enter's a11y action had just put on the style toolbar.
|
|
278
|
+
if (isMetaRelease) {
|
|
279
|
+
for (const code of keysPressedWithMetaRef.current) {
|
|
280
|
+
editor.inputs.keys.delete(code)
|
|
281
|
+
heldKeysRef.current.delete(code)
|
|
282
|
+
}
|
|
283
|
+
keysPressedWithMetaRef.current.clear()
|
|
284
|
+
}
|
|
249
285
|
}
|
|
250
286
|
|
|
251
287
|
function handleTouchStart(e: TouchEvent) {
|
|
@@ -334,6 +370,7 @@ export function useDocumentEvents() {
|
|
|
334
370
|
})
|
|
335
371
|
}
|
|
336
372
|
heldKeys.clear()
|
|
373
|
+
keysPressedWithMetaRef.current.clear()
|
|
337
374
|
}
|
|
338
375
|
|
|
339
376
|
win.addEventListener('blur', handleWindowBlur)
|
|
@@ -343,6 +380,17 @@ export function useDocumentEvents() {
|
|
|
343
380
|
}, [editor])
|
|
344
381
|
}
|
|
345
382
|
|
|
383
|
+
const MODIFIER_CODES = new Set([
|
|
384
|
+
'ShiftLeft',
|
|
385
|
+
'ShiftRight',
|
|
386
|
+
'AltLeft',
|
|
387
|
+
'AltRight',
|
|
388
|
+
'ControlLeft',
|
|
389
|
+
'ControlRight',
|
|
390
|
+
'MetaLeft',
|
|
391
|
+
'MetaRight',
|
|
392
|
+
])
|
|
393
|
+
|
|
346
394
|
function areShortcutsDisabled(editor: Editor) {
|
|
347
395
|
return (
|
|
348
396
|
editor.menus.hasOpenMenus() ||
|
|
@@ -88,6 +88,45 @@ describe('LicenseManager', () => {
|
|
|
88
88
|
}
|
|
89
89
|
})
|
|
90
90
|
|
|
91
|
+
it('Signals that it is development mode for blob URLs created by a development page', async () => {
|
|
92
|
+
process.env.NODE_ENV = 'production'
|
|
93
|
+
try {
|
|
94
|
+
const origins = ['http://example.com', 'https://localhost:3000', 'https://127.0.0.1']
|
|
95
|
+
for (const origin of origins) {
|
|
96
|
+
// @ts-ignore
|
|
97
|
+
delete window.location
|
|
98
|
+
// @ts-ignore
|
|
99
|
+
window.location = new URL(`blob:${origin}/6ec2dc7a-afe9-45d9-bd71-1749f9568d28`)
|
|
100
|
+
|
|
101
|
+
const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
|
|
102
|
+
const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
|
|
103
|
+
const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
|
|
104
|
+
expect(result).toMatchObject({ isLicenseParseable: true, isDevelopment: true })
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
process.env.NODE_ENV = 'test'
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('Signals that it is not development mode for blob URLs created by a production page', async () => {
|
|
112
|
+
process.env.NODE_ENV = 'production'
|
|
113
|
+
try {
|
|
114
|
+
// @ts-ignore
|
|
115
|
+
delete window.location
|
|
116
|
+
// @ts-ignore
|
|
117
|
+
window.location = new URL(
|
|
118
|
+
'blob:https://www.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28'
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
const testEnvLicenseManager = new LicenseManager('', keyPair.publicKey)
|
|
122
|
+
const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
|
|
123
|
+
const result = await testEnvLicenseManager.getLicenseFromKey(licenseKey)
|
|
124
|
+
expect(result).toMatchObject({ isLicenseParseable: true, isDevelopment: false })
|
|
125
|
+
} finally {
|
|
126
|
+
process.env.NODE_ENV = 'test'
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
91
130
|
it('Signals that it is development mode when NODE_ENV is not production', async () => {
|
|
92
131
|
process.env.NODE_ENV = 'development'
|
|
93
132
|
// @ts-ignore
|
|
@@ -370,6 +409,46 @@ describe('LicenseManager', () => {
|
|
|
370
409
|
expect(result.isDomainValid).toBe(false)
|
|
371
410
|
})
|
|
372
411
|
|
|
412
|
+
it('Succeeds if the page is a blob URL created by a licensed host', async () => {
|
|
413
|
+
// @ts-ignore
|
|
414
|
+
delete window.location
|
|
415
|
+
// @ts-ignore
|
|
416
|
+
window.location = new URL('blob:https://www.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
|
|
417
|
+
|
|
418
|
+
const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
|
|
419
|
+
const result = (await licenseManager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
|
|
420
|
+
expect(result.isDomainValid).toBe(true)
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('Succeeds if the page is a blob URL created by a wildcard-licensed host', async () => {
|
|
424
|
+
// @ts-ignore
|
|
425
|
+
delete window.location
|
|
426
|
+
// @ts-ignore
|
|
427
|
+
window.location = new URL('blob:https://sub.example.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
|
|
428
|
+
|
|
429
|
+
const permissiveHostsInfo = JSON.parse(STANDARD_LICENSE_INFO)
|
|
430
|
+
permissiveHostsInfo[PROPERTIES.HOSTS] = ['*.example.com']
|
|
431
|
+
const permissiveLicenseKey = await generateLicenseKey(
|
|
432
|
+
JSON.stringify(permissiveHostsInfo),
|
|
433
|
+
keyPair
|
|
434
|
+
)
|
|
435
|
+
const result = (await licenseManager.getLicenseFromKey(
|
|
436
|
+
permissiveLicenseKey
|
|
437
|
+
)) as ValidLicenseKeyResult
|
|
438
|
+
expect(result.isDomainValid).toBe(true)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
it('Fails if the page is a blob URL created by an unlicensed host', async () => {
|
|
442
|
+
// @ts-ignore
|
|
443
|
+
delete window.location
|
|
444
|
+
// @ts-ignore
|
|
445
|
+
window.location = new URL('blob:https://www.foo.com/6ec2dc7a-afe9-45d9-bd71-1749f9568d28')
|
|
446
|
+
|
|
447
|
+
const licenseKey = await generateLicenseKey(STANDARD_LICENSE_INFO, keyPair)
|
|
448
|
+
const result = (await licenseManager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
|
|
449
|
+
expect(result.isDomainValid).toBe(false)
|
|
450
|
+
})
|
|
451
|
+
|
|
373
452
|
it('Succeeds if it is a vscode extension', async () => {
|
|
374
453
|
// @ts-ignore
|
|
375
454
|
delete window.location
|
|
@@ -599,6 +678,131 @@ describe('LicenseManager', () => {
|
|
|
599
678
|
)
|
|
600
679
|
})
|
|
601
680
|
|
|
681
|
+
describe('Canonical tldraw hosts', () => {
|
|
682
|
+
let fetchSpy: ReturnType<typeof vi.fn>
|
|
683
|
+
|
|
684
|
+
beforeEach(() => {
|
|
685
|
+
process.env.NODE_ENV = 'production'
|
|
686
|
+
fetchSpy = vi.fn(() => Promise.resolve(new Response()))
|
|
687
|
+
vi.stubGlobal('fetch', fetchSpy)
|
|
688
|
+
})
|
|
689
|
+
|
|
690
|
+
afterEach(() => {
|
|
691
|
+
vi.unstubAllGlobals()
|
|
692
|
+
process.env.NODE_ENV = 'test'
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
// The shared licenseManager was built against jsdom's localhost default, so it's in dev mode.
|
|
696
|
+
// Build one on the host itself so these run the constructor path a real page on our domains
|
|
697
|
+
// takes: production dev detection, license state, feature flags and the tracking ping.
|
|
698
|
+
async function createManagerOnHost(url: string, licenseKey = '') {
|
|
699
|
+
// @ts-ignore
|
|
700
|
+
delete window.location
|
|
701
|
+
// @ts-ignore
|
|
702
|
+
window.location = new URL(url)
|
|
703
|
+
|
|
704
|
+
const manager = new LicenseManager(licenseKey, keyPair.publicKey)
|
|
705
|
+
await vi.waitFor(() => expect(manager.state.get()).not.toBe('pending'))
|
|
706
|
+
return manager
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
it('Licenses tldraw-owned hosts with a watermark when no key is provided', async () => {
|
|
710
|
+
const hosts = [
|
|
711
|
+
'https://tldraw.com',
|
|
712
|
+
'https://www.tldraw.com',
|
|
713
|
+
'https://makereal.tldraw.com',
|
|
714
|
+
'https://tldraw.dev',
|
|
715
|
+
'https://staging.tldraw.dev',
|
|
716
|
+
'https://preview.tldraw.club',
|
|
717
|
+
'https://fairies.tldraw.workers.dev',
|
|
718
|
+
'https://teach.tldraw.xyz',
|
|
719
|
+
]
|
|
720
|
+
|
|
721
|
+
for (const host of hosts) {
|
|
722
|
+
const manager = await createManagerOnHost(host)
|
|
723
|
+
|
|
724
|
+
expect(manager.isDevelopment).toBe(false)
|
|
725
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
726
|
+
isLicenseParseable: true,
|
|
727
|
+
isDomainValid: true,
|
|
728
|
+
isLicensedWithWatermark: true,
|
|
729
|
+
isCollaborationEnabled: true,
|
|
730
|
+
isCommentingEnabled: true,
|
|
731
|
+
isAnnualLicenseExpired: false,
|
|
732
|
+
isPerpetualLicenseExpired: false,
|
|
733
|
+
daysSinceExpiry: 0,
|
|
734
|
+
})
|
|
735
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
736
|
+
expect(manager.featureFlags.get()).toEqual({ collaboration: true, commenting: true })
|
|
737
|
+
manager.dispose()
|
|
738
|
+
}
|
|
739
|
+
})
|
|
740
|
+
|
|
741
|
+
it('Does not license hosts that merely look like ours', async () => {
|
|
742
|
+
const hosts = [
|
|
743
|
+
'https://nottldraw.com',
|
|
744
|
+
'https://tldraw.com.example.com',
|
|
745
|
+
'https://eviltldraw.dev',
|
|
746
|
+
'https://tldraw.co',
|
|
747
|
+
'https://tldraw.workers.dev.example.com',
|
|
748
|
+
]
|
|
749
|
+
|
|
750
|
+
for (const host of hosts) {
|
|
751
|
+
const manager = await createManagerOnHost(host)
|
|
752
|
+
|
|
753
|
+
expect(await manager.getLicenseFromKey('')).toMatchObject({
|
|
754
|
+
isLicenseParseable: false,
|
|
755
|
+
reason: 'no-key-provided',
|
|
756
|
+
})
|
|
757
|
+
expect(manager.state.get()).toBe('unlicensed-production')
|
|
758
|
+
expect(manager.featureFlags.get()).toEqual({ collaboration: false, commenting: false })
|
|
759
|
+
manager.dispose()
|
|
760
|
+
}
|
|
761
|
+
})
|
|
762
|
+
|
|
763
|
+
it('Never sends a watermark tracking ping for our own domains', async () => {
|
|
764
|
+
const manager = await createManagerOnHost('https://makereal.tldraw.com')
|
|
765
|
+
|
|
766
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
767
|
+
expect(fetchSpy).not.toHaveBeenCalled()
|
|
768
|
+
manager.dispose()
|
|
769
|
+
})
|
|
770
|
+
|
|
771
|
+
it('Still tracks a real watermarked key on one of our domains', async () => {
|
|
772
|
+
const licenseKey = await generateLicenseKey(
|
|
773
|
+
JSON.stringify([
|
|
774
|
+
'a-real-key',
|
|
775
|
+
['*.tldraw.com'],
|
|
776
|
+
FLAGS.ANNUAL_LICENSE | FLAGS.WITH_WATERMARK,
|
|
777
|
+
expiryDate,
|
|
778
|
+
]),
|
|
779
|
+
keyPair
|
|
780
|
+
)
|
|
781
|
+
const manager = await createManagerOnHost('https://makereal.tldraw.com', licenseKey)
|
|
782
|
+
|
|
783
|
+
expect(manager.state.get()).toBe('licensed-with-watermark')
|
|
784
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1)
|
|
785
|
+
const trackUrl = new URL(fetchSpy.mock.calls[0][0] as string)
|
|
786
|
+
expect(trackUrl.searchParams.get('license_type')).toBe('with_watermark')
|
|
787
|
+
expect(trackUrl.searchParams.get('license_id')).toBe('a-real-key')
|
|
788
|
+
manager.dispose()
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
it('Lets an explicitly provided key win over the canonical-host grant', async () => {
|
|
792
|
+
const licenseKey = await generateLicenseKey(
|
|
793
|
+
JSON.stringify(['a-real-key', ['*.tldraw.com'], FLAGS.ANNUAL_LICENSE, expiryDate]),
|
|
794
|
+
keyPair
|
|
795
|
+
)
|
|
796
|
+
const manager = await createManagerOnHost('https://tldraw.com', licenseKey)
|
|
797
|
+
|
|
798
|
+
const result = (await manager.getLicenseFromKey(licenseKey)) as ValidLicenseKeyResult
|
|
799
|
+
expect(result.license.id).toBe('a-real-key')
|
|
800
|
+
expect(result.isLicensedWithWatermark).toBe(false)
|
|
801
|
+
expect(manager.state.get()).toBe('licensed')
|
|
802
|
+
manager.dispose()
|
|
803
|
+
})
|
|
804
|
+
})
|
|
805
|
+
|
|
602
806
|
describe('License types and flags', () => {
|
|
603
807
|
it('Checks for internal license', async () => {
|
|
604
808
|
const internalLicenseInfo = JSON.parse(STANDARD_LICENSE_INFO)
|
|
@@ -42,6 +42,28 @@ const NUMBER_OF_KNOWN_PROPERTIES = Object.keys(PROPERTIES).length
|
|
|
42
42
|
|
|
43
43
|
const LICENSE_EMAIL = 'sales@tldraw.com'
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* tldraw's own public domains. A page served from one of these hosts — or any subdomain of one — is
|
|
47
|
+
* licensed without a key, because we own them: minting and rotating keys for our own properties was
|
|
48
|
+
* a recurring source of expired and wrong-domain deployments.
|
|
49
|
+
*
|
|
50
|
+
* This list ships in the bundle, so it holds only domains we already serve from publicly. tldraw's
|
|
51
|
+
* private domains are deliberately absent and keep using a real license key.
|
|
52
|
+
*/
|
|
53
|
+
const CANONICAL_TLDRAW_HOSTS = [
|
|
54
|
+
'tldraw.com',
|
|
55
|
+
'tldraw.dev',
|
|
56
|
+
'tldraw.club',
|
|
57
|
+
'tldraw.workers.dev',
|
|
58
|
+
'tldraw.xyz',
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
/** Marks a license as the keyless canonical-host grant, which is never tracked. */
|
|
62
|
+
const CANONICAL_HOST_LICENSE_ID = 'tldraw-canonical-host'
|
|
63
|
+
|
|
64
|
+
/** The canonical-host grant has no key behind it, and so no expiry date to fall due. */
|
|
65
|
+
const CANONICAL_HOST_NEVER_EXPIRES = new Date(8640000000000000)
|
|
66
|
+
|
|
45
67
|
const WATERMARK_TRACK_SRC = `${getDefaultCdnBaseUrl()}/watermarks/watermark-track.svg`
|
|
46
68
|
|
|
47
69
|
/** @internal */
|
|
@@ -185,11 +207,10 @@ export class LicenseManager {
|
|
|
185
207
|
}
|
|
186
208
|
|
|
187
209
|
private getIsDevelopment() {
|
|
188
|
-
const protocol =
|
|
189
|
-
const hostname = window.location.hostname
|
|
210
|
+
const { protocol, hostname } = this.getOriginLocation()
|
|
190
211
|
|
|
191
212
|
// Tauri uses `tauri://localhost` on macOS and Linux and `http://tauri.localhost` on Windows.
|
|
192
|
-
if (hostname.
|
|
213
|
+
if (hostname.endsWith('.localhost')) {
|
|
193
214
|
return process.env.NODE_ENV !== 'production'
|
|
194
215
|
}
|
|
195
216
|
|
|
@@ -200,13 +221,70 @@ export class LicenseManager {
|
|
|
200
221
|
)
|
|
201
222
|
}
|
|
202
223
|
|
|
224
|
+
private getOriginLocation() {
|
|
225
|
+
// A document loaded from `blob:https://example.com/<uuid>` has protocol `blob:` and an empty
|
|
226
|
+
// hostname, but its origin is the page that created the blob. Opaque origins ('null': custom
|
|
227
|
+
// schemes, file:, about:) keep using the location.
|
|
228
|
+
const { origin, protocol, hostname } = window.location
|
|
229
|
+
const source = origin && origin !== 'null' ? new URL(origin) : { protocol, hostname }
|
|
230
|
+
return { protocol: source.protocol, hostname: source.hostname.toLowerCase() }
|
|
231
|
+
}
|
|
232
|
+
|
|
203
233
|
private isLoopbackHost(hostname: string) {
|
|
204
234
|
// localhost, IPv4 loopback (127.0.0.0/8) and IPv6 loopback (::1) are all local development hosts
|
|
205
235
|
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '')
|
|
206
236
|
return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host)
|
|
207
237
|
}
|
|
208
238
|
|
|
239
|
+
/** Whether the page is served from one of tldraw's own domains, or a subdomain of one. */
|
|
240
|
+
private isCanonicalTldrawHost() {
|
|
241
|
+
const hostname = window.location.hostname.toLowerCase()
|
|
242
|
+
return CANONICAL_TLDRAW_HOSTS.some((host) => hostname === host || hostname.endsWith(`.${host}`))
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The license tldraw's own domains get without a key. It keeps the watermark — our properties
|
|
247
|
+
* advertise the SDK the same way any other watermarked deployment does — and grants the
|
|
248
|
+
* collaboration features our apps are built on.
|
|
249
|
+
*
|
|
250
|
+
* Every expiry-derived field is a literal rather than something computed from a date, because
|
|
251
|
+
* there is no key here to expire: the grant lasts as long as the host is ours.
|
|
252
|
+
*/
|
|
253
|
+
private getCanonicalHostLicense(): ValidLicenseKeyResult {
|
|
254
|
+
return {
|
|
255
|
+
isLicenseParseable: true,
|
|
256
|
+
license: {
|
|
257
|
+
id: CANONICAL_HOST_LICENSE_ID,
|
|
258
|
+
hosts: [window.location.hostname.toLowerCase()],
|
|
259
|
+
flags: FLAGS.WITH_WATERMARK | FLAGS.FEAT_COLLABORATION | FLAGS.FEAT_COMMENTING,
|
|
260
|
+
expiryDate: CANONICAL_HOST_NEVER_EXPIRES.toISOString(),
|
|
261
|
+
},
|
|
262
|
+
isDevelopment: this.isDevelopment,
|
|
263
|
+
isDomainValid: true,
|
|
264
|
+
expiryDate: CANONICAL_HOST_NEVER_EXPIRES,
|
|
265
|
+
isAnnualLicense: false,
|
|
266
|
+
isAnnualLicenseExpired: false,
|
|
267
|
+
isPerpetualLicense: false,
|
|
268
|
+
isPerpetualLicenseExpired: false,
|
|
269
|
+
isInternalLicense: false,
|
|
270
|
+
isNativeLicense: false,
|
|
271
|
+
isLicensedWithWatermark: true,
|
|
272
|
+
isEvaluationLicense: false,
|
|
273
|
+
isEvaluationLicenseExpired: false,
|
|
274
|
+
isCollaborationEnabled: true,
|
|
275
|
+
isCommentingEnabled: true,
|
|
276
|
+
daysSinceExpiry: 0,
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
209
280
|
private getTrackType(result: LicenseFromKeyResult, licenseState: LicenseState): TrackType {
|
|
281
|
+
// Our own domains are licensed without a key, and the watermark ping exists to tell us about
|
|
282
|
+
// deployments we don't already know about. Reporting our own apps back to ourselves would only
|
|
283
|
+
// add noise to the numbers we read, so the canonical-host grant is never tracked.
|
|
284
|
+
if ('license' in result && result.license.id === CANONICAL_HOST_LICENSE_ID) {
|
|
285
|
+
return null
|
|
286
|
+
}
|
|
287
|
+
|
|
210
288
|
// Track watermark for unlicensed production deployments
|
|
211
289
|
if (licenseState === 'unlicensed-production') {
|
|
212
290
|
return 'unlicensed'
|
|
@@ -315,6 +393,12 @@ export class LicenseManager {
|
|
|
315
393
|
}
|
|
316
394
|
|
|
317
395
|
async getLicenseFromKey(licenseKey?: string): Promise<LicenseFromKeyResult> {
|
|
396
|
+
// A key that is provided still wins, so an app on one of our hosts can opt into a different
|
|
397
|
+
// license — a native or feature-specific key — just by passing it.
|
|
398
|
+
if (!licenseKey && this.isCanonicalTldrawHost()) {
|
|
399
|
+
return this.getCanonicalHostLicense()
|
|
400
|
+
}
|
|
401
|
+
|
|
318
402
|
if (!licenseKey) {
|
|
319
403
|
if (!this.isDevelopment) {
|
|
320
404
|
this.outputNoLicenseKeyProvided()
|
|
@@ -398,7 +482,7 @@ export class LicenseManager {
|
|
|
398
482
|
}
|
|
399
483
|
|
|
400
484
|
private isDomainValid(licenseInfo: LicenseInfo) {
|
|
401
|
-
const currentHostname =
|
|
485
|
+
const currentHostname = this.getOriginLocation().hostname
|
|
402
486
|
|
|
403
487
|
return licenseInfo.hosts.some((host) => {
|
|
404
488
|
const normalizedHostOrUrlRegex = host.toLowerCase().trim()
|
package/src/lib/options.ts
CHANGED
|
@@ -313,7 +313,7 @@ export const defaultTldrawOptions = {
|
|
|
313
313
|
collaboratorIdleTimeoutMs: 3000,
|
|
314
314
|
collaboratorCheckIntervalMs: 1200,
|
|
315
315
|
cameraMovingTimeoutMs: 64,
|
|
316
|
-
hitTestMargin:
|
|
316
|
+
hitTestMargin: 4.5,
|
|
317
317
|
coarseHitTestMargin: 4,
|
|
318
318
|
edgeScrollDelay: 200,
|
|
319
319
|
edgeScrollEaseDuration: 200,
|