@tldraw/driver 4.5.0-canary.84ac7a331515

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.
@@ -0,0 +1,753 @@
1
+ import {
2
+ Editor,
3
+ Mat,
4
+ PageRecordType,
5
+ ROTATE_CORNER_TO_SELECTION_CORNER,
6
+ RotateCorner,
7
+ SelectionHandle,
8
+ TLArrowShape,
9
+ TLContent,
10
+ TLKeyboardEventInfo,
11
+ TLPinchEventInfo,
12
+ TLPointerEventInfo,
13
+ TLShape,
14
+ TLShapeId,
15
+ TLWheelEventInfo,
16
+ Vec,
17
+ VecLike,
18
+ compact,
19
+ createShapeId,
20
+ isAccelKey,
21
+ rotateSelectionHandle,
22
+ tlenv,
23
+ } from '@tldraw/editor'
24
+
25
+ /** Options for pointer events. Either partial pointer event info or a shape ID to target. @public */
26
+ export type PointerEventInit = Partial<TLPointerEventInfo> | TLShapeId
27
+
28
+ /** Modifier keys for events. @public */
29
+ export type EventModifiers = Partial<Pick<TLPointerEventInfo, 'shiftKey' | 'ctrlKey' | 'altKey'>>
30
+
31
+ /**
32
+ * Driver wraps an Editor instance and provides an imperative API for driving it
33
+ * programmatically. Useful for scripting, automation, REPL usage, and testing.
34
+ *
35
+ * All methods use only public Editor APIs and return `this` for fluent chaining.
36
+ *
37
+ * @public
38
+ */
39
+ export class Driver {
40
+ /** The underlying Editor instance. */
41
+ private _cleanup: (() => void) | null = null
42
+
43
+ constructor(public readonly editor: Editor) {
44
+ this._cleanup = this.editor.sideEffects.registerAfterCreateHandler('shape', (record) => {
45
+ this._lastCreatedShapes.push(record)
46
+ if (this._lastCreatedShapes.length > 1000) {
47
+ this._lastCreatedShapes = this._lastCreatedShapes.slice(-500)
48
+ }
49
+ })
50
+ }
51
+
52
+ /** Remove all registered side-effect handlers. Call when this controller is no longer needed. */
53
+ dispose() {
54
+ this._cleanup?.()
55
+ this._cleanup = null
56
+ }
57
+
58
+ /** Local clipboard content. Used by copy, cut, and paste. */
59
+ clipboard: TLContent | null = null
60
+
61
+ private _lastCreatedShapes: TLShape[] = []
62
+
63
+ /**
64
+ * Get the last created shapes.
65
+ * @param count - The number of shapes to get.
66
+ */
67
+ getLastCreatedShapes(count = 1) {
68
+ return this._lastCreatedShapes.slice(-count).map((s) => this.editor.getShape(s)!)
69
+ }
70
+
71
+ /**
72
+ * Get the last created shape.
73
+ */
74
+ getLastCreatedShape<T extends TLShape>() {
75
+ const lastShape = this._lastCreatedShapes[this._lastCreatedShapes.length - 1] as T
76
+ return this.editor.getShape<T>(lastShape)!
77
+ }
78
+
79
+ /* ---------------------- IDs ---------------------- */
80
+
81
+ /**
82
+ * Creates a shape ID from a string.
83
+ * @param id - The string to convert to a shape ID.
84
+ */
85
+ createShapeID(id: string) {
86
+ return createShapeId(id)
87
+ }
88
+
89
+ /**
90
+ * Creates a page ID from a string.
91
+ * @param id - The string to convert to a page ID.
92
+ */
93
+ createPageID(id: string) {
94
+ return PageRecordType.createId(id)
95
+ }
96
+
97
+ /* ------------------- Clipboard ------------------- */
98
+
99
+ /**
100
+ * Copies the given shapes to the controller clipboard. Defaults to the current selection.
101
+ * @param ids - Shape IDs to copy. Defaults to the current selection.
102
+ */
103
+ copy(ids = this.editor.getSelectedShapeIds()) {
104
+ if (ids.length > 0) {
105
+ const content = this.editor.getContentFromCurrentPage(ids)
106
+ if (content) {
107
+ this.clipboard = content
108
+ }
109
+ }
110
+ return this
111
+ }
112
+
113
+ /**
114
+ * Cuts the given shapes (copy to clipboard, then delete). Defaults to the current selection.
115
+ * @param ids - Shape IDs to cut. Defaults to the current selection.
116
+ */
117
+ cut(ids = this.editor.getSelectedShapeIds()) {
118
+ if (ids.length > 0) {
119
+ const content = this.editor.getContentFromCurrentPage(ids)
120
+ if (content) {
121
+ this.clipboard = content
122
+ }
123
+ this.editor.deleteShapes(ids)
124
+ }
125
+ return this
126
+ }
127
+
128
+ /**
129
+ * Pastes content from the controller clipboard. If shift is held, uses current pointer. Otherwise uses the given point.
130
+ * @param point - Page coordinates for paste location. If omitted and shift is not held, placement may vary.
131
+ */
132
+ paste(point?: VecLike) {
133
+ if (this.clipboard !== null) {
134
+ const p = this.editor.inputs.getShiftKey() ? this.editor.inputs.getCurrentPagePoint() : point
135
+
136
+ this.editor.markHistoryStoppingPoint('pasting')
137
+ this.editor.putContentOntoCurrentPage(this.clipboard, {
138
+ point: p,
139
+ select: true,
140
+ })
141
+ }
142
+ return this
143
+ }
144
+
145
+ /* ------------------- Queries ------------------- */
146
+
147
+ /** Returns the center of the viewport in page coordinates. */
148
+ getViewportPageCenter() {
149
+ return this.editor.getViewportPageBounds().center
150
+ }
151
+
152
+ /** Returns the center of the current selection in page coordinates, or null if nothing is selected. */
153
+ getSelectionPageCenter() {
154
+ const selectionRotation = this.editor.getSelectionRotation()
155
+ const selectionBounds = this.editor.getSelectionRotatedPageBounds()
156
+ if (!selectionBounds) return null
157
+ return Vec.RotWith(selectionBounds.center, selectionBounds.point, selectionRotation)
158
+ }
159
+
160
+ /**
161
+ * Returns the center of a shape in page coordinates, or null if the shape has no page transform.
162
+ * @param shape - The shape to get the center of.
163
+ */
164
+ getPageCenter(shape: TLShape) {
165
+ const pageTransform = this.editor.getShapePageTransform(shape.id)
166
+ if (!pageTransform) return null
167
+ const center = this.editor.getShapeGeometry(shape).bounds.center
168
+ return Mat.applyToPoint(pageTransform, center)
169
+ }
170
+
171
+ /**
172
+ * Returns the rotation of a shape in page space by ID, in radians.
173
+ * @param id - The shape ID.
174
+ */
175
+ getPageRotationById(id: TLShapeId): number {
176
+ const pageTransform = this.editor.getShapePageTransform(id)
177
+ if (pageTransform) {
178
+ return Mat.Decompose(pageTransform).rotation
179
+ }
180
+ return 0
181
+ }
182
+
183
+ /**
184
+ * Returns the rotation of a shape in page space, in radians.
185
+ * @param shape - The shape to get the rotation of.
186
+ */
187
+ getPageRotation(shape: TLShape) {
188
+ return this.getPageRotationById(shape.id)
189
+ }
190
+
191
+ /**
192
+ * Returns all arrow shapes bound to the given shape.
193
+ * @param shapeId - The shape ID to find arrows bound to.
194
+ */
195
+ getArrowsBoundTo(shapeId: TLShapeId) {
196
+ const ids = new Set(this.editor.getBindingsToShape(shapeId, 'arrow').map((b) => b.fromId))
197
+ return compact(Array.from(ids, (id) => this.editor.getShape<TLArrowShape>(id)))
198
+ }
199
+
200
+ /* --------------- Event building --------------- */
201
+
202
+ /**
203
+ * Builds a TLPointerEventInfo object for input simulation.
204
+ * @param x - Screen x coordinate. Defaults to current pointer.
205
+ * @param y - Screen y coordinate. Defaults to current pointer.
206
+ * @param options - Target shape/selection or partial event info.
207
+ * @param modifiers - Override shift, ctrl, or alt key state.
208
+ */
209
+ private getPointerEventInfo(
210
+ x = this.editor.inputs.getCurrentScreenPoint().x,
211
+ y = this.editor.inputs.getCurrentScreenPoint().y,
212
+ options?: PointerEventInit,
213
+ modifiers?: EventModifiers
214
+ ): TLPointerEventInfo {
215
+ if (typeof options === 'string') {
216
+ options = { target: 'shape', shape: this.editor.getShape(options) }
217
+ } else if (options === undefined) {
218
+ options = { target: 'canvas' }
219
+ }
220
+ return {
221
+ name: 'pointer_down',
222
+ type: 'pointer',
223
+ pointerId: 1,
224
+ shiftKey: this.editor.inputs.getShiftKey(),
225
+ ctrlKey: this.editor.inputs.getCtrlKey(),
226
+ altKey: this.editor.inputs.getAltKey(),
227
+ metaKey: this.editor.inputs.getMetaKey(),
228
+ accelKey: isAccelKey({ ...this.editor.inputs.toJson(), ...modifiers }),
229
+ point: { x, y, z: null },
230
+ button: 0,
231
+ isPen: false,
232
+ ...options,
233
+ ...modifiers,
234
+ } as TLPointerEventInfo
235
+ }
236
+
237
+ /**
238
+ * Builds a TLKeyboardEventInfo object for input simulation.
239
+ * @param key - The key being pressed.
240
+ * @param name - The event name (key_down, key_up, key_repeat).
241
+ * @param options - Partial event info overrides.
242
+ */
243
+ private getKeyboardEventInfo(
244
+ key: string,
245
+ name: TLKeyboardEventInfo['name'],
246
+ options = {} as Partial<Omit<TLKeyboardEventInfo, 'point'>>
247
+ ): TLKeyboardEventInfo {
248
+ return {
249
+ shiftKey: key === 'Shift',
250
+ ctrlKey: key === 'Control' || key === 'Meta',
251
+ altKey: key === 'Alt',
252
+ metaKey: key === 'Meta',
253
+ accelKey: tlenv.isDarwin ? key === 'Meta' : key === 'Control' || key === 'Meta',
254
+ ...options,
255
+ name,
256
+ code:
257
+ key === 'Shift'
258
+ ? 'ShiftLeft'
259
+ : key === 'Alt'
260
+ ? 'AltLeft'
261
+ : key === 'Control'
262
+ ? 'CtrlLeft'
263
+ : key === 'Meta'
264
+ ? 'MetaLeft'
265
+ : key === ' '
266
+ ? 'Space'
267
+ : key === 'Enter' ||
268
+ key === 'ArrowRight' ||
269
+ key === 'ArrowLeft' ||
270
+ key === 'ArrowUp' ||
271
+ key === 'ArrowDown'
272
+ ? key
273
+ : 'Key' + key[0].toUpperCase() + key.slice(1),
274
+ type: 'keyboard',
275
+ key,
276
+ }
277
+ }
278
+
279
+ /* --------------- Input events --------------- */
280
+
281
+ /**
282
+ * Emits tick events to advance the editor by the given number of frames (default 1).
283
+ * @param count - Number of tick events to emit. Defaults to 1.
284
+ */
285
+ forceTick(count = 1) {
286
+ for (let i = 0; i < count; i++) {
287
+ this.editor.emit('tick', 16)
288
+ }
289
+ return this
290
+ }
291
+
292
+ /**
293
+ * Dispatches a pointer move event at the given screen coordinates.
294
+ * @param x - Screen x coordinate. Defaults to current pointer.
295
+ * @param y - Screen y coordinate. Defaults to current pointer.
296
+ * @param options - Target shape/selection or partial event info.
297
+ * @param modifiers - Override shift, ctrl, or alt key state.
298
+ */
299
+ pointerMove(
300
+ x = this.editor.inputs.getCurrentScreenPoint().x,
301
+ y = this.editor.inputs.getCurrentScreenPoint().y,
302
+ options?: PointerEventInit,
303
+ modifiers?: EventModifiers
304
+ ) {
305
+ this.editor.dispatch({
306
+ ...this.getPointerEventInfo(x, y, options, modifiers),
307
+ name: 'pointer_move',
308
+ })
309
+ this.forceTick()
310
+ return this
311
+ }
312
+
313
+ /**
314
+ * Dispatches a pointer down event at the given screen coordinates.
315
+ * @param x - Screen x coordinate. Defaults to current pointer.
316
+ * @param y - Screen y coordinate. Defaults to current pointer.
317
+ * @param options - Target shape/selection or partial event info.
318
+ * @param modifiers - Override shift, ctrl, or alt key state.
319
+ */
320
+ pointerDown(
321
+ x = this.editor.inputs.getCurrentScreenPoint().x,
322
+ y = this.editor.inputs.getCurrentScreenPoint().y,
323
+ options?: PointerEventInit,
324
+ modifiers?: EventModifiers
325
+ ) {
326
+ this.editor.dispatch({
327
+ ...this.getPointerEventInfo(x, y, options, modifiers),
328
+ name: 'pointer_down',
329
+ })
330
+ this.forceTick()
331
+ return this
332
+ }
333
+
334
+ /**
335
+ * Dispatches a pointer up event at the given screen coordinates.
336
+ * @param x - Screen x coordinate. Defaults to current pointer.
337
+ * @param y - Screen y coordinate. Defaults to current pointer.
338
+ * @param options - Target shape/selection or partial event info.
339
+ * @param modifiers - Override shift, ctrl, or alt key state.
340
+ */
341
+ pointerUp(
342
+ x = this.editor.inputs.getCurrentScreenPoint().x,
343
+ y = this.editor.inputs.getCurrentScreenPoint().y,
344
+ options?: PointerEventInit,
345
+ modifiers?: EventModifiers
346
+ ) {
347
+ this.editor.dispatch({
348
+ ...this.getPointerEventInfo(x, y, options, modifiers),
349
+ name: 'pointer_up',
350
+ })
351
+ this.forceTick()
352
+ return this
353
+ }
354
+
355
+ /**
356
+ * Dispatches a pointer down followed by pointer up at the given screen coordinates.
357
+ * @param x - Screen x coordinate. Defaults to current pointer.
358
+ * @param y - Screen y coordinate. Defaults to current pointer.
359
+ * @param options - Target shape/selection or partial event info.
360
+ * @param modifiers - Override shift, ctrl, or alt key state.
361
+ */
362
+ click(
363
+ x = this.editor.inputs.getCurrentScreenPoint().x,
364
+ y = this.editor.inputs.getCurrentScreenPoint().y,
365
+ options?: PointerEventInit,
366
+ modifiers?: EventModifiers
367
+ ) {
368
+ this.pointerDown(x, y, options, modifiers)
369
+ this.pointerUp(x, y, options, modifiers)
370
+ return this
371
+ }
372
+
373
+ /**
374
+ * Dispatches a right-click (button 2) down and up at the given screen coordinates.
375
+ * @param x - Screen x coordinate. Defaults to current pointer.
376
+ * @param y - Screen y coordinate. Defaults to current pointer.
377
+ * @param options - Target shape/selection or partial event info.
378
+ * @param modifiers - Override shift, ctrl, or alt key state.
379
+ */
380
+ rightClick(
381
+ x = this.editor.inputs.getCurrentScreenPoint().x,
382
+ y = this.editor.inputs.getCurrentScreenPoint().y,
383
+ options?: PointerEventInit,
384
+ modifiers?: EventModifiers
385
+ ) {
386
+ this.editor.dispatch({
387
+ ...this.getPointerEventInfo(x, y, options, modifiers),
388
+ name: 'pointer_down',
389
+ button: 2,
390
+ })
391
+ this.forceTick()
392
+ this.editor.dispatch({
393
+ ...this.getPointerEventInfo(x, y, options, modifiers),
394
+ name: 'pointer_up',
395
+ button: 2,
396
+ })
397
+ this.forceTick()
398
+ return this
399
+ }
400
+
401
+ /**
402
+ * Dispatches a double-click sequence at the given screen coordinates.
403
+ * @param x - Screen x coordinate. Defaults to current pointer.
404
+ * @param y - Screen y coordinate. Defaults to current pointer.
405
+ * @param options - Target shape/selection or partial event info.
406
+ * @param modifiers - Override shift, ctrl, or alt key state.
407
+ */
408
+ doubleClick(
409
+ x = this.editor.inputs.getCurrentScreenPoint().x,
410
+ y = this.editor.inputs.getCurrentScreenPoint().y,
411
+ options?: PointerEventInit,
412
+ modifiers?: EventModifiers
413
+ ) {
414
+ this.pointerDown(x, y, options, modifiers)
415
+ this.pointerUp(x, y, options, modifiers)
416
+ this.editor.dispatch({
417
+ ...this.getPointerEventInfo(x, y, options, modifiers),
418
+ type: 'click',
419
+ name: 'double_click',
420
+ phase: 'down',
421
+ })
422
+ this.editor.dispatch({
423
+ ...this.getPointerEventInfo(x, y, options, modifiers),
424
+ type: 'click',
425
+ name: 'double_click',
426
+ phase: 'up',
427
+ })
428
+ this.forceTick()
429
+ return this
430
+ }
431
+
432
+ /**
433
+ * Dispatches a key down followed by key up for the given key.
434
+ * @param key - The key to press (e.g. 'a', 'Enter', 'Shift').
435
+ * @param options - Partial keyboard event overrides.
436
+ */
437
+ keyPress(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
438
+ this.keyDown(key, options)
439
+ this.keyUp(key, options)
440
+ return this
441
+ }
442
+
443
+ /**
444
+ * Dispatches a key down event for the given key.
445
+ * @param key - The key to press (e.g. 'a', 'Enter', 'Shift').
446
+ * @param options - Partial keyboard event overrides.
447
+ */
448
+ keyDown(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
449
+ this.editor.dispatch({ ...this.getKeyboardEventInfo(key, 'key_down', options) })
450
+ this.forceTick()
451
+ return this
452
+ }
453
+
454
+ /**
455
+ * Dispatches a key repeat event for the given key.
456
+ * @param key - The key that is repeating (e.g. 'a', 'ArrowDown').
457
+ * @param options - Partial keyboard event overrides.
458
+ */
459
+ keyRepeat(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
460
+ this.editor.dispatch({ ...this.getKeyboardEventInfo(key, 'key_repeat', options) })
461
+ this.forceTick()
462
+ return this
463
+ }
464
+
465
+ /**
466
+ * Dispatches a key up event for the given key.
467
+ * @param key - The key to release (e.g. 'a', 'Enter', 'Shift').
468
+ * @param options - Partial keyboard event overrides.
469
+ */
470
+ keyUp(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
471
+ this.editor.dispatch({
472
+ ...this.getKeyboardEventInfo(key, 'key_up', {
473
+ shiftKey: this.editor.inputs.getShiftKey() && key !== 'Shift',
474
+ ctrlKey: this.editor.inputs.getCtrlKey() && !(key === 'Control' || key === 'Meta'),
475
+ altKey: this.editor.inputs.getAltKey() && key !== 'Alt',
476
+ metaKey: this.editor.inputs.getMetaKey() && key !== 'Meta',
477
+ ...options,
478
+ }),
479
+ })
480
+ this.forceTick()
481
+ return this
482
+ }
483
+
484
+ /**
485
+ * Dispatches a wheel event with the given delta values.
486
+ * @param dx - Horizontal scroll delta.
487
+ * @param dy - Vertical scroll delta.
488
+ * @param options - Partial wheel event overrides.
489
+ */
490
+ wheel(dx: number, dy: number, options = {} as Partial<Omit<TLWheelEventInfo, 'delta'>>) {
491
+ const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint()
492
+ this.editor.dispatch({
493
+ type: 'wheel',
494
+ name: 'wheel',
495
+ point: new Vec(currentScreenPoint.x, currentScreenPoint.y),
496
+ shiftKey: this.editor.inputs.getShiftKey(),
497
+ ctrlKey: this.editor.inputs.getCtrlKey(),
498
+ altKey: this.editor.inputs.getAltKey(),
499
+ metaKey: this.editor.inputs.getMetaKey(),
500
+ accelKey: isAccelKey(this.editor.inputs),
501
+ ...options,
502
+ delta: { x: dx, y: dy },
503
+ })
504
+ this.forceTick(2)
505
+ return this
506
+ }
507
+
508
+ /**
509
+ * Pans the camera by the given offset (in page coordinates). Does nothing if camera is locked.
510
+ * @param offset - The pan delta (x, y) in page coordinates.
511
+ */
512
+ pan(offset: VecLike) {
513
+ const { isLocked, panSpeed } = this.editor.getCameraOptions()
514
+ if (isLocked) return this
515
+ const { x: cx, y: cy, z: cz } = this.editor.getCamera()
516
+ this.editor.setCamera(
517
+ new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz),
518
+ { immediate: true }
519
+ )
520
+ return this
521
+ }
522
+
523
+ /**
524
+ * Dispatches a pinch start event.
525
+ * @param x - Screen x coordinate. Defaults to current pointer.
526
+ * @param y - Screen y coordinate. Defaults to current pointer.
527
+ * @param z - Pinch scale/factor.
528
+ * @param dx - Delta x for pinch.
529
+ * @param dy - Delta y for pinch.
530
+ * @param dz - Delta z for pinch.
531
+ * @param options - Partial pinch event overrides.
532
+ */
533
+ pinchStart(
534
+ x = this.editor.inputs.getCurrentScreenPoint().x,
535
+ y = this.editor.inputs.getCurrentScreenPoint().y,
536
+ z: number,
537
+ dx: number,
538
+ dy: number,
539
+ dz: number,
540
+ options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
541
+ ) {
542
+ this.editor.dispatch({
543
+ type: 'pinch',
544
+ name: 'pinch_start',
545
+ shiftKey: this.editor.inputs.getShiftKey(),
546
+ ctrlKey: this.editor.inputs.getCtrlKey(),
547
+ altKey: this.editor.inputs.getAltKey(),
548
+ metaKey: this.editor.inputs.getMetaKey(),
549
+ accelKey: isAccelKey(this.editor.inputs),
550
+ ...options,
551
+ point: { x, y, z },
552
+ delta: { x: dx, y: dy, z: dz },
553
+ })
554
+ this.forceTick()
555
+ return this
556
+ }
557
+
558
+ /**
559
+ * Dispatches a pinch move event (pinch_to).
560
+ * @param x - Screen x coordinate. Defaults to current pointer.
561
+ * @param y - Screen y coordinate. Defaults to current pointer.
562
+ * @param z - Pinch scale/factor.
563
+ * @param dx - Delta x for pinch.
564
+ * @param dy - Delta y for pinch.
565
+ * @param dz - Delta z for pinch.
566
+ * @param options - Partial pinch event overrides.
567
+ */
568
+ pinchTo(
569
+ x = this.editor.inputs.getCurrentScreenPoint().x,
570
+ y = this.editor.inputs.getCurrentScreenPoint().y,
571
+ z: number,
572
+ dx: number,
573
+ dy: number,
574
+ dz: number,
575
+ options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
576
+ ) {
577
+ this.editor.dispatch({
578
+ type: 'pinch',
579
+ name: 'pinch',
580
+ shiftKey: this.editor.inputs.getShiftKey(),
581
+ ctrlKey: this.editor.inputs.getCtrlKey(),
582
+ altKey: this.editor.inputs.getAltKey(),
583
+ metaKey: this.editor.inputs.getMetaKey(),
584
+ accelKey: isAccelKey(this.editor.inputs),
585
+ ...options,
586
+ point: { x, y, z },
587
+ delta: { x: dx, y: dy, z: dz },
588
+ })
589
+ return this
590
+ }
591
+
592
+ /**
593
+ * Dispatches a pinch end event.
594
+ * @param x - Screen x coordinate. Defaults to current pointer.
595
+ * @param y - Screen y coordinate. Defaults to current pointer.
596
+ * @param z - Pinch scale/factor.
597
+ * @param dx - Delta x for pinch.
598
+ * @param dy - Delta y for pinch.
599
+ * @param dz - Delta z for pinch.
600
+ * @param options - Partial pinch event overrides.
601
+ */
602
+ pinchEnd(
603
+ x = this.editor.inputs.getCurrentScreenPoint().x,
604
+ y = this.editor.inputs.getCurrentScreenPoint().y,
605
+ z: number,
606
+ dx: number,
607
+ dy: number,
608
+ dz: number,
609
+ options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
610
+ ) {
611
+ this.editor.dispatch({
612
+ type: 'pinch',
613
+ name: 'pinch_end',
614
+ shiftKey: this.editor.inputs.getShiftKey(),
615
+ ctrlKey: this.editor.inputs.getCtrlKey(),
616
+ altKey: this.editor.inputs.getAltKey(),
617
+ metaKey: this.editor.inputs.getMetaKey(),
618
+ accelKey: isAccelKey(this.editor.inputs),
619
+ ...options,
620
+ point: { x, y, z },
621
+ delta: { x: dx, y: dy, z: dz },
622
+ })
623
+ this.forceTick()
624
+ return this
625
+ }
626
+
627
+ /* --------------- Interaction helpers --------------- */
628
+
629
+ /**
630
+ * Converts a point from page coordinates to screen coordinates.
631
+ * Pointer events operate in screen space, so page-space points must be
632
+ * converted before being passed to pointerDown/Move/Up.
633
+ */
634
+ private pageToScreen(point: VecLike) {
635
+ return this.editor.pageToScreen(point)
636
+ }
637
+
638
+ /**
639
+ * Simulates rotating the current selection by the given angle in radians via the rotation handle.
640
+ * @param angleRadians - Rotation angle in radians.
641
+ * @param options - Optional handle and shiftKey. handle defaults to 'top_left_rotate'.
642
+ */
643
+ rotateSelection(
644
+ angleRadians: number,
645
+ options: { handle?: RotateCorner; shiftKey?: boolean } = {}
646
+ ) {
647
+ const { handle = 'top_left_rotate', shiftKey = false } = options
648
+ if (this.editor.getSelectedShapeIds().length === 0) {
649
+ throw new Error('No selection')
650
+ }
651
+
652
+ this.editor.setCurrentTool('select')
653
+
654
+ const handlePoint = this.editor
655
+ .getSelectionRotatedPageBounds()!
656
+ .getHandlePoint(ROTATE_CORNER_TO_SELECTION_CORNER[handle])
657
+ .clone()
658
+ .rotWith(
659
+ this.editor.getSelectionRotatedPageBounds()!.point,
660
+ this.editor.getSelectionRotation()
661
+ )
662
+
663
+ const targetHandlePoint = Vec.RotWith(handlePoint, this.getSelectionPageCenter()!, angleRadians)
664
+
665
+ const screenHandle = this.pageToScreen(handlePoint)
666
+ const screenTarget = this.pageToScreen(targetHandlePoint)
667
+
668
+ this.pointerDown(screenHandle.x, screenHandle.y, { target: 'selection', handle })
669
+ this.pointerMove(screenTarget.x, screenTarget.y, { shiftKey })
670
+ this.pointerUp()
671
+ return this
672
+ }
673
+
674
+ /**
675
+ * Simulates translating the current selection by the given delta in page coordinates.
676
+ * @param dx - Horizontal delta in page coordinates.
677
+ * @param dy - Vertical delta in page coordinates.
678
+ * @param options - Partial pointer event overrides (e.g. altKey for center-based scaling).
679
+ */
680
+ translateSelection(dx: number, dy: number, options?: Partial<TLPointerEventInfo>) {
681
+ if (this.editor.getSelectedShapeIds().length === 0) {
682
+ throw new Error('No selection')
683
+ }
684
+ this.editor.setCurrentTool('select')
685
+
686
+ const center = this.getSelectionPageCenter()!
687
+ const screenCenter = this.pageToScreen(center)
688
+
689
+ this.pointerDown(screenCenter.x, screenCenter.y, this.editor.getSelectedShapeIds()[0])
690
+ const numSteps = 10
691
+ for (let i = 1; i < numSteps; i++) {
692
+ const p = this.pageToScreen({
693
+ x: center.x + (i * dx) / numSteps,
694
+ y: center.y + (i * dy) / numSteps,
695
+ })
696
+ this.pointerMove(p.x, p.y, options)
697
+ }
698
+ const endScreen = this.pageToScreen({ x: center.x + dx, y: center.y + dy })
699
+ this.pointerUp(endScreen.x, endScreen.y, options)
700
+ return this
701
+ }
702
+
703
+ /**
704
+ * Simulates resizing the current selection via the given handle, with optional scale factors.
705
+ * @param scale - Scale factors for x and y. Defaults to `\{ scaleX: 1, scaleY: 1 \}`.
706
+ * @param handle - The selection handle to drag (e.g. 'top', 'bottom_right').
707
+ * @param options - Partial pointer event overrides (e.g. altKey to scale from center).
708
+ */
709
+ resizeSelection(
710
+ scale: { scaleX?: number; scaleY?: number } = {},
711
+ handle: SelectionHandle,
712
+ options?: Partial<TLPointerEventInfo>
713
+ ) {
714
+ const { scaleX = 1, scaleY = 1 } = scale
715
+ if (this.editor.getSelectedShapeIds().length === 0) {
716
+ throw new Error('No selection')
717
+ }
718
+ this.editor.setCurrentTool('select')
719
+ const bounds = this.editor.getSelectionRotatedPageBounds()!
720
+ const preRotationHandlePoint = bounds.getHandlePoint(handle)
721
+
722
+ const preRotationScaleOriginPoint = options?.altKey
723
+ ? bounds.center
724
+ : bounds.getHandlePoint(rotateSelectionHandle(handle, Math.PI))
725
+
726
+ const preRotationTargetHandlePoint = Vec.Add(
727
+ Vec.Sub(preRotationHandlePoint, preRotationScaleOriginPoint).mulV({
728
+ x: scaleX,
729
+ y: scaleY,
730
+ }),
731
+ preRotationScaleOriginPoint
732
+ )
733
+
734
+ const handlePoint = Vec.RotWith(
735
+ preRotationHandlePoint,
736
+ bounds.point,
737
+ this.editor.getSelectionRotation()
738
+ )
739
+ const targetHandlePoint = Vec.RotWith(
740
+ preRotationTargetHandlePoint,
741
+ bounds.point,
742
+ this.editor.getSelectionRotation()
743
+ )
744
+
745
+ const screenHandle = this.pageToScreen(handlePoint)
746
+ const screenTarget = this.pageToScreen(targetHandlePoint)
747
+
748
+ this.pointerDown(screenHandle.x, screenHandle.y, { target: 'selection', handle }, options)
749
+ this.pointerMove(screenTarget.x, screenTarget.y, options)
750
+ this.pointerUp(screenTarget.x, screenTarget.y, options)
751
+ return this
752
+ }
753
+ }