import { Mat, StateNode, TLArrowShape, TLHandle, TLLineShape, TLPointerEventInfo, TLShapeId, TLShapePartial, Vec, kickoutOccludedShapes, snapAngle, sortByIndex, structuredClone, warnOnce, } from '@tldraw/editor' import { ArrowShapeUtil } from '../../../shapes/arrow/ArrowShapeUtil' import { clearArrowTargetState } from '../../../shapes/arrow/arrowTargetState' import { getArrowBindings } from '../../../shapes/arrow/shared' export type DraggingHandleInfo = TLPointerEventInfo & { shape: TLArrowShape | TLLineShape target: 'handle' onInteractionEnd?: string | (() => void) isCreating?: boolean creatingMarkId?: string } export class DraggingHandle extends StateNode { static override id = 'dragging_handle' static override trackPerformance = true shapeId!: TLShapeId initialHandle!: TLHandle initialAdjacentHandle!: TLHandle | null initialPagePoint!: Vec markId!: string initialPageTransform!: Mat initialPageRotation!: number info!: DraggingHandleInfo isPrecise = false isPreciseId: TLShapeId | null = null pointingId: TLShapeId | null = null override onEnter(info: DraggingHandleInfo) { const { shape, isCreating, creatingMarkId, handle } = info this.info = info if (typeof info.onInteractionEnd === 'string') { this.parent.setCurrentToolIdMask(info.onInteractionEnd) } this.shapeId = shape.id this.markId = '' if (isCreating) { if (creatingMarkId) { this.markId = creatingMarkId } else { // handle legacy implicit `creating:{shapeId}` marks const markId = this.editor.getMarkIdMatching( `creating:${this.editor.getOnlySelectedShapeId()}` ) if (markId) { this.markId = markId } } } else { this.markId = this.editor.markHistoryStoppingPoint('dragging handle') } this.initialHandle = structuredClone(handle) this.initialPageTransform = this.editor.getShapePageTransform(shape)! this.initialPageRotation = this.initialPageTransform.rotation() this.initialPagePoint = this.editor.inputs.getOriginPagePoint().clone() this.editor.setCursor({ type: isCreating ? 'cross' : 'grabbing', rotation: 0 }) const handles = this.editor.getShapeHandles(shape)!.sort(sortByIndex) const index = handles.findIndex((h) => h.id === info.handle.id) // Find the adjacent handle this.initialAdjacentHandle = null // First, check if the handle specifies a custom reference handle if (info.handle.snapReferenceHandleId) { const customHandle = handles.find((h) => h.id === info.handle.snapReferenceHandleId) if (customHandle) { this.initialAdjacentHandle = customHandle } } // If no custom reference handle, use default behavior if (!this.initialAdjacentHandle) { // Start from the handle and work forward for (let i = index + 1; i < handles.length; i++) { const handle = handles[i] if (handle.type === 'vertex' && handle.id !== 'middle' && handle.id !== info.handle.id) { this.initialAdjacentHandle = handle break } } // If still no handle, start from the end and work backward if (!this.initialAdjacentHandle) { for (let i = handles.length - 1; i >= 0; i--) { const handle = handles[i] if (handle.type === 'vertex' && handle.id !== 'middle' && handle.id !== info.handle.id) { this.initialAdjacentHandle = handle break } } } } // // Call onHandleDragStart callback const handleDragInfo = { handle: this.initialHandle, isPrecise: this.isPrecise, isCreatingShape: !!this.info.isCreating, initial: shape, } const util = this.editor.getShapeUtil(shape) const startChanges = util.onHandleDragStart?.(shape, handleDragInfo) if (startChanges) { this.editor.updateShapes([{ ...startChanges, id: shape.id, type: shape.type }]) } this.update() this.editor.select(this.shapeId) } // Only relevant to arrows private exactTimeout = -1 // Only relevant to arrows private resetExactTimeout() { const arrowUtil = this.editor.getShapeUtil('arrow') const timeoutValue = arrowUtil.options.pointingPreciseTimeout if (this.exactTimeout !== -1) { this.clearExactTimeout() } this.exactTimeout = this.editor.timers.setTimeout(() => { if (this.getIsActive() && !this.isPrecise) { this.isPrecise = true this.isPreciseId = this.pointingId this.update() } this.exactTimeout = -1 }, timeoutValue) } // Only relevant to arrows private clearExactTimeout() { if (this.exactTimeout !== -1) { clearTimeout(this.exactTimeout) this.exactTimeout = -1 } } override onPointerMove() { this.update() } override onKeyDown() { this.update() } override onKeyUp() { this.update() } override onPointerUp() { this.complete() } override onComplete() { this.update() this.complete() } override onCancel() { this.cancel() } override onExit() { this.parent.setCurrentToolIdMask(undefined) clearArrowTargetState(this.editor) this.editor.snaps.clearIndicators() this.editor.setCursor({ type: 'default', rotation: 0 }) } private complete() { this.editor.snaps.clearIndicators() kickoutOccludedShapes(this.editor, [this.shapeId]) // Call onHandleDragEnd callback before state transitions const shape = this.editor.getShape(this.shapeId) if (shape) { const util = this.editor.getShapeUtil(shape) const handleDragInfo = { handle: this.initialHandle, isPrecise: this.isPrecise, isCreatingShape: !!this.info.isCreating, initial: this.info.shape, } const endChanges = util.onHandleDragEnd?.(shape, handleDragInfo) if (endChanges) { this.editor.updateShapes([{ ...endChanges, id: shape.id }]) } } const { onInteractionEnd } = this.info if (onInteractionEnd) { if (typeof onInteractionEnd === 'string') { if (this.editor.getInstanceState().isToolLocked && onInteractionEnd) { // Return to the tool that was active before this one but only if tool lock is turned on! this.editor.setCurrentTool(onInteractionEnd, { shapeId: this.shapeId }) return } } else { onInteractionEnd?.() return } } this.parent.transition('idle') } private cancel() { // Call onHandleDragCancel callback before bailing to mark const shape = this.editor.getShape(this.shapeId) if (shape) { const util = this.editor.getShapeUtil(shape) const handleDragInfo = { handle: this.initialHandle, isPrecise: this.isPrecise, isCreatingShape: !!this.info.isCreating, initial: this.info.shape, } util.onHandleDragCancel?.(shape, handleDragInfo) } this.editor.bailToMark(this.markId) this.editor.snaps.clearIndicators() const { onInteractionEnd } = this.info if (onInteractionEnd) { if (typeof onInteractionEnd === 'string') { // Return to the tool that was active before this one, whether tool lock is turned on or not! this.editor.setCurrentTool(onInteractionEnd, { shapeId: this.shapeId }) } else { onInteractionEnd?.() } return } this.parent.transition('idle') } private update() { const { editor, shapeId, initialPagePoint } = this const { initialHandle, initialPageRotation, initialAdjacentHandle } = this const isSnapMode = this.editor.user.getIsSnapMode() const { snaps } = editor const currentPagePoint = editor.inputs.getCurrentPagePoint() const shiftKey = editor.inputs.getShiftKey() const ctrlKey = editor.inputs.getCtrlKey() const altKey = editor.inputs.getAltKey() const pointerVelocity = editor.inputs.getPointerVelocity() const initial = this.info.shape const shape = editor.getShape(shapeId) if (!shape) return const util = editor.getShapeUtil(shape) const initialBinding = editor.isShapeOfType(shape, 'arrow') ? getArrowBindings(editor, shape)[initialHandle.id as 'start' | 'end'] : undefined let point = currentPagePoint .clone() .sub(initialPagePoint) .rot(-initialPageRotation) .add(initialHandle) if (shiftKey && initialAdjacentHandle && initialHandle.id !== 'middle') { const angle = Vec.Angle(initialAdjacentHandle, point) const snappedAngle = snapAngle(angle, 24) const angleDifference = snappedAngle - angle point = Vec.RotWith(point, initialAdjacentHandle, angleDifference) } // Clear any existing snaps editor.snaps.clearIndicators() let nextHandle = { ...initialHandle, x: point.x, y: point.y } let canSnap = false // eslint-disable-next-line @typescript-eslint/no-deprecated if (initialHandle.canSnap && initialHandle.snapType) { warnOnce( 'canSnap is deprecated. Cannot use both canSnap and snapType together - snapping disabled. Please use only snapType.' ) } else { // eslint-disable-next-line @typescript-eslint/no-deprecated canSnap = initialHandle.canSnap || initialHandle.snapType !== undefined } if (canSnap && (isSnapMode ? !ctrlKey : ctrlKey)) { // We're snapping const pageTransform = editor.getShapePageTransform(shape.id) if (!pageTransform) throw Error('Expected a page transform') const snap = snaps.handles.snapHandle({ currentShapeId: shapeId, handle: nextHandle }) if (snap) { snap.nudge.rot(-editor.getShapeParentTransform(shape)!.rotation()) point.add(snap.nudge) nextHandle = { ...initialHandle, x: point.x, y: point.y } } } const changes = util.onHandleDrag?.(shape, { handle: nextHandle, isPrecise: this.isPrecise || altKey, isCreatingShape: !!this.info.isCreating, initial: initial, }) const next: TLShapePartial = { id: shape.id, type: shape.type, ...changes } // Arrows if (initialHandle.type === 'vertex' && this.editor.isShapeOfType(shape, 'arrow')) { const bindingAfter = getArrowBindings(editor, shape)[initialHandle.id as 'start' | 'end'] if (bindingAfter) { if (initialBinding?.toId !== bindingAfter.toId) { this.pointingId = bindingAfter.toId this.isPrecise = pointerVelocity.len() < 0.5 || altKey this.isPreciseId = this.isPrecise ? bindingAfter.toId : null this.resetExactTimeout() } } else { if (initialBinding) { this.pointingId = null this.isPrecise = false this.isPreciseId = null this.resetExactTimeout() } } } if (changes) { editor.updateShapes([next]) } } }