Files
upstream-tldraw/packages/driver/src/lib/Driver.ts
upstream-mirror a774ac673a
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

731 lines
22 KiB
TypeScript

import {
Editor,
Mat,
PageRecordType,
ROTATE_CORNER_TO_SELECTION_CORNER,
RotateCorner,
SelectionHandle,
TLArrowShape,
TLContent,
TLKeyboardEventInfo,
TLPinchEventInfo,
TLPointerEventInfo,
TLShape,
TLShapeId,
TLWheelEventInfo,
Vec,
VecLike,
compact,
createShapeId,
isAccelKey,
rotateSelectionHandle,
tlenv,
} from '@tldraw/editor'
/** Options for pointer events. Either partial pointer event info or a shape ID to target. @public */
export type PointerEventInit = Partial<TLPointerEventInfo> | TLShapeId
/** Modifier keys for events. @public */
export type EventModifiers = Partial<Pick<TLPointerEventInfo, 'shiftKey' | 'ctrlKey' | 'altKey'>>
const KEY_CODES: Record<string, string> = {
Shift: 'ShiftLeft',
Alt: 'AltLeft',
Control: 'CtrlLeft',
Meta: 'MetaLeft',
' ': 'Space',
Enter: 'Enter',
ArrowRight: 'ArrowRight',
ArrowLeft: 'ArrowLeft',
ArrowUp: 'ArrowUp',
ArrowDown: 'ArrowDown',
}
/**
* Driver wraps an Editor instance and provides an imperative API for driving it
* programmatically. Useful for scripting, automation, REPL usage, and testing.
*
* All methods use only public Editor APIs and return `this` for fluent chaining.
*
* @public
*/
export class Driver {
/** The underlying Editor instance. */
private _cleanup: (() => void) | null
private _lastCreatedShapes: TLShape[] = []
constructor(public readonly editor: Editor) {
this._cleanup = this.editor.sideEffects.registerAfterCreateHandler('shape', (record) => {
this._lastCreatedShapes.push(record)
if (this._lastCreatedShapes.length > 1000) {
this._lastCreatedShapes = this._lastCreatedShapes.slice(-500)
}
})
}
/** Remove all registered side-effect handlers. Call when this controller is no longer needed. */
dispose() {
this._cleanup?.()
this._cleanup = null
}
/** Local clipboard content. Used by copy, cut, and paste. */
clipboard: TLContent | null = null
/**
* Get the last created shapes.
* @param count - The number of shapes to get.
*/
getLastCreatedShapes(count = 1) {
return this._lastCreatedShapes.slice(-count).map((s) => this.editor.getShape(s)!)
}
/**
* Get the last created shape.
*/
getLastCreatedShape<T extends TLShape>() {
const lastShape = this._lastCreatedShapes[this._lastCreatedShapes.length - 1] as T
return this.editor.getShape<T>(lastShape)!
}
/* ---------------------- IDs ---------------------- */
/**
* Creates a shape ID from a string.
* @param id - The string to convert to a shape ID.
*/
createShapeID(id: string) {
return createShapeId(id)
}
/**
* Creates a page ID from a string.
* @param id - The string to convert to a page ID.
*/
createPageID(id: string) {
return PageRecordType.createId(id)
}
/* ------------------- Clipboard ------------------- */
/**
* Copies the given shapes to the controller clipboard. Defaults to the current selection.
* @param ids - Shape IDs to copy. Defaults to the current selection.
*/
copy(ids = this.editor.getSelectedShapeIds()) {
if (ids.length > 0) {
const content = this.editor.getContentFromCurrentPage(ids)
if (content) {
this.clipboard = content
}
}
return this
}
/**
* Cuts the given shapes (copy to clipboard, then delete). Defaults to the current selection.
* @param ids - Shape IDs to cut. Defaults to the current selection.
*/
cut(ids = this.editor.getSelectedShapeIds()) {
if (ids.length > 0) {
this.copy(ids)
this.editor.deleteShapes(ids)
}
return this
}
/**
* Pastes content from the controller clipboard. If shift is held, uses current pointer. Otherwise uses the given point.
* @param point - Page coordinates for paste location. If omitted and shift is not held, placement may vary.
*/
paste(point?: VecLike) {
if (this.clipboard !== null) {
const p = this.editor.inputs.getShiftKey() ? this.editor.inputs.getCurrentPagePoint() : point
this.editor.markHistoryStoppingPoint('pasting')
this.editor.putContentOntoCurrentPage(this.clipboard, {
point: p,
select: true,
})
}
return this
}
/* ------------------- Queries ------------------- */
/** Returns the center of the viewport in page coordinates. */
getViewportPageCenter() {
return this.editor.getViewportPageBounds().center
}
/** Returns the center of the current selection in page coordinates, or null if nothing is selected. */
getSelectionPageCenter() {
const selectionRotation = this.editor.getSelectionRotation()
const selectionBounds = this.editor.getSelectionRotatedPageBounds()
if (!selectionBounds) return null
return Vec.RotWith(selectionBounds.center, selectionBounds.point, selectionRotation)
}
/**
* Returns the center of a shape in page coordinates, or null if the shape has no page transform.
* @param shape - The shape to get the center of.
*/
getPageCenter(shape: TLShape) {
const pageTransform = this.editor.getShapePageTransform(shape.id)
if (!pageTransform) return null
const center = this.editor.getShapeGeometry(shape).bounds.center
return Mat.applyToPoint(pageTransform, center)
}
/**
* Returns the rotation of a shape in page space by ID, in radians.
* @param id - The shape ID.
*/
getPageRotationById(id: TLShapeId): number {
const pageTransform = this.editor.getShapePageTransform(id)
return pageTransform ? pageTransform.rotation() : 0
}
/**
* Returns the rotation of a shape in page space, in radians.
* @param shape - The shape to get the rotation of.
*/
getPageRotation(shape: TLShape) {
return this.getPageRotationById(shape.id)
}
/**
* Returns all arrow shapes bound to the given shape.
* @param shapeId - The shape ID to find arrows bound to.
*/
getArrowsBoundTo(shapeId: TLShapeId) {
const ids = new Set(this.editor.getBindingsToShape(shapeId, 'arrow').map((b) => b.fromId))
return compact(Array.from(ids, (id) => this.editor.getShape<TLArrowShape>(id)))
}
/* --------------- Event building --------------- */
private getModifierKeys() {
const { inputs } = this.editor
return {
shiftKey: inputs.getShiftKey(),
ctrlKey: inputs.getCtrlKey(),
altKey: inputs.getAltKey(),
metaKey: inputs.getMetaKey(),
accelKey: isAccelKey(inputs),
}
}
/**
* Builds a TLPointerEventInfo object for input simulation.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
private getPointerEventInfo(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
): TLPointerEventInfo {
if (typeof options === 'string') {
options = { target: 'shape', shape: this.editor.getShape(options) }
} else if (options === undefined) {
options = { target: 'canvas' }
}
const info = {
name: 'pointer_down',
type: 'pointer',
pointerId: 1,
...this.getModifierKeys(),
accelKey: isAccelKey({ ...this.editor.inputs.toJson(), ...modifiers }),
point: { x, y, z: null },
button: 0,
isPen: false,
...options,
...modifiers,
} as TLPointerEventInfo
// In simulated input, a pen is assumed to be a direct-display pen (which auto-enables pen
// mode) unless a test explicitly opts out with `isPenDirect: false`.
if (info.isPenDirect === undefined) info.isPenDirect = info.isPen
if (tlenv.isDarwin && info.button === 0 && info.ctrlKey && !info.metaKey) {
info.button = 2
}
return info
}
/**
* Builds a TLKeyboardEventInfo object for input simulation.
* @param key - The key being pressed.
* @param name - The event name (key_down, key_up, key_repeat).
* @param options - Partial event info overrides.
*/
private getKeyboardEventInfo(
key: string,
name: TLKeyboardEventInfo['name'],
options = {} as Partial<Omit<TLKeyboardEventInfo, 'point'>>
): TLKeyboardEventInfo {
// Like a real DOM keyboard event, the flags reflect every modifier currently held — not
// just the key being pressed. We OR in the editor's current modifier state so that, e.g.,
// pressing Shift while Control is held still reports ctrlKey: true. (keyUp passes explicit
// flag overrides for its release semantics, so those win over these defaults.)
const flags = {
shiftKey: key === 'Shift' || this.editor.inputs.getShiftKey(),
ctrlKey: key === 'Control' || key === 'Meta' || this.editor.inputs.getCtrlKey(),
altKey: key === 'Alt' || this.editor.inputs.getAltKey(),
metaKey: key === 'Meta' || this.editor.inputs.getMetaKey(),
...options,
}
return {
...flags,
accelKey: flags.accelKey ?? isAccelKey(flags),
name,
code: options.code ?? KEY_CODES[key] ?? 'Key' + key[0].toUpperCase() + key.slice(1),
type: 'keyboard',
key,
}
}
/* --------------- Input events --------------- */
/**
* Emits tick events to advance the editor by the given number of frames (default 1).
* @param count - Number of tick events to emit. Defaults to 1.
*/
forceTick(count = 1) {
for (let i = 0; i < count; i++) {
this.editor.emit('tick', 16)
}
return this
}
/**
* Dispatches a pointer move event at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
pointerMove(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_move',
})
this.forceTick()
return this
}
/**
* Dispatches a pointer down event at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
pointerDown(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_down',
})
this.forceTick()
return this
}
/**
* Dispatches a pointer up event at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
pointerUp(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_up',
})
this.forceTick()
return this
}
/**
* Dispatches a pointer down followed by pointer up at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
click(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.pointerDown(x, y, options, modifiers)
this.pointerUp(x, y, options, modifiers)
return this
}
/**
* Dispatches a right-click (button 2) down and up at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
rightClick(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_down',
button: 2,
})
this.forceTick()
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
name: 'pointer_up',
button: 2,
})
this.forceTick()
return this
}
/**
* Dispatches a double-click sequence at the given screen coordinates.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param options - Target shape/selection or partial event info.
* @param modifiers - Override shift, ctrl, or alt key state.
*/
doubleClick(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
options?: PointerEventInit,
modifiers?: EventModifiers
) {
this.click(x, y, options, modifiers)
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
type: 'click',
name: 'double_click',
phase: 'down',
})
this.editor.dispatch({
...this.getPointerEventInfo(x, y, options, modifiers),
type: 'click',
name: 'double_click',
phase: 'up',
})
this.forceTick()
return this
}
/**
* Dispatches a key down followed by key up for the given key.
* @param key - The key to press (e.g. 'a', 'Enter', 'Shift').
* @param options - Partial keyboard event overrides.
*/
keyPress(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
this.keyDown(key, options)
this.keyUp(key, options)
return this
}
/**
* Dispatches a key down event for the given key.
* @param key - The key to press (e.g. 'a', 'Enter', 'Shift').
* @param options - Partial keyboard event overrides.
*/
keyDown(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
this.editor.dispatch(this.getKeyboardEventInfo(key, 'key_down', options))
this.forceTick()
return this
}
/**
* Dispatches a key repeat event for the given key.
* @param key - The key that is repeating (e.g. 'a', 'ArrowDown').
* @param options - Partial keyboard event overrides.
*/
keyRepeat(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
this.editor.dispatch(this.getKeyboardEventInfo(key, 'key_repeat', options))
this.forceTick()
return this
}
/**
* Dispatches a key up event for the given key.
* @param key - The key to release (e.g. 'a', 'Enter', 'Shift').
* @param options - Partial keyboard event overrides.
*/
keyUp(key: string, options = {} as Partial<Omit<TLKeyboardEventInfo, 'key'>>) {
this.editor.dispatch(
this.getKeyboardEventInfo(key, 'key_up', {
shiftKey: this.editor.inputs.getShiftKey() && key !== 'Shift',
ctrlKey: this.editor.inputs.getCtrlKey() && !(key === 'Control' || key === 'Meta'),
altKey: this.editor.inputs.getAltKey() && key !== 'Alt',
metaKey: this.editor.inputs.getMetaKey() && key !== 'Meta',
...options,
})
)
this.forceTick()
return this
}
/**
* Dispatches a wheel event with the given delta values.
* @param dx - Horizontal scroll delta.
* @param dy - Vertical scroll delta.
* @param options - Partial wheel event overrides.
*/
wheel(dx: number, dy: number, options = {} as Partial<Omit<TLWheelEventInfo, 'delta'>>) {
const currentScreenPoint = this.editor.inputs.getCurrentScreenPoint()
this.editor.dispatch({
type: 'wheel',
name: 'wheel',
point: new Vec(currentScreenPoint.x, currentScreenPoint.y),
...this.getModifierKeys(),
...options,
delta: { x: dx, y: dy },
})
this.forceTick(2)
return this
}
/**
* Pans the camera by the given offset (in page coordinates). Does nothing if camera is locked.
* @param offset - The pan delta (x, y) in page coordinates.
*/
pan(offset: VecLike) {
const { isLocked, panSpeed } = this.editor.getCameraOptions()
if (isLocked) return this
const { x: cx, y: cy, z: cz } = this.editor.getCamera()
this.editor.setCamera(
new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz),
{ immediate: true }
)
return this
}
private pinch(
name: TLPinchEventInfo['name'],
x: number,
y: number,
z: number,
dx: number,
dy: number,
dz: number,
options: Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
) {
this.editor.dispatch({
type: 'pinch',
name,
...this.getModifierKeys(),
...options,
point: { x, y, z },
delta: { x: dx, y: dy, z: dz },
})
}
/**
* Dispatches a pinch start event.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param z - Pinch scale/factor.
* @param dx - Delta x for pinch.
* @param dy - Delta y for pinch.
* @param dz - Delta z for pinch.
* @param options - Partial pinch event overrides.
*/
pinchStart(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
z: number,
dx: number,
dy: number,
dz: number,
options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
) {
this.pinch('pinch_start', x, y, z, dx, dy, dz, options)
this.forceTick()
return this
}
/**
* Dispatches a pinch move event (pinch_to).
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param z - Pinch scale/factor.
* @param dx - Delta x for pinch.
* @param dy - Delta y for pinch.
* @param dz - Delta z for pinch.
* @param options - Partial pinch event overrides.
*/
pinchTo(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
z: number,
dx: number,
dy: number,
dz: number,
options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
) {
this.pinch('pinch', x, y, z, dx, dy, dz, options)
return this
}
/**
* Dispatches a pinch end event.
* @param x - Screen x coordinate. Defaults to current pointer.
* @param y - Screen y coordinate. Defaults to current pointer.
* @param z - Pinch scale/factor.
* @param dx - Delta x for pinch.
* @param dy - Delta y for pinch.
* @param dz - Delta z for pinch.
* @param options - Partial pinch event overrides.
*/
pinchEnd(
x = this.editor.inputs.getCurrentScreenPoint().x,
y = this.editor.inputs.getCurrentScreenPoint().y,
z: number,
dx: number,
dy: number,
dz: number,
options = {} as Partial<Omit<TLPinchEventInfo, 'point' | 'delta' | 'offset'>>
) {
this.pinch('pinch_end', x, y, z, dx, dy, dz, options)
this.forceTick()
return this
}
/* --------------- Interaction helpers --------------- */
/**
* Simulates rotating the current selection by the given angle in radians via the rotation handle.
* @param angleRadians - Rotation angle in radians.
* @param options - Optional handle and shiftKey. handle defaults to 'top_left_rotate'.
*/
rotateSelection(
angleRadians: number,
options: { handle?: RotateCorner; shiftKey?: boolean } = {}
) {
const { handle = 'top_left_rotate', shiftKey = false } = options
if (this.editor.getSelectedShapeIds().length === 0) {
throw new Error('No selection')
}
this.editor.setCurrentTool('select')
const bounds = this.editor.getSelectionRotatedPageBounds()!
const handlePoint = bounds
.getHandlePoint(ROTATE_CORNER_TO_SELECTION_CORNER[handle])
.clone()
.rotWith(bounds.point, this.editor.getSelectionRotation())
const targetHandlePoint = Vec.RotWith(handlePoint, this.getSelectionPageCenter()!, angleRadians)
const screenHandle = this.editor.pageToScreen(handlePoint)
const screenTarget = this.editor.pageToScreen(targetHandlePoint)
this.pointerDown(screenHandle.x, screenHandle.y, { target: 'selection', handle })
this.pointerMove(screenTarget.x, screenTarget.y, { shiftKey })
this.pointerUp()
return this
}
/**
* Simulates translating the current selection by the given delta in page coordinates.
* @param dx - Horizontal delta in page coordinates.
* @param dy - Vertical delta in page coordinates.
* @param options - Partial pointer event overrides (e.g. altKey for center-based scaling).
*/
translateSelection(dx: number, dy: number, options?: Partial<TLPointerEventInfo>) {
if (this.editor.getSelectedShapeIds().length === 0) {
throw new Error('No selection')
}
this.editor.setCurrentTool('select')
const center = this.getSelectionPageCenter()!
const screenCenter = this.editor.pageToScreen(center)
this.pointerDown(screenCenter.x, screenCenter.y, this.editor.getSelectedShapeIds()[0])
const numSteps = 10
for (let i = 1; i < numSteps; i++) {
const p = this.editor.pageToScreen({
x: center.x + (i * dx) / numSteps,
y: center.y + (i * dy) / numSteps,
})
this.pointerMove(p.x, p.y, options)
}
const endScreen = this.editor.pageToScreen({ x: center.x + dx, y: center.y + dy })
this.pointerUp(endScreen.x, endScreen.y, options)
return this
}
/**
* Simulates resizing the current selection via the given handle, with optional scale factors.
* @param scale - Scale factors for x and y. Defaults to `\{ scaleX: 1, scaleY: 1 \}`.
* @param handle - The selection handle to drag (e.g. 'top', 'bottom_right').
* @param options - Partial pointer event overrides (e.g. altKey to scale from center).
*/
resizeSelection(
scale: { scaleX?: number; scaleY?: number } = {},
handle: SelectionHandle,
options?: Partial<TLPointerEventInfo>
) {
const { scaleX = 1, scaleY = 1 } = scale
if (this.editor.getSelectedShapeIds().length === 0) {
throw new Error('No selection')
}
this.editor.setCurrentTool('select')
const bounds = this.editor.getSelectionRotatedPageBounds()!
const rotation = this.editor.getSelectionRotation()
const preRotationHandlePoint = bounds.getHandlePoint(handle)
const preRotationScaleOriginPoint = options?.altKey
? bounds.center
: bounds.getHandlePoint(rotateSelectionHandle(handle, Math.PI))
const preRotationTargetHandlePoint = Vec.Add(
Vec.Sub(preRotationHandlePoint, preRotationScaleOriginPoint).mulV({
x: scaleX,
y: scaleY,
}),
preRotationScaleOriginPoint
)
const handlePoint = Vec.RotWith(preRotationHandlePoint, bounds.point, rotation)
const targetHandlePoint = Vec.RotWith(preRotationTargetHandlePoint, bounds.point, rotation)
const screenHandle = this.editor.pageToScreen(handlePoint)
const screenTarget = this.editor.pageToScreen(targetHandlePoint)
this.pointerDown(screenHandle.x, screenHandle.y, { target: 'selection', handle }, options)
this.pointerMove(screenTarget.x, screenTarget.y, options)
this.pointerUp(screenTarget.x, screenTarget.y, options)
return this
}
}