import { Region, createRect } from "@noya-app/noya-geometry"; import { DEFAULT_MOVEMENT_THRESHOLD, isMoving } from "../utils/isMoving"; import { makeSelection } from "../utils/selectionUtils"; import { createCanvasPlugin } from "./baseCanvas"; import { BaseContextProperties, ElementProperties, SelectableProperties, } from "./contextProperties"; import { BaseMode, NoneMode, modeSwitch } from "./PluginSystem"; import { PointerData } from "./pointerPlugin"; export type MarqueeMode = { type: "marquee"; region: Region; selectedElementIdsSnapshot: string[]; }; export function isMarqueeMode(mode: BaseMode): mode is MarqueeMode { return mode.type === "marquee"; } export type MarqueePluginMode = NoneMode | MarqueeMode; export type MarqueeData = { selectedElementIds: string[]; zoom: number; } & Pick; export interface MarqueeHandlerContext extends BaseContextProperties, ElementProperties, SelectableProperties { adjustMarqueeRegion?: (event: React.PointerEvent, region: Region) => Region; } export type MarqueePluginOptions = { movementThreshold?: number; }; export function marqueePlugin( options: MarqueePluginOptions = {} ) { const { movementThreshold = DEFAULT_MOVEMENT_THRESHOLD } = options; return createCanvasPlugin({ name: "marquee", data: { selectedElementIds: [], zoom: 1, }, handlers: ({ getMode, setMode, getData, setData, adjustMarqueeRegion = (_, region) => region, ...context }: MarqueeHandlerContext) => ({ onPointerMove(event) { modeSwitch(getMode(), { none: () => { const data = getData(); if (!data.pointerDown) return; if ( isMoving( data.pointerDown.anchor, data.pointerDown.head, data.zoom, movementThreshold ) ) { setMode({ type: "marquee", region: adjustMarqueeRegion(event, data.pointerDown), selectedElementIdsSnapshot: data.selectedElementIds, }); event.preventDefault(); } }, marquee: ({ selectedElementIdsSnapshot }) => { const data = getData(); if (!data.pointerDown) return; const region = adjustMarqueeRegion(event, data.pointerDown); const elements = context.filterElements({ at: createRect(region), predicate: context.isSelectable, }); setData({ ...data, selectedElementIds: elements.map((element) => context.getElementId(element) ), }); setMode({ type: "marquee", region, selectedElementIdsSnapshot, }); event.preventDefault(); }, }); }, onPointerUp(event) { modeSwitch(getMode(), { marquee: ({ selectedElementIdsSnapshot }) => { const data = getData(); if (!data.pointerDown) return; const region = adjustMarqueeRegion(event, data.pointerDown); // If not a move, select elements at the start point if ( !isMoving( data.pointerDown.anchor, data.pointerDown.head, data.zoom, movementThreshold ) ) { const elements = context.filterElements({ at: region.anchor, predicate: context.isSelectable, }); setData({ ...data, selectedElementIds: makeSelection( selectedElementIdsSnapshot, elements.length > 0 ? [context.getElementId(elements[0])] : [], "symmetricDifference" ), }); } else { const elements = context.filterElements({ at: createRect(region), predicate: context.isSelectable, }); setData({ ...data, selectedElementIds: elements.map((element) => context.getElementId(element) ), }); } setMode({ type: "none" }); event.preventDefault(); }, }); }, }), }); }