@tldraw/editor 5.5.2 → 5.6.0-canary.035b741dc331
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.js +1 -1
- package/dist-cjs/lib/editor/Editor.js +15 -4
- package/dist-cjs/lib/editor/Editor.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/shapes/shared/resizeBox.js +21 -16
- package/dist-cjs/lib/editor/shapes/shared/resizeBox.js.map +2 -2
- package/dist-cjs/lib/hooks/useCursor.js +12 -8
- package/dist-cjs/lib/hooks/useCursor.js.map +2 -2
- package/dist-cjs/lib/utils/sync/TLLocalSyncClient.js +12 -5
- 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.mjs +1 -1
- package/dist-esm/lib/editor/Editor.mjs +17 -5
- package/dist-esm/lib/editor/Editor.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/shapes/shared/resizeBox.mjs +21 -16
- package/dist-esm/lib/editor/shapes/shared/resizeBox.mjs.map +2 -2
- package/dist-esm/lib/hooks/useCursor.mjs +12 -8
- package/dist-esm/lib/hooks/useCursor.mjs.map +2 -2
- package/dist-esm/lib/utils/sync/TLLocalSyncClient.mjs +13 -6
- 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 +17 -19
- package/src/lib/editor/Editor.ts +38 -9
- package/src/lib/editor/managers/ScribbleManager/ScribbleManager.ts +4 -6
- package/src/lib/editor/shapes/shared/resizeBox.ts +21 -16
- package/src/lib/hooks/useCursor.ts +14 -7
- package/src/lib/test/useCursor.test.tsx +36 -0
- package/src/lib/utils/sync/TLLocalSyncClient.test.ts +73 -0
- package/src/lib/utils/sync/TLLocalSyncClient.ts +24 -8
- package/src/version.ts +3 -3
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tldraw/editor",
|
|
3
3
|
"description": "tldraw infinite canvas SDK (editor).",
|
|
4
|
-
"version": "5.
|
|
4
|
+
"version": "5.6.0-canary.035b741dc331",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "tldraw Inc.",
|
|
7
7
|
"email": "hello@tldraw.com"
|
|
@@ -39,27 +39,16 @@
|
|
|
39
39
|
"dist-cjs",
|
|
40
40
|
"src"
|
|
41
41
|
],
|
|
42
|
-
"scripts": {
|
|
43
|
-
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
|
44
|
-
"test": "yarn run -T vitest --passWithNoTests",
|
|
45
|
-
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
|
46
|
-
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
|
|
47
|
-
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
|
|
48
|
-
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
|
|
49
|
-
"postpack": "../../internal/scripts/postpack.sh",
|
|
50
|
-
"pack-tarball": "yarn pack",
|
|
51
|
-
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
|
|
52
|
-
},
|
|
53
42
|
"dependencies": {
|
|
54
43
|
"@tiptap/core": "^3.12.1",
|
|
55
44
|
"@tiptap/pm": "^3.12.1",
|
|
56
45
|
"@tiptap/react": "^3.12.1",
|
|
57
|
-
"@tldraw/state": "5.
|
|
58
|
-
"@tldraw/state-react": "5.
|
|
59
|
-
"@tldraw/store": "5.
|
|
60
|
-
"@tldraw/tlschema": "5.
|
|
61
|
-
"@tldraw/utils": "5.
|
|
62
|
-
"@tldraw/validate": "5.
|
|
46
|
+
"@tldraw/state": "5.6.0-canary.035b741dc331",
|
|
47
|
+
"@tldraw/state-react": "5.6.0-canary.035b741dc331",
|
|
48
|
+
"@tldraw/store": "5.6.0-canary.035b741dc331",
|
|
49
|
+
"@tldraw/tlschema": "5.6.0-canary.035b741dc331",
|
|
50
|
+
"@tldraw/utils": "5.6.0-canary.035b741dc331",
|
|
51
|
+
"@tldraw/validate": "5.6.0-canary.035b741dc331",
|
|
63
52
|
"classnames": "^2.5.1",
|
|
64
53
|
"eventemitter3": "^4.0.7",
|
|
65
54
|
"idb": "^7.1.1",
|
|
@@ -79,7 +68,6 @@
|
|
|
79
68
|
"@types/react-dom": "^19.2.3",
|
|
80
69
|
"@types/wicg-file-system-access": "^2020.9.8",
|
|
81
70
|
"fake-indexeddb": "^4.0.2",
|
|
82
|
-
"lazyrepo": "0.0.0-alpha.27",
|
|
83
71
|
"react": "^19.2.1",
|
|
84
72
|
"react-dom": "^19.2.1",
|
|
85
73
|
"resize-observer-polyfill": "^1.5.1",
|
|
@@ -96,5 +84,15 @@
|
|
|
96
84
|
"require": "./dist-cjs/index.js"
|
|
97
85
|
},
|
|
98
86
|
"./editor.css": "./editor.css"
|
|
87
|
+
},
|
|
88
|
+
"scripts": {
|
|
89
|
+
"test-ci": "pnpm exec vitest run --passWithNoTests",
|
|
90
|
+
"test": "pnpm exec vitest --passWithNoTests",
|
|
91
|
+
"test-coverage": "pnpm exec vitest run --coverage --passWithNoTests",
|
|
92
|
+
"build": "pnpm exec tsx ../../internal/scripts/build-package.ts",
|
|
93
|
+
"build-api": "pnpm exec tsx ../../internal/scripts/build-api.ts",
|
|
94
|
+
"pack-tarball": "pnpm pack",
|
|
95
|
+
"lint": "pnpm exec tsx ../../internal/scripts/lint.ts",
|
|
96
|
+
"build-types": "tsc --build"
|
|
99
97
|
}
|
|
100
98
|
}
|
package/src/lib/editor/Editor.ts
CHANGED
|
@@ -137,6 +137,7 @@ import {
|
|
|
137
137
|
areAnglesCompatible,
|
|
138
138
|
clamp,
|
|
139
139
|
pointInPolygon,
|
|
140
|
+
shortAngleDist,
|
|
140
141
|
} from '../primitives/utils'
|
|
141
142
|
import { Vec, VecLike } from '../primitives/Vec'
|
|
142
143
|
import { areShapesContentEqual } from '../utils/areShapesContentEqual'
|
|
@@ -5122,7 +5123,11 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5122
5123
|
const freshPage = this.getPage(id) // get the most recent version of the page anyway
|
|
5123
5124
|
if (!freshPage) return this
|
|
5124
5125
|
|
|
5125
|
-
|
|
5126
|
+
// Read the source page's own camera rather than getCamera(), which is the
|
|
5127
|
+
// current page's and is wrong when duplicating a page that isn't being viewed
|
|
5128
|
+
const prevCamera = {
|
|
5129
|
+
...(this.store.get(CameraRecordType.createId(freshPage.id)) ?? this.getCamera()),
|
|
5130
|
+
}
|
|
5126
5131
|
const content = this.getContentFromCurrentPage(this.getSortedChildIdsForParent(freshPage.id))
|
|
5127
5132
|
|
|
5128
5133
|
this.run(() => {
|
|
@@ -5137,8 +5142,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
5137
5142
|
this.setCamera(prevCamera)
|
|
5138
5143
|
|
|
5139
5144
|
if (content) {
|
|
5140
|
-
//
|
|
5141
|
-
|
|
5145
|
+
// Without preservePosition the paste rule recenters off-screen content
|
|
5146
|
+
// in the viewport, so the copy's layout would not match the original
|
|
5147
|
+
return this.putContentOntoCurrentPage(content, { preservePosition: true })
|
|
5142
5148
|
}
|
|
5143
5149
|
})
|
|
5144
5150
|
|
|
@@ -8313,6 +8319,9 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8313
8319
|
initialShape: TLShape
|
|
8314
8320
|
isAspectRatioLocked: boolean
|
|
8315
8321
|
initialPageTransform: MatLike
|
|
8322
|
+
dragHandle?: TLResizeHandle
|
|
8323
|
+
mode?: TLResizeMode
|
|
8324
|
+
skipStartAndEndCallbacks?: boolean
|
|
8316
8325
|
}
|
|
8317
8326
|
) {
|
|
8318
8327
|
const { type } = options.initialShape
|
|
@@ -8337,19 +8346,39 @@ export class Editor extends EventEmitter<TLEventMap> {
|
|
|
8337
8346
|
initialBounds: options.initialBounds,
|
|
8338
8347
|
isAspectRatioLocked: options.isAspectRatioLocked,
|
|
8339
8348
|
initialPageTransform: options.initialPageTransform,
|
|
8349
|
+
dragHandle: options.dragHandle,
|
|
8350
|
+
mode: options.mode,
|
|
8351
|
+
// this is one frame of the same resize, not a new one, so don't fire start/end again
|
|
8352
|
+
skipStartAndEndCallbacks: options.skipStartAndEndCallbacks,
|
|
8340
8353
|
})
|
|
8341
8354
|
|
|
8342
8355
|
// then if the shape is flipped in one axis only, we need to apply an extra rotation
|
|
8343
8356
|
// to make sure the shape is mirrored correctly
|
|
8344
8357
|
if (Math.sign(scale.x) * Math.sign(scale.y) < 0) {
|
|
8345
|
-
//
|
|
8346
|
-
// For a shape with local rotation `localRot`
|
|
8358
|
+
// Mirroring across an axis at angle `axisRot` maps a page rotation `pageRot` to
|
|
8359
|
+
// `2 * axisRot - pageRot`. For a shape with local rotation `localRot` whose parent had
|
|
8360
|
+
// page rotation `parentRot` when the resize began:
|
|
8347
8361
|
// - pageRot = parentRot + localRot
|
|
8348
|
-
// - newPageRot =
|
|
8349
|
-
// - newPageRot = parentRot +
|
|
8350
|
-
// - Therefore: newLocalRot =
|
|
8362
|
+
// - newPageRot = 2 * axisRot - pageRot
|
|
8363
|
+
// - newPageRot = (parentRot + parentDelta) + newLocalRot
|
|
8364
|
+
// - Therefore: newLocalRot = 2 * axisRot - localRot - 2 * parentRot - parentDelta
|
|
8365
|
+
// `parentDelta` is how far the parent has rotated since the resize began. Nested resizes
|
|
8366
|
+
// commit ancestors before descendants, so an unaligned parent may itself have been
|
|
8367
|
+
// flipped by the time its child gets here; reading the parent's current rotation as if
|
|
8368
|
+
// it were the initial one would then double-count that flip.
|
|
8351
8369
|
const parentRotation = this.getShapeParentTransform(id).rotation()
|
|
8352
|
-
|
|
8370
|
+
// take the shortest arc so the stored rotation doesn't drift by 2π when the parent
|
|
8371
|
+
// hasn't moved and the initial page rotation happens to be wrapped
|
|
8372
|
+
const parentDelta = shortAngleDist(
|
|
8373
|
+
Mat.Cast(options.initialPageTransform).rotation() - options.initialShape.rotation,
|
|
8374
|
+
parentRotation
|
|
8375
|
+
)
|
|
8376
|
+
const initialParentRotation = parentRotation - parentDelta
|
|
8377
|
+
const rotation =
|
|
8378
|
+
2 * options.scaleAxisRotation -
|
|
8379
|
+
options.initialShape.rotation -
|
|
8380
|
+
2 * initialParentRotation -
|
|
8381
|
+
parentDelta
|
|
8353
8382
|
this.updateShapes([{ id, type, rotation }])
|
|
8354
8383
|
}
|
|
8355
8384
|
|
|
@@ -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
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VecModel } from '@tldraw/tlschema'
|
|
2
2
|
import { Box } from '../../../primitives/Box'
|
|
3
|
+
import { clamp } from '../../../primitives/utils'
|
|
3
4
|
import { Vec } from '../../../primitives/Vec'
|
|
4
5
|
import { TLResizeHandle } from '../../types/selection-types'
|
|
5
6
|
import type { TLBaseBoxShape } from '../BaseBoxShapeUtil'
|
|
@@ -36,29 +37,31 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
36
37
|
const offset = new Vec(0, 0)
|
|
37
38
|
|
|
38
39
|
if (w > 0) {
|
|
39
|
-
|
|
40
|
+
const clampedW = clamp(w, minWidth, maxWidth)
|
|
41
|
+
if (clampedW !== w) {
|
|
40
42
|
switch (handle) {
|
|
41
43
|
case 'top_left':
|
|
42
44
|
case 'left':
|
|
43
45
|
case 'bottom_left': {
|
|
44
|
-
offset.x = w -
|
|
46
|
+
offset.x = w - clampedW
|
|
45
47
|
break
|
|
46
48
|
}
|
|
47
49
|
case 'top':
|
|
48
50
|
case 'bottom': {
|
|
49
|
-
offset.x = (w -
|
|
51
|
+
offset.x = (w - clampedW) / 2
|
|
50
52
|
break
|
|
51
53
|
}
|
|
52
54
|
default: {
|
|
53
55
|
offset.x = 0
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
|
-
w =
|
|
58
|
+
w = clampedW
|
|
57
59
|
}
|
|
58
60
|
} else {
|
|
59
61
|
offset.x = w
|
|
60
62
|
w = -w
|
|
61
|
-
|
|
63
|
+
const clampedW = clamp(w, minWidth, maxWidth)
|
|
64
|
+
if (clampedW !== w) {
|
|
62
65
|
switch (handle) {
|
|
63
66
|
case 'top_left':
|
|
64
67
|
case 'left':
|
|
@@ -67,26 +70,27 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
67
70
|
break
|
|
68
71
|
}
|
|
69
72
|
default: {
|
|
70
|
-
offset.x = -
|
|
73
|
+
offset.x = -clampedW
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
74
|
-
w =
|
|
77
|
+
w = clampedW
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
if (h > 0) {
|
|
79
|
-
|
|
82
|
+
const clampedH = clamp(h, minHeight, maxHeight)
|
|
83
|
+
if (clampedH !== h) {
|
|
80
84
|
switch (handle) {
|
|
81
85
|
case 'top_left':
|
|
82
86
|
case 'top':
|
|
83
87
|
case 'top_right': {
|
|
84
|
-
offset.y = h -
|
|
88
|
+
offset.y = h - clampedH
|
|
85
89
|
break
|
|
86
90
|
}
|
|
87
91
|
case 'right':
|
|
88
92
|
case 'left': {
|
|
89
|
-
offset.y = (h -
|
|
93
|
+
offset.y = (h - clampedH) / 2
|
|
90
94
|
break
|
|
91
95
|
}
|
|
92
96
|
default: {
|
|
@@ -94,12 +98,13 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
h =
|
|
101
|
+
h = clampedH
|
|
98
102
|
}
|
|
99
103
|
} else {
|
|
100
104
|
offset.y = h
|
|
101
105
|
h = -h
|
|
102
|
-
|
|
106
|
+
const clampedH = clamp(h, minHeight, maxHeight)
|
|
107
|
+
if (clampedH !== h) {
|
|
103
108
|
switch (handle) {
|
|
104
109
|
case 'top_left':
|
|
105
110
|
case 'top':
|
|
@@ -108,10 +113,10 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
108
113
|
break
|
|
109
114
|
}
|
|
110
115
|
default: {
|
|
111
|
-
offset.y = -
|
|
116
|
+
offset.y = -clampedH
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
|
-
h =
|
|
119
|
+
h = clampedH
|
|
115
120
|
}
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -122,8 +127,8 @@ export function resizeBox<T extends TLBaseBoxShape>(
|
|
|
122
127
|
x,
|
|
123
128
|
y,
|
|
124
129
|
props: {
|
|
125
|
-
w
|
|
126
|
-
h
|
|
130
|
+
w,
|
|
131
|
+
h,
|
|
127
132
|
},
|
|
128
133
|
}
|
|
129
134
|
}
|
|
@@ -23,8 +23,9 @@ function getCursorCss(
|
|
|
23
23
|
const dx = 1 * c - 1 * s
|
|
24
24
|
const dy = 1 * s + 1 * c
|
|
25
25
|
|
|
26
|
+
// A raw '#' in the colour (hex) would end the unencoded data url as a fragment.
|
|
26
27
|
return (
|
|
27
|
-
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${color};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
|
|
28
|
+
`url("data:image/svg+xml,<svg height='32' width='32' viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg' style='color: ${encodeURIComponent(color)};'><defs><filter id='shadow' y='-40%' x='-40%' width='180px' height='180%' color-interpolation-filters='sRGB'><feDropShadow dx='${dx}' dy='${dy}' stdDeviation='1.2' flood-opacity='.5'/></filter></defs><g fill='none' transform='rotate(${
|
|
28
29
|
r + tr
|
|
29
30
|
} 16 16)${f ? ` scale(-1,-1) translate(0, -32)` : ''}' filter='url(%23shadow)'>` +
|
|
30
31
|
svg.replaceAll(`"`, `'`) +
|
|
@@ -32,7 +33,8 @@ function getCursorCss(
|
|
|
32
33
|
)
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
// Cursors that map straight to a prerendered `--tl-cursor-*` css variable rather than a runtime svg.
|
|
37
|
+
const STATIC_CURSORS = new Set<TLCursorType>([
|
|
36
38
|
'default',
|
|
37
39
|
'pointer',
|
|
38
40
|
'cross',
|
|
@@ -43,11 +45,14 @@ const STATIC_CURSORS = [
|
|
|
43
45
|
'text',
|
|
44
46
|
'zoom-in',
|
|
45
47
|
'zoom-out',
|
|
46
|
-
|
|
48
|
+
'rotate',
|
|
49
|
+
'resize-edge',
|
|
50
|
+
'resize-corner',
|
|
51
|
+
])
|
|
47
52
|
|
|
48
53
|
type CursorFunction = (rotation: number, flip: boolean, color: string) => string
|
|
49
54
|
|
|
50
|
-
const
|
|
55
|
+
const DYNAMIC_CURSORS: Record<TLCursorType, CursorFunction> = {
|
|
51
56
|
none: () => 'none',
|
|
52
57
|
'ew-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 0, f, c),
|
|
53
58
|
'ns-resize': (r, f, c) => getCursorCss(EDGE_SVG, r, 90, f, c),
|
|
@@ -61,7 +66,8 @@ const CURSORS: Record<TLCursorType, CursorFunction> = {
|
|
|
61
66
|
|
|
62
67
|
/** @public */
|
|
63
68
|
export function getCursor(cursor: TLCursorType, rotation = 0, color = 'black') {
|
|
64
|
-
|
|
69
|
+
if (STATIC_CURSORS.has(cursor)) return `var(--tl-cursor-${cursor})`
|
|
70
|
+
return DYNAMIC_CURSORS[cursor](radiansToDegrees(rotation), false, color)
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
export function useCursor() {
|
|
@@ -73,8 +79,9 @@ export function useCursor() {
|
|
|
73
79
|
() => {
|
|
74
80
|
const { type, rotation } = editor.getInstanceState().cursor
|
|
75
81
|
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// Static cursors don't need the theme colour, so skip reading it to avoid depending on it.
|
|
83
|
+
if (STATIC_CURSORS.has(type)) {
|
|
84
|
+
container.style.setProperty('--tl-cursor', getCursor(type))
|
|
78
85
|
return
|
|
79
86
|
}
|
|
80
87
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { act, render } from '@testing-library/react'
|
|
2
|
+
import { createTLStore } from '../config/createTLStore'
|
|
3
|
+
import { Editor } from '../editor/Editor'
|
|
4
|
+
import { getCursor } from '../hooks/useCursor'
|
|
5
|
+
import { TL_CONTAINER_CLASS, TldrawEditor } from '../TldrawEditor'
|
|
6
|
+
|
|
7
|
+
describe('useCursor', () => {
|
|
8
|
+
it('maps the static schema cursor types to their css variables', async () => {
|
|
9
|
+
let editor: Editor
|
|
10
|
+
const store = createTLStore({ shapeUtils: [], bindingUtils: [] })
|
|
11
|
+
await act(async () => {
|
|
12
|
+
render(<TldrawEditor store={store} onMount={(e) => void (editor = e)} />)
|
|
13
|
+
})
|
|
14
|
+
const container = document.querySelector(`.${TL_CONTAINER_CLASS}`) as HTMLElement
|
|
15
|
+
|
|
16
|
+
for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
|
|
17
|
+
act(() => editor.setCursor({ type, rotation: 0 }))
|
|
18
|
+
expect(container.style.getPropertyValue('--tl-cursor')).toBe(`var(--tl-cursor-${type})`)
|
|
19
|
+
}
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('getCursor', () => {
|
|
24
|
+
it('returns the css variable for the static cursor types', () => {
|
|
25
|
+
for (const type of ['rotate', 'resize-edge', 'resize-corner', 'default'] as const) {
|
|
26
|
+
expect(getCursor(type)).toBe(`var(--tl-cursor-${type})`)
|
|
27
|
+
expect(getCursor(type, Math.PI / 2, '#000000')).toBe(`var(--tl-cursor-${type})`)
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('encodes the colour so a hex value does not end the data url as a fragment', () => {
|
|
32
|
+
const css = getCursor('ew-resize', 0, '#000000')
|
|
33
|
+
expect(css).toContain("style='color: %23000000;'")
|
|
34
|
+
expect(css).not.toContain('#')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
@@ -484,3 +484,76 @@ test('a buffered newer schema reports a load error without announcing a successf
|
|
|
484
484
|
client.close()
|
|
485
485
|
expect(client.db.storeSnapshot).not.toHaveBeenCalled()
|
|
486
486
|
})
|
|
487
|
+
|
|
488
|
+
test('a client created on the same key while a closed client is still flushing waits for that flush', async () => {
|
|
489
|
+
const inFlightWrite = promiseWithResolve<void>()
|
|
490
|
+
const { client, tick } = testClient()
|
|
491
|
+
client.db.storeSnapshot.mockImplementationOnce(() => inFlightWrite)
|
|
492
|
+
await tick()
|
|
493
|
+
client.store.put([PageRecordType.create({ name: 'first', index: 'a0' as IndexKey })])
|
|
494
|
+
await tick()
|
|
495
|
+
expect(client.db.storeSnapshot).toHaveBeenCalledTimes(1) // still in flight
|
|
496
|
+
client.store.put([PageRecordType.create({ name: 'during write', index: 'a1' as IndexKey })])
|
|
497
|
+
client.close()
|
|
498
|
+
|
|
499
|
+
// remount on the same key while the old client is still flushing
|
|
500
|
+
const loadSpy = vi.spyOn(LocalIndexedDb.prototype, 'load')
|
|
501
|
+
const next = testClient()
|
|
502
|
+
await tick()
|
|
503
|
+
// reading now would miss the queued edit, then erase it on the first write
|
|
504
|
+
expect(loadSpy).not.toHaveBeenCalled()
|
|
505
|
+
expect(next.onLoad).not.toHaveBeenCalled()
|
|
506
|
+
|
|
507
|
+
inFlightWrite.resolve()
|
|
508
|
+
await tick()
|
|
509
|
+
expect(client.db.storeChanges).toHaveBeenCalledTimes(1)
|
|
510
|
+
for (let i = 0; i < 20; i++) await Promise.resolve()
|
|
511
|
+
await next.tick()
|
|
512
|
+
expect(loadSpy).toHaveBeenCalledTimes(1)
|
|
513
|
+
expect(next.onLoad).toHaveBeenCalledTimes(1)
|
|
514
|
+
loadSpy.mockRestore()
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
test('two clients closing on the same key flush in order', async () => {
|
|
518
|
+
const writes: string[] = []
|
|
519
|
+
const inFlightWrite = promiseWithResolve<void>()
|
|
520
|
+
|
|
521
|
+
const a = testClient()
|
|
522
|
+
const b = testClient()
|
|
523
|
+
await a.tick()
|
|
524
|
+
await b.tick()
|
|
525
|
+
// hold a's first write in flight
|
|
526
|
+
a.client.db.storeSnapshot.mockImplementation(() => {
|
|
527
|
+
writes.push('a')
|
|
528
|
+
return inFlightWrite
|
|
529
|
+
})
|
|
530
|
+
a.client.db.storeChanges.mockImplementation(() => {
|
|
531
|
+
writes.push('a')
|
|
532
|
+
return Promise.resolve()
|
|
533
|
+
})
|
|
534
|
+
b.client.db.storeSnapshot.mockImplementation(() => {
|
|
535
|
+
writes.push('b')
|
|
536
|
+
return Promise.resolve()
|
|
537
|
+
})
|
|
538
|
+
b.client.db.storeChanges.mockImplementation(() => {
|
|
539
|
+
writes.push('b')
|
|
540
|
+
return Promise.resolve()
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
a.client.store.put([PageRecordType.create({ name: 'a1', index: 'a0' as IndexKey })])
|
|
544
|
+
await a.tick()
|
|
545
|
+
expect(writes).toEqual(['a']) // in flight
|
|
546
|
+
a.client.store.put([PageRecordType.create({ name: 'a2', index: 'a1' as IndexKey })])
|
|
547
|
+
a.client.close()
|
|
548
|
+
|
|
549
|
+
b.client.store.put([PageRecordType.create({ name: 'b1', index: 'a2' as IndexKey })])
|
|
550
|
+
b.client.close()
|
|
551
|
+
await b.tick()
|
|
552
|
+
// b's snapshot landing first would lose a's queued changes
|
|
553
|
+
expect(writes).toEqual(['a'])
|
|
554
|
+
|
|
555
|
+
inFlightWrite.resolve()
|
|
556
|
+
await a.tick()
|
|
557
|
+
await b.tick()
|
|
558
|
+
expect(writes).toEqual(['a', 'a', 'b'])
|
|
559
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Signal, transact } from '@tldraw/state'
|
|
2
2
|
import { RecordsDiff, SerializedSchema, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
|
|
3
3
|
import { TLStore } from '@tldraw/tlschema'
|
|
4
|
-
import { assert } from '@tldraw/utils'
|
|
4
|
+
import { assert, noop } from '@tldraw/utils'
|
|
5
5
|
import {
|
|
6
6
|
TAB_ID,
|
|
7
7
|
TLSessionStateSnapshot,
|
|
@@ -64,6 +64,9 @@ export class BroadcastChannelMock {
|
|
|
64
64
|
|
|
65
65
|
const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : BroadcastChannel
|
|
66
66
|
|
|
67
|
+
// Flushes still running for closed clients, by key. See connect() and close().
|
|
68
|
+
const pendingFlushes = new Map<string, Promise<void>>()
|
|
69
|
+
|
|
67
70
|
/** @internal */
|
|
68
71
|
export class TLLocalSyncClient {
|
|
69
72
|
private disposables = new Set<() => void>()
|
|
@@ -238,6 +241,11 @@ export class TLLocalSyncClient {
|
|
|
238
241
|
this.channel.close()
|
|
239
242
|
})
|
|
240
243
|
|
|
244
|
+
// our first write is a full snapshot, so it would erase whatever a client that just closed
|
|
245
|
+
// on this key is still flushing
|
|
246
|
+
await pendingFlushes.get(this.persistenceKey)
|
|
247
|
+
if (this.didDispose) return
|
|
248
|
+
|
|
241
249
|
try {
|
|
242
250
|
data = await this.db.load({ sessionId: this.sessionId })
|
|
243
251
|
} catch (error: any) {
|
|
@@ -313,8 +321,6 @@ export class TLLocalSyncClient {
|
|
|
313
321
|
if (this.didDispose) return
|
|
314
322
|
this.debug('closing')
|
|
315
323
|
this.didDispose = true
|
|
316
|
-
// Store listeners normally run on the next frame; collect the last edits before removal.
|
|
317
|
-
this.store._flushHistory()
|
|
318
324
|
this.disposables.forEach((d) => d())
|
|
319
325
|
if (this.scheduledPersistTimeout) {
|
|
320
326
|
clearTimeout(this.scheduledPersistTimeout)
|
|
@@ -323,7 +329,18 @@ export class TLLocalSyncClient {
|
|
|
323
329
|
if (typeof window !== 'undefined' && (window as any).tlsync === this) {
|
|
324
330
|
delete (window as any).tlsync
|
|
325
331
|
}
|
|
326
|
-
|
|
332
|
+
// flush now when the key is idle: close() runs during teardown, where a deferred write may
|
|
333
|
+
// never run. wait when it isn't, or our snapshot lands first and gets overwritten.
|
|
334
|
+
// connect() awaits this, so it must never reject
|
|
335
|
+
const previous = pendingFlushes.get(this.persistenceKey)
|
|
336
|
+
const flush = (previous ? previous.then(() => this.flushAndCloseDb()) : this.flushAndCloseDb())
|
|
337
|
+
.catch(noop)
|
|
338
|
+
.then(() => {
|
|
339
|
+
if (pendingFlushes.get(this.persistenceKey) === flush) {
|
|
340
|
+
pendingFlushes.delete(this.persistenceKey)
|
|
341
|
+
}
|
|
342
|
+
})
|
|
343
|
+
pendingFlushes.set(this.persistenceKey, flush)
|
|
327
344
|
}
|
|
328
345
|
|
|
329
346
|
/**
|
|
@@ -332,17 +349,16 @@ export class TLLocalSyncClient {
|
|
|
332
349
|
* store), and only when something is queued: shouldDoFullDBWrite stays true until the first
|
|
333
350
|
* persist, so an unconditional flush would rewrite the whole document on every no-op close.
|
|
334
351
|
*/
|
|
335
|
-
private flushAndCloseDb() {
|
|
352
|
+
private flushAndCloseDb(): Promise<void> {
|
|
336
353
|
// A persist in flight can't be joined (persistIfNeeded bails while one is running) and,
|
|
337
354
|
// now that we're disposed, won't schedule a follow-up. Edits queued during that write
|
|
338
355
|
// would be lost if the db closed now, so wait for it and flush again.
|
|
339
356
|
if (this.persistPromise) {
|
|
340
|
-
this.persistPromise.then(() => this.flushAndCloseDb())
|
|
341
|
-
return
|
|
357
|
+
return this.persistPromise.then(() => this.flushAndCloseDb())
|
|
342
358
|
}
|
|
343
359
|
if (this.didLoad && this.diffQueue.length > 0) this.persistIfNeeded()
|
|
344
360
|
// the db waits for the transaction the flush just opened before actually closing
|
|
345
|
-
this.db.close()
|
|
361
|
+
return this.db.close()
|
|
346
362
|
}
|
|
347
363
|
|
|
348
364
|
private isPersisting = false
|
package/src/version.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// This file is automatically generated by internal/scripts/refresh-assets.ts.
|
|
2
2
|
// Do not edit manually. Or do, I'm a comment, not a cop.
|
|
3
3
|
|
|
4
|
-
export const version = '5.
|
|
4
|
+
export const version = '5.6.0-canary.035b741dc331'
|
|
5
5
|
export const publishDates = {
|
|
6
6
|
major: '2026-05-06T16:28:18.473Z',
|
|
7
|
-
minor: '2026-
|
|
8
|
-
patch: '2026-10-
|
|
7
|
+
minor: '2026-10-06T06:00:33.486Z',
|
|
8
|
+
patch: '2026-10-06T06:00:33.486Z',
|
|
9
9
|
}
|