@tldraw/editor 5.2.4 → 5.3.0-canary.00f14de1f81d
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 +76 -10
- package/dist-cjs/index.js +3 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/TldrawEditor.js +6 -1
- package/dist-cjs/lib/TldrawEditor.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +67 -15
- package/dist-cjs/lib/editor/Editor.js.map +2 -2
- package/dist-cjs/lib/editor/managers/EditorManager.js +52 -0
- package/dist-cjs/lib/editor/managers/EditorManager.js.map +7 -0
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js +9 -11
- package/dist-cjs/lib/editor/managers/FocusManager/FocusManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js +9 -0
- package/dist-cjs/lib/editor/managers/FontManager/FontManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js +5 -9
- package/dist-cjs/lib/editor/managers/InputsManager/InputsManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js +18 -29
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/RBushIndex.js.map +2 -2
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js +96 -54
- package/dist-cjs/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js +27 -19
- package/dist-cjs/lib/editor/managers/TextManager/TextManager.js.map +2 -2
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js +4 -7
- package/dist-cjs/lib/editor/managers/TickManager/TickManager.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js +3 -0
- package/dist-cjs/lib/editor/shapes/BaseFrameLikeShapeUtil.js.map +2 -2
- package/dist-cjs/lib/editor/shapes/ShapeUtil.js.map +1 -1
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js +1 -2
- package/dist-cjs/lib/editor/tools/BaseBoxShapeTool/children/Pointing.js.map +2 -2
- package/dist-cjs/lib/editor/types/emit-types.js.map +1 -1
- package/dist-cjs/lib/license/LicenseManager.js +14 -5
- package/dist-cjs/lib/license/LicenseManager.js.map +2 -2
- package/dist-cjs/lib/options.js +2 -1
- package/dist-cjs/lib/options.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 +76 -10
- package/dist-esm/index.mjs +3 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/TldrawEditor.mjs +6 -1
- package/dist-esm/lib/TldrawEditor.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +67 -15
- package/dist-esm/lib/editor/Editor.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/EditorManager.mjs +32 -0
- package/dist-esm/lib/editor/managers/EditorManager.mjs.map +7 -0
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs +9 -11
- package/dist-esm/lib/editor/managers/FocusManager/FocusManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs +9 -0
- package/dist-esm/lib/editor/managers/FontManager/FontManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs +5 -9
- package/dist-esm/lib/editor/managers/InputsManager/InputsManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs +18 -29
- package/dist-esm/lib/editor/managers/SpatialIndexManager/RBushIndex.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs +103 -56
- package/dist-esm/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs +27 -19
- package/dist-esm/lib/editor/managers/TextManager/TextManager.mjs.map +2 -2
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs +4 -7
- package/dist-esm/lib/editor/managers/TickManager/TickManager.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs +3 -0
- package/dist-esm/lib/editor/shapes/BaseFrameLikeShapeUtil.mjs.map +2 -2
- package/dist-esm/lib/editor/shapes/ShapeUtil.mjs.map +1 -1
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs +1 -2
- package/dist-esm/lib/editor/tools/BaseBoxShapeTool/children/Pointing.mjs.map +2 -2
- package/dist-esm/lib/license/LicenseManager.mjs +14 -5
- package/dist-esm/lib/license/LicenseManager.mjs.map +2 -2
- package/dist-esm/lib/options.mjs +2 -1
- package/dist-esm/lib/options.mjs.map +2 -2
- package/dist-esm/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/editor.css +10 -0
- package/package.json +7 -7
- package/src/index.ts +1 -1
- package/src/lib/TldrawEditor.tsx +10 -1
- package/src/lib/editor/Editor.ts +101 -20
- package/src/lib/editor/managers/EditorManager.test.ts +51 -0
- package/src/lib/editor/managers/EditorManager.ts +60 -0
- package/src/lib/editor/managers/FocusManager/FocusManager.test.ts +2 -1
- package/src/lib/editor/managers/FocusManager/FocusManager.ts +10 -14
- package/src/lib/editor/managers/FontManager/FontManager.test.ts +66 -0
- package/src/lib/editor/managers/FontManager/FontManager.ts +14 -0
- package/src/lib/editor/managers/InputsManager/InputsManager.ts +6 -9
- package/src/lib/editor/managers/SpatialIndexManager/RBushIndex.ts +19 -33
- package/src/lib/editor/managers/SpatialIndexManager/SpatialIndexManager.ts +173 -86
- package/src/lib/editor/managers/TextManager/TextManager.test.ts +61 -1
- package/src/lib/editor/managers/TextManager/TextManager.ts +36 -19
- package/src/lib/editor/managers/TickManager/TickManager.test.ts +0 -10
- package/src/lib/editor/managers/TickManager/TickManager.ts +5 -6
- package/src/lib/editor/shapes/BaseFrameLikeShapeUtil.tsx +6 -1
- package/src/lib/editor/shapes/ShapeUtil.ts +1 -0
- package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +2 -2
- package/src/lib/editor/types/emit-types.ts +1 -0
- package/src/lib/license/LicenseManager.test.ts +99 -29
- package/src/lib/license/LicenseManager.ts +16 -4
- package/src/lib/options.ts +3 -1
- package/src/version.ts +3 -3
- package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js +0 -28
- package/dist-cjs/lib/editor/tools/cancelShapeCreationOnLongPress.js.map +0 -7
- package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs +0 -8
- package/dist-esm/lib/editor/tools/cancelShapeCreationOnLongPress.mjs.map +0 -7
- package/src/lib/editor/tools/cancelShapeCreationOnLongPress.ts +0 -31
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Computed,
|
|
3
|
+
RESET_VALUE,
|
|
4
|
+
computed,
|
|
5
|
+
isUninitialized,
|
|
6
|
+
unsafe__withoutCapture,
|
|
7
|
+
} from '@tldraw/state'
|
|
2
8
|
import type { RecordsDiff } from '@tldraw/store'
|
|
3
|
-
import { TLPageId, TLShape, TLShapeId, isShape } from '@tldraw/tlschema'
|
|
9
|
+
import { TLPageId, TLShape, TLShapeId, isBinding, isPageId, isShape } from '@tldraw/tlschema'
|
|
4
10
|
import type { TLRecord } from '@tldraw/tlschema'
|
|
5
11
|
import { objectMapValues } from '@tldraw/utils'
|
|
6
12
|
import { Box } from '../../../primitives/Box'
|
|
@@ -11,16 +17,24 @@ import { RBushIndex, type SpatialElement } from './RBushIndex'
|
|
|
11
17
|
* Manages spatial indexing for efficient shape location queries.
|
|
12
18
|
*
|
|
13
19
|
* Uses an R-tree (via RBush) to enable O(log n) spatial queries instead of O(n) iteration.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* Derived-bounds changes (arrows following bound shapes, groups resizing with children) are
|
|
21
|
+
* tracked structurally: each record diff is expanded into a dirty set covering the changed
|
|
22
|
+
* shapes' descendants, ancestors, and binding partners, and only those shapes' bounds are
|
|
23
|
+
* rechecked — O(affected) per update instead of O(page).
|
|
16
24
|
*
|
|
17
25
|
* Key features:
|
|
18
|
-
* - Incremental updates using filterHistory pattern
|
|
19
|
-
* -
|
|
20
|
-
* - Works with any custom shape type with computed bounds
|
|
26
|
+
* - Incremental updates using filterHistory pattern (shape and binding history)
|
|
27
|
+
* - Dirty-set expansion via parent/child and binding relationships
|
|
21
28
|
* - Per-page index (rebuilds on page change)
|
|
22
29
|
* - Optimized for viewport culling queries
|
|
23
30
|
*
|
|
31
|
+
* Known limitation: a custom shape whose geometry reads other shapes outside the
|
|
32
|
+
* parent/child and binding relationships is not rechecked when those shapes change.
|
|
33
|
+
* Core shapes only depend on other shapes via bindings (arrows) and parent/child
|
|
34
|
+
* relationships (groups), which are both covered. This narrows the previous
|
|
35
|
+
* approach, which rechecked every indexed shape on every update and so happened to
|
|
36
|
+
* cover such shapes; the structural expansion trades that for O(affected) updates.
|
|
37
|
+
*
|
|
24
38
|
* @internal
|
|
25
39
|
*/
|
|
26
40
|
export class SpatialIndexManager {
|
|
@@ -52,27 +66,28 @@ export class SpatialIndexManager {
|
|
|
52
66
|
const bindingHistory = this.editor.store.query.filterHistory('binding')
|
|
53
67
|
|
|
54
68
|
return computed<number>('spatialIndex', (_prevValue, lastComputedEpoch) => {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
// These three reads are the computed's only tracked dependencies.
|
|
70
|
+
// Every input to a shape's page bounds is a shape or binding record,
|
|
71
|
+
// so the two histories cover all invalidation; the rebuild/update
|
|
72
|
+
// paths below run without capture to avoid registering a dependency
|
|
73
|
+
// per checked shape on every update (and ~page-size dependencies on
|
|
74
|
+
// every rebuild).
|
|
59
75
|
const shapeDiff = shapeHistory.getDiffSince(lastComputedEpoch)
|
|
60
76
|
const bindingDiff = bindingHistory.getDiffSince(lastComputedEpoch)
|
|
61
|
-
|
|
62
|
-
if (shapeDiff === RESET_VALUE || bindingDiff === RESET_VALUE) {
|
|
63
|
-
return this.rebuildAndBumpEpoch()
|
|
64
|
-
}
|
|
65
|
-
|
|
66
77
|
const currentPageId = this.editor.getCurrentPageId()
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
isUninitialized(_prevValue) ||
|
|
81
|
+
shapeDiff === RESET_VALUE ||
|
|
82
|
+
bindingDiff === RESET_VALUE ||
|
|
83
|
+
this.lastPageId !== currentPageId
|
|
84
|
+
) {
|
|
85
|
+
return unsafe__withoutCapture(() => this.rebuildAndBumpEpoch())
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
if (shapeDiff.length === 0 && bindingDiff.length === 0) return this._boundsEpoch
|
|
72
89
|
|
|
73
|
-
|
|
74
|
-
// and the step-2 sweep re-checks the indexed bounds of every shape.
|
|
75
|
-
if (this.processIncrementalUpdate(shapeDiff)) {
|
|
90
|
+
if (unsafe__withoutCapture(() => this.processIncrementalUpdate(shapeDiff, bindingDiff))) {
|
|
76
91
|
this._boundsEpoch++
|
|
77
92
|
}
|
|
78
93
|
return this._boundsEpoch
|
|
@@ -101,88 +116,160 @@ export class SpatialIndexManager {
|
|
|
101
116
|
this.rbush.bulkLoad(elements)
|
|
102
117
|
}
|
|
103
118
|
|
|
104
|
-
private processIncrementalUpdate(
|
|
105
|
-
|
|
106
|
-
|
|
119
|
+
private processIncrementalUpdate(
|
|
120
|
+
shapeDiff: RecordsDiff<TLRecord>[],
|
|
121
|
+
bindingDiff: RecordsDiff<TLRecord>[]
|
|
122
|
+
): boolean {
|
|
123
|
+
// Rbush mutations are collected and applied as one batch at the end so
|
|
124
|
+
// large updates can use a bulk rebuild instead of per-item tree ops.
|
|
125
|
+
const pendingRemoves = new Set<TLShapeId>()
|
|
126
|
+
const pendingUpserts: SpatialElement[] = []
|
|
127
|
+
|
|
128
|
+
// Shapes whose page transform may have moved (their record was added or
|
|
129
|
+
// updated). Their descendants' transforms move with them.
|
|
130
|
+
const transformChanged = new Set<TLShapeId>()
|
|
131
|
+
// Shapes whose page bounds need rechecking against the index.
|
|
132
|
+
const dirty = new Set<TLShapeId>()
|
|
133
|
+
// Subset of `dirty` not yet expanded into ancestors/binding partners.
|
|
134
|
+
const queue: TLShapeId[] = []
|
|
107
135
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
136
|
+
const markDirty = (id: TLShapeId) => {
|
|
137
|
+
if (!dirty.has(id)) {
|
|
138
|
+
dirty.add(id)
|
|
139
|
+
queue.push(id)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 1: seed the dirty set from the record diffs.
|
|
112
144
|
for (const changes of shapeDiff) {
|
|
113
145
|
for (const shape of objectMapValues(changes.added) as TLShape[]) {
|
|
114
|
-
if (isShape(shape)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
this.rbush.upsert(shape.id, bounds)
|
|
118
|
-
changed = true
|
|
119
|
-
}
|
|
120
|
-
processedShapeIds.add(shape.id)
|
|
121
|
-
}
|
|
146
|
+
if (!isShape(shape)) continue
|
|
147
|
+
transformChanged.add(shape.id)
|
|
148
|
+
markDirty(shape.id)
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
for (const shape of objectMapValues(changes.removed) as TLShape[]) {
|
|
125
|
-
if (isShape(shape))
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
changed = true
|
|
129
|
-
}
|
|
130
|
-
processedShapeIds.add(shape.id)
|
|
152
|
+
if (!isShape(shape)) continue
|
|
153
|
+
if (this.rbush.has(shape.id)) {
|
|
154
|
+
pendingRemoves.add(shape.id)
|
|
131
155
|
}
|
|
156
|
+
// The old parent's derived bounds (groups) may shrink. Bindings
|
|
157
|
+
// involving the shape are deleted in the same transaction, so the
|
|
158
|
+
// binding diff below dirties the other ends.
|
|
159
|
+
if (!isPageId(shape.parentId)) markDirty(shape.parentId)
|
|
132
160
|
}
|
|
133
161
|
|
|
134
|
-
for (const [, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
|
|
162
|
+
for (const [from, to] of objectMapValues(changes.updated) as [TLShape, TLShape][]) {
|
|
135
163
|
if (!isShape(to)) continue
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
164
|
+
transformChanged.add(to.id)
|
|
165
|
+
markDirty(to.id)
|
|
166
|
+
// On reparent, the old ancestor chain needs rechecking too
|
|
167
|
+
if (isShape(from) && from.parentId !== to.parentId && !isPageId(from.parentId)) {
|
|
168
|
+
markDirty(from.parentId)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const changes of bindingDiff) {
|
|
174
|
+
for (const binding of objectMapValues(changes.added)) {
|
|
175
|
+
if (!isBinding(binding)) continue
|
|
176
|
+
markDirty(binding.fromId)
|
|
177
|
+
markDirty(binding.toId)
|
|
178
|
+
}
|
|
179
|
+
for (const binding of objectMapValues(changes.removed)) {
|
|
180
|
+
if (!isBinding(binding)) continue
|
|
181
|
+
markDirty(binding.fromId)
|
|
182
|
+
markDirty(binding.toId)
|
|
183
|
+
}
|
|
184
|
+
for (const [from, to] of objectMapValues(changes.updated)) {
|
|
185
|
+
if (!isBinding(to)) continue
|
|
186
|
+
markDirty(to.fromId)
|
|
187
|
+
markDirty(to.toId)
|
|
188
|
+
// A reassigned binding leaves its old endpoints needing a
|
|
189
|
+
// recheck too (mirrors the reparent case for shapes above).
|
|
190
|
+
if (isBinding(from) && (from.fromId !== to.fromId || from.toId !== to.toId)) {
|
|
191
|
+
markDirty(from.fromId)
|
|
192
|
+
markDirty(from.toId)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Step 2: a moved record moves the page transforms of all its
|
|
198
|
+
// descendants, so their bounds need rechecking even though their
|
|
199
|
+
// records never changed. Expand before the fixpoint below so each
|
|
200
|
+
// descendant also gets its ancestors and binding partners visited.
|
|
201
|
+
const transformQueue = [...transformChanged]
|
|
202
|
+
while (transformQueue.length) {
|
|
203
|
+
const id = transformQueue.pop()!
|
|
204
|
+
for (const childId of this.editor.getSortedChildIdsForParent(id)) {
|
|
205
|
+
if (!transformChanged.has(childId)) {
|
|
206
|
+
transformChanged.add(childId)
|
|
207
|
+
transformQueue.push(childId)
|
|
155
208
|
}
|
|
209
|
+
markDirty(childId)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Step 3: expand to a fixpoint. Any shape whose bounds may have changed
|
|
214
|
+
// can resize its ancestor groups and move arrows bound to it. A derived
|
|
215
|
+
// bounds change does not move the shape's own transform, so descendants
|
|
216
|
+
// are not affected by this expansion (only by step 2).
|
|
217
|
+
while (queue.length) {
|
|
218
|
+
const id = queue.pop()!
|
|
219
|
+
|
|
220
|
+
const record = this.editor.store.unsafeGetWithoutCapture(id)
|
|
221
|
+
if (record && isShape(record) && !isPageId(record.parentId)) {
|
|
222
|
+
markDirty(record.parentId)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
for (const binding of this.editor.getBindingsInvolvingShape(id)) {
|
|
226
|
+
markDirty(binding.fromId)
|
|
227
|
+
markDirty(binding.toId)
|
|
156
228
|
}
|
|
157
229
|
}
|
|
158
230
|
|
|
159
|
-
// Step
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
231
|
+
// Step 4: recheck only the dirty shapes' bounds. The batch stays empty
|
|
232
|
+
// on prop-only updates with unchanged bounds, so they don't bump the
|
|
233
|
+
// epoch.
|
|
234
|
+
for (const id of dirty) {
|
|
235
|
+
// A shape removed in an earlier diff entry may have been re-added in
|
|
236
|
+
// a later one; its pending removal means the indexed bounds no
|
|
237
|
+
// longer count.
|
|
238
|
+
const indexedElement = pendingRemoves.has(id) ? undefined : this.rbush.getElement(id)
|
|
239
|
+
const record = this.editor.store.unsafeGetWithoutCapture(id)
|
|
240
|
+
|
|
241
|
+
if (
|
|
242
|
+
!record ||
|
|
243
|
+
!isShape(record) ||
|
|
244
|
+
this.editor.getAncestorPageId(record) !== this.lastPageId
|
|
245
|
+
) {
|
|
246
|
+
if (this.rbush.has(id)) {
|
|
247
|
+
pendingRemoves.add(id)
|
|
248
|
+
}
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const bounds = this.editor.getShapePageBounds(id)
|
|
253
|
+
if (bounds && bounds.isValid()) {
|
|
254
|
+
if (!this.areBoundsEqualToSpatialElement(bounds, indexedElement)) {
|
|
255
|
+
pendingRemoves.delete(id)
|
|
256
|
+
pendingUpserts.push({
|
|
257
|
+
minX: bounds.minX,
|
|
258
|
+
minY: bounds.minY,
|
|
259
|
+
maxX: bounds.maxX,
|
|
260
|
+
maxY: bounds.maxY,
|
|
261
|
+
id,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
} else if (this.rbush.has(id)) {
|
|
265
|
+
pendingRemoves.add(id)
|
|
181
266
|
}
|
|
182
|
-
changed = true
|
|
183
267
|
}
|
|
184
268
|
|
|
185
|
-
return
|
|
269
|
+
if (pendingRemoves.size === 0 && pendingUpserts.length === 0) return false
|
|
270
|
+
|
|
271
|
+
this.rbush.applyBatch(pendingRemoves, pendingUpserts)
|
|
272
|
+
return true
|
|
186
273
|
}
|
|
187
274
|
|
|
188
275
|
private areBoundsEqualToSpatialElement(
|
|
@@ -110,7 +110,6 @@ describe('TextManager', () => {
|
|
|
110
110
|
describe('constructor', () => {
|
|
111
111
|
it('should create a TextManager instance', () => {
|
|
112
112
|
expect(textManager).toBeInstanceOf(TextManager)
|
|
113
|
-
expect(textManager.editor).toBe(mockEditor)
|
|
114
113
|
})
|
|
115
114
|
})
|
|
116
115
|
|
|
@@ -247,6 +246,67 @@ describe('TextManager', () => {
|
|
|
247
246
|
expect(result.spans).toHaveLength(0)
|
|
248
247
|
})
|
|
249
248
|
|
|
249
|
+
it('should skip characters that produce no layout rectangles', () => {
|
|
250
|
+
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
|
|
251
|
+
RangeMock.mockImplementationOnce(function () {
|
|
252
|
+
return {
|
|
253
|
+
setStart: vi.fn(),
|
|
254
|
+
setEnd: vi.fn(),
|
|
255
|
+
// simulate a browser returning no rects for a measured character,
|
|
256
|
+
// which previously crashed with "Cannot read properties of
|
|
257
|
+
// undefined (reading 'top')". See issue #9112.
|
|
258
|
+
getClientRects: vi.fn(() => []),
|
|
259
|
+
}
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const mockTextNode = {
|
|
263
|
+
nodeType: 3, // TEXT_NODE
|
|
264
|
+
textContent: 'Hello',
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const mockElementWithText = {
|
|
268
|
+
childNodes: [mockTextNode],
|
|
269
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let result
|
|
273
|
+
expect(() => {
|
|
274
|
+
result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
|
|
275
|
+
}).not.toThrow()
|
|
276
|
+
|
|
277
|
+
expect(result!.spans).toHaveLength(0)
|
|
278
|
+
expect(result!.didTruncate).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('should measure grapheme clusters as a single unit', () => {
|
|
282
|
+
const startOffsets: number[] = []
|
|
283
|
+
const RangeMock = global.Range as unknown as ReturnType<typeof vi.fn>
|
|
284
|
+
RangeMock.mockImplementationOnce(function () {
|
|
285
|
+
return {
|
|
286
|
+
setStart: vi.fn((_node: any, offset: number) => startOffsets.push(offset)),
|
|
287
|
+
setEnd: vi.fn(),
|
|
288
|
+
getClientRects: vi.fn(() => [
|
|
289
|
+
{ width: 10, height: 16, left: 0, top: 0, right: 10, bottom: 16 },
|
|
290
|
+
]),
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// 👨👩👧 is 5 code points (8 UTF-16 units) joined by zero-width joiners
|
|
295
|
+
const family = '\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}'
|
|
296
|
+
const mockTextNode = { nodeType: 3, textContent: `a${family}b` }
|
|
297
|
+
const mockElementWithText = {
|
|
298
|
+
childNodes: [mockTextNode],
|
|
299
|
+
getBoundingClientRect: () => ({ left: 0, top: 0 }),
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const result = textManager.measureElementTextNodeSpans(mockElementWithText as any)
|
|
303
|
+
|
|
304
|
+
// the emoji is measured as one grapheme, so the index jumps over all 8 units
|
|
305
|
+
expect(startOffsets).toEqual([0, 1, 9])
|
|
306
|
+
// and its code points are kept together in the span text
|
|
307
|
+
expect(result.spans[0].text).toBe(`a${family}b`)
|
|
308
|
+
})
|
|
309
|
+
|
|
250
310
|
it('should handle truncation option', () => {
|
|
251
311
|
const mockTextNode = {
|
|
252
312
|
nodeType: 3, // TEXT_NODE
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { BoxModel, TLDefaultHorizontalAlignStyle } from '@tldraw/tlschema'
|
|
2
2
|
import { objectMapKeys } from '@tldraw/utils'
|
|
3
3
|
import type { Editor } from '../../Editor'
|
|
4
|
+
import { EditorManager } from '../EditorManager'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The whole-pixel line-height for a given font size and tldraw's unitless line-height
|
|
@@ -93,6 +94,10 @@ export interface TLMeasureTextSpanOpts {
|
|
|
93
94
|
|
|
94
95
|
const spaceCharacterRegex = /\s/
|
|
95
96
|
|
|
97
|
+
// Iterate by grapheme cluster (e.g. emoji sequences, flags, accented letters)
|
|
98
|
+
// rather than by code point, so a single glyph is measured as one unit.
|
|
99
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
|
100
|
+
|
|
96
101
|
const initialDefaultStyles = Object.freeze({
|
|
97
102
|
'overflow-wrap': 'break-word',
|
|
98
103
|
'word-break': 'auto',
|
|
@@ -103,13 +108,21 @@ const initialDefaultStyles = Object.freeze({
|
|
|
103
108
|
})
|
|
104
109
|
|
|
105
110
|
/** @public */
|
|
106
|
-
export class TextManager {
|
|
111
|
+
export class TextManager extends EditorManager {
|
|
107
112
|
private elm: HTMLDivElement
|
|
108
113
|
private poolElms: PoolItem[] = []
|
|
109
114
|
|
|
110
|
-
constructor(
|
|
115
|
+
constructor(editor: Editor) {
|
|
116
|
+
super(editor)
|
|
111
117
|
this.elm = this.createMeasurementEl()
|
|
112
118
|
this.editor.getContainer().appendChild(this.elm)
|
|
119
|
+
this.register(() => {
|
|
120
|
+
this.elm.remove()
|
|
121
|
+
for (const { el } of this.poolElms) {
|
|
122
|
+
el.remove()
|
|
123
|
+
}
|
|
124
|
+
this.poolElms.length = 0
|
|
125
|
+
})
|
|
113
126
|
}
|
|
114
127
|
|
|
115
128
|
private createMeasurementEl(): HTMLDivElement {
|
|
@@ -170,6 +183,9 @@ export class TextManager {
|
|
|
170
183
|
'font-weight': opts.fontWeight,
|
|
171
184
|
'font-size': opts.fontSize + 'px',
|
|
172
185
|
'line-height': `${resolveLineHeightPx(opts.fontSize, opts.lineHeight)}px`,
|
|
186
|
+
// Unitless multiplier consumed by the .tl-rich-text h1–h6 rule (see editor.css),
|
|
187
|
+
// so heading measurement matches on-canvas rendering.
|
|
188
|
+
'--tl-rich-text-heading-line-height': `${opts.lineHeight}`,
|
|
173
189
|
padding: opts.padding,
|
|
174
190
|
'max-width': opts.maxWidth ? opts.maxWidth + 'px' : undefined,
|
|
175
191
|
'min-width': opts.minWidth ? opts.minWidth + 'px' : undefined,
|
|
@@ -178,14 +194,6 @@ export class TextManager {
|
|
|
178
194
|
}
|
|
179
195
|
}
|
|
180
196
|
|
|
181
|
-
dispose() {
|
|
182
|
-
this.elm.remove()
|
|
183
|
-
for (const { el } of this.poolElms) {
|
|
184
|
-
el.remove()
|
|
185
|
-
}
|
|
186
|
-
this.poolElms.length = 0
|
|
187
|
-
}
|
|
188
|
-
|
|
189
197
|
private ensurePoolSize(size: number) {
|
|
190
198
|
if (this.poolElms.length >= size) return
|
|
191
199
|
|
|
@@ -301,15 +309,24 @@ export class TextManager {
|
|
|
301
309
|
for (const childNode of element.childNodes) {
|
|
302
310
|
if (childNode.nodeType !== Node.TEXT_NODE) continue
|
|
303
311
|
|
|
304
|
-
for (const
|
|
305
|
-
// place the range around the
|
|
312
|
+
for (const { segment } of graphemeSegmenter.segment(childNode.textContent ?? '')) {
|
|
313
|
+
// place the range around the grapheme we're interested in
|
|
306
314
|
range.setStart(textNode, idx)
|
|
307
|
-
range.setEnd(textNode, idx +
|
|
315
|
+
range.setEnd(textNode, idx + segment.length)
|
|
308
316
|
// measure the range. some browsers return multiple rects for the
|
|
309
317
|
// first char in a new line - one for the line break, and one for
|
|
310
318
|
// the character itself. we're only interested in the character.
|
|
311
319
|
const rects = range.getClientRects()
|
|
312
|
-
const rect = rects[rects.length - 1]
|
|
320
|
+
const rect = rects[rects.length - 1]
|
|
321
|
+
|
|
322
|
+
// some graphemes produce no layout rectangle (e.g. zero-width
|
|
323
|
+
// spaces or newlines). skip them rather than crashing, but keep
|
|
324
|
+
// advancing the index so later graphemes stay aligned.
|
|
325
|
+
// See https://github.com/tldraw/tldraw/issues/9112.
|
|
326
|
+
if (!rect) {
|
|
327
|
+
idx += segment.length
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
313
330
|
|
|
314
331
|
// calculate the position of the character relative to the element
|
|
315
332
|
const top = rect.top + offsetY
|
|
@@ -317,7 +334,7 @@ export class TextManager {
|
|
|
317
334
|
const right = rect.right + offsetX
|
|
318
335
|
const isRTL = left < prevCharLeftForRTLTest
|
|
319
336
|
|
|
320
|
-
const isSpaceCharacter = spaceCharacterRegex.test(
|
|
337
|
+
const isSpaceCharacter = spaceCharacterRegex.test(segment)
|
|
321
338
|
if (
|
|
322
339
|
// If we're at a word boundary...
|
|
323
340
|
isSpaceCharacter !== prevCharWasSpaceCharacter ||
|
|
@@ -341,7 +358,7 @@ export class TextManager {
|
|
|
341
358
|
// start a new span
|
|
342
359
|
currentSpan = {
|
|
343
360
|
box: { x: left, y: top, w: rect.width, h: rect.height },
|
|
344
|
-
text:
|
|
361
|
+
text: segment,
|
|
345
362
|
}
|
|
346
363
|
prevCharLeftForRTLTest = left
|
|
347
364
|
} else {
|
|
@@ -352,16 +369,16 @@ export class TextManager {
|
|
|
352
369
|
|
|
353
370
|
// otherwise we just need to extend the current span with the next character
|
|
354
371
|
currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
|
|
355
|
-
currentSpan.text +=
|
|
372
|
+
currentSpan.text += segment
|
|
356
373
|
}
|
|
357
374
|
|
|
358
|
-
if (
|
|
375
|
+
if (segment === '\n') {
|
|
359
376
|
prevCharLeftForRTLTest = 0
|
|
360
377
|
}
|
|
361
378
|
|
|
362
379
|
prevCharWasSpaceCharacter = isSpaceCharacter
|
|
363
380
|
prevCharTop = top
|
|
364
|
-
idx +=
|
|
381
|
+
idx += segment.length
|
|
365
382
|
}
|
|
366
383
|
}
|
|
367
384
|
|
|
@@ -19,7 +19,6 @@ describe('TickManager', () => {
|
|
|
19
19
|
let editor: Mocked<Editor>
|
|
20
20
|
let tickManager: TickManager
|
|
21
21
|
let mockEmit: Mock
|
|
22
|
-
let mockDisposablesAdd: Mock
|
|
23
22
|
|
|
24
23
|
beforeEach(() => {
|
|
25
24
|
vi.clearAllMocks()
|
|
@@ -38,13 +37,9 @@ describe('TickManager', () => {
|
|
|
38
37
|
mockCancelAnimationFrame.mockImplementation(() => {})
|
|
39
38
|
|
|
40
39
|
mockEmit = vi.fn()
|
|
41
|
-
mockDisposablesAdd = vi.fn()
|
|
42
40
|
|
|
43
41
|
editor = {
|
|
44
42
|
emit: mockEmit,
|
|
45
|
-
disposables: {
|
|
46
|
-
add: mockDisposablesAdd,
|
|
47
|
-
},
|
|
48
43
|
} as unknown as Mocked<Editor>
|
|
49
44
|
|
|
50
45
|
tickManager = new TickManager(editor)
|
|
@@ -58,15 +53,10 @@ describe('TickManager', () => {
|
|
|
58
53
|
|
|
59
54
|
describe('constructor and initialization', () => {
|
|
60
55
|
it('should initialize with correct default values', () => {
|
|
61
|
-
expect(tickManager.editor).toBe(editor)
|
|
62
56
|
expect(tickManager.isPaused).toBe(false)
|
|
63
57
|
expect(tickManager.now).toBe(1000)
|
|
64
58
|
})
|
|
65
59
|
|
|
66
|
-
it('should add dispose method to editor disposables', () => {
|
|
67
|
-
expect(mockDisposablesAdd).toHaveBeenCalledWith(tickManager.dispose)
|
|
68
|
-
})
|
|
69
|
-
|
|
70
60
|
it('should start the tick loop on construction', () => {
|
|
71
61
|
expect(mockRequestAnimationFrame).toHaveBeenCalled()
|
|
72
62
|
})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { throttleToNextFrame as _throttleToNextFrame, bind } from '@tldraw/utils'
|
|
2
2
|
import type { Editor } from '../../Editor'
|
|
3
|
+
import { EditorManager } from '../EditorManager'
|
|
3
4
|
|
|
4
5
|
const throttleToNextFrame =
|
|
5
6
|
typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
|
|
@@ -13,9 +14,9 @@ const throttleToNextFrame =
|
|
|
13
14
|
: _throttleToNextFrame
|
|
14
15
|
|
|
15
16
|
/** @internal */
|
|
16
|
-
export class TickManager {
|
|
17
|
-
constructor(
|
|
18
|
-
|
|
17
|
+
export class TickManager extends EditorManager {
|
|
18
|
+
constructor(editor: Editor) {
|
|
19
|
+
super(editor)
|
|
19
20
|
this.start()
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -45,11 +46,9 @@ export class TickManager {
|
|
|
45
46
|
this.cancelRaf = throttleToNextFrame(this.tick)
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// Clear the listener
|
|
49
|
-
@bind
|
|
50
49
|
dispose() {
|
|
51
50
|
this.isPaused = true
|
|
52
|
-
|
|
53
51
|
this.cancelRaf?.()
|
|
52
|
+
super.dispose()
|
|
54
53
|
}
|
|
55
54
|
}
|
|
@@ -5,7 +5,7 @@ import { BaseBoxShapeUtil, TLBaseBoxShape } from './BaseBoxShapeUtil'
|
|
|
5
5
|
import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* A base class for frame-like shapes — containers that clip their children,
|
|
8
|
+
* A base class for frame-like shapes — containers that clip their children except arrows,
|
|
9
9
|
* require full-brush selection, block erasure from inside, and support
|
|
10
10
|
* drag-and-drop reparenting.
|
|
11
11
|
*
|
|
@@ -17,6 +17,7 @@ import { TLDragShapesInInfo, TLDragShapesOutInfo } from './ShapeUtil'
|
|
|
17
17
|
* - `canReceiveNewChildrenOfType()` returns `true` unless the container is locked
|
|
18
18
|
* - `canRemoveChildrenOfType()` returns `true` unless the container is locked
|
|
19
19
|
* - `getClipPath()` returns the shape geometry's vertices
|
|
20
|
+
* - `shouldClipChild()` clips all children except arrows
|
|
20
21
|
* - `onDragShapesIn()` reparents shapes into the frame (with index restoration)
|
|
21
22
|
* - `onDragShapesOut()` reparents shapes back to the page
|
|
22
23
|
*
|
|
@@ -69,6 +70,10 @@ export abstract class BaseFrameLikeShapeUtil<
|
|
|
69
70
|
return this.editor.getShapeGeometry(shape.id).vertices
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
override shouldClipChild(child: TLShape): boolean {
|
|
74
|
+
return child.type !== 'arrow'
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
override onDragShapesIn(
|
|
73
78
|
shape: Shape,
|
|
74
79
|
draggingShapes: TLShape[],
|
|
@@ -4,7 +4,6 @@ import { Vec } from '../../../../primitives/Vec'
|
|
|
4
4
|
import type { Editor } from '../../../Editor'
|
|
5
5
|
import { TLBaseBoxShape } from '../../../shapes/BaseBoxShapeUtil'
|
|
6
6
|
import { TLPointerEventInfo } from '../../../types/event-types'
|
|
7
|
-
import { cancelShapeCreationOnLongPress } from '../../cancelShapeCreationOnLongPress'
|
|
8
7
|
import { StateNode } from '../../StateNode'
|
|
9
8
|
import type { BaseBoxShapeTool } from '../BaseBoxShapeTool'
|
|
10
9
|
|
|
@@ -67,7 +66,8 @@ export class Pointing extends StateNode {
|
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
override onLongPress() {
|
|
70
|
-
|
|
69
|
+
// On a touch (coarse pointer) long-press, cancel the pending shape so it leaves nothing behind.
|
|
70
|
+
if (this.editor.getInstanceState().isCoarsePointer) this.cancel()
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
override onCancel() {
|