import { CompassDirection, getCompassDirectionCursor, getRegionDelta, Point, Rect, rectContainsPoint, } from "@noya-app/noya-geometry"; import { getDragHandles } from "../utils/dragHandleUtils"; import { isMoving } from "../utils/isMoving"; import { getScalingOptionsForEvent, ScalingOptions, } from "../utils/scalingUtils"; import { createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, ElementProperties, ScalableProperties, } from "./contextProperties"; import { modeSwitch, NoneMode } from "./PluginSystem"; import { PointerData } from "./pointerPlugin"; export type ScaleMode = | NoneMode | { type: "scaling"; delta: Point; snapshot: StateSnapshot; direction: CompassDirection; options: ScalingOptions; }; export type ScaleData = { zoom: number; selectedElementIds: string[]; dragHandleSize: number; hoveredDragHandleDirection?: CompassDirection; } & Pick; export type ScaleElementParameters = { elementIds: string[]; delta: Point; snapshot: StateSnapshot; direction: CompassDirection; options: ScalingOptions; }; export interface ScaleContextProperties extends BaseContextProperties, ScaleData>, ElementProperties, ScalableProperties { getStateSnapshot: () => StateSnapshot; adjustScaleDelta?: ( event: React.PointerEvent, parameters: ScaleElementParameters ) => Point; scaleElements: (parameters: ScaleElementParameters) => void; /** * Returns the union of the selected elements' rects, in canvas coordinates (no transform). */ getSelectionRect: () => Rect | undefined; canScale?: () => boolean; } export type ScalePluginOptions = { movementThreshold?: number; dragHandleSize?: number; }; export const DEFAULT_DRAG_HANDLE_SIZE = 8; export function scalePlugin( options: ScalePluginOptions = {} ) { const { movementThreshold = 0, dragHandleSize = DEFAULT_DRAG_HANDLE_SIZE } = options; return createCanvasPlugin({ name: "scale", data: { zoom: 1, selectedElementIds: [], dragHandleSize, }, handlers: ({ getMode, setMode, getData, setData, adjustScaleDelta = (_, options) => options.delta, canScale = () => true, ...context }: ScaleContextProperties) => ({ onPointerDown(event) { if (!canScale()) return; const data = getData(); if (getMode().type !== "none") return; const pointerDown = data.pointerDown; if (!pointerDown) return; const selectionRect = context.getSelectionRect(); if (!selectionRect) return; const dragHandles = getDragHandles({ selectionRect, data }); const dragHandle = dragHandles.find((handle) => rectContainsPoint(handle.rect, pointerDown.anchor) ); if (dragHandle) { event.preventDefault(); } }, onPointerMove(event) { if (!canScale()) return; modeSwitch(getMode(), { none: () => { const selectionRect = context.getSelectionRect(); const data = getData(); if (!selectionRect) { setData((data) => ({ ...data, hoveredDragHandleDirection: undefined, })); return; } const dragHandles = getDragHandles({ selectionRect, data }); const pointerDown = data.pointerDown; const pointer = data.pointer; if (!pointerDown) { const dragHandle = pointer ? dragHandles.find((handle) => rectContainsPoint(handle.rect, pointer) ) : undefined; setData((data) => ({ ...data, hoveredDragHandleDirection: dragHandle?.compassDirection, })); return; } const dragHandle = dragHandles.find((handle) => rectContainsPoint(handle.rect, pointerDown.anchor) ); if ( dragHandle && isMoving( pointerDown.anchor, pointerDown.head, data.zoom, movementThreshold ) ) { const parameters: ScaleElementParameters = { elementIds: data.selectedElementIds, delta: getRegionDelta(pointerDown), snapshot: context.getStateSnapshot(), direction: dragHandle.compassDirection, options: getScalingOptionsForEvent(event), }; const delta = adjustScaleDelta(event, parameters); setMode({ type: "scaling", delta, snapshot: parameters.snapshot, direction: parameters.direction, options: parameters.options, }); event.preventDefault(); } }, scaling: ({ snapshot, direction }) => { const data = getData(); if (!data.pointerDown) return; const parameters: ScaleElementParameters = { elementIds: data.selectedElementIds, delta: getRegionDelta(data.pointerDown), snapshot, direction, options: getScalingOptionsForEvent(event), }; const delta = adjustScaleDelta(event, parameters); setMode({ type: "scaling", delta, snapshot, direction, options: parameters.options, }); context.scaleElements?.({ ...parameters, delta, }); event.preventDefault(); }, }); }, onPointerUp(event) { if (!canScale()) return; const data = getData(); if (data.hoveredDragHandleDirection) { const selectionRect = context.getSelectionRect(); const dragHandle = selectionRect ? getDragHandles({ selectionRect, data }).find( (handle) => handle.compassDirection === data.hoveredDragHandleDirection ) : undefined; setData((data) => ({ ...data, hoveredDragHandleDirection: dragHandle?.compassDirection, })); } if (getMode().type !== "scaling") return; setMode({ type: "none" }); event.preventDefault(); }, }), getCursor: ({ data, mode }) => { if ( (mode.type === "none" || mode.type === "scaling") && data.hoveredDragHandleDirection ) { return getCompassDirectionCursor( data.hoveredDragHandleDirection, "bidirectional" ); } return undefined; }, }); }