import { Matrix2d, PI, PI2, SelectionCorner, SelectionEdge, StateNode, TAU, TLEnterEventHandler, TLEventHandlers, TLFrameShape, TLPointerEventInfo, TLShape, TLShapeId, TLShapePartial, TLTickEventHandler, Vec2d, VecLike, areAnglesCompatible, compact, moveCameraWhenCloseToEdge, } from '@bigbluebutton/editor' type ResizingInfo = TLPointerEventInfo & { target: 'selection' handle: SelectionEdge | SelectionCorner isCreating?: boolean onCreate?: (shape: TLShape | null) => void creationCursorOffset?: VecLike onInteractionEnd?: string } export class Resizing extends StateNode { static override id = 'resizing' info = {} as ResizingInfo markId = '' // A switch to detect when the user is holding ctrl private didHoldCommand = false // we transition into the resizing state from the geo pointing state, which starts with a shape of size w: 1, h: 1, // so if the user drags x: +50, y: +50 after mouseDown, the shape will be w: 51, h: 51, which is too many pixels, alas // so we allow passing a further offset into this state to negate such issues creationCursorOffset = { x: 0, y: 0 } as VecLike private snapshot = {} as any as Snapshot override onEnter: TLEnterEventHandler = (info: ResizingInfo) => { const { isCreating = false, creationCursorOffset = { x: 0, y: 0 } } = info this.info = info this.didHoldCommand = false this.parent.setCurrentToolIdMask(info.onInteractionEnd) this.creationCursorOffset = creationCursorOffset this.snapshot = this._createSnapshot() if (isCreating) { this.markId = `creating:${this.editor.getOnlySelectedShape()!.id}` this.editor.updateInstanceState( { cursor: { type: 'cross', rotation: 0 } }, { ephemeral: true } ) } else { this.markId = 'starting resizing' this.editor.mark(this.markId) } this.handleResizeStart() this.updateShapes() } override onTick: TLTickEventHandler = () => { moveCameraWhenCloseToEdge(this.editor) } override onPointerMove: TLEventHandlers['onPointerMove'] = () => { this.updateShapes() } override onKeyDown: TLEventHandlers['onKeyDown'] = () => { this.updateShapes() } override onKeyUp: TLEventHandlers['onKeyUp'] = () => { this.updateShapes() } override onPointerUp: TLEventHandlers['onPointerUp'] = () => { this.complete() } override onComplete: TLEventHandlers['onComplete'] = () => { this.complete() } override onCancel: TLEventHandlers['onCancel'] = () => { this.cancel() } private cancel() { // Restore initial models this.editor.bailToMark(this.markId) if (this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, {}) } else { this.parent.transition('idle') } } private complete() { this.handleResizeEnd() if (this.info.isCreating && this.info.onCreate) { this.info.onCreate?.(this.editor.getOnlySelectedShape()) return } if (this.editor.getInstanceState().isToolLocked && this.info.onInteractionEnd) { this.editor.setCurrentTool(this.info.onInteractionEnd, {}) return } this.parent.transition('idle') } private handleResizeStart() { const { shapeSnapshots } = this.snapshot const changes: TLShapePartial[] = [] shapeSnapshots.forEach(({ shape }) => { const util = this.editor.getShapeUtil(shape) const change = util.onResizeStart?.(shape) if (change) { changes.push(change) } }) if (changes.length > 0) { this.editor.updateShapes(changes) } } private handleResizeEnd() { const { shapeSnapshots } = this.snapshot const changes: TLShapePartial[] = [] shapeSnapshots.forEach(({ shape }) => { const current = this.editor.getShape(shape.id)! const util = this.editor.getShapeUtil(shape) const change = util.onResizeEnd?.(shape, current) if (change) { changes.push(change) } }) if (changes.length > 0) { this.editor.updateShapes(changes) } } private updateShapes() { const { altKey, shiftKey } = this.editor.inputs const { frames, shapeSnapshots, selectionBounds, cursorHandleOffset, selectedShapeIds, selectionRotation, canShapesDeform, } = this.snapshot const isAspectRatioLocked = shiftKey || !canShapesDeform // first negate the 'cursor handle offset' // we need to do this because we do grid snapping based on the page point of the handle // rather than the page point of the cursor, so it's easier to pretend that the cursor // is really where the handle actually is // // *** Massively zoomed-in diagram of the initial mouseDown *** // // // │ // │ // │ // │ // │ // │ // │ // │corner handle // ┌───┴───┐ // selection │ │ // ───────────────────┤ x◄──┼──── drag handle point ▲ // │ │ │ // └───────┘ ├─ cursorHandleOffset.y // │ // originPagePoint───────►x─┐ ▼ // │ └─┐ // │ └─┐ // │ │ mouse (sorry) // └──┐ ┌┘ // │ │ // └─┘ // ◄──┬──► // │ // cursorHandleOffset.x const { ctrlKey } = this.editor.inputs const currentPagePoint = this.editor.inputs.currentPagePoint .clone() .sub(cursorHandleOffset) .sub(this.creationCursorOffset) const originPagePoint = this.editor.inputs.originPagePoint.clone().sub(cursorHandleOffset) if (this.editor.getInstanceState().isGridMode && !ctrlKey) { const { gridSize } = this.editor.getDocumentSettings() currentPagePoint.snapToGrid(gridSize) } const dragHandle = this.info.handle as SelectionCorner | SelectionEdge const scaleOriginHandle = rotateSelectionHandle(dragHandle, Math.PI) this.editor.snaps.clear() const shouldSnap = this.editor.user.getIsSnapMode() ? !ctrlKey : ctrlKey if (shouldSnap && selectionRotation % TAU === 0) { const { nudge } = this.editor.snaps.snapResize({ dragDelta: Vec2d.Sub(currentPagePoint, originPagePoint), initialSelectionPageBounds: this.snapshot.initialSelectionPageBounds, handle: rotateSelectionHandle(dragHandle, selectionRotation), isAspectRatioLocked, isResizingFromCenter: altKey, }) currentPagePoint.add(nudge) } // get the page point of the selection handle opposite to the drag handle // or the center of the selection box if altKey is pressed const scaleOriginPage = Vec2d.RotWith( altKey ? selectionBounds.center : selectionBounds.getHandlePoint(scaleOriginHandle), selectionBounds.point, selectionRotation ) // calculate the scale by measuring the current distance between the drag handle and the scale origin // and dividing by the original distance between the drag handle and the scale origin const distanceFromScaleOriginNow = Vec2d.Sub(currentPagePoint, scaleOriginPage).rot( -selectionRotation ) const distanceFromScaleOriginAtStart = Vec2d.Sub(originPagePoint, scaleOriginPage).rot( -selectionRotation ) const scale = Vec2d.DivV(distanceFromScaleOriginNow, distanceFromScaleOriginAtStart) if (!Number.isFinite(scale.x)) scale.x = 1 if (!Number.isFinite(scale.y)) scale.y = 1 const isXLocked = dragHandle === 'top' || dragHandle === 'bottom' const isYLocked = dragHandle === 'left' || dragHandle === 'right' // lock an axis if required if (isAspectRatioLocked) { if (isYLocked) { // holding shift and dragging either the left or the right edge scale.y = Math.abs(scale.x) } else if (isXLocked) { // holding shift and dragging either the top or the bottom edge scale.x = Math.abs(scale.y) } else if (Math.abs(scale.x) > Math.abs(scale.y)) { // holding shift and the drag has moved further in the x dimension scale.y = Math.abs(scale.x) * (scale.y < 0 ? -1 : 1) } else { // holding shift and the drag has moved further in the y dimension scale.x = Math.abs(scale.y) * (scale.x < 0 ? -1 : 1) } } else { // not holding shift, but still need to lock axes if dragging an edge if (isXLocked) { scale.x = 1 } if (isYLocked) { scale.y = 1 } } if (!this.info.isCreating) { this.updateCursor({ dragHandle, isFlippedX: scale.x < 0, isFlippedY: scale.y < 0, rotation: selectionRotation, }) } for (const id of shapeSnapshots.keys()) { const snapshot = shapeSnapshots.get(id)! this.editor.resizeShape(id, scale, { initialShape: snapshot.shape, initialBounds: snapshot.bounds, initialPageTransform: snapshot.pageTransform, dragHandle, mode: selectedShapeIds.length === 1 && id === selectedShapeIds[0] ? 'resize_bounds' : 'scale_shape', scaleOrigin: scaleOriginPage, scaleAxisRotation: selectionRotation, }) } if (this.editor.inputs.ctrlKey) { this.didHoldCommand = true for (const { id, children } of frames) { if (!children.length) continue const initial = shapeSnapshots.get(id)!.shape const current = this.editor.getShape(id)! if (!(initial && current)) continue // If the user is holding ctrl, then preseve the position of the frame's children const dx = current.x - initial.x const dy = current.y - initial.y const delta = new Vec2d(dx, dy).rot(-initial.rotation) if (delta.x !== 0 || delta.y !== 0) { for (const child of children) { this.editor.updateShape({ id: child.id, type: child.type, x: child.x - delta.x, y: child.y - delta.y, }) } } } } else if (this.didHoldCommand) { this.didHoldCommand = false for (const { children } of frames) { if (!children.length) continue for (const child of children) { this.editor.updateShape({ id: child.id, type: child.type, x: child.x, y: child.y, }) } } } } // --- private updateCursor({ dragHandle, isFlippedX, isFlippedY, rotation, }: { dragHandle: SelectionCorner | SelectionEdge isFlippedX: boolean isFlippedY: boolean rotation: number }) { const nextCursor = { ...this.editor.getInstanceState().cursor } switch (dragHandle) { case 'top_left': case 'bottom_right': { nextCursor.type = 'nwse-resize' if (isFlippedX !== isFlippedY) { nextCursor.type = 'nesw-resize' } break } case 'top_right': case 'bottom_left': { nextCursor.type = 'nesw-resize' if (isFlippedX !== isFlippedY) { nextCursor.type = 'nwse-resize' } break } } nextCursor.rotation = rotation this.editor.setCursor(nextCursor) } override onExit = () => { this.parent.setCurrentToolIdMask(undefined) this.editor.updateInstanceState( { cursor: { type: 'default', rotation: 0 } }, { ephemeral: true } ) this.editor.snaps.clear() } _createSnapshot = () => { const selectedShapeIds = this.editor.getSelectedShapeIds() const selectionRotation = this.editor.getSelectionRotation() const { inputs: { originPagePoint }, } = this.editor const selectionBounds = this.editor.getSelectionRotatedPageBounds()! const dragHandlePoint = Vec2d.RotWith( selectionBounds.getHandlePoint(this.info.handle!), selectionBounds.point, selectionRotation ) const cursorHandleOffset = Vec2d.Sub(originPagePoint, dragHandlePoint) const shapeSnapshots = new Map() const frames: { id: TLShapeId; children: TLShape[] }[] = [] selectedShapeIds.forEach((id) => { const shape = this.editor.getShape(id) if (shape) { if (shape.type === 'frame') { frames.push({ id, children: compact( this.editor.getSortedChildIdsForParent(shape).map((id) => this.editor.getShape(id)) ), }) } shapeSnapshots.set(shape.id, this._createShapeSnapshot(shape)) if ( this.editor.isShapeOfType(shape, 'frame') && selectedShapeIds.length === 1 ) return this.editor.visitDescendants(shape.id, (descendantId) => { const descendent = this.editor.getShape(descendantId) if (descendent) { shapeSnapshots.set(descendent.id, this._createShapeSnapshot(descendent)) if (this.editor.isShapeOfType(descendent, 'frame')) { return false } } }) } }) const canShapesDeform = ![...shapeSnapshots.values()].some( (shape) => !areAnglesCompatible(shape.pageRotation, selectionRotation) || shape.isAspectRatioLocked ) return { shapeSnapshots, selectionBounds, cursorHandleOffset, selectionRotation, selectedShapeIds, canShapesDeform, initialSelectionPageBounds: this.editor.getSelectionPageBounds()!, frames, } } _createShapeSnapshot = (shape: TLShape) => { const pageTransform = this.editor.getShapePageTransform(shape)! const util = this.editor.getShapeUtil(shape) return { shape, bounds: this.editor.getShapeGeometry(shape).bounds, pageTransform, pageRotation: Matrix2d.Decompose(pageTransform!).rotation, isAspectRatioLocked: util.isAspectRatioLocked(shape), } } } type Snapshot = ReturnType type ShapeSnapshot = ReturnType const ORDERED_SELECTION_HANDLES: (SelectionEdge | SelectionCorner)[] = [ 'top', 'top_right', 'right', 'bottom_right', 'bottom', 'bottom_left', 'left', 'top_left', ] export function rotateSelectionHandle(handle: SelectionEdge | SelectionCorner, rotation: number) { // first find out how many tau we need to rotate by rotation = rotation % PI2 const numSteps = Math.round(rotation / (PI / 4)) const currentIndex = ORDERED_SELECTION_HANDLES.indexOf(handle) return ORDERED_SELECTION_HANDLES[(currentIndex + numSteps) % ORDERED_SELECTION_HANDLES.length] }