@tldraw/editor 5.2.1 → 5.3.0-canary.7d00ddf70f63
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 +45 -8
- package/dist-cjs/index.js +3 -3
- package/dist-cjs/index.js.map +2 -2
- package/dist-cjs/lib/editor/Editor.js +12 -2
- 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/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 +24 -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/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/version.js +3 -3
- package/dist-cjs/version.js.map +1 -1
- package/dist-esm/index.d.mts +45 -8
- package/dist-esm/index.mjs +3 -3
- package/dist-esm/index.mjs.map +2 -2
- package/dist-esm/lib/editor/Editor.mjs +12 -2
- 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/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 +24 -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/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/version.mjs +3 -3
- package/dist-esm/version.mjs.map +1 -1
- package/package.json +7 -7
- package/src/index.ts +1 -1
- package/src/lib/editor/Editor.ts +15 -2
- 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/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 +33 -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/ShapeUtil.ts +1 -0
- package/src/lib/editor/tools/BaseBoxShapeTool/children/Pointing.ts +2 -2
- 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 {
|
|
@@ -178,14 +191,6 @@ export class TextManager {
|
|
|
178
191
|
}
|
|
179
192
|
}
|
|
180
193
|
|
|
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
194
|
private ensurePoolSize(size: number) {
|
|
190
195
|
if (this.poolElms.length >= size) return
|
|
191
196
|
|
|
@@ -301,15 +306,24 @@ export class TextManager {
|
|
|
301
306
|
for (const childNode of element.childNodes) {
|
|
302
307
|
if (childNode.nodeType !== Node.TEXT_NODE) continue
|
|
303
308
|
|
|
304
|
-
for (const
|
|
305
|
-
// place the range around the
|
|
309
|
+
for (const { segment } of graphemeSegmenter.segment(childNode.textContent ?? '')) {
|
|
310
|
+
// place the range around the grapheme we're interested in
|
|
306
311
|
range.setStart(textNode, idx)
|
|
307
|
-
range.setEnd(textNode, idx +
|
|
312
|
+
range.setEnd(textNode, idx + segment.length)
|
|
308
313
|
// measure the range. some browsers return multiple rects for the
|
|
309
314
|
// first char in a new line - one for the line break, and one for
|
|
310
315
|
// the character itself. we're only interested in the character.
|
|
311
316
|
const rects = range.getClientRects()
|
|
312
|
-
const rect = rects[rects.length - 1]
|
|
317
|
+
const rect = rects[rects.length - 1]
|
|
318
|
+
|
|
319
|
+
// some graphemes produce no layout rectangle (e.g. zero-width
|
|
320
|
+
// spaces or newlines). skip them rather than crashing, but keep
|
|
321
|
+
// advancing the index so later graphemes stay aligned.
|
|
322
|
+
// See https://github.com/tldraw/tldraw/issues/9112.
|
|
323
|
+
if (!rect) {
|
|
324
|
+
idx += segment.length
|
|
325
|
+
continue
|
|
326
|
+
}
|
|
313
327
|
|
|
314
328
|
// calculate the position of the character relative to the element
|
|
315
329
|
const top = rect.top + offsetY
|
|
@@ -317,7 +331,7 @@ export class TextManager {
|
|
|
317
331
|
const right = rect.right + offsetX
|
|
318
332
|
const isRTL = left < prevCharLeftForRTLTest
|
|
319
333
|
|
|
320
|
-
const isSpaceCharacter = spaceCharacterRegex.test(
|
|
334
|
+
const isSpaceCharacter = spaceCharacterRegex.test(segment)
|
|
321
335
|
if (
|
|
322
336
|
// If we're at a word boundary...
|
|
323
337
|
isSpaceCharacter !== prevCharWasSpaceCharacter ||
|
|
@@ -341,7 +355,7 @@ export class TextManager {
|
|
|
341
355
|
// start a new span
|
|
342
356
|
currentSpan = {
|
|
343
357
|
box: { x: left, y: top, w: rect.width, h: rect.height },
|
|
344
|
-
text:
|
|
358
|
+
text: segment,
|
|
345
359
|
}
|
|
346
360
|
prevCharLeftForRTLTest = left
|
|
347
361
|
} else {
|
|
@@ -352,16 +366,16 @@ export class TextManager {
|
|
|
352
366
|
|
|
353
367
|
// otherwise we just need to extend the current span with the next character
|
|
354
368
|
currentSpan.box.w = isRTL ? currentSpan.box.w + rect.width : right - currentSpan.box.x
|
|
355
|
-
currentSpan.text +=
|
|
369
|
+
currentSpan.text += segment
|
|
356
370
|
}
|
|
357
371
|
|
|
358
|
-
if (
|
|
372
|
+
if (segment === '\n') {
|
|
359
373
|
prevCharLeftForRTLTest = 0
|
|
360
374
|
}
|
|
361
375
|
|
|
362
376
|
prevCharWasSpaceCharacter = isSpaceCharacter
|
|
363
377
|
prevCharTop = top
|
|
364
|
-
idx +=
|
|
378
|
+
idx += segment.length
|
|
365
379
|
}
|
|
366
380
|
}
|
|
367
381
|
|
|
@@ -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
|
}
|
|
@@ -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() {
|
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.3.0-canary.7d00ddf70f63'
|
|
5
5
|
export const publishDates = {
|
|
6
6
|
major: '2026-05-06T16:28:18.473Z',
|
|
7
|
-
minor: '2026-07-
|
|
8
|
-
patch: '2026-07-
|
|
7
|
+
minor: '2026-07-01T14:32:31.738Z',
|
|
8
|
+
patch: '2026-07-01T14:32:31.738Z',
|
|
9
9
|
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __defProp = Object.defineProperty;
|
|
3
|
-
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
-
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
-
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
-
var __export = (target, all) => {
|
|
7
|
-
for (var name in all)
|
|
8
|
-
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
-
};
|
|
10
|
-
var __copyProps = (to, from, except, desc) => {
|
|
11
|
-
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
-
for (let key of __getOwnPropNames(from))
|
|
13
|
-
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
-
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
-
}
|
|
16
|
-
return to;
|
|
17
|
-
};
|
|
18
|
-
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
-
var cancelShapeCreationOnLongPress_exports = {};
|
|
20
|
-
__export(cancelShapeCreationOnLongPress_exports, {
|
|
21
|
-
cancelShapeCreationOnLongPress: () => cancelShapeCreationOnLongPress
|
|
22
|
-
});
|
|
23
|
-
module.exports = __toCommonJS(cancelShapeCreationOnLongPress_exports);
|
|
24
|
-
function cancelShapeCreationOnLongPress(editor, cancelPendingCreation) {
|
|
25
|
-
if (!editor.getInstanceState().isCoarsePointer) return;
|
|
26
|
-
cancelPendingCreation();
|
|
27
|
-
}
|
|
28
|
-
//# sourceMappingURL=cancelShapeCreationOnLongPress.js.map
|
|
@@ -1,7 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"version": 3,
|
|
3
|
-
"sources": ["../../../../src/lib/editor/tools/cancelShapeCreationOnLongPress.ts"],
|
|
4
|
-
"sourcesContent": ["import type { Editor } from '../Editor'\n\n/**\n * Shared handler for a long-press in a discrete shape-creation tool's pointing\n * state (geo, note, line, text, arrow, frame). On a coarse pointer this cancels\n * any pending creation so the long-press leaves no shape behind (issue #8277).\n * It does not open a context menu \u2014 that is a select-tool surface, reached on\n * touch only from the select tool, and on desktop from a right-click (which the\n * editor routes through the select tool).\n *\n * Gated on `isCoarsePointer`: on a fine pointer (mouse) the long-press timer\n * powers the deliberate \"pause before drag to start a precise arrow\" gesture,\n * which we must not disturb. The bug only manifests on coarse pointers, because\n * that is also where the browser fires `contextmenu` at the long-press mark.\n *\n * The `cancelPendingCreation` callback is the tool's own cleanup \u2014 it knows\n * whether it needs to `bailToMark` a shape it created early or simply transition\n * back to idle. Continuous-gesture tools (draw, highlight, eraser, laser) are\n * excluded: their press is their own action, so they handle (or ignore) the\n * long-press themselves.\n *\n * @internal\n */\nexport function cancelShapeCreationOnLongPress(\n\teditor: Editor,\n\tcancelPendingCreation: () => void\n): void {\n\tif (!editor.getInstanceState().isCoarsePointer) return\n\n\tcancelPendingCreation()\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAuBO,SAAS,+BACf,QACA,uBACO;AACP,MAAI,CAAC,OAAO,iBAAiB,EAAE,gBAAiB;AAEhD,wBAAsB;AACvB;",
|
|
6
|
-
"names": []
|
|
7
|
-
}
|