From 55a8000536765c4b4970717a537cc61081f32823 Mon Sep 17 00:00:00 2001 From: draedful Date: Thu, 31 Jul 2025 14:57:55 +0300 Subject: [PATCH 01/10] feat(Camera): add autopan --- package-lock.json | 13 +- src/graph.ts | 4 + src/graphConfig.ts | 11 ++ src/services/DragController.ts | 237 +++++++++++++++++++++++++++++++++ src/services/camera/Camera.ts | 142 +++++++++++++++++++- src/store/settings.ts | 3 + 6 files changed, 402 insertions(+), 8 deletions(-) create mode 100644 src/services/DragController.ts diff --git a/package-lock.json b/package-lock.json index 599c9b0a..54cb6a96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,8 @@ "monaco-editor": "^0.52.0", "prettier": "^3.0.0", "process": "^0.11.10", + "react": "^18.2.0", + "react-dom": "^18.2.0", "sass": "^1.77.1", "size-limit": "^10.0.1", "storybook": "^8.1.11", @@ -76,10 +78,6 @@ "engines": { "pnpm": "Please use npm instead of pnpm to install dependencies", "yarn": "Please use npm instead of yarn to install dependencies" - }, - "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0" } }, "node_modules/@adobe/css-tools": { @@ -14086,7 +14084,8 @@ "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true }, "node_modules/js-yaml": { "version": "4.1.0", @@ -14541,6 +14540,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -16298,6 +16298,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -16396,6 +16397,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -17063,6 +17065,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, "dependencies": { "loose-envify": "^1.1.0" } diff --git a/src/graph.ts b/src/graph.ts index 93114032..12a7ff62 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -10,6 +10,7 @@ import { SelectionLayer } from "./components/canvas/layers/selectionLayer/Select import { TGraphColors, TGraphConstants, initGraphColors, initGraphConstants } from "./graphConfig"; import { GraphEventParams, GraphEventsDefinitions } from "./graphEvents"; import { scheduler } from "./lib/Scheduler"; +import { DragController } from "./services/DragController"; import { HitTest } from "./services/HitTest"; import { Layer } from "./services/Layer"; import { Layers } from "./services/LayersService"; @@ -63,6 +64,8 @@ export class Graph { public hitTest = new HitTest(); + public dragController = new DragController(this); + protected graphLayer: GraphLayer; protected belowLayer: BelowLayer; @@ -383,6 +386,7 @@ export class Graph { * In order to initialize hitboxes we need to start scheduler and wait untils every component registered in hitTest service * Immediatelly after registering startign a rendering process. * @param cb - Callback to run after graph is ready + * @returns void */ public runAfterGraphReady(cb: () => void) { this.hitTest.waitUsableRectUpdate(cb); diff --git a/src/graphConfig.ts b/src/graphConfig.ts index fc621eef..e196e759 100644 --- a/src/graphConfig.ts +++ b/src/graphConfig.ts @@ -95,6 +95,13 @@ export type TGraphConstants = { SPEED: number; /* Step on camera scale */ STEP: number; + /* Edge panning settings */ + EDGE_PANNING: { + /* Size of edge detection zone in pixels */ + EDGE_SIZE: number; + /* Speed of camera movement during edge panning */ + SPEED: number; + }; }; block: { @@ -140,6 +147,10 @@ export const initGraphConstants: TGraphConstants = { camera: { SPEED: 1, STEP: 0.008, + EDGE_PANNING: { + EDGE_SIZE: 150, + SPEED: 15, + }, }, block: { WIDTH_MIN: 16 * 10, diff --git a/src/services/DragController.ts b/src/services/DragController.ts new file mode 100644 index 00000000..574589b0 --- /dev/null +++ b/src/services/DragController.ts @@ -0,0 +1,237 @@ +import { Graph } from "../graph"; +import { ESchedulerPriority, scheduler } from "../lib/Scheduler"; +import { dragListener } from "../utils/functions/dragListener"; +import { EVENTS } from "../utils/types/events"; + +import { TEdgePanningConfig } from "./camera/Camera"; + +/** + * Интерфейс для компонентов, которые могут быть перетаскиваемыми + */ +export interface DragHandler { + /** + * Вызывается при начале перетаскивания + * @param event - Событие мыши + */ + onDraggingStart(event: MouseEvent): void; + + /** + * Вызывается при обновлении позиции во время перетаскивания + * @param event - Событие мыши + */ + onDragUpdate(event: MouseEvent): void; + + /** + * Вызывается при завершении перетаскивания + * @param event - Событие мыши + */ + onDragEnd(event: MouseEvent): void; +} + +/** + * Конфигурация для DragController + */ +export interface DragControllerConfig { + /** Включить автоматическое движение камеры при приближении к границам */ + enableEdgePanning?: boolean; + /** Конфигурация edge panning */ + edgePanningConfig?: Partial; +} + +/** + * Централизованный контроллер для управления перетаскиванием компонентов + */ +export class DragController { + private graph: Graph; + + private currentDragHandler?: DragHandler; + + private isDragging = false; + + private lastMouseEvent?: MouseEvent; + + private updateScheduler?: () => void; + + constructor(graph: Graph) { + this.graph = graph; + } + + /** + * Начинает процесс перетаскивания для указанного компонента + * @param component - Компонент, который будет перетаскиваться + * @param event - Исходное событие мыши + * @param config - Конфигурация перетаскивания + * @returns void + */ + public start(component: DragHandler, event: MouseEvent, config: DragControllerConfig = {}): void { + if (this.isDragging) { + // eslint-disable-next-line no-console + console.warn("DragController: attempt to start dragging while already dragging"); + return; + } + + this.currentDragHandler = component; + this.isDragging = true; + this.lastMouseEvent = event; + + // Включаем edge panning если необходимо + if (config.enableEdgePanning ?? true) { + const camera = this.graph.getGraphLayer().$.camera; + const defaultConfig = this.graph.graphConstants.camera.EDGE_PANNING; + + camera.enableEdgePanning({ + speed: config.edgePanningConfig?.speed || defaultConfig.SPEED, + edgeSize: config.edgePanningConfig?.edgeSize || defaultConfig.EDGE_SIZE, + }); + + // Запускаем периодическое обновление компонента для синхронизации с движением камеры + this.startContinuousUpdate(); + } + + // TODO: Нужно передать EventedComponent вместо DragController + // this.graph.getGraphLayer().captureEvents(this); + + // Вызываем обработчик начала перетаскивания + component.onDraggingStart(event); + + // Запускаем dragListener для отслеживания движений мыши + this.startDragListener(event); + } + + /** + * Обновляет состояние перетаскивания + * @param event - Событие мыши + * @returns void + */ + public update(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + this.lastMouseEvent = event; + this.currentDragHandler.onDragUpdate(event); + } + + /** + * Завершает процесс перетаскивания + * @param event - Событие мыши + * @returns void + */ + public end(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + // TODO: Нужно передать EventedComponent вместо DragController + // this.graph.getGraphLayer().releaseCapture(); + + // Останавливаем непрерывное обновление + this.stopContinuousUpdate(); + + // Отключаем edge panning + const camera = this.graph.getGraphLayer().$.camera; + camera.disableEdgePanning(); + + // Вызываем обработчик завершения перетаскивания + this.currentDragHandler.onDragEnd(event); + + // Сбрасываем состояние + this.currentDragHandler = undefined; + this.isDragging = false; + this.lastMouseEvent = undefined; + } + + /** + * Проверяет, происходит ли в данный момент перетаскивание + * @returns true если происходит перетаскивание + */ + public isDragInProgress(): boolean { + return this.isDragging; + } + + /** + * Получает текущий перетаскиваемый компонент + * @returns текущий DragHandler или undefined + */ + public getCurrentDragHandler(): DragHandler | undefined { + return this.currentDragHandler; + } + + /** + * Запускает непрерывное обновление компонента для синхронизации с движением камеры + * @returns void + */ + private startContinuousUpdate(): void { + if (this.updateScheduler) { + return; + } + + const update = () => { + if (!this.isDragging || !this.currentDragHandler || !this.lastMouseEvent) { + return; + } + + // Создаем синтетическое событие мыши с текущими координатами + // Это позволяет компонентам обновлять свою позицию при движении камеры + // даже когда физическое движение мыши не происходит + const syntheticEvent = new MouseEvent("mousemove", { + clientX: this.lastMouseEvent.clientX, + clientY: this.lastMouseEvent.clientY, + bubbles: false, + cancelable: false, + }); + + // Копируем pageX/pageY вручную, так как в MouseEventInit их нет + Object.defineProperty(syntheticEvent, "pageX", { value: this.lastMouseEvent.pageX }); + Object.defineProperty(syntheticEvent, "pageY", { value: this.lastMouseEvent.pageY }); + + // TODO: лучше в onDragUpdate передавать только deltaX/deltaY и clientX/clientY + + this.currentDragHandler.onDragUpdate(syntheticEvent); + }; + + // Используем средний приоритет для обновлений чтобы синхронизироваться с движением камеры + this.updateScheduler = scheduler.addScheduler({ performUpdate: update }, ESchedulerPriority.MEDIUM); + } + + /** + * Останавливает непрерывное обновление + * @returns void + */ + private stopContinuousUpdate(): void { + if (this.updateScheduler) { + this.updateScheduler(); + this.updateScheduler = undefined; + } + } + + /** + * Запускает dragListener для отслеживания событий мыши + * @param _initialEvent - Начальное событие мыши (не используется) + * @returns void + */ + private startDragListener(_initialEvent: MouseEvent): void { + const ownerDocument = this.graph.getGraphCanvas().ownerDocument; + + dragListener(ownerDocument) + .on(EVENTS.DRAG_START, (event: MouseEvent) => { + this.lastMouseEvent = event; + }) + .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { + this.update(event); + }) + .on(EVENTS.DRAG_END, (event: MouseEvent) => { + this.end(event); + }); + } + + /** + * Принудительно завершает текущее перетаскивание (например, при размонтировании) + * @returns void + */ + public forceEnd(): void { + if (this.isDragging && this.lastMouseEvent) { + this.end(this.lastMouseEvent); + } + } +} diff --git a/src/services/camera/Camera.ts b/src/services/camera/Camera.ts index a1f0bb3b..f25f9a8a 100644 --- a/src/services/camera/Camera.ts +++ b/src/services/camera/Camera.ts @@ -1,12 +1,13 @@ import { EventedComponent } from "../../components/canvas/EventedComponent/EventedComponent"; import { TGraphLayerContext } from "../../components/canvas/layers/graphLayer/GraphLayer"; -import { Component } from "../../lib"; +import { Component, ESchedulerPriority } from "../../lib"; import { TComponentProps, TComponentState } from "../../lib/Component"; import { ComponentDescriptor } from "../../lib/CoreComponent"; import { getXY, isMetaKeyEvent, isTrackpadWheelEvent, isWindows } from "../../utils/functions"; import { clamp } from "../../utils/functions/clamp"; import { dragListener } from "../../utils/functions/dragListener"; import { EVENTS } from "../../utils/types/events"; +import { schedule } from "../../utils/utils/schedule"; import { ICamera } from "./CameraService"; @@ -15,6 +16,18 @@ export type TCameraProps = TComponentProps & { children: ComponentDescriptor[]; }; +export type TEdgePanningConfig = { + edgeSize: number; // размер зоны для активации edge panning (в пикселях) + speed: number; // скорость движения камеры + enabled: boolean; // включен ли режим +}; + +const DEFAULT_EDGE_PANNING_CONFIG: TEdgePanningConfig = { + edgeSize: 100, + speed: 15, + enabled: false, +}; + export class Camera extends EventedComponent { private camera: ICamera; @@ -22,6 +35,12 @@ export class Camera extends EventedComponent void; + + private lastMousePosition: { x: number; y: number } = { x: 0, y: 0 }; + constructor(props: TCameraProps, parent: Component) { super(props, parent); @@ -61,6 +80,7 @@ export class Camera extends EventedComponent { @@ -69,8 +89,8 @@ export class Camera extends EventedComponent this.onDragStart(event)) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onDragUpdate(event)) + .on(EVENTS.DRAG_START, (dragEvent: MouseEvent) => this.onDragStart(dragEvent)) + .on(EVENTS.DRAG_UPDATE, (dragEvent: MouseEvent) => this.onDragUpdate(dragEvent)) .on(EVENTS.DRAG_END, () => this.onDragEnd()); } }; @@ -167,4 +187,120 @@ export class Camera extends EventedComponent = {}): void { + this.edgePanningConfig = { ...this.edgePanningConfig, ...config, enabled: true }; + + if (this.props.root) { + this.props.root.addEventListener("mousemove", this.handleEdgePanningMouseMove); + this.props.root.addEventListener("mouseleave", this.handleEdgePanningMouseLeave); + } + } + + /** + * Отключает автоматическое перемещение камеры + * @returns void + */ + public disableEdgePanning(): void { + this.edgePanningConfig.enabled = false; + + if (this.props.root) { + this.props.root.removeEventListener("mousemove", this.handleEdgePanningMouseMove); + this.props.root.removeEventListener("mouseleave", this.handleEdgePanningMouseLeave); + } + + this.stopEdgePanningAnimation(); + } + + private handleEdgePanningMouseMove = (event: MouseEvent): void => { + if (!this.edgePanningConfig.enabled || !this.props.root) { + return; + } + + this.lastMousePosition = { x: event.clientX, y: event.clientY }; + this.updateEdgePanning(); + }; + + private handleEdgePanningMouseLeave = (): void => { + this.stopEdgePanningAnimation(); + }; + + private updateEdgePanning(): void { + if (!this.edgePanningConfig.enabled || !this.props.root) { + return; + } + + const rect = this.props.root.getBoundingClientRect(); + const { x, y } = this.lastMousePosition; + const { edgeSize, speed } = this.edgePanningConfig; + + // Вычисляем расстояния до границ + const distanceToLeft = x - rect.left; + const distanceToRight = rect.right - x; + const distanceToTop = y - rect.top; + const distanceToBottom = rect.bottom - y; + + let deltaX = 0; + let deltaY = 0; + + // Проверяем левую границу - при приближении к левому краю двигаем камеру вправо + if (distanceToLeft < edgeSize && distanceToLeft >= 0) { + const intensity = 1 - distanceToLeft / edgeSize; + deltaX = speed * intensity; // Положительный - камера двигается вправо + } + // Проверяем правую границу - при приближении к правому краю двигаем камеру влево + else if (distanceToRight < edgeSize && distanceToRight >= 0) { + const intensity = 1 - distanceToRight / edgeSize; + deltaX = -speed * intensity; // Отрицательный - камера двигается влево + } + + // Проверяем верхнюю границу - при приближении к верхнему краю двигаем камеру вниз + if (distanceToTop < edgeSize && distanceToTop >= 0) { + const intensity = 1 - distanceToTop / edgeSize; + deltaY = speed * intensity; // Положительный - камера двигается вниз + } + // Проверяем нижнюю границу - при приближении к нижнему краю двигаем камеру вверх + else if (distanceToBottom < edgeSize && distanceToBottom >= 0) { + const intensity = 1 - distanceToBottom / edgeSize; + deltaY = -speed * intensity; // Отрицательный - камера двигается вверх + } + + // Если нужно двигать камеру + if (deltaX !== 0 || deltaY !== 0) { + this.startEdgePanningAnimation(deltaX, deltaY); + } else { + this.stopEdgePanningAnimation(); + } + } + + private startEdgePanningAnimation(deltaX: number, deltaY: number): void { + // Останавливаем предыдущую анимацию + this.stopEdgePanningAnimation(); + + // Используем schedule с высоким приоритетом и частотой обновления каждый фрейм + this.edgePanningAnimation = schedule( + () => { + if (this.edgePanningConfig.enabled) { + this.camera.move(deltaX, deltaY); + } + }, + { + priority: ESchedulerPriority.HIGH, + frameInterval: 1, // Каждый фрейм + once: false, // Повторяющаяся анимация + } + ); + } + + private stopEdgePanningAnimation(): void { + if (this.edgePanningAnimation) { + this.edgePanningAnimation(); // Вызываем функцию для остановки + this.edgePanningAnimation = undefined; + } + } } diff --git a/src/store/settings.ts b/src/store/settings.ts index c49fc8cc..2a4d8343 100644 --- a/src/store/settings.ts +++ b/src/store/settings.ts @@ -18,6 +18,8 @@ export enum ECanChangeBlockGeometry { export type TGraphSettingsConfig = { canDragCamera: boolean; canZoomCamera: boolean; + /** Enable automatic camera movement when mouse approaches viewport edges during block dragging */ + enableEdgePanning: boolean; /** @deprecated Use NewBlockLayer parameters instead */ canDuplicateBlocks?: boolean; canChangeBlockGeometry: ECanChangeBlockGeometry; @@ -37,6 +39,7 @@ export type TGraphSettingsConfig Date: Thu, 31 Jul 2025 14:58:29 +0300 Subject: [PATCH 02/10] refactor(Block): refactor block selection and drag --- .../EventedComponent/EventedComponent.ts | 9 ++ src/components/canvas/blocks/Block.ts | 14 ++- src/components/canvas/blocks/Blocks.ts | 103 +++++++++++++++--- .../blocks/controllers/BlockController.ts | 83 -------------- 4 files changed, 106 insertions(+), 103 deletions(-) delete mode 100644 src/components/canvas/blocks/controllers/BlockController.ts diff --git a/src/components/canvas/EventedComponent/EventedComponent.ts b/src/components/canvas/EventedComponent/EventedComponent.ts index 23e63816..d9dfbcfe 100644 --- a/src/components/canvas/EventedComponent/EventedComponent.ts +++ b/src/components/canvas/EventedComponent/EventedComponent.ts @@ -4,6 +4,8 @@ type TEventedComponentListener = Component | ((e: Event) => void); const listeners = new WeakMap>>(); +const parents = new WeakMap(); + export class EventedComponent< Props extends TComponentProps = TComponentProps, State extends TComponentState = TComponentState, @@ -50,7 +52,14 @@ export class EventedComponent< } } + protected getTargetComponent(event: Event): EventedComponent { + return parents.get(event); + } + public _fireEvent(cmp: Component, event: Event) { + if (!parents.has(event)) { + parents.set(event, this); + } const handlers = listeners.get(cmp)?.get?.(event.type); handlers?.forEach((cb) => { diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 08e8be1a..f0aebf07 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -7,7 +7,8 @@ import { TGraphSettingsConfig } from "../../../store"; import { EAnchorType } from "../../../store/anchor/Anchor"; import { BlockState, IS_BLOCK_TYPE, TBlockId } from "../../../store/block/Block"; import { selectBlockById } from "../../../store/block/selectors"; -import { getXY } from "../../../utils/functions"; +import { ECanChangeBlockGeometry } from "../../../store/settings"; +import { getXY, isAllowChangeBlockGeometry } from "../../../utils/functions"; import { TMeasureTextOptions } from "../../../utils/functions/text"; import { TTExtRect, renderText } from "../../../utils/renderers/text"; import { EVENTS } from "../../../utils/types/events"; @@ -16,8 +17,6 @@ import { GraphComponent } from "../GraphComponent"; import { Anchor, TAnchor } from "../anchors"; import { GraphLayer, TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; -import { BlockController } from "./controllers/BlockController"; - export type TBlockSettings = { /** Phantom blocks are blocks whose dimensions and position * are not taken into account when calculating the usable rect. */ @@ -114,8 +113,6 @@ export class Block({ zIndex: 0, order: 0 }); constructor(props: Props, parent: GraphLayer) { @@ -151,6 +148,13 @@ export class Block void)[]; - private font: string; constructor(props: {}, context: any) { super(props, context); - this.unsubscribe = this.subscribe(); + this.subscribe(); this.prepareFont(this.getFontScale()); } + protected handleEvent(_: Event): void {} + + protected willMount(): void { + this.addEventListener("click", (event) => { + const blockInstance = this.getTargetComponent(event); + + if (!(blockInstance instanceof Block)) { + return; + } + event.stopPropagation(); + + const { connectionsList } = this.context.graph.rootStore; + const isAnyConnectionSelected = connectionsList.$selectedConnections.value.size !== 0; + + if (!isMetaKeyEvent(event as MouseEvent) && isAnyConnectionSelected) { + connectionsList.resetSelection(); + } + + this.context.graph.api.selectBlocks( + [blockInstance.props.id], + /** + * On click with meta key we want to select only one block, otherwise we want to toggle selection + */ + !isMetaKeyEvent(event as MouseEvent) ? true : !blockInstance.state.selected, + /** + * On click with meta key we want to append selection, otherwise we want to replace selection + */ + !isMetaKeyEvent(event as MouseEvent) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND + ); + }); + + this.addEventListener("mousedown", (event) => { + const blockInstance = this.getTargetComponent(event); + // const { target: blockInstance, sourceEvent } = graphEvent.detail; + + if (!(blockInstance instanceof Block) || !blockInstance.isDraggable()) { + return; + } + + event.stopPropagation(); + + const blockState = blockInstance.connectedState; + + const selectedBlocksStates = getSelectedBlocks(blockState, this.context.graph.rootStore.blocksList); + const selectedBlocksComponents = selectedBlocksStates.map((block) => block.getViewComponent()); + + this.context.graph.dragController.start( + { + onDraggingStart: (event) => { + dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_START, event)); + }, + onDragUpdate: (event) => { + dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_UPDATE, event)); + }, + onDragEnd: (event) => { + dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_END, event)); + }, + }, + event as MouseEvent + ); + }); + } + protected getFontScale() { return this.context.graph.rootStore.settings.getConfigFlag("scaleFontSize"); } @@ -35,16 +100,14 @@ export class Blocks extends Component { protected subscribe() { this.blocks = this.context.graph.rootStore.blocksList.$blocks.value; this.blocksView = this.context.graph.rootStore.settings.getConfigFlag("blockComponents"); - return [ - this.context.graph.rootStore.blocksList.$blocks.subscribe((blocks) => { - this.blocks = blocks; - this.rerender(); - }), - this.context.graph.rootStore.settings.$blockComponents.subscribe((blockComponents) => { - this.blocksView = blockComponents; - this.rerender(); - }), - ]; + this.subscribeSignal(this.context.graph.rootStore.blocksList.$blocks, (blocks) => { + this.blocks = blocks; + this.rerender(); + }); + this.subscribeSignal(this.context.graph.rootStore.settings.$blockComponents, (blockComponents) => { + this.blocksView = blockComponents; + this.rerender(); + }); } private prepareFont(scaleFontSize) { @@ -71,3 +134,13 @@ export class Blocks extends Component { }); } } + +export function getSelectedBlocks(currentBlockState: BlockState, blocksState: BlockListStore) { + let selected; + if (currentBlockState.selected) { + selected = blocksState.$selectedBlocks.value; + } else { + selected = [currentBlockState]; + } + return selected; +} diff --git a/src/components/canvas/blocks/controllers/BlockController.ts b/src/components/canvas/blocks/controllers/BlockController.ts deleted file mode 100644 index 92096a31..00000000 --- a/src/components/canvas/blocks/controllers/BlockController.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { BlockState } from "../../../../store/block/Block"; -import { BlockListStore } from "../../../../store/block/BlocksList"; -import { selectBlockById } from "../../../../store/block/selectors"; -import { ECanChangeBlockGeometry } from "../../../../store/settings"; -import { - addEventListeners, - createCustomDragEvent, - dispatchEvents, - isAllowChangeBlockGeometry, - isMetaKeyEvent, -} from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; -import { EVENTS } from "../../../../utils/types/events"; -import { ESelectionStrategy } from "../../../../utils/types/types"; -import { Block } from "../Block"; - -export class BlockController { - constructor(block: Block) { - addEventListeners(block as EventTarget, { - click(event: MouseEvent) { - event.stopPropagation(); - - const { connectionsList } = block.context.graph.rootStore; - const isAnyConnectionSelected = connectionsList.$selectedConnections.value.size !== 0; - - if (!isMetaKeyEvent(event) && isAnyConnectionSelected) { - connectionsList.resetSelection(); - } - - block.context.graph.api.selectBlocks( - [block.props.id], - /** - * On click with meta key we want to select only one block, otherwise we want to toggle selection - */ - !isMetaKeyEvent(event) ? true : !block.state.selected, - /** - * On click with meta key we want to append selection, otherwise we want to replace selection - */ - !isMetaKeyEvent(event) ? ESelectionStrategy.REPLACE : ESelectionStrategy.APPEND - ); - }, - - mousedown(event: MouseEvent) { - const blockState = selectBlockById(block.context.graph, block.props.id); - const allowChangeBlockGeometry = isAllowChangeBlockGeometry( - block.getConfigFlag("canChangeBlockGeometry") as ECanChangeBlockGeometry, - blockState.selected - ); - - if (!allowChangeBlockGeometry) return; - - event.stopPropagation(); - - const blocksListState = this.context.graph.rootStore.blocksList; - const selectedBlocksStates = getSelectedBlocks(blockState, blocksListState); - const selectedBlocksComponents = selectedBlocksStates.map((block) => block.getViewComponent()); - - dragListener(block.context.ownerDocument) - .on(EVENTS.DRAG_START, (_event: MouseEvent) => { - block.context.graph.getGraphLayer().captureEvents(this); - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_START, _event)); - }) - .on(EVENTS.DRAG_UPDATE, (_event: MouseEvent) => { - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_UPDATE, _event)); - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - block.context.graph.getGraphLayer().releaseCapture(); - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_END, _event)); - }); - }, - }); - } -} - -export function getSelectedBlocks(currentBlockState: BlockState, blocksState: BlockListStore) { - let selected; - if (currentBlockState.selected) { - selected = blocksState.$selectedBlocks.value; - } else { - selected = [currentBlockState]; - } - return selected; -} From e948e7ae537efedafc97da98a2a0914b4cd33e5b Mon Sep 17 00:00:00 2001 From: draedful Date: Thu, 31 Jul 2025 14:58:59 +0300 Subject: [PATCH 03/10] refactor(GraphComponent): use DragController to drag GraphComponent --- .../canvas/GraphComponent/index.tsx | 60 ++++++++++--------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index a8a0c096..7f695783 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -5,8 +5,6 @@ import { Component } from "../../../lib"; import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component"; import { HitBox, HitBoxData } from "../../../services/HitTest"; import { getXY } from "../../../utils/functions"; -import { dragListener } from "../../../utils/functions/dragListener"; -import { EVENTS } from "../../../utils/types/events"; import { EventedComponent } from "../EventedComponent/EventedComponent"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -22,7 +20,7 @@ export class GraphComponent< > extends EventedComponent { public hitBox: HitBox; - private unsubscribe: (() => void)[] = []; + protected unsubscribe: (() => void)[] = []; constructor(props: Props, parent: Component) { super(props, parent); @@ -54,32 +52,36 @@ export class GraphComponent< return; } event.stopPropagation(); - dragListener(this.context.ownerDocument) - .on(EVENTS.DRAG_START, (event: MouseEvent) => { - if (onDragStart?.(event) === false) { - return; - } - this.context.graph.getGraphLayer().captureEvents(this); - const xy = getXY(this.context.canvas, event); - startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]); - }) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { - if (!startDragCoords.length) return; - - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); - - const diffX = (startDragCoords[0] - currentCoords[0]) | 0; - const diffY = (startDragCoords[1] - currentCoords[1]) | 0; - - onDragUpdate?.({ prevCoords: startDragCoords, currentCoords, diffX, diffY }, event); - startDragCoords = currentCoords; - }) - .on(EVENTS.DRAG_END, (_event: MouseEvent) => { - this.context.graph.getGraphLayer().releaseCapture(); - startDragCoords = undefined; - onDrop?.(_event); - }); + this.context.graph.dragController.start( + { + onDraggingStart: (event) => { + if (onDragStart?.(event) === false) { + return; + } + this.context.graph.getGraphLayer().captureEvents(this); + const xy = getXY(this.context.canvas, event); + startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]); + }, + onDragUpdate: (event) => { + if (!startDragCoords.length) return; + + const [canvasX, canvasY] = getXY(this.context.canvas, event); + const currentCoords = this.context.camera.applyToPoint(canvasX, canvasY); + + const diffX = (startDragCoords[0] - currentCoords[0]) | 0; + const diffY = (startDragCoords[1] - currentCoords[1]) | 0; + + onDragUpdate?.({ prevCoords: startDragCoords, currentCoords, diffX, diffY }, event); + startDragCoords = currentCoords; + }, + onDragEnd: (event) => { + this.context.graph.getGraphLayer().releaseCapture(); + startDragCoords = undefined; + onDrop?.(event); + }, + }, + event + ); }); } From 51c51920ac5726dee779578b3bd1feebf96fc6ea Mon Sep 17 00:00:00 2001 From: draedful Date: Thu, 31 Jul 2025 15:25:47 +0300 Subject: [PATCH 04/10] feat(SelectionLayer): use DragController to select area with autopanning feat(ConnectionLayer): use DragController to creating connections feat(NewBlockLayer): use DragController to drag shadow block --- .../layers/connectionLayer/ConnectionLayer.ts | 80 +++++++++++++------ .../layers/newBlockLayer/NewBlockLayer.ts | 20 +++-- .../layers/selectionLayer/SelectionLayer.ts | 68 +++++++++------- 3 files changed, 105 insertions(+), 63 deletions(-) diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 9c0013b1..43cc28cb 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,12 +1,11 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/DragController"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; -import { getXY, isBlock, isShiftKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; +import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; import { renderSVG } from "../../../../utils/renderers/svgPath"; -import { EVENTS } from "../../../../utils/types/events"; import { Point, TPoint } from "../../../../utils/types/shapes"; import { ESelectionStrategy } from "../../../../utils/types/types"; import { Anchor } from "../../../canvas/anchors"; @@ -158,6 +157,7 @@ export class ConnectionLayer extends Layer< * Called after initialization and when the layer is reattached. * This is where we set up event subscriptions to ensure they work properly * after the layer is unmounted and reattached. + * @returns {void} */ protected afterInit(): void { // Register event listeners with the graphOn wrapper method for automatic cleanup when unmounted @@ -169,14 +169,27 @@ export class ConnectionLayer extends Layer< super.afterInit(); } + /** + * Enables connection creation functionality + * @returns {void} + */ public enable = () => { this.enabled = true; }; + /** + * Disables connection creation functionality + * @returns {void} + */ public disable = () => { this.enabled = false; }; + /** + * Handles mousedown events to initiate connection creation + * @param {GraphMouseEvent} nativeEvent - The graph mouse event + * @returns {void} + */ protected handleMouseDown = (nativeEvent: GraphMouseEvent) => { const target = nativeEvent.detail.target; const event = extractNativeGraphMouseEvent(nativeEvent); @@ -198,20 +211,26 @@ export class ConnectionLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .on(EVENTS.DRAG_START, (dStartEvent: MouseEvent) => { + + const connectionHandler: DragHandler = { + onDraggingStart: (dStartEvent: MouseEvent) => { this.onStartConnection(dStartEvent, this.context.graph.getPointInCameraSpace(dStartEvent)); - }) - .on(EVENTS.DRAG_UPDATE, (dUpdateEvent: MouseEvent) => - this.onMoveNewConnection(dUpdateEvent, this.context.graph.getPointInCameraSpace(dUpdateEvent)) - ) - .on(EVENTS.DRAG_END, (dEndEvent: MouseEvent) => - this.onEndNewConnection(this.context.graph.getPointInCameraSpace(dEndEvent)) - ); + }, + onDragUpdate: (dUpdateEvent: MouseEvent) => { + this.onMoveNewConnection(dUpdateEvent, this.context.graph.getPointInCameraSpace(dUpdateEvent)); + }, + onDragEnd: (dEndEvent: MouseEvent) => { + this.onEndNewConnection(this.context.graph.getPointInCameraSpace(dEndEvent)); + }, + }; + + this.context.graph.dragController.start(connectionHandler, event, { + enableEdgePanning: true, // Включаем edge panning для соединений + }); } }; - protected renderEndpoint(ctx: CanvasRenderingContext2D) { + protected renderEndpoint(ctx: CanvasRenderingContext2D, endCanvasX: number, endCanvasY: number) { ctx.beginPath(); if (!this.target && this.props.createIcon) { renderSVG( @@ -223,7 +242,7 @@ export class ConnectionLayer extends Layer< initialHeight: this.props.createIcon.viewHeight, }, ctx, - { x: this.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 } + { x: endCanvasX, y: endCanvasY - 12, width: 24, height: 24 } ); } else if (this.props.point) { ctx.fillStyle = this.props.point.fill || this.context.colors.canvas.belowLayerBackground; @@ -240,7 +259,7 @@ export class ConnectionLayer extends Layer< initialHeight: this.props.point.viewHeight, }, ctx, - { x: this.connectionState.tx, y: this.connectionState.ty - 12, width: 24, height: 24 } + { x: endCanvasX, y: endCanvasY - 12, width: 24, height: 24 } ); } ctx.closePath(); @@ -252,10 +271,19 @@ export class ConnectionLayer extends Layer< return; } + // Преобразуем мировые координаты в координаты canvas для рендеринга + const scale = this.context.camera.getCameraScale(); + const cameraRect = this.context.camera.getCameraRect(); + + const startCanvasX = this.connectionState.sx * scale + cameraRect.x; + const startCanvasY = this.connectionState.sy * scale + cameraRect.y; + const endCanvasX = this.connectionState.tx * scale + cameraRect.x; + const endCanvasY = this.connectionState.ty * scale + cameraRect.y; + if (this.props.drawLine) { const { path, style } = this.props.drawLine( - { x: this.connectionState.sx, y: this.connectionState.sy }, - { x: this.connectionState.tx, y: this.connectionState.ty } + { x: startCanvasX, y: startCanvasY }, + { x: endCanvasX, y: endCanvasY } ); this.context.ctx.strokeStyle = style.color; @@ -264,14 +292,14 @@ export class ConnectionLayer extends Layer< } else { this.context.ctx.beginPath(); this.context.ctx.strokeStyle = this.context.colors.connection.selectedBackground; - this.context.ctx.moveTo(this.connectionState.sx, this.connectionState.sy); - this.context.ctx.lineTo(this.connectionState.tx, this.connectionState.ty); + this.context.ctx.moveTo(startCanvasX, startCanvasY); + this.context.ctx.lineTo(endCanvasX, endCanvasY); this.context.ctx.stroke(); this.context.ctx.closePath(); } render(this.context.ctx, (ctx) => { - this.renderEndpoint(ctx); + this.renderEndpoint(ctx, endCanvasX, endCanvasY); }); } @@ -298,11 +326,11 @@ export class ConnectionLayer extends Layer< this.sourceComponent = sourceComponent.connectedState; - const xy = getXY(this.context.graphCanvas, event); + // Используем мировые координаты вместо координат canvas this.connectionState = { ...this.connectionState, - sx: xy[0], - sy: xy[1], + sx: point.x, + sy: point.y, }; this.context.graph.executеDefaultEventAction( @@ -328,12 +356,12 @@ export class ConnectionLayer extends Layer< private onMoveNewConnection(event: MouseEvent, point: Point) { const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - const xy = getXY(this.context.graphCanvas, event); + // Используем мировые координаты вместо координат canvas this.connectionState = { ...this.connectionState, - tx: xy[0], - ty: xy[1], + tx: point.x, + ty: point.y, }; this.performRender(); diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts index 7ff617ac..1d182996 100644 --- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts +++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts @@ -2,9 +2,8 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graph import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { BlockState } from "../../../../store/block/Block"; import { getXY, isAltKeyEvent, isBlock } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; import { render } from "../../../../utils/renderers/render"; -import { EVENTS } from "../../../../utils/types/events"; +import { DragHandler } from "../../../../services/DragController"; import { TPoint } from "../../../../utils/types/shapes"; import { ESelectionStrategy } from "../../../../utils/types/types"; import { Block } from "../../../canvas/blocks/Block"; @@ -110,12 +109,17 @@ export class NewBlockLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .on(EVENTS.DRAG_START, (event: MouseEvent) => this.onStartNewBlock(event, target)) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => this.onMoveNewBlock(event)) - .on(EVENTS.DRAG_END, (event: MouseEvent) => - this.onEndNewBlock(event, this.context.graph.getPointInCameraSpace(event)) - ); + + const newBlockHandler: DragHandler = { + onDraggingStart: (event: MouseEvent) => this.onStartNewBlock(event, target), + onDragUpdate: (event: MouseEvent) => this.onMoveNewBlock(event), + onDragEnd: (event: MouseEvent) => + this.onEndNewBlock(event, this.context.graph.getPointInCameraSpace(event)), + }; + + this.context.graph.dragController.start(newBlockHandler, event, { + enableEdgePanning: true, // Включаем edge panning для создания новых блоков + }); } }; diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts index 1a1071db..dcf2cfa1 100644 --- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts +++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts @@ -1,10 +1,9 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/DragController"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { selectBlockList } from "../../../../store/block/selectors"; -import { getXY, isBlock, isMetaKeyEvent } from "../../../../utils/functions"; -import { dragListener } from "../../../../utils/functions/dragListener"; +import { isBlock, isMetaKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; -import { EVENTS } from "../../../../utils/types/events"; import { TRect } from "../../../../utils/types/shapes"; import { Anchor } from "../../anchors"; import { Block } from "../../blocks/Block"; @@ -39,6 +38,10 @@ export class SelectionLayer extends Layer< this.setContext({ canvas: this.getCanvas(), ctx: this.getCanvas().getContext("2d"), + camera: props.camera, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, }); } @@ -46,6 +49,7 @@ export class SelectionLayer extends Layer< * Called after initialization and when the layer is reattached. * This is where we set up event subscriptions to ensure they work properly * after the layer is unmounted and reattached. + * @returns {void} */ protected afterInit(): void { // Set up event handlers here instead of in constructor @@ -74,13 +78,17 @@ export class SelectionLayer extends Layer< ctx.fillStyle = this.context.colors.selection.background; ctx.strokeStyle = this.context.colors.selection.border; ctx.beginPath(); - ctx.roundRect( - this.selection.x, - this.selection.y, - this.selection.width, - this.selection.height, - Number(this.context.graph.layers.getDPR()) - ); + + // Преобразуем мировые координаты в координаты canvas для рендеринга + const scale = this.context.camera.getCameraScale(); + const cameraRect = this.context.camera.getCameraRect(); + + const canvasX = this.selection.x * scale + cameraRect.x; + const canvasY = this.selection.y * scale + cameraRect.y; + const canvasWidth = this.selection.width * scale; + const canvasHeight = this.selection.height * scale; + + ctx.roundRect(canvasX, canvasY, canvasWidth, canvasHeight, Number(this.context.graph.layers.getDPR())); ctx.closePath(); ctx.fill(); @@ -102,24 +110,30 @@ export class SelectionLayer extends Layer< if (event && isMetaKeyEvent(event)) { nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - dragListener(this.root.ownerDocument) - .on(EVENTS.DRAG_START, this.startSelectionRender) - .on(EVENTS.DRAG_UPDATE, this.updateSelectionRender) - .on(EVENTS.DRAG_END, this.endSelectionRender); + + const selectionHandler: DragHandler = { + onDraggingStart: this.startSelectionRender, + onDragUpdate: this.updateSelectionRender, + onDragEnd: this.endSelectionRender, + }; + + this.context.graph.dragController.start(selectionHandler, event, { + enableEdgePanning: true, // Отключаем edge panning для выделения + }); } }; private updateSelectionRender = (event: MouseEvent) => { - const [x, y] = getXY(this.context.canvas, event); - this.selection.width = x - this.selection.x; - this.selection.height = y - this.selection.y; + const worldPoint = this.context.graph.getPointInCameraSpace(event); + this.selection.width = worldPoint.x - this.selection.x; + this.selection.height = worldPoint.y - this.selection.y; this.performRender(); }; private startSelectionRender = (event: MouseEvent) => { - const [x, y] = getXY(this.context.canvas, event); - this.selection.x = x; - this.selection.y = y; + const worldPoint = this.context.graph.getPointInCameraSpace(event); + this.selection.x = worldPoint.x; + this.selection.y = worldPoint.y; }; private endSelectionRender = (event: MouseEvent) => { @@ -127,15 +141,11 @@ export class SelectionLayer extends Layer< return; } - const [x, y] = getXY(this.context.canvas, event); - const selectionRect = getSelectionRect(this.selection.x, this.selection.y, x, y); - const cameraRect = this.context.graph.cameraService.applyToRect( - selectionRect[0], - selectionRect[1], - selectionRect[2], - selectionRect[3] - ); - this.applySelectedArea(cameraRect[0], cameraRect[1], cameraRect[2], cameraRect[3]); + const worldPoint = this.context.graph.getPointInCameraSpace(event); + const selectionRect = getSelectionRect(this.selection.x, this.selection.y, worldPoint.x, worldPoint.y); + + // Координаты уже в мировом пространстве, преобразование не нужно + this.applySelectedArea(selectionRect[0], selectionRect[1], selectionRect[2], selectionRect[3]); this.selection.width = 0; this.selection.height = 0; this.performRender(); From 85d8c732d298c4be543b503deedb3562eccf1b2b Mon Sep 17 00:00:00 2001 From: draedful Date: Thu, 31 Jul 2025 19:33:02 +0300 Subject: [PATCH 05/10] ... --- .../canvas/GraphComponent/index.tsx | 2 +- src/components/canvas/blocks/Block.ts | 64 ++--- src/components/canvas/blocks/Blocks.ts | 25 +- .../layers/connectionLayer/ConnectionLayer.ts | 22 +- .../canvas/layers/graphLayer/GraphLayer.ts | 10 +- .../layers/newBlockLayer/NewBlockLayer.ts | 19 +- .../layers/selectionLayer/SelectionLayer.ts | 40 ++- src/index.ts | 2 + src/services/DragController.ts | 62 +++-- src/services/DragInfo.ts | 253 ++++++++++++++++++ 10 files changed, 375 insertions(+), 124 deletions(-) create mode 100644 src/services/DragInfo.ts diff --git a/src/components/canvas/GraphComponent/index.tsx b/src/components/canvas/GraphComponent/index.tsx index 7f695783..cafdd95e 100644 --- a/src/components/canvas/GraphComponent/index.tsx +++ b/src/components/canvas/GraphComponent/index.tsx @@ -54,7 +54,7 @@ export class GraphComponent< event.stopPropagation(); this.context.graph.dragController.start( { - onDraggingStart: (event) => { + onDragStart: (event) => { if (onDragStart?.(event) === false) { return; } diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index f0aebf07..de2bf5da 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -2,16 +2,16 @@ import { signal } from "@preact/signals-core"; import cloneDeep from "lodash/cloneDeep"; import isObject from "lodash/isObject"; +import { DragInfo } from "../../../services/DragInfo"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { TGraphSettingsConfig } from "../../../store"; import { EAnchorType } from "../../../store/anchor/Anchor"; import { BlockState, IS_BLOCK_TYPE, TBlockId } from "../../../store/block/Block"; import { selectBlockById } from "../../../store/block/selectors"; import { ECanChangeBlockGeometry } from "../../../store/settings"; -import { getXY, isAllowChangeBlockGeometry } from "../../../utils/functions"; +import { isAllowChangeBlockGeometry } from "../../../utils/functions"; import { TMeasureTextOptions } from "../../../utils/functions/text"; import { TTExtRect, renderText } from "../../../utils/renderers/text"; -import { EVENTS } from "../../../utils/types/events"; import { TPoint, TRect } from "../../../utils/types/shapes"; import { GraphComponent } from "../GraphComponent"; import { Anchor, TAnchor } from "../anchors"; @@ -97,9 +97,7 @@ export class Block; - protected lastDragEvent?: MouseEvent; - - protected startDragCoords: number[] = []; + protected startDragCoords?: [number, number]; protected shouldRenderText: boolean; @@ -119,10 +117,6 @@ export class Block { - this.lastDragEvent = event; - const xy = getXY(this.context.canvas, event); - this.startDragCoords = this.context.camera.applyToPoint(xy[0], xy[1]).concat([this.state.x, this.state.y]); + this.startDragCoords = [this.state.x, this.state.y]; this.raiseBlock(); } ); } - protected onDragUpdate(event: MouseEvent) { + public onDragUpdate(event: MouseEvent, dragInfo: DragInfo) { if (!this.startDragCoords) return; - this.lastDragEvent = event; - - const [canvasX, canvasY] = getXY(this.context.canvas, event); - const [cameraSpaceX, cameraSpaceY] = this.context.camera.applyToPoint(canvasX, canvasY); - - const [x, y] = this.calcNextDragPosition(cameraSpaceX, cameraSpaceY); + const [x, y] = this.calcNextDragPosition(dragInfo); this.context.graph.executеDefaultEventAction( "block-drag", @@ -295,16 +265,15 @@ export class Block 1) { + if (snapToGrid && spanGridSize > 1) { nextX = Math.round(nextX / spanGridSize) * spanGridSize; nextY = Math.round(nextY / spanGridSize) * spanGridSize; } @@ -316,15 +285,14 @@ export class Block block.getViewComponent()); + const selectedBlocksComponents: Block[] = selectedBlocksStates.map((block) => block.getViewComponent()); + this.context.graph.getGraphLayer().captureEvents(blockInstance); this.context.graph.dragController.start( { - onDraggingStart: (event) => { - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_START, event)); + onDragStart: (dragEvent, _dragInfo) => { + for (const block of selectedBlocksComponents) { + block.onDragStart(dragEvent); + } }, - onDragUpdate: (event) => { - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_UPDATE, event)); + onDragUpdate: (dragEvent, dragInfo) => { + for (const block of selectedBlocksComponents) { + block.onDragUpdate(dragEvent, dragInfo); + } }, - onDragEnd: (event) => { - dispatchEvents(selectedBlocksComponents, createCustomDragEvent(EVENTS.DRAG_END, event)); + onDragEnd: (dragEvent, dragInfo) => { + this.context.graph.getGraphLayer().releaseCapture(); + for (const block of selectedBlocksComponents) { + block.onDragEnd(dragEvent, dragInfo); + } }, }, event as MouseEvent diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 43cc28cb..135a06f8 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,5 +1,6 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; import { DragHandler } from "../../../../services/DragController"; +import { DragInfo } from "../../../../services/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { AnchorState } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; @@ -213,20 +214,21 @@ export class ConnectionLayer extends Layer< nativeEvent.stopPropagation(); const connectionHandler: DragHandler = { - onDraggingStart: (dStartEvent: MouseEvent) => { - this.onStartConnection(dStartEvent, this.context.graph.getPointInCameraSpace(dStartEvent)); + onDragStart: (dStartEvent: MouseEvent, dragInfo: DragInfo) => { + this.onStartConnection(dStartEvent, new Point(dragInfo.startCameraX, dragInfo.startCameraY)); }, - onDragUpdate: (dUpdateEvent: MouseEvent) => { - this.onMoveNewConnection(dUpdateEvent, this.context.graph.getPointInCameraSpace(dUpdateEvent)); + onDragUpdate: (dUpdateEvent: MouseEvent, dragInfo: DragInfo) => { + this.onMoveNewConnection( + dUpdateEvent, + new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number) + ); }, - onDragEnd: (dEndEvent: MouseEvent) => { - this.onEndNewConnection(this.context.graph.getPointInCameraSpace(dEndEvent)); + onDragEnd: (dEndEvent: MouseEvent, dragInfo: DragInfo) => { + this.onEndNewConnection(new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number)); }, }; - this.context.graph.dragController.start(connectionHandler, event, { - enableEdgePanning: true, // Включаем edge panning для соединений - }); + this.context.graph.dragController.start(connectionHandler, event); } }; @@ -271,7 +273,6 @@ export class ConnectionLayer extends Layer< return; } - // Преобразуем мировые координаты в координаты canvas для рендеринга const scale = this.context.camera.getCameraScale(); const cameraRect = this.context.camera.getCameraRect(); @@ -326,7 +327,6 @@ export class ConnectionLayer extends Layer< this.sourceComponent = sourceComponent.connectedState; - // Используем мировые координаты вместо координат canvas this.connectionState = { ...this.connectionState, sx: point.x, diff --git a/src/components/canvas/layers/graphLayer/GraphLayer.ts b/src/components/canvas/layers/graphLayer/GraphLayer.ts index 917e7ccf..d2b07d74 100644 --- a/src/components/canvas/layers/graphLayer/GraphLayer.ts +++ b/src/components/canvas/layers/graphLayer/GraphLayer.ts @@ -2,7 +2,7 @@ import { Graph } from "../../../../graph"; import { GraphMouseEventNames, isNativeGraphEventName } from "../../../../graphEvents"; import { Component } from "../../../../lib/Component"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; -import { Camera, TCameraProps } from "../../../../services/camera/Camera"; +import { Camera, TCameraProps, TEdgePanningConfig } from "../../../../services/camera/Camera"; import { ICamera } from "../../../../services/camera/CameraService"; import { getEventDelta } from "../../../../utils/functions"; import { EventedComponent } from "../../EventedComponent/EventedComponent"; @@ -113,6 +113,14 @@ export class GraphLayer extends Layer { super.afterInit(); } + public enableEdgePanning(config: Partial = {}): void { + this.$.camera.enableEdgePanning(config); + } + + public disableEdgePanning(): void { + this.$.camera.disableEdgePanning(); + } + /** * Attaches DOM event listeners to the root element. * All event listeners are registered with the rootOn wrapper method to ensure they are properly cleaned up diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts index 1d182996..5bee2ec6 100644 --- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts +++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts @@ -1,10 +1,11 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; +import { DragHandler } from "../../../../services/DragController"; +import { DragInfo } from "../../../../services/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { BlockState } from "../../../../store/block/Block"; import { getXY, isAltKeyEvent, isBlock } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; -import { DragHandler } from "../../../../services/DragController"; -import { TPoint } from "../../../../utils/types/shapes"; +import { Point, TPoint } from "../../../../utils/types/shapes"; import { ESelectionStrategy } from "../../../../utils/types/types"; import { Block } from "../../../canvas/blocks/Block"; @@ -109,17 +110,15 @@ export class NewBlockLayer extends Layer< nativeEvent.preventDefault(); nativeEvent.stopPropagation(); - + const newBlockHandler: DragHandler = { - onDraggingStart: (event: MouseEvent) => this.onStartNewBlock(event, target), - onDragUpdate: (event: MouseEvent) => this.onMoveNewBlock(event), - onDragEnd: (event: MouseEvent) => - this.onEndNewBlock(event, this.context.graph.getPointInCameraSpace(event)), + onDragStart: (dragEvent: MouseEvent, _dragInfo: DragInfo) => this.onStartNewBlock(dragEvent, target), + onDragUpdate: (dragEvent: MouseEvent, _dragInfo: DragInfo) => this.onMoveNewBlock(dragEvent), + onDragEnd: (dragEvent: MouseEvent, dragInfo: DragInfo) => + this.onEndNewBlock(dragEvent, new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number)), }; - this.context.graph.dragController.start(newBlockHandler, event, { - enableEdgePanning: true, // Включаем edge panning для создания новых блоков - }); + this.context.graph.dragController.start(newBlockHandler, event); } }; diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts index dcf2cfa1..49619081 100644 --- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts +++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts @@ -1,5 +1,6 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; import { DragHandler } from "../../../../services/DragController"; +import { DragInfo } from "../../../../services/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { selectBlockList } from "../../../../store/block/selectors"; import { isBlock, isMetaKeyEvent } from "../../../../utils/functions"; @@ -52,12 +53,10 @@ export class SelectionLayer extends Layer< * @returns {void} */ protected afterInit(): void { - // Set up event handlers here instead of in constructor this.onGraphEvent("mousedown", this.handleMouseDown, { capture: true, }); - // Call parent afterInit to ensure proper initialization super.afterInit(); } @@ -79,7 +78,6 @@ export class SelectionLayer extends Layer< ctx.strokeStyle = this.context.colors.selection.border; ctx.beginPath(); - // Преобразуем мировые координаты в координаты canvas для рендеринга const scale = this.context.camera.getCameraScale(); const cameraRect = this.context.camera.getCameraRect(); @@ -112,39 +110,39 @@ export class SelectionLayer extends Layer< nativeEvent.stopPropagation(); const selectionHandler: DragHandler = { - onDraggingStart: this.startSelectionRender, - onDragUpdate: this.updateSelectionRender, - onDragEnd: this.endSelectionRender, + onDragStart: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.startSelectionRender(dragEvent, dragInfo), + onDragUpdate: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.updateSelectionRender(dragEvent, dragInfo), + onDragEnd: (dragEvent: MouseEvent, dragInfo: DragInfo) => this.endSelectionRender(dragEvent, dragInfo), }; - this.context.graph.dragController.start(selectionHandler, event, { - enableEdgePanning: true, // Отключаем edge panning для выделения - }); + this.context.graph.dragController.start(selectionHandler, event); } }; - private updateSelectionRender = (event: MouseEvent) => { - const worldPoint = this.context.graph.getPointInCameraSpace(event); - this.selection.width = worldPoint.x - this.selection.x; - this.selection.height = worldPoint.y - this.selection.y; + private updateSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { + // Используем готовые координаты из dragInfo + this.selection.width = (dragInfo.lastCameraX as number) - this.selection.x; + this.selection.height = (dragInfo.lastCameraY as number) - this.selection.y; this.performRender(); }; - private startSelectionRender = (event: MouseEvent) => { - const worldPoint = this.context.graph.getPointInCameraSpace(event); - this.selection.x = worldPoint.x; - this.selection.y = worldPoint.y; + private startSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { + // Используем готовые координаты из dragInfo + this.selection.x = dragInfo.startCameraX; + this.selection.y = dragInfo.startCameraY; }; - private endSelectionRender = (event: MouseEvent) => { + private endSelectionRender = (event: MouseEvent, dragInfo: DragInfo) => { if (this.selection.width === 0 && this.selection.height === 0) { return; } - const worldPoint = this.context.graph.getPointInCameraSpace(event); - const selectionRect = getSelectionRect(this.selection.x, this.selection.y, worldPoint.x, worldPoint.y); + // Используем готовые координаты из dragInfo + const endX = dragInfo.lastCameraX as number; + const endY = dragInfo.lastCameraY as number; + + const selectionRect = getSelectionRect(this.selection.x, this.selection.y, endX, endY); - // Координаты уже в мировом пространстве, преобразование не нужно this.applySelectedArea(selectionRect[0], selectionRect[1], selectionRect[2], selectionRect[3]); this.selection.width = 0; this.selection.height = 0; diff --git a/src/index.ts b/src/index.ts index 3943bf73..33eb6c69 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,8 @@ export type { TGraphColors, TGraphConstants } from "./graphConfig"; export { type UnwrapGraphEventsDetail } from "./graphEvents"; export * from "./plugins"; export { ECameraScaleLevel } from "./services/camera/CameraService"; +export { DragController, type DragHandler, type DragControllerConfig } from "./services/DragController"; +export { DragInfo } from "./services/DragInfo"; export * from "./services/Layer"; export * from "./store"; export { EAnchorType } from "./store/anchor/Anchor"; diff --git a/src/services/DragController.ts b/src/services/DragController.ts index 574589b0..e5cb843b 100644 --- a/src/services/DragController.ts +++ b/src/services/DragController.ts @@ -3,7 +3,7 @@ import { ESchedulerPriority, scheduler } from "../lib/Scheduler"; import { dragListener } from "../utils/functions/dragListener"; import { EVENTS } from "../utils/types/events"; -import { TEdgePanningConfig } from "./camera/Camera"; +import { DragInfo } from "./DragInfo"; /** * Интерфейс для компонентов, которые могут быть перетаскиваемыми @@ -12,20 +12,23 @@ export interface DragHandler { /** * Вызывается при начале перетаскивания * @param event - Событие мыши + * @param dragInfo - Statefull модель с информацией о перетаскивании */ - onDraggingStart(event: MouseEvent): void; + onDragStart(event: MouseEvent, dragInfo: DragInfo): void; /** * Вызывается при обновлении позиции во время перетаскивания * @param event - Событие мыши + * @param dragInfo - Statefull модель с информацией о перетаскивании */ - onDragUpdate(event: MouseEvent): void; + onDragUpdate(event: MouseEvent, dragInfo: DragInfo): void; /** * Вызывается при завершении перетаскивания * @param event - Событие мыши + * @param dragInfo - Statefull модель с информацией о перетаскивании */ - onDragEnd(event: MouseEvent): void; + onDragEnd(event: MouseEvent, dragInfo: DragInfo): void; } /** @@ -34,8 +37,6 @@ export interface DragHandler { export interface DragControllerConfig { /** Включить автоматическое движение камеры при приближении к границам */ enableEdgePanning?: boolean; - /** Конфигурация edge panning */ - edgePanningConfig?: Partial; } /** @@ -50,10 +51,13 @@ export class DragController { private lastMouseEvent?: MouseEvent; + private dragInfo: DragInfo; + private updateScheduler?: () => void; constructor(graph: Graph) { this.graph = graph; + this.dragInfo = new DragInfo(graph); } /** @@ -74,27 +78,23 @@ export class DragController { this.isDragging = true; this.lastMouseEvent = event; - // Включаем edge panning если необходимо + // Инициализируем DragInfo с начальным событием + this.dragInfo.init(event); + if (config.enableEdgePanning ?? true) { - const camera = this.graph.getGraphLayer().$.camera; const defaultConfig = this.graph.graphConstants.camera.EDGE_PANNING; - camera.enableEdgePanning({ - speed: config.edgePanningConfig?.speed || defaultConfig.SPEED, - edgeSize: config.edgePanningConfig?.edgeSize || defaultConfig.EDGE_SIZE, + this.graph.getGraphLayer().enableEdgePanning({ + speed: defaultConfig.SPEED, + edgeSize: defaultConfig.EDGE_SIZE, }); // Запускаем периодическое обновление компонента для синхронизации с движением камеры this.startContinuousUpdate(); } - // TODO: Нужно передать EventedComponent вместо DragController - // this.graph.getGraphLayer().captureEvents(this); - - // Вызываем обработчик начала перетаскивания - component.onDraggingStart(event); + component.onDragStart(event, this.dragInfo); - // Запускаем dragListener для отслеживания движений мыши this.startDragListener(event); } @@ -109,7 +109,11 @@ export class DragController { } this.lastMouseEvent = event; - this.currentDragHandler.onDragUpdate(event); + + // Обновляем состояние DragInfo + this.dragInfo.update(event); + + this.currentDragHandler.onDragUpdate(event, this.dragInfo); } /** @@ -129,16 +133,19 @@ export class DragController { this.stopContinuousUpdate(); // Отключаем edge panning - const camera = this.graph.getGraphLayer().$.camera; - camera.disableEdgePanning(); + this.graph.getGraphLayer().disableEdgePanning(); + + // Завершаем процесс в DragInfo + this.dragInfo.end(event); // Вызываем обработчик завершения перетаскивания - this.currentDragHandler.onDragEnd(event); + this.currentDragHandler.onDragEnd(event, this.dragInfo); // Сбрасываем состояние this.currentDragHandler = undefined; this.isDragging = false; this.lastMouseEvent = undefined; + this.dragInfo.reset(); } /** @@ -157,6 +164,14 @@ export class DragController { return this.currentDragHandler; } + /** + * Получает текущую информацию о перетаскивании + * @returns экземпляр DragInfo (всегда доступен) + */ + public getCurrentDragInfo(): DragInfo { + return this.dragInfo; + } + /** * Запускает непрерывное обновление компонента для синхронизации с движением камеры * @returns void @@ -185,9 +200,10 @@ export class DragController { Object.defineProperty(syntheticEvent, "pageX", { value: this.lastMouseEvent.pageX }); Object.defineProperty(syntheticEvent, "pageY", { value: this.lastMouseEvent.pageY }); - // TODO: лучше в onDragUpdate передавать только deltaX/deltaY и clientX/clientY + // Обновляем состояние DragInfo для синтетического события + this.dragInfo.update(this.lastMouseEvent); - this.currentDragHandler.onDragUpdate(syntheticEvent); + this.currentDragHandler.onDragUpdate(syntheticEvent, this.dragInfo); }; // Используем средний приоритет для обновлений чтобы синхронизироваться с движением камеры diff --git a/src/services/DragInfo.ts b/src/services/DragInfo.ts new file mode 100644 index 00000000..e6c11e39 --- /dev/null +++ b/src/services/DragInfo.ts @@ -0,0 +1,253 @@ +import { Graph } from "../graph"; +import { Point } from "../utils/types/shapes"; + +/** + * Statefull модель для хранения информации о процессе перетаскивания + * Использует ленивые вычисления через getter-ы для оптимальной производительности + */ +export class DragInfo { + protected initialEvent: MouseEvent | null = null; + protected currentEvent: MouseEvent | null = null; + + // Кэш для координат камеры + private _startCameraPoint: Point | null = null; + private _currentCameraPoint: Point | null = null; + + constructor(protected graph: Graph) {} + + /** + * Сбрасывает состояние DragInfo + * @returns void + */ + public reset(): void { + this.initialEvent = null; + this.currentEvent = null; + this._startCameraPoint = null; + this._currentCameraPoint = null; + } + + /** + * Инициализирует начальное состояние перетаскивания + * @param event - Начальное событие мыши + * @returns void + */ + public init(event: MouseEvent): void { + this.initialEvent = event; + this.currentEvent = event; + this._startCameraPoint = null; // Будет вычислен лениво + this._currentCameraPoint = null; + } + + /** + * Обновляет текущее состояние перетаскивания + * @param event - Текущее событие мыши + * @returns void + */ + public update(event: MouseEvent): void { + this.currentEvent = event; + this._currentCameraPoint = null; // Сбрасываем кэш для перевычисления + } + + /** + * Завершает процесс перетаскивания + * @param event - Финальное событие мыши + * @returns void + */ + public end(event: MouseEvent): void { + this.currentEvent = event; + this._currentCameraPoint = null; // Финальное обновление + } + + // === ЛЕНИВЫЕ ГЕТТЕРЫ ДЛЯ ЭКРАННЫХ КООРДИНАТ === + + /** + * Начальные координаты X в экранном пространстве + */ + public get startX(): number { + return this.initialEvent?.clientX ?? 0; + } + + /** + * Начальные координаты Y в экранном пространстве + */ + public get startY(): number { + return this.initialEvent?.clientY ?? 0; + } + + /** + * Текущие координаты X в экранном пространстве + */ + public get lastX(): number { + return this.currentEvent?.clientX ?? this.startX; + } + + /** + * Текущие координаты Y в экранном пространстве + */ + public get lastY(): number { + return this.currentEvent?.clientY ?? this.startY; + } + + // === ЛЕНИВЫЕ ГЕТТЕРЫ ДЛЯ КООРДИНАТ КАМЕРЫ === + + /** + * Начальные координаты в пространстве камеры + */ + protected get startCameraPoint(): Point { + if (!this._startCameraPoint && this.initialEvent) { + this._startCameraPoint = this.graph.getPointInCameraSpace(this.initialEvent); + } + return this._startCameraPoint ?? new Point(0, 0); + } + + /** + * Текущие координаты в пространстве камеры + */ + protected get currentCameraPoint(): Point { + if (!this._currentCameraPoint && this.currentEvent) { + this._currentCameraPoint = this.graph.getPointInCameraSpace(this.currentEvent); + } + return this._currentCameraPoint ?? this.startCameraPoint; + } + + /** + * Начальная координата X в пространстве камеры + */ + public get startCameraX(): number { + return this.startCameraPoint.x; + } + + /** + * Начальная координата Y в пространстве камеры + */ + public get startCameraY(): number { + return this.startCameraPoint.y; + } + + /** + * Текущая координата X в пространстве камеры + */ + public get lastCameraX(): number { + return this.currentCameraPoint.x; + } + + /** + * Текущая координата Y в пространстве камеры + */ + public get lastCameraY(): number { + return this.currentCameraPoint.y; + } + + // === ВЫЧИСЛЯЕМЫЕ СВОЙСТВА === + + /** + * Разность координат в экранном пространстве + */ + public get screenDelta(): { x: number; y: number } { + return { + x: this.lastX - this.startX, + y: this.lastY - this.startY, + }; + } + + /** + * Разность координат в пространстве камеры + */ + public get worldDelta(): { x: number; y: number } { + return { + x: this.lastCameraX - this.startCameraX, + y: this.lastCameraY - this.startCameraY, + }; + } + + /** + * Расстояние перетаскивания в экранном пространстве + */ + public get screenDistance(): number { + const delta = this.screenDelta; + return Math.sqrt(delta.x * delta.x + delta.y * delta.y); + } + + /** + * Расстояние перетаскивания в пространстве камеры + */ + public get worldDistance(): number { + const delta = this.worldDelta; + return Math.sqrt(delta.x * delta.x + delta.y * delta.y); + } + + /** + * Направление перетаскивания в пространстве камеры + */ + public get worldDirection(): "horizontal" | "vertical" | "diagonal" | "none" { + const delta = this.worldDelta; + const deltaX = Math.abs(delta.x); + const deltaY = Math.abs(delta.y); + + if (deltaX < 3 && deltaY < 3) return "none"; + + const ratio = deltaX / deltaY; + if (ratio > 2) return "horizontal"; + if (ratio < 0.5) return "vertical"; + return "diagonal"; + } + + /** + * Проверяет, является ли перетаскивание микросдвигом + * @param threshold - Порог расстояния в пикселях (по умолчанию 5) + * @returns true если расстояние меньше порога + */ + public isMicroDrag(threshold = 5): boolean { + return this.worldDistance < threshold; + } + + /** + * Продолжительность перетаскивания в миллисекундах + */ + public get duration(): number { + if (!this.initialEvent || !this.currentEvent) return 0; + return this.currentEvent.timeStamp - this.initialEvent.timeStamp; + } + + /** + * Скорость перетаскивания в пикселях в миллисекунду + */ + public get velocity(): { vx: number; vy: number } { + const duration = this.duration; + if (duration <= 0) return { vx: 0, vy: 0 }; + + const delta = this.worldDelta; + return { + vx: delta.x / duration, + vy: delta.y / duration, + }; + } + + /** + * Исходное событие мыши + */ + public get initialMouseEvent(): MouseEvent | null { + return this.initialEvent; + } + + /** + * Текущее событие мыши + */ + public get currentMouseEvent(): MouseEvent | null { + return this.currentEvent; + } + + /** + * Проверяет, инициализирован ли DragInfo + */ + public get isInitialized(): boolean { + return this.initialEvent !== null; + } + + /** + * Проверяет, есть ли движение с момента инициализации + */ + public get hasMovement(): boolean { + return this.currentEvent !== this.initialEvent; + } +} From 9a1259dc91c8a8920064289d7d13a6e7e0be61a6 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 1 Aug 2025 00:29:27 +0300 Subject: [PATCH 06/10] ... --- README-ru.md | 1 + README.md | 1 + docs/system/drag-system.md | 1050 ++++++++++++++++++++++++ src/components/canvas/blocks/Block.ts | 22 +- src/components/canvas/blocks/Blocks.ts | 35 +- src/index.ts | 9 +- src/services/DragController.ts | 52 +- src/services/DragInfo.ts | 391 ++++++++- src/utils/functions/index.ts | 90 +- 9 files changed, 1621 insertions(+), 30 deletions(-) create mode 100644 docs/system/drag-system.md diff --git a/README-ru.md b/README-ru.md index aeaf5f23..390d0161 100644 --- a/README-ru.md +++ b/README-ru.md @@ -204,6 +204,7 @@ graph.zoomTo("center", { padding: 100 }); | Раздел | Описание | Документация | |--------|-----------|--------------| | Жизненный цикл компонентов | Инициализация, обновление, отрисовка и удаление компонентов | [Подробнее](docs/system/component-lifecycle.md) | +| Система перетаскивания | Управление drag-операциями, модификаторы позиций, поддержка множественного выбора | [Подробнее](docs/system/drag-system.md) | | Механизм отрисовки | Процесс отрисовки и оптимизации | [Подробнее](docs/rendering/rendering-mechanism.md) | | Система событий | Обработка и распространение событий | [Подробнее](docs/system/events.md) | diff --git a/README.md b/README.md index 709448f9..079fb638 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,7 @@ graph.zoomTo("center", { padding: 100 }); 1. System - [Component Lifecycle](docs/system/component-lifecycle.md) + - [Drag System](docs/system/drag-system.md) - [Events](docs/system/events.md) - [Graph Settings](docs/system/graph-settings.md) - [Public API](docs/system/public_api.md) diff --git a/docs/system/drag-system.md b/docs/system/drag-system.md new file mode 100644 index 00000000..02eb6364 --- /dev/null +++ b/docs/system/drag-system.md @@ -0,0 +1,1050 @@ +# Drag System: Smart Dragging with Position Modifiers + +## Introduction + +The drag system in @gravity-ui/graph is designed to solve a fundamental problem: how to make dragging intuitive, precise, and extensible while working with complex graph structures that can be zoomed, panned, and contain multiple selected elements. + +Traditional drag implementations often struggle with: +- **Coordinate space confusion** - mixing screen pixels with world coordinates +- **Inconsistent snapping** - grid alignment that doesn't work across zoom levels +- **Multiple selection complexity** - dragging several blocks while maintaining their relative positions +- **Extensibility limitations** - hard to add new behaviors like anchor magnetism or alignment guides + +Our solution introduces a two-component architecture: +- **DragController** - The orchestrator that manages the drag lifecycle +- **DragInfo** - The smart state container that handles coordinate transformations and position modifications + +This system automatically handles coordinate space conversions, provides extensible position modification through modifiers, and seamlessly supports both single and multiple entity dragging. + +## How DragController Works + +DragController acts as the central command center for all drag operations. Think of it as the conductor of an orchestra - it doesn't make the music itself, but coordinates all the participants to create a harmonious experience. + +### The Drag Lifecycle + +When you start dragging a block, here's what happens behind the scenes: + +1. **Initialization Phase**: DragController receives the initial mouse event and configuration +2. **Setup Phase**: It creates a DragInfo instance with all the necessary state and modifiers +3. **Update Phase**: For each mouse movement, it updates DragInfo and calls your drag handlers +4. **Cleanup Phase**: When dragging ends, it cleans up resources and calls final handlers + +The key insight is that DragController doesn't just pass raw mouse events to your components. Instead, it enriches the events with computed information about position modifications, coordinate transformations, and movement deltas. + +### Configuration: Telling DragController What You Need + +DragController is highly configurable through the `DragControllerConfig` object. Let's break down each option: + +**positionModifiers** - This is where the magic happens. You can provide an array of functions that can modify the drag position in real-time. For example: +```typescript +positionModifiers: [ + DragModifiers.gridSnap(20), // Snap to 20px grid + anchorMagnetism, // Custom anchor snapping + alignmentGuides // Block-to-block alignment +] +``` + +**context** - This is your way to pass dynamic configuration and data to modifiers. It's like a shared context that all modifiers can read from: +```typescript +context: { + enableGridSnap: user.preferences.snapToGrid, + gridSize: layout.cellSize, + selectedBlocks: selectionManager.getSelectedBlocks(), + nearbyAnchors: anchorDetector.findNearby(mousePosition, 50) +} +``` + +**initialEntityPosition** - This is crucial for accurate positioning. It tells DragController where the dragged entity actually starts, not where the mouse cursor is. This distinction is important because you might click anywhere on a block, but the block's position is defined by its top-left corner. + +### A Real Example: Setting Up Block Dragging + +Here's how you would set up dragging for a block with grid snapping enabled: + +```typescript +// In your Blocks component +const startDrag = (event, mainBlock) => { + dragController.start({ + // Your drag event handlers + onDragStart: (event, dragInfo) => { + // Highlight selected blocks, show drag indicators + }, + onDragUpdate: (event, dragInfo) => { + // Update block positions using dragInfo + selectedBlocks.forEach(block => { + const newPos = dragInfo.applyAdjustedDelta(block.startX, block.startY); + block.updatePosition(newPos.x, newPos.y); + }); + }, + beforeUpdate: (dragInfo) => { + // Choose which position modifier to apply + if (shiftPressed) { + dragInfo.selectModifier('gridSnap'); + } else { + dragInfo.selectByDistance(); // Pick the closest suggestion + } + }, + onDragEnd: (event, dragInfo) => { + // Finalize positions, update state + } + }, event, { + positionModifiers: [DragModifiers.gridSnap(gridSize)], + context: { + enableGridSnap: !event.ctrlKey, // Disable with Ctrl + selectedBlocks: getSelectedBlocks() + }, + initialEntityPosition: { + x: mainBlock.state.x, + y: mainBlock.state.y + } + }); +}; +``` + +## Understanding DragInfo: The Smart State Container + +DragInfo is where all the computational intelligence of the drag system lives. While DragController orchestrates the process, DragInfo does the heavy lifting of coordinate calculations, position modifications, and state management. + +### Why DragInfo Exists + +In a complex graph editor, a simple drag operation involves many coordinate systems and calculations: +- Converting between screen pixels and world coordinates +- Handling zoom and pan transformations +- Applying snapping and alignment rules +- Managing multiple selected entities +- Computing movement deltas and velocities + +Instead of scattering this logic across different components, DragInfo centralizes all this intelligence in one place, providing a clean API for drag handlers to use. + +### The Coordinate Systems Problem + +One of the biggest challenges in implementing drag operations is managing different coordinate systems. DragInfo automatically tracks and converts between: + +**Screen Coordinates** (`startX`, `lastX`, `startY`, `lastY`): +These are raw pixel coordinates relative to the browser window. This is what you get directly from mouse events. However, these coordinates become useless when the user zooms or pans the graph. + +**Camera Coordinates** (`startCameraX`, `lastCameraX`, `startCameraY`, `lastCameraY`): +These are coordinates in the "world space" of your graph. They account for zoom level and pan offset. When you zoom in 2x, a 10-pixel mouse movement translates to a 5-unit movement in camera space. DragInfo automatically performs these calculations using the graph's camera service. + +**Entity Coordinates** (`entityStartX`, `entityStartY`, `currentEntityPosition`): +These represent the actual position of the dragged object. This is crucial because the mouse cursor might be anywhere on a block, but the block's position is typically defined by its top-left corner or center point. + +### How Position Modification Works + +The position modification system is the heart of DragInfo's intelligence. Here's how it works step by step: + +**Step 1: Collecting Modifier Suggestions** +When the mouse moves, DragInfo doesn't immediately update the entity position. Instead, it asks all registered position modifiers: "Given the current mouse position, what position would you suggest for the entity?" + +Each modifier that is `applicable()` provides a suggestion. For example: +- GridSnap modifier suggests the nearest grid intersection +- Anchor magnetism suggests the position that would align with nearby anchors +- Alignment guides suggest positions that would align with other blocks + +**Step 2: Lazy Suggestion Calculation** +Position modification can be computationally expensive (imagine calculating distances to hundreds of anchors). DragInfo uses lazy evaluation - suggestions are only calculated when actually needed, and results are cached. + +**Step 3: Modifier Selection** +Multiple modifiers might provide suggestions simultaneously. DragInfo provides several strategies for choosing which one to apply: + +- **selectByPriority()**: Choose the modifier with the highest priority value +- **selectByDistance()**: Choose the suggestion closest to the raw mouse position +- **selectByCustom()**: Let your code implement custom selection logic +- **selectModifier(name)**: Directly select a specific modifier + +**Step 4: Position Application** +Once a modifier is selected, DragInfo calculates the final entity position and makes it available through `adjustedEntityPosition`. + +### Entity vs Mouse Position: A Critical Distinction + +Traditional drag implementations often confuse mouse position with entity position. Here's why the distinction matters: + +Imagine you have a 100x50 pixel block, and the user clicks at the center of the block to start dragging. The mouse is at the block's center, but the block's coordinate system defines its position by its top-left corner. + +**Without this distinction:** +- Mouse moves to (150, 100) +- GridSnap snaps to (160, 100) +- Block position becomes (160, 100) +- But now the block's center is at (210, 125) - the mouse is no longer at the center! + +**With DragInfo's approach:** +- DragInfo calculates the offset between mouse click and entity origin +- Mouse moves to (150, 100) +- Entity position (accounting for offset) would be at (100, 75) +- GridSnap snaps entity to (100, 80) +- Block's center remains properly aligned with the adjusted mouse position + +### Delta-based Movement for Multiple Entities + +When multiple blocks are selected, DragInfo uses a sophisticated delta-based approach: + +1. **Primary Entity**: One block (usually the first clicked) serves as the "primary" entity +2. **Delta Calculation**: DragInfo calculates how much the primary entity has moved: `adjustedEntityPosition - entityStartPosition` +3. **Delta Application**: Each secondary entity applies this same delta to its own starting position + +This ensures that: +- All selected blocks move together as a group +- Position modifications (like grid snapping) affect the entire group consistently +- Relative positioning between blocks is preserved + +Example: +```typescript +// Primary block starts at (100, 100), moves to (120, 100) due to grid snap +// Delta = (20, 0) + +// Secondary block starts at (200, 150) +// Its new position = (200, 150) + (20, 0) = (220, 150) + +selectedBlocks.forEach(block => { + const newPos = dragInfo.applyAdjustedDelta(block.startX, block.startY); + block.updatePosition(newPos.x, newPos.y); +}); +``` + +## Building Position Modifiers: Making Dragging Intelligent + +Position modifiers are the secret sauce that transforms basic mouse movements into intelligent, context-aware positioning. They're small, focused functions that can suggest alternative positions for your dragged entities. + +### The Philosophy Behind Modifiers + +The key insight behind position modifiers is separation of concerns. Instead of hardcoding snapping logic into your drag handlers, you define modifiers as independent, composable functions. This approach offers several benefits: + +1. **Reusability**: A grid snapping modifier works for blocks, connections, and any other draggable entity +2. **Composability**: You can combine multiple modifiers (grid + anchor + alignment) and let the system choose the best suggestion +3. **Testability**: Each modifier is a pure function that's easy to test in isolation +4. **Extensibility**: Adding new behaviors doesn't require changing existing code + +### Anatomy of a Position Modifier + +Every position modifier implements the `PositionModifier` interface with four key properties: + +**name** - A unique string identifier. This is used for debugging, logging, and direct modifier selection. Choose descriptive names like "gridSnap", "anchorMagnetism", or "blockAlignment". + +**priority** - A numeric value that determines which modifier wins when multiple modifiers compete. Higher numbers = higher priority. This is useful for scenarios like "anchor snapping should always override grid snapping". + +**applicable(position, dragInfo, context)** - A function that determines whether this modifier should even be considered. This is your chance to implement conditional logic: +- Only apply grid snapping when the user isn't holding Ctrl +- Only suggest anchor magnetism when there are anchors within 50 pixels +- Only activate alignment guides when multiple blocks are visible + +**suggest(position, dragInfo, context)** - The core function that calculates the modified position. It receives the current entity position and returns a new position suggestion. + +### How the Built-in GridSnap Works + +Let's examine the gridSnap modifier to understand how modifiers work in practice: + +```typescript +gridSnap: (gridSize = 20) => ({ + name: 'gridSnap', + priority: 5, + + applicable: (pos, dragInfo, ctx) => { + // Don't snap during micro-movements (prevents jitter) + if (dragInfo.isMicroDrag()) return false; + + // Check if grid snapping is enabled in context + if (ctx.enableGridSnap === false) return false; + + return true; + }, + + suggest: (pos, dragInfo, ctx) => { + // Allow context to override grid size + const effectiveGridSize = (ctx.gridSize as number) || gridSize; + + // Calculate nearest grid intersection + const snappedX = Math.round(pos.x / effectiveGridSize) * effectiveGridSize; + const snappedY = Math.round(pos.y / effectiveGridSize) * effectiveGridSize; + + return new Point(snappedX, snappedY); + } +}) +``` + +Notice how the modifier is implemented as a factory function. This allows you to create multiple grid snap modifiers with different grid sizes, while still maintaining the same interface. + +### Creating Your Own Modifiers + +Let's walk through creating a more complex modifier - anchor magnetism. This modifier snaps blocks to nearby connection anchors: + +```typescript +const anchorMagnetism: PositionModifier = { + name: 'anchorMagnetism', + priority: 10, // Higher priority than grid snapping + + applicable: (pos, dragInfo, ctx) => { + // Only apply if we're not doing micro-movements + if (dragInfo.isMicroDrag()) return false; + + // Only apply if there are anchors in the context + const anchors = ctx.nearbyAnchors as Anchor[]; + return anchors && anchors.length > 0; + }, + + suggest: (pos, dragInfo, ctx) => { + const anchors = ctx.nearbyAnchors as Anchor[]; + const magnetDistance = ctx.magnetDistance as number || 30; + + // Find the closest anchor within magnet distance + let closestAnchor = null; + let closestDistance = magnetDistance; + + for (const anchor of anchors) { + const distance = Math.sqrt( + Math.pow(pos.x - anchor.x, 2) + + Math.pow(pos.y - anchor.y, 2) + ); + + if (distance < closestDistance) { + closestAnchor = anchor; + closestDistance = distance; + } + } + + // If we found a nearby anchor, snap to it + if (closestAnchor) { + return new Point(closestAnchor.x, closestAnchor.y); + } + + // No snapping suggestion + return pos; + } +}; +``` + +### Context: Sharing Data Between Components and Modifiers + +The context system is how you pass dynamic data and configuration to your modifiers. It's a simple object that gets passed to all modifier functions, allowing you to: + +**Pass Configuration Data:** +```typescript +context: { + enableGridSnap: userPreferences.snapToGrid, + gridSize: 25, + magnetDistance: 40 +} +``` + +**Share Runtime Data:** +```typescript +context: { + selectedBlocks: selectionManager.getSelected(), + nearbyAnchors: anchorDetector.findNearby(mousePos, 100), + allBlocks: graph.blocks.getAll(), + cameraZoom: graph.camera.getZoom() +} +``` + +**Provide Component References:** +```typescript +context: { + graph: this.context.graph, + layer: this.layer, + hitTester: this.hitTester +} +``` + +The beauty of the context system is that it's completely flexible. Different modifiers can look for different properties in the context, and you can add new data without breaking existing modifiers. + +### Advanced Modifier Patterns + +**Conditional Modifiers:** +Sometimes you want modifiers that only activate under specific conditions: + +```typescript +const shiftGridSnap = { + name: 'shiftGridSnap', + priority: 8, + applicable: (pos, dragInfo, ctx) => { + // Only active when Shift is pressed + return ctx.shiftPressed && !dragInfo.isMicroDrag(); + }, + suggest: (pos, dragInfo, ctx) => { + // Snap to larger grid when shift is pressed + const largeGridSize = 100; + return new Point( + Math.round(pos.x / largeGridSize) * largeGridSize, + Math.round(pos.y / largeGridSize) * largeGridSize + ); + } +}; +``` + +**Multi-axis Modifiers:** +Some modifiers might want to snap only horizontally or vertically: + +```typescript +const horizontalAlignment = { + name: 'horizontalAlignment', + priority: 7, + applicable: (pos, dragInfo, ctx) => { + const nearbyBlocks = ctx.nearbyBlocks as Block[]; + return nearbyBlocks && nearbyBlocks.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const nearbyBlocks = ctx.nearbyBlocks as Block[]; + + // Find blocks with similar Y coordinates + const alignmentCandidates = nearbyBlocks.filter(block => + Math.abs(block.y - pos.y) < 10 + ); + + if (alignmentCandidates.length > 0) { + // Suggest aligning Y coordinate with the first candidate + return new Point(pos.x, alignmentCandidates[0].y); + } + + return pos; + } +}; +``` + +## Handling Multiple Selected Entities + +One of the most challenging aspects of implementing a drag system is handling multiple selected entities. Users expect to be able to select several blocks and drag them as a cohesive group, while maintaining their relative positions and applying consistent modifications. + +### The Challenge of Group Dragging + +When dragging a single block, the solution is straightforward: calculate the new position and apply it. But with multiple blocks, several questions arise: + +1. **Which block determines the snapping behavior?** If you have 5 selected blocks, and grid snapping is enabled, which block's position gets snapped to the grid? + +2. **How do you maintain relative positioning?** If blocks were initially 50 pixels apart, they should remain 50 pixels apart after dragging, even with position modifications. + +3. **How do you handle conflicts?** What if one block would snap to a grid intersection, but doing so would cause another block to move outside the visible area? + +### Our Solution: Primary Entity + Delta Propagation + +The drag system solves these challenges using a "primary entity" approach: + +**Step 1: Designate a Primary Entity** +When dragging begins, one of the selected entities (typically the one that was clicked) becomes the "primary entity". This entity's position is used for all position modification calculations. + +**Step 2: Calculate the Primary Entity's Movement** +The system applies all position modifiers to the primary entity, calculating where it should move to. This gives us both: +- The raw movement delta (how far the mouse moved) +- The adjusted movement delta (after applying snapping, alignment, etc.) + +**Step 3: Propagate the Delta to Secondary Entities** +All other selected entities apply the same adjusted delta to their own starting positions. This ensures they move together as a cohesive group. + +### API: Single vs Multiple Entity Patterns + +The drag system provides two different API patterns depending on your use case: + +**For Single Entity Dragging:** +```typescript +onDragUpdate: (event, dragInfo) => { + // Get the final adjusted position directly + const newPos = dragInfo.adjustedEntityPosition; + entity.updatePosition(newPos.x, newPos.y); +} +``` + +This is perfect when you're only dragging one entity. The `adjustedEntityPosition` gives you the final position after all modifications have been applied. + +**For Multiple Entity Dragging:** +```typescript +onDragUpdate: (event, dragInfo) => { + selectedEntities.forEach(entity => { + // Apply the adjusted delta to each entity's starting position + const newPos = dragInfo.applyAdjustedDelta(entity.startX, entity.startY); + entity.updatePosition(newPos.x, newPos.y); + }); +} +``` + +Here, `applyAdjustedDelta()` takes the starting position of each entity and applies the same movement delta that was calculated for the primary entity. + +### A Detailed Example + +Let's trace through a real example to see how this works: + +**Initial Setup:** +- Block A (primary): starts at (100, 100) +- Block B: starts at (250, 150) +- Block C: starts at (200, 50) +- Grid snapping is enabled with 20px grid size + +**User Drags 15 pixels to the right:** +1. Raw mouse movement: +15 pixels horizontally +2. Primary entity (Block A) would move to (115, 100) +3. Grid snapping modifies this to (120, 100) - nearest grid intersection +4. Adjusted delta = (120, 100) - (100, 100) = (20, 0) + +**Delta Applied to All Blocks:** +- Block A: (100, 100) + (20, 0) = (120, 100) +- Block B: (250, 150) + (20, 0) = (270, 150) +- Block C: (200, 50) + (20, 0) = (220, 50) + +**Result:** +All blocks moved together, maintaining their relative positions, and the entire group was snapped to the grid based on the primary entity's final position. + +### Advanced Scenarios + +**Modifier Selection for Groups:** +When multiple modifiers are available, the selection logic (`selectByPriority`, `selectByDistance`, etc.) operates on the primary entity's position. The chosen modifier then affects the entire group. + +**Context Sharing:** +The context object can include information about all selected entities, allowing modifiers to make group-aware decisions: + +```typescript +context: { + selectedBlocks: [blockA, blockB, blockC], + groupBounds: calculateBounds(selectedBlocks), + groupCenter: calculateCenter(selectedBlocks) +} +``` + +**Boundary Checking:** +You might want to implement modifiers that prevent the group from moving outside certain boundaries: + +```typescript +const boundaryModifier = { + name: 'boundaryCheck', + priority: 15, // High priority + applicable: (pos, dragInfo, ctx) => true, + suggest: (pos, dragInfo, ctx) => { + const groupBounds = ctx.groupBounds; + const adjustedBounds = calculateGroupBoundsAtPosition(pos, groupBounds); + + if (adjustedBounds.right > canvasWidth) { + pos.x -= (adjustedBounds.right - canvasWidth); + } + if (adjustedBounds.bottom > canvasHeight) { + pos.y -= (adjustedBounds.bottom - canvasHeight); + } + + return pos; + } +}; +``` + +## Understanding the Context System + +The context system is the communication bridge between your application and the position modifiers. It allows you to pass dynamic data, configuration, and component references to modifiers without tightly coupling them to your specific implementation. + +### Why Context Matters + +Without a context system, position modifiers would be isolated functions with no knowledge of your application's state. They wouldn't know: +- Whether grid snapping is currently enabled in your UI +- What other blocks exist that could be used for alignment +- What the current zoom level is +- Whether certain keyboard modifiers are pressed +- What the user's preferences are + +Context solves this by providing a generic, extensible way to share this information. + +### Built-in Context Properties + +DragInfo automatically includes several useful properties in the context: + +**graph** - Reference to the main Graph instance, giving modifiers access to camera, layers, and services + +**currentPosition** - The current mouse position in camera space (accounts for zoom/pan) + +**currentEntityPosition** - The current position of the dragged entity (after offset calculations) + +**entityStartPosition** - The initial position of the dragged entity + +**Custom Context Properties** - Any additional data you provide when starting the drag + +### Designing Context for Your Use Cases + +The key to effective context design is thinking about what data your modifiers need to make intelligent decisions. Here are some common patterns: + +**User Preferences:** +```typescript +context: { + snapToGrid: userSettings.snapToGrid, + gridSize: userSettings.gridSize, + magnetism: userSettings.enableMagnetism, + showAlignmentGuides: userSettings.showGuides +} +``` + +**Keyboard Modifiers:** +```typescript +context: { + shiftPressed: event.shiftKey, + ctrlPressed: event.ctrlKey, + altPressed: event.altKey +} +``` + +**Spatial Data:** +```typescript +context: { + allBlocks: graph.blocks.getAll(), + visibleBlocks: viewport.getVisibleBlocks(), + nearbyAnchors: anchorService.findNearby(mousePos, 100), + canvasBounds: { width: canvas.width, height: canvas.height } +} +``` + +**Selection State:** +```typescript +context: { + selectedBlocks: selectionManager.getSelected(), + isMultiSelect: selectionManager.count() > 1, + primaryBlock: selectionManager.getPrimary() +} +``` + +### Dynamic Context Updates + +Context can be recalculated on each mouse movement if needed. This is useful for data that changes during the drag operation: + +```typescript +onDragUpdate: (event, dragInfo) => { + // Update context with fresh data + const updatedContext = { + nearbyAnchors: anchorService.findNearby(dragInfo.currentPosition, 50), + visibleBlocks: viewport.getVisibleBlocks() + }; + + // The modifier system will use the updated context + dragInfo.updateContext(updatedContext); +} +``` + +## Real-world Use Cases and Implementation Patterns + +The drag system's flexibility allows it to handle a wide variety of interaction patterns. Here are some common use cases and how to implement them: + +### Precise Grid Snapping + +Grid snapping is essential for creating clean, aligned layouts. The built-in gridSnap modifier handles this, but you can customize its behavior: + +```typescript +// Basic grid snapping +positionModifiers: [DragModifiers.gridSnap(20)] + +// Dynamic grid size based on zoom level +context: { + gridSize: Math.max(10, 20 / camera.zoom) // Larger grid when zoomed out +} + +// Conditional grid snapping +context: { + enableGridSnap: !event.ctrlKey // Disable when Ctrl is held +} +``` + +### Magnetic Anchor Snapping + +When creating connections, you want blocks to snap to nearby anchor points: + +```typescript +const anchorMagnetism = { + name: 'anchorMagnetism', + priority: 10, + applicable: (pos, dragInfo, ctx) => { + return ctx.nearbyAnchors && ctx.nearbyAnchors.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const magnetDistance = 25; + const closest = findClosestAnchor(pos, ctx.nearbyAnchors, magnetDistance); + return closest ? new Point(closest.x, closest.y) : pos; + } +}; + +// Update nearby anchors during drag +context: { + nearbyAnchors: anchorService.findWithinRadius(currentPos, 50) +} +``` + +### Visual Alignment Guides + +Alignment guides help users line up blocks with each other: + +```typescript +const alignmentGuides = { + name: 'alignment', + priority: 8, + applicable: (pos, dragInfo, ctx) => { + return ctx.otherBlocks && ctx.otherBlocks.length > 0; + }, + suggest: (pos, dragInfo, ctx) => { + const tolerance = 5; + + // Check for horizontal alignment + for (const block of ctx.otherBlocks) { + if (Math.abs(pos.y - block.y) < tolerance) { + return new Point(pos.x, block.y); // Snap to same Y + } + if (Math.abs(pos.y - (block.y + block.height)) < tolerance) { + return new Point(pos.x, block.y + block.height); // Snap to bottom edge + } + } + + // Check for vertical alignment + for (const block of ctx.otherBlocks) { + if (Math.abs(pos.x - block.x) < tolerance) { + return new Point(block.x, pos.y); // Snap to same X + } + } + + return pos; + } +}; +``` + +### Boundary Constraints + +Prevent blocks from being dragged outside the canvas or into restricted areas: + +```typescript +const boundaryConstraint = { + name: 'boundary', + priority: 20, // High priority - always enforce + applicable: () => true, + suggest: (pos, dragInfo, ctx) => { + const bounds = ctx.canvasBounds; + const blockSize = ctx.blockSize || { width: 100, height: 50 }; + + return new Point( + Math.max(0, Math.min(pos.x, bounds.width - blockSize.width)), + Math.max(0, Math.min(pos.y, bounds.height - blockSize.height)) + ); + } +}; +``` + +### Keyboard-modified Behavior + +Different modifier keys can change snapping behavior: + +```typescript +// In beforeUpdate handler +beforeUpdate: (dragInfo) => { + if (dragInfo.context.shiftPressed) { + // Shift = constrain to single axis + dragInfo.selectModifier('axisConstraint'); + } else if (dragInfo.context.ctrlPressed) { + // Ctrl = disable all snapping + dragInfo.selectDefault(); + } else { + // Normal mode = use closest suggestion + dragInfo.selectByDistance(); + } +} +``` + +### Velocity-based Interactions + +Use movement speed to trigger different behaviors: + +```typescript +const velocityModifier = { + name: 'velocity', + priority: 5, + applicable: (pos, dragInfo, ctx) => { + return dragInfo.velocity > 500; // Fast movement + }, + suggest: (pos, dragInfo, ctx) => { + // During fast movement, disable precise snapping + // to allow fluid motion + return pos; + } +}; +``` + +## Integration with Graph Components + +The drag system is designed to integrate seamlessly with existing graph components. Here's how different parts of the system work together: + +### Block Component Integration + +Individual block components participate in the drag system by implementing drag handlers. Each block receives enriched dragInfo and can respond appropriately: + +```typescript +class Block extends Component { + onDragUpdate(event: MouseEvent, dragInfo: DragInfo) { + // For multiple selection, apply delta to this block's start position + const newPos = dragInfo.applyAdjustedDelta( + this.startDragCoords[0], + this.startDragCoords[1] + ); + + // Update block position with snapping/alignment applied + this.updatePosition(newPos.x, newPos.y); + + // Trigger any visual feedback + this.showPositionPreview(newPos); + } + + onDragEnd(event: MouseEvent, dragInfo: DragInfo) { + // Finalize position and clear any temporary visual indicators + this.finalizePosition(); + this.hidePositionPreview(); + } +} +``` + +The key insight is that individual blocks don't need to know about snapping logic, coordinate transformations, or multiple selection. They simply apply the computed delta to their starting position. + +### Layer Integration for Visual Feedback + +Rendering layers can access drag state to provide visual feedback like grid overlays, alignment guides, or drop zones: + +```typescript +class AlignmentGuidesLayer extends Layer { + render(ctx: CanvasRenderingContext2D) { + if (!this.graph.dragController.isDragging) return; + + const dragInfo = this.graph.dragController.dragInfo; + + // Show alignment lines when alignment modifier is active + if (dragInfo.isModified('alignment')) { + this.renderAlignmentGuides(ctx, dragInfo); + } + + // Show grid when grid snapping is active + if (dragInfo.isModified('gridSnap')) { + this.renderGrid(ctx, dragInfo.context.gridSize); + } + } + + renderAlignmentGuides(ctx: CanvasRenderingContext2D, dragInfo: DragInfo) { + // Draw helper lines showing alignment with other blocks + const guides = this.calculateAlignmentGuides(dragInfo); + guides.forEach(guide => this.drawGuideLine(ctx, guide)); + } +} +``` + +### Service Integration + +Camera and other services provide essential coordinate transformation capabilities: + +```typescript +// DragInfo automatically uses camera service for coordinate transformations +class DragInfo { + get startCameraX(): number { + if (!this._startCameraPoint) { + // Convert screen coordinates to camera space + this._startCameraPoint = this.graph.getPointInCameraSpace(this.initialEvent); + } + return this._startCameraPoint.x; + } +} +``` + +This ensures that drag operations work correctly regardless of zoom level or pan position. + +## Performance Optimization and Best Practices + +The drag system is designed for high performance during interactive dragging. Here are the key optimizations and how to use them effectively: + +### Lazy Evaluation Strategy + +Many drag calculations are expensive and might not be needed every frame. The system uses lazy evaluation extensively: + +**Coordinate Transformations:** +```typescript +// Camera space coordinates are only calculated when first accessed +get lastCameraX(): number { + if (!this._currentCameraPoint) { + this._currentCameraPoint = this.graph.getPointInCameraSpace(this.lastEvent); + } + return this._currentCameraPoint.x; +} +``` + +**Modifier Suggestions:** +```typescript +// Position suggestions are only calculated when the modifier is applicable +const suggestion = { + name: modifier.name, + priority: modifier.priority, + getSuggestedPosition: () => { + // Lazy evaluation - only calculated when accessed + if (!this.cachedPosition) { + this.cachedPosition = modifier.suggest(pos, dragInfo, context); + } + return this.cachedPosition; + } +}; +``` + +### Micro-drag Detection + +Small mouse movements (micro-drags) are filtered out to prevent jittery behavior: + +```typescript +// Built into DragInfo +isMicroDrag(): boolean { + const threshold = 3; // pixels + return this.distance < threshold; +} + +// Used by modifiers to avoid unnecessary snapping +applicable: (pos, dragInfo, ctx) => { + if (dragInfo.isMicroDrag()) return false; // Skip snapping for tiny movements + return true; +} +``` + +### Efficient Context Updates + +Context updates can be expensive if done inefficiently. Best practices: + +```typescript +// Good: Update only changed properties +const updatedContext = { + ...existingContext, + nearbyAnchors: newAnchors // Only update what changed +}; + +// Avoid: Recalculating everything every frame +const expensiveContext = { + allBlocks: graph.blocks.getAll(), // Expensive! + allConnections: graph.connections.getAll(), // Expensive! + // ... computed every mouse move +}; +``` + +### Memory Management + +The drag system minimizes object creation during drag operations: + +```typescript +// Reuse Point objects where possible +const reusablePoint = new Point(0, 0); + +suggest: (pos, dragInfo, ctx) => { + // Modify existing object instead of creating new one + reusablePoint.x = Math.round(pos.x / gridSize) * gridSize; + reusablePoint.y = Math.round(pos.y / gridSize) * gridSize; + return reusablePoint; +} +``` + +## Complete API Reference + +### DragController + +**Methods:** +```typescript +start(handler: DragHandler, event: MouseEvent, config?: DragControllerConfig): void +// Begins a drag operation with the given handler and configuration + +update(event: MouseEvent): void +// Processes a mouse move event during dragging + +end(event: MouseEvent): void +// Completes the drag operation and cleans up resources + +get isDragging(): boolean +// Returns true if a drag operation is currently active + +get dragInfo(): DragInfo | null +// Returns the current DragInfo instance, or null if not dragging +``` + +**Configuration (DragControllerConfig):** +```typescript +interface DragControllerConfig { + positionModifiers?: PositionModifier[]; // Array of position modification functions + context?: Record; // Custom data passed to modifiers + initialEntityPosition?: { x: number; y: number }; // Starting position of dragged entity +} +``` + +### DragInfo + +**Position Properties:** +```typescript +// Screen coordinates (raw pixel values) +readonly startX: number; // Initial mouse X position +readonly startY: number; // Initial mouse Y position +readonly lastX: number; // Current mouse X position +readonly lastY: number; // Current mouse Y position + +// Camera coordinates (world space, accounting for zoom/pan) +readonly startCameraX: number; // Initial mouse position in camera space +readonly startCameraY: number; +readonly lastCameraX: number; // Current mouse position in camera space +readonly lastCameraY: number; + +// Entity coordinates (position of dragged object) +readonly entityStartX: number; // Initial entity X position +readonly entityStartY: number; // Initial entity Y position +readonly currentEntityPosition: Point; // Current entity position (before modifications) +readonly adjustedEntityPosition: Point; // Final entity position (after modifications) +``` + +**Movement Calculations:** +```typescript +readonly worldDelta: { x: number; y: number }; // Raw movement delta +readonly adjustedWorldDelta: { x: number; y: number }; // Movement delta with modifications +readonly distance: number; // Total distance moved +readonly velocity: number; // Current movement velocity +readonly duration: number; // Time since drag started +``` + +**Modifier Management:** +```typescript +analyzeSuggestions(): void; // Generate suggestions from all applicable modifiers +selectByPriority(): void; // Choose modifier with highest priority +selectByDistance(): void; // Choose modifier with closest suggestion +selectByCustom(fn: SelectionFunction): void; // Use custom selection logic +selectModifier(name: string): void; // Directly select a specific modifier +selectDefault(): void; // Use no modifications (raw position) + +isApplicable(modifierName: string): boolean; // Check if a modifier is currently applicable +isModified(modifierName?: string): boolean; // Check if any/specific modifier is active +``` + +**Position Application:** +```typescript +applyAdjustedDelta(startX: number, startY: number): { x: number; y: number }; +// Apply the adjusted movement delta to any starting position +// Essential for multiple entity dragging +``` + +**Context Management:** +```typescript +updateContext(newContext: Record): void; +// Update the custom context during drag operation +// Merges new context with existing data and invalidates cache + +readonly context: DragContext; // Access to context data +``` + +**Utility Methods:** +```typescript +isMicroDrag(): boolean; // True if movement is below threshold +``` + +### Position Modifiers + +**Interface:** +```typescript +interface PositionModifier { + name: string; // Unique identifier + priority: number; // Conflict resolution priority + applicable(pos: Point, dragInfo: DragInfo, ctx: DragContext): boolean; // Availability check + suggest(pos: Point, dragInfo: DragInfo, ctx: DragContext): Point; // Position calculation +} +``` + +**Built-in Factory:** +```typescript +DragModifiers.gridSnap(gridSize?: number): PositionModifier; +// Creates a grid snapping modifier with the specified grid size +``` + +**Context Interface:** +```typescript +interface DragContext { + graph: Graph; // Graph instance reference + currentPosition: Point; // Current mouse position in camera space + currentEntityPosition: Point; // Current entity position + entityStartPosition: Point | null; // Initial entity position + [key: string]: unknown; // Custom context properties +} +``` + +This comprehensive API allows for flexible, performant drag implementations that can handle everything from simple block movement to complex multi-entity operations with sophisticated snapping and alignment behaviors. \ No newline at end of file diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index de2bf5da..0ae1223d 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -251,32 +251,26 @@ export class Block this.applyNextPosition(x, y) + () => this.applyNextPosition(newPos.x, newPos.y) ); } - protected calcNextDragPosition(dragInfo: DragInfo, snapToGrid?: boolean) { + protected calcNextDragPosition(dragInfo: DragInfo) { const diff = dragInfo.worldDelta; - let nextX = this.startDragCoords[0] + diff.x; - let nextY = this.startDragCoords[1] + diff.y; - - const spanGridSize = 15; //this.context.constants.block.SNAPPING_GRID_SIZE; - - if (snapToGrid && spanGridSize > 1) { - nextX = Math.round(nextX / spanGridSize) * spanGridSize; - nextY = Math.round(nextY / spanGridSize) * spanGridSize; - } + const nextX = this.startDragCoords[0] + diff.x; + const nextY = this.startDragCoords[1] + diff.y; return [nextX, nextY]; } diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index b423b8c2..9274472f 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -1,6 +1,6 @@ import { BlockState } from "../../../store/block/Block"; import { BlockListStore } from "../../../store/block/BlocksList"; -import { isMetaKeyEvent } from "../../../utils/functions"; +import { DragModifiers, isMetaKeyEvent } from "../../../utils/functions"; import { ESelectionStrategy } from "../../../utils/types/types"; import { GraphComponent } from "../GraphComponent"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -56,7 +56,6 @@ export class Blocks extends GraphComponent { this.addEventListener("mousedown", (event) => { const blockInstance = this.getTargetComponent(event); - // const { target: blockInstance, sourceEvent } = graphEvent.detail; if (!(blockInstance instanceof Block) || !blockInstance.isDraggable()) { return; @@ -70,26 +69,46 @@ export class Blocks extends GraphComponent { const selectedBlocksComponents: Block[] = selectedBlocksStates.map((block) => block.getViewComponent()); this.context.graph.getGraphLayer().captureEvents(blockInstance); + const gridSnap = DragModifiers.gridSnap(15, "drop"); + + // Получаем начальную позицию основного блока (который инициировал драг) + const mainBlockState = blockInstance.connectedState; + const initialEntityPosition = { x: mainBlockState.x, y: mainBlockState.y }; + this.context.graph.dragController.start( { - onDragStart: (dragEvent, _dragInfo) => { - for (const block of selectedBlocksComponents) { + onDragStart: (dragEvent, dragInfo) => { + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { block.onDragStart(dragEvent); } }, + beforeUpdate: (dragInfo) => { + dragInfo.selectModifier(gridSnap.name); + }, onDragUpdate: (dragEvent, dragInfo) => { - for (const block of selectedBlocksComponents) { + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { block.onDragUpdate(dragEvent, dragInfo); } }, onDragEnd: (dragEvent, dragInfo) => { this.context.graph.getGraphLayer().releaseCapture(); - for (const block of selectedBlocksComponents) { - block.onDragEnd(dragEvent, dragInfo); + const blocks = dragInfo.context.selectedBlocks as Block[]; + for (const block of blocks) { + block.onDragEnd(dragEvent); } }, }, - event as MouseEvent + event as MouseEvent, + { + positionModifiers: [gridSnap], + initialEntityPosition: initialEntityPosition, + context: { + enableGridSnap: true, + selectedBlocks: selectedBlocksComponents, + }, + } ); }); } diff --git a/src/index.ts b/src/index.ts index 33eb6c69..229c2848 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,7 +7,14 @@ export { type UnwrapGraphEventsDetail } from "./graphEvents"; export * from "./plugins"; export { ECameraScaleLevel } from "./services/camera/CameraService"; export { DragController, type DragHandler, type DragControllerConfig } from "./services/DragController"; -export { DragInfo } from "./services/DragInfo"; +export { + DragInfo, + type PositionModifier, + type DragContext, + type ModifierSuggestion, + type DragStage, +} from "./services/DragInfo"; +export { DragModifiers } from "./utils/functions"; export * from "./services/Layer"; export * from "./store"; export { EAnchorType } from "./store/anchor/Anchor"; diff --git a/src/services/DragController.ts b/src/services/DragController.ts index e5cb843b..4c7766e2 100644 --- a/src/services/DragController.ts +++ b/src/services/DragController.ts @@ -3,12 +3,18 @@ import { ESchedulerPriority, scheduler } from "../lib/Scheduler"; import { dragListener } from "../utils/functions/dragListener"; import { EVENTS } from "../utils/types/events"; -import { DragInfo } from "./DragInfo"; +import { DragInfo, PositionModifier } from "./DragInfo"; /** * Интерфейс для компонентов, которые могут быть перетаскиваемыми */ export interface DragHandler { + /** + * Вызывается перед обновлением позиции для выбора модификатора + * @param dragInfo - Statefull модель с информацией о перетаскивании + */ + beforeUpdate?(dragInfo: DragInfo): void; + /** * Вызывается при начале перетаскивания * @param event - Событие мыши @@ -37,6 +43,12 @@ export interface DragHandler { export interface DragControllerConfig { /** Включить автоматическое движение камеры при приближении к границам */ enableEdgePanning?: boolean; + /** Модификаторы позиции для коррекции координат во время перетаскивания */ + positionModifiers?: PositionModifier[]; + /** Дополнительный контекст для передачи в модификаторы */ + context?: Record; + /** Начальная позиция перетаскиваемой сущности в пространстве камеры */ + initialEntityPosition?: { x: number; y: number }; } /** @@ -57,7 +69,6 @@ export class DragController { constructor(graph: Graph) { this.graph = graph; - this.dragInfo = new DragInfo(graph); } /** @@ -78,7 +89,13 @@ export class DragController { this.isDragging = true; this.lastMouseEvent = event; - // Инициализируем DragInfo с начальным событием + // Создаем DragInfo с модификаторами, контекстом и инициализируем + this.dragInfo = new DragInfo( + this.graph, + config.positionModifiers || [], + config.context, + config.initialEntityPosition + ); this.dragInfo.init(event); if (config.enableEdgePanning ?? true) { @@ -113,6 +130,17 @@ export class DragController { // Обновляем состояние DragInfo this.dragInfo.update(event); + // Анализируем модификаторы позиции + this.dragInfo.analyzeSuggestions(); + + // Даем возможность выбрать модификатор в beforeUpdate + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Дефолтная стратегия - по расстоянию + this.dragInfo.selectDefault(); + } + this.currentDragHandler.onDragUpdate(event, this.dragInfo); } @@ -135,10 +163,24 @@ export class DragController { // Отключаем edge panning this.graph.getGraphLayer().disableEdgePanning(); - // Завершаем процесс в DragInfo + // Завершаем процесс в DragInfo (устанавливает стадию 'drop') this.dragInfo.end(event); - // Вызываем обработчик завершения перетаскивания + // Анализируем модификаторы на стадии 'drop' + this.dragInfo.analyzeSuggestions(); + + // Даем возможность выбрать модификатор на стадии drop + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Дефолтная стратегия - по расстоянию + this.dragInfo.selectDefault(); + } + + // Вызываем onDragUpdate с финальными позициями (на стадии 'drop') + this.currentDragHandler.onDragUpdate(event, this.dragInfo); + + // Затем вызываем обработчик завершения перетаскивания this.currentDragHandler.onDragEnd(event, this.dragInfo); // Сбрасываем состояние diff --git a/src/services/DragInfo.ts b/src/services/DragInfo.ts index e6c11e39..77100c20 100644 --- a/src/services/DragInfo.ts +++ b/src/services/DragInfo.ts @@ -1,6 +1,53 @@ import { Graph } from "../graph"; import { Point } from "../utils/types/shapes"; +/** + * Стадии жизненного цикла перетаскивания + */ +export type DragStage = "start" | "dragging" | "drop"; + +/** + * Интерфейс для модификатора позиции при перетаскивании + */ +export interface PositionModifier { + name: string; + priority: number; + + /** Проверяет, применим ли модификатор для данной позиции */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => boolean; + + /** Предлагает новую позицию (ленивое вычисление) */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => Point | null; +} + +/** + * Контекст для модификаторов перетаскивания + */ +export interface DragContext { + graph: Graph; + currentPosition: Point; + stage: DragStage; + [key: string]: unknown; +} + +/** + * Предложение модификатора с ленивым вычислением + */ +export interface ModifierSuggestion { + name: string; + priority: number; + distance: number | null; + + /** Получает предложенную позицию (с кэшированием) */ + getSuggestedPosition(): Point | null; + + /** @private Ленивая функция вычисления */ + _suggester: () => Point | null; + + /** @private Кэш позиции */ + _cachedPosition?: Point | null; +} + /** * Statefull модель для хранения информации о процессе перетаскивания * Использует ленивые вычисления через getter-ы для оптимальной производительности @@ -13,7 +60,33 @@ export class DragInfo { private _startCameraPoint: Point | null = null; private _currentCameraPoint: Point | null = null; - constructor(protected graph: Graph) {} + // Система модификаторов позиции + private modifiers: PositionModifier[] = []; + private suggestions: ModifierSuggestion[] = []; + private selectedModifier: string | null = null; + private contextCache: DragContext | null = null; + private customContext: Record; + + // Стадия перетаскивания + private currentStage: DragStage = "start"; + + // Позиция перетаскиваемой сущности + private entityStartPosition: Point | null = null; + private mouseToEntityOffset: Point | null = null; + + constructor( + protected graph: Graph, + modifiers: PositionModifier[] = [], + customContext?: Record, + initialEntityPosition?: { x: number; y: number } + ) { + this.modifiers = modifiers; + this.customContext = customContext || {}; + + if (initialEntityPosition) { + this.entityStartPosition = new Point(initialEntityPosition.x, initialEntityPosition.y); + } + } /** * Сбрасывает состояние DragInfo @@ -24,6 +97,18 @@ export class DragInfo { this.currentEvent = null; this._startCameraPoint = null; this._currentCameraPoint = null; + this.suggestions = []; + this.selectedModifier = null; + this.contextCache = null; + this.currentStage = "start"; // Возвращаем к начальной стадии + // Кастомный контекст не сбрасываем, так как он задается при создании DragInfo + } + + /** + * Получает текущую стадию перетаскивания + */ + public get stage(): DragStage { + return this.currentStage; } /** @@ -36,6 +121,20 @@ export class DragInfo { this.currentEvent = event; this._startCameraPoint = null; // Будет вычислен лениво this._currentCameraPoint = null; + this.currentStage = "start"; // Устанавливаем стадию инициализации + + // Вычисляем offset между мышью и сущностью при инициализации + if (this.entityStartPosition) { + const mouseStartPoint = this.graph.getPointInCameraSpace(event); + this.mouseToEntityOffset = new Point( + mouseStartPoint.x - this.entityStartPosition.x, + mouseStartPoint.y - this.entityStartPosition.y + ); + } + } + + public get context(): DragContext | null { + return this.getDragContext(); } /** @@ -46,6 +145,8 @@ export class DragInfo { public update(event: MouseEvent): void { this.currentEvent = event; this._currentCameraPoint = null; // Сбрасываем кэш для перевычисления + this.currentStage = "dragging"; // Устанавливаем стадию активного перетаскивания + this.contextCache = null; // Сбрасываем кэш контекста для обновления stage } /** @@ -56,6 +157,18 @@ export class DragInfo { public end(event: MouseEvent): void { this.currentEvent = event; this._currentCameraPoint = null; // Финальное обновление + this.currentStage = "drop"; // Устанавливаем стадию завершения + this.contextCache = null; // Сбрасываем кэш контекста для обновления stage + } + + /** + * Обновляет кастомный контекст во время операции перетаскивания + * @param newContext - Новые данные контекста для объединения с существующими + * @returns void + */ + public updateContext(newContext: Record): void { + this.customContext = { ...this.customContext, ...newContext }; + this.contextCache = null; // Сбрасываем кэш контекста для перевычисления } // === ЛЕНИВЫЕ ГЕТТЕРЫ ДЛЯ ЭКРАННЫХ КООРДИНАТ === @@ -250,4 +363,280 @@ export class DragInfo { public get hasMovement(): boolean { return this.currentEvent !== this.initialEvent; } + + // === СИСТЕМА МОДИФИКАТОРОВ ПОЗИЦИИ === + + /** + * Анализирует все модификаторы и создает предложения + * @returns void + */ + public analyzeSuggestions(): void { + if (this.modifiers.length === 0) { + this.suggestions = []; + return; + } + + // Используем позицию сущности для модификаторов, а не позицию мыши + const entityPos = this.currentEntityPosition; + const context = this.getDragContext(); + + this.suggestions = this.modifiers + .filter((m) => m.applicable(entityPos, this, context)) + .map((modifier) => this.createSuggestion(modifier, entityPos, context)); + } + + /** + * Создает ленивое предложение модификатора + * @param modifier - Модификатор позиции + * @param pos - Исходная позиция + * @param ctx - Контекст перетаскивания + * @returns Предложение с ленивым вычислением + */ + private createSuggestion(modifier: PositionModifier, pos: Point, ctx: DragContext): ModifierSuggestion { + return { + name: modifier.name, + priority: modifier.priority, + distance: null, // Ленивое вычисление + _suggester: () => modifier.suggest(pos, this, ctx), + _cachedPosition: undefined, + + getSuggestedPosition(): Point | null { + if (this._cachedPosition === undefined) { + this._cachedPosition = this._suggester(); + } + return this._cachedPosition; + }, + }; + } + + /** + * Выбирает модификатор по приоритету (первый с наименьшим приоритетом) + * @returns void + */ + public selectByPriority(): void { + const best = this.suggestions.sort((a, b) => a.priority - b.priority)[0]; + this.selectedModifier = best?.name || null; + } + + /** + * Выбирает модификатор по расстоянию (ближайший к исходной позиции) + * @returns void + */ + public selectByDistance(): void { + const withDistances = this.suggestions + .map((s) => ({ + ...s, + distance: this.calculateDistance(s), + })) + .sort((a, b) => a.distance - b.distance); + + this.selectedModifier = withDistances[0]?.name || null; + } + + /** + * Выбирает модификатор с помощью кастомной функции + * @param selector - Функция выбора модификатора + * @returns void + */ + public selectByCustom(selector: (suggestions: ModifierSuggestion[]) => string | null): void { + this.selectedModifier = selector(this.suggestions); + } + + /** + * Выбирает конкретный модификатор по имени + * @param name - Имя модификатора + * @returns void + */ + public selectModifier(name: string): void { + if (this.suggestions.some((s) => s.name === name)) { + this.selectedModifier = name; + } + } + + /** + * Выбирает модификатор по умолчанию (по расстоянию) + * @returns void + */ + public selectDefault(): void { + this.selectByDistance(); + } + + /** + * Вычисляет расстояние от исходной до предложенной позиции + * @param suggestion - Предложение модификатора + * @returns Расстояние в пикселях + */ + private calculateDistance(suggestion: ModifierSuggestion): number { + const original = new Point(this.lastCameraX, this.lastCameraY); + const suggested = suggestion.getSuggestedPosition(); + + if (!suggested) return Infinity; + + return Math.sqrt((suggested.x - original.x) ** 2 + (suggested.y - original.y) ** 2); + } + + /** + * Проверяет, применим ли модификатор с указанным именем + * @param modifierName - Имя модификатора + * @returns true если модификатор применим + */ + public isApplicable(modifierName: string): boolean { + return this.suggestions.some((s) => s.name === modifierName); + } + + /** + * Проверяет, применен ли модификатор с указанным именем + * @param modifierName - Имя модификатора + * @returns true если модификатор применен + */ + public isModified(modifierName: string): boolean { + return this.selectedModifier === modifierName; + } + + /** + * Получает скорректированную позицию с учетом примененного модификатора + */ + public get adjustedPosition(): Point { + if (!this.selectedModifier) { + return new Point(this.lastCameraX, this.lastCameraY); + } + + const suggestion = this.suggestions.find((s) => s.name === this.selectedModifier); + const adjustedPos = suggestion?.getSuggestedPosition(); + + return adjustedPos || new Point(this.lastCameraX, this.lastCameraY); + } + + /** + * Получает скорректированную координату X + */ + public get adjustedCameraX(): number { + return this.adjustedPosition.x; + } + + /** + * Получает скорректированную координату Y + */ + public get adjustedCameraY(): number { + return this.adjustedPosition.y; + } + + // === ПОЗИЦИЯ СУЩНОСТИ === + + /** + * Начальная позиция сущности + */ + public get entityStartX(): number { + return this.entityStartPosition?.x ?? 0; + } + + /** + * Начальная позиция сущности + */ + public get entityStartY(): number { + return this.entityStartPosition?.y ?? 0; + } + + /** + * Текущая позиция сущности (без модификаторов) + */ + public get currentEntityPosition(): Point { + if (!this.entityStartPosition || !this.mouseToEntityOffset) { + // Fallback к позиции мыши если нет данных о сущности + return new Point(this.lastCameraX, this.lastCameraY); + } + + const currentMousePos = new Point(this.lastCameraX, this.lastCameraY); + return new Point(currentMousePos.x - this.mouseToEntityOffset.x, currentMousePos.y - this.mouseToEntityOffset.y); + } + + /** + * Скорректированная позиция сущности с учетом модификаторов + */ + public get adjustedEntityPosition(): Point { + if (!this.selectedModifier) { + return this.currentEntityPosition; + } + + const suggestion = this.suggestions.find((s) => s.name === this.selectedModifier); + const adjustedPos = suggestion?.getSuggestedPosition(); + + return adjustedPos || this.currentEntityPosition; + } + + /** + * Скорректированная X координата сущности + */ + public get adjustedEntityX(): number { + return this.adjustedEntityPosition.x; + } + + /** + * Скорректированная Y координата сущности + */ + public get adjustedEntityY(): number { + return this.adjustedEntityPosition.y; + } + + /** + * Дельта между начальной и скорректированной позицией сущности + * Используется для применения той же дельты к другим сущностям + */ + public get adjustedWorldDelta(): { x: number; y: number } { + if (!this.entityStartPosition) { + return { x: 0, y: 0 }; + } + + const adjustedPos = this.adjustedEntityPosition; + return { + x: adjustedPos.x - this.entityStartPosition.x, + y: adjustedPos.y - this.entityStartPosition.y, + }; + } + + /** + * Применяет скорректированную дельту к произвольной начальной позиции + * @param startX - Начальная X координата сущности + * @param startY - Начальная Y координата сущности + * @returns Новая позиция с примененной дельтой + */ + public applyAdjustedDelta(startX: number, startY: number): { x: number; y: number } { + const delta = this.adjustedWorldDelta; + return { + x: startX + delta.x, + y: startY + delta.y, + }; + } + + // === КОНТЕКСТ ПЕРЕТАСКИВАНИЯ === + + /** + * Получает контекст перетаскивания (с кэшированием) + * @returns Контекст перетаскивания + */ + private getDragContext(): DragContext { + if (!this.contextCache) { + this.contextCache = this.createSimpleContext(); + } + return this.contextCache; + } + + /** + * Создает простой контекст перетаскивания + * @returns Базовый контекст с дополнительными данными от пользователя + */ + private createSimpleContext(): DragContext { + const mousePos = new Point(this.lastCameraX, this.lastCameraY); + const entityPos = this.currentEntityPosition; + + return { + graph: this.graph, + currentPosition: mousePos, // Позиция мыши (для совместимости) + currentEntityPosition: entityPos, // Позиция сущности + entityStartPosition: this.entityStartPosition, + stage: this.currentStage, // Текущая стадия перетаскивания + // Добавляем пользовательский контекст + ...this.customContext, + }; + } } diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index c3661762..e6a39205 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -1,8 +1,9 @@ import { Block } from "../../components/canvas/blocks/Block"; +import type { DragStage, PositionModifier } from "../../services/DragInfo"; import { BlockState, TBlockId } from "../../store/block/Block"; import { ECanChangeBlockGeometry } from "../../store/settings"; import { EVENTS_DETAIL, SELECTION_EVENT_TYPES } from "../types/events"; -import { Rect, TRect } from "../types/shapes"; +import { Point, Rect, TRect } from "../types/shapes"; export { parseClassNames } from "./classNames"; @@ -255,3 +256,90 @@ export function computeCssVariable(name: string) { // Re-export scheduler utilities export { schedule, debounce, throttle } from "../utils/schedule"; + +// === POSITION MODIFIERS === + +/** + * Утилиты для создания стандартных модификаторов позиции + * + * Новая архитектура поддерживает позицию сущности вместо позиции мыши: + * - `adjustedEntityPosition` - скорректированная позиция сущности (блока) + * - `initialEntityPosition` - начальная позиция сущности + * - Модификаторы работают с позицией сущности, а не курсора + * + * @example + * dragController.start(handler, event, { + * positionModifiers: [DragModifiers.gridSnap(20)], + * initialEntityPosition: { x: block.x, y: block.y }, // Позиция блока + * context: { + * enableGridSnap: true, + * gridSize: 15, // Переопределяем размер сетки + * selectedBlocks: [block1, block2] // любые данные + * } + * }); + * + * // В обработчике для одного блока: + * onDragUpdate(event, dragInfo) { + * const newPos = dragInfo.adjustedEntityPosition; // Позиция основного блока + * mainBlock.updatePosition(newPos.x, newPos.y); + * } + * + * // Для множественного выбора (каждый блок использует свою начальную позицию): + * onDragUpdate(event, dragInfo) { + * const newPos = dragInfo.applyAdjustedDelta(this.startX, this.startY); + * thisBlock.updatePosition(newPos.x, newPos.y); + * } + */ +export const DragModifiers = { + /** + * Создает модификатор для привязки к сетке + * @param gridSize - Размер ячейки сетки в пикселях + * @returns Модификатор gridSnap + */ + gridSnap: (gridSize: number, stage: DragStage = "drop"): PositionModifier => ({ + name: "grid-snap", + priority: 10, + + applicable: (pos, dragInfo, ctx) => { + // Применяем только если есть движение (не микросдвиг) + const isEnabled = ctx.enableGridSnap !== false; // По умолчанию включен + return !dragInfo.isMicroDrag() && isEnabled && ctx.stage === stage; + }, + + suggest: (pos, _dragInfo, ctx) => { + // Можно переопределить размер сетки через контекст + const effectiveGridSize = (ctx.gridSize as number) || gridSize; + return new Point( + Math.round(pos.x / effectiveGridSize) * effectiveGridSize, + Math.round(pos.y / effectiveGridSize) * effectiveGridSize + ); + }, + }), + + /** + * Создает модификатор для привязки к сетке только при завершении перетаскивания (stage === 'drop') + * Применяется только на стадии 'drop' для snap только при отпускании мыши + * @param gridSize - Размер ячейки сетки в пикселях + * @returns Модификатор gridSnapOnDrop + */ + gridSnapOnDrop: (gridSize: number): PositionModifier => ({ + name: "grid-snap-on-drop", + priority: 10, + + applicable: (pos, dragInfo, ctx) => { + // Применяется только на стадии 'drop' + const isDropStage = ctx.stage === "drop"; + const isEnabled = ctx.enableGridSnap !== false; // По умолчанию включен + return isDropStage && !dragInfo.isMicroDrag() && isEnabled; + }, + + suggest: (pos, _dragInfo, ctx) => { + // Можно переопределить размер сетки через контекст + const effectiveGridSize = (ctx.gridSize as number) || gridSize; + return new Point( + Math.round(pos.x / effectiveGridSize) * effectiveGridSize, + Math.round(pos.y / effectiveGridSize) * effectiveGridSize + ); + }, + }), +}; From 3e3013cb979e384e6e1ae13dfd5b0d621259c14e Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 1 Aug 2025 00:55:00 +0300 Subject: [PATCH 07/10] ... --- docs/system/drag-system.md | 242 ++++++++++++++++++++++++- src/components/canvas/blocks/Blocks.ts | 2 +- src/utils/functions/index.ts | 27 --- 3 files changed, 242 insertions(+), 29 deletions(-) diff --git a/docs/system/drag-system.md b/docs/system/drag-system.md index 02eb6364..b2d0668e 100644 --- a/docs/system/drag-system.md +++ b/docs/system/drag-system.md @@ -1047,4 +1047,244 @@ interface DragContext { } ``` -This comprehensive API allows for flexible, performant drag implementations that can handle everything from simple block movement to complex multi-entity operations with sophisticated snapping and alignment behaviors. \ No newline at end of file +This comprehensive API allows for flexible, performant drag implementations that can handle everything from simple block movement to complex multi-entity operations with sophisticated snapping and alignment behaviors. + +## Drag Lifecycle and State Transitions + +The drag system operates through a well-defined lifecycle with three distinct stages. Understanding these stages is crucial for implementing effective position modifiers and drag behaviors. + +### Drag Stage State Machine + +```mermaid +stateDiagram-v2 + [*] --> start: dragController.start() + start --> dragging: dragInfo.update() + dragging --> dragging: dragInfo.update() + dragging --> drop: dragInfo.end() + drop --> [*]: dragInfo.reset() + + state start { + [*] --> onDragStart + onDragStart --> [*] + } + + state dragging { + [*] --> analyzeSuggestions + analyzeSuggestions --> beforeUpdate + beforeUpdate --> onDragUpdate + onDragUpdate --> [*] + } + + state drop { + [*] --> analyzeSuggestions_final + analyzeSuggestions_final --> beforeUpdate_final + beforeUpdate_final --> onDragUpdate_final + onDragUpdate_final --> onDragEnd + onDragEnd --> [*] + } +``` + +### Stage Descriptions + +**Start Stage (`"start"`):** +- **Trigger**: User initiates drag (mouse down + initial movement) +- **Purpose**: Initialize drag state, show visual feedback, prepare for movement +- **Modifiers**: Generally inactive to avoid interfering with drag initiation +- **Duration**: Single event cycle + +**Dragging Stage (`"dragging"`):** +- **Trigger**: Mouse movement during active drag +- **Purpose**: Continuous position updates, real-time visual feedback +- **Modifiers**: Can apply for real-time effects (previews, smooth snapping) +- **Duration**: Multiple event cycles until mouse release + +**Drop Stage (`"drop"`):** +- **Trigger**: User releases mouse button +- **Purpose**: Final position calculation, snap to final positions +- **Modifiers**: Ideal for snap behaviors (grid, alignment, magnetism) +- **Duration**: Single event cycle before cleanup + +### Method Call Sequence + +The following sequence diagram shows the precise order of method calls during a complete drag operation: + +```mermaid +sequenceDiagram + participant User + participant DragController + participant DragInfo + participant DragHandler + participant Modifiers + + Note over User,Modifiers: START PHASE + User->>DragController: start(handler, event, config) + DragController->>DragInfo: new DragInfo(modifiers, context) + DragController->>DragInfo: init(event) [stage = "start"] + DragController->>DragHandler: onDragStart(event, dragInfo) + + Note over User,Modifiers: DRAGGING PHASE (multiple times) + User->>DragController: update(event) + DragController->>DragInfo: update(event) [stage = "dragging"] + DragController->>DragInfo: analyzeSuggestions() + DragInfo->>Modifiers: applicable(pos, dragInfo, ctx) + Modifiers-->>DragInfo: true/false based on stage + DragInfo->>Modifiers: suggest(pos, dragInfo, ctx) + Modifiers-->>DragInfo: modified position + DragController->>DragHandler: beforeUpdate(dragInfo) + DragHandler->>DragInfo: selectModifier() or selectDefault() + DragController->>DragHandler: onDragUpdate(event, dragInfo) + + Note over User,Modifiers: DROP PHASE + User->>DragController: end(event) + DragController->>DragInfo: end(event) [stage = "drop"] + DragController->>DragInfo: analyzeSuggestions() + DragInfo->>Modifiers: applicable(pos, dragInfo, ctx) + Note right of Modifiers: gridSnapOnDrop returns true
for stage === "drop" + DragInfo->>Modifiers: suggest(pos, dragInfo, ctx) + Modifiers-->>DragInfo: snapped position + DragController->>DragHandler: beforeUpdate(dragInfo) + DragController->>DragHandler: onDragUpdate(event, dragInfo) + Note right of DragHandler: Gets final snapped positions + DragController->>DragHandler: onDragEnd(event, dragInfo) + DragController->>DragInfo: reset() +``` + +### Position Modifier Flow by Stage + +This flowchart illustrates how position modifiers are evaluated and applied at different stages: + +```mermaid +flowchart TD + A[Mouse Event] --> B{Stage?} + + B -->|start| C[Stage: START] + B -->|dragging| D[Stage: DRAGGING] + B -->|drop| E[Stage: DROP] + + C --> C1[No modifiers applied] + C1 --> C2[onDragStart called] + + D --> D1[Check modifiers] + D1 --> D2{gridSnap applicable?} + D2 -->|No for dragging| D3[Skip gridSnap] + D2 -->|Yes for dragging| D4[Apply gridSnap] + D3 --> D5[Smooth movement] + D4 --> D5 + D5 --> D6[onDragUpdate called] + + E --> E1[Check modifiers] + E1 --> E2{gridSnapOnDrop applicable?} + E2 -->|Yes for drop| E3[Apply snap to grid] + E2 -->|No for drop| E4[No snap] + E3 --> E5[Final snapped position] + E4 --> E5 + E5 --> E6[onDragUpdate with final pos] + E6 --> E7[onDragEnd called] + + subgraph Examples ["Modifier Examples"] + M1["gridSnap(20, 'dragging')
applies during movement"] + M2["gridSnapOnDrop(20)
applies only at end"] + M3["previewSnap
shows preview during drag"] + end +``` + +## Stage-based Modifier Patterns + +### Pattern 1: Snap Only on Drop +Perfect for clean final positioning without interference during movement: + +```typescript +const snapOnDrop = DragModifiers.gridSnapOnDrop(20); +// Only activates when ctx.stage === "drop" +``` + +### Pattern 2: Real-time Snapping +For immediate visual feedback during movement: + +```typescript +const realtimeSnap = DragModifiers.gridSnap(20, "dragging"); +// Activates when ctx.stage === "dragging" +``` + +### Pattern 3: Progressive Behavior +Different behaviors for different stages: + +```typescript +const progressiveModifier = { + name: "progressive", + priority: 10, + applicable: (pos, dragInfo, ctx) => { + if (ctx.stage === "start") return false; // No modification at start + if (ctx.stage === "dragging") return true; // Loose snapping during drag + if (ctx.stage === "drop") return true; // Precise snapping on drop + }, + suggest: (pos, dragInfo, ctx) => { + const gridSize = ctx.stage === "dragging" ? 40 : 20; // Larger grid during drag + return new Point( + Math.round(pos.x / gridSize) * gridSize, + Math.round(pos.y / gridSize) * gridSize + ); + } +}; +``` + +### Pattern 4: Preview + Final +Show preview during drag, apply on drop: + +```typescript +const previewModifier = { + name: "preview", + applicable: (pos, dragInfo, ctx) => ctx.stage === "dragging", + suggest: (pos, dragInfo, ctx) => { + // Calculate snap position but don't apply + ctx.previewPosition = calculateSnapPosition(pos); + return pos; // Return original position + } +}; + +const finalModifier = { + name: "final", + applicable: (pos, dragInfo, ctx) => ctx.stage === "drop", + suggest: (pos, dragInfo, ctx) => { + // Apply the previewed position + return ctx.previewPosition || pos; + } +}; +``` + +## Implementation Guidelines + +### Best Practices for Stage-aware Modifiers + +1. **Start Stage**: Avoid position modifications to prevent interfering with drag initiation +2. **Dragging Stage**: Use for real-time feedback, loose snapping, or preview calculations +3. **Drop Stage**: Ideal for precise final positioning, grid snapping, alignment + +### Performance Considerations + +- **Dragging stage modifiers** run on every mouse move - keep them lightweight +- **Drop stage modifiers** run once - can be more computationally expensive +- Use `ctx.stage` checks early in `applicable()` for optimal performance + +### Multi-stage Modifier Design + +```typescript +const smartModifier = { + name: "smart", + applicable: (pos, dragInfo, ctx) => { + // Quick stage filtering + if (ctx.stage === "start") return false; + + // Stage-specific logic + return ctx.stage === "dragging" ? + ctx.showPreview : + ctx.enableSnapping; + }, + suggest: (pos, dragInfo, ctx) => { + const intensity = ctx.stage === "dragging" ? 0.5 : 1.0; + return applySnapping(pos, intensity); + } +}; +``` + +This stage-based architecture provides a clean, extensible foundation for implementing sophisticated drag behaviors while maintaining optimal performance and user experience. \ No newline at end of file diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index 9274472f..617714e5 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -69,7 +69,7 @@ export class Blocks extends GraphComponent { const selectedBlocksComponents: Block[] = selectedBlocksStates.map((block) => block.getViewComponent()); this.context.graph.getGraphLayer().captureEvents(blockInstance); - const gridSnap = DragModifiers.gridSnap(15, "drop"); + const gridSnap = DragModifiers.gridSnap(10); // Получаем начальную позицию основного блока (который инициировал драг) const mainBlockState = blockInstance.connectedState; diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index e6a39205..13e400bb 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -315,31 +315,4 @@ export const DragModifiers = { ); }, }), - - /** - * Создает модификатор для привязки к сетке только при завершении перетаскивания (stage === 'drop') - * Применяется только на стадии 'drop' для snap только при отпускании мыши - * @param gridSize - Размер ячейки сетки в пикселях - * @returns Модификатор gridSnapOnDrop - */ - gridSnapOnDrop: (gridSize: number): PositionModifier => ({ - name: "grid-snap-on-drop", - priority: 10, - - applicable: (pos, dragInfo, ctx) => { - // Применяется только на стадии 'drop' - const isDropStage = ctx.stage === "drop"; - const isEnabled = ctx.enableGridSnap !== false; // По умолчанию включен - return isDropStage && !dragInfo.isMicroDrag() && isEnabled; - }, - - suggest: (pos, _dragInfo, ctx) => { - // Можно переопределить размер сетки через контекст - const effectiveGridSize = (ctx.gridSize as number) || gridSize; - return new Point( - Math.round(pos.x / effectiveGridSize) * effectiveGridSize, - Math.round(pos.y / effectiveGridSize) * effectiveGridSize - ); - }, - }), }; From 143420e0392d980a76d9656c809f3a5db5d2ce14 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 1 Aug 2025 02:20:21 +0300 Subject: [PATCH 08/10] ... --- src/components/canvas/blocks/Block.ts | 2 +- src/components/canvas/blocks/Blocks.ts | 11 +- .../layers/connectionLayer/ConnectionLayer.ts | 65 +++- .../layers/newBlockLayer/NewBlockLayer.ts | 4 +- .../layers/selectionLayer/SelectionLayer.ts | 4 +- src/graph.ts | 2 +- src/index.ts | 5 +- src/services/Drag/DragController.ts | 295 ++++++++++++++++++ src/services/{ => Drag}/DragInfo.ts | 234 +++++++------- .../Drag/modifiers/GridSnapModifier.ts | 92 ++++++ .../Drag/modifiers/MagneticModifier.ts | 169 ++++++++++ src/services/DragController.ts | 295 ------------------ src/utils/functions/index.ts | 62 +--- 13 files changed, 738 insertions(+), 502 deletions(-) create mode 100644 src/services/Drag/DragController.ts rename src/services/{ => Drag}/DragInfo.ts (53%) create mode 100644 src/services/Drag/modifiers/GridSnapModifier.ts create mode 100644 src/services/Drag/modifiers/MagneticModifier.ts delete mode 100644 src/services/DragController.ts diff --git a/src/components/canvas/blocks/Block.ts b/src/components/canvas/blocks/Block.ts index 0ae1223d..9ed99e62 100644 --- a/src/components/canvas/blocks/Block.ts +++ b/src/components/canvas/blocks/Block.ts @@ -2,7 +2,7 @@ import { signal } from "@preact/signals-core"; import cloneDeep from "lodash/cloneDeep"; import isObject from "lodash/isObject"; -import { DragInfo } from "../../../services/DragInfo"; +import { DragInfo } from "../../../services/Drag/DragInfo"; import { ECameraScaleLevel } from "../../../services/camera/CameraService"; import { TGraphSettingsConfig } from "../../../store"; import { EAnchorType } from "../../../store/anchor/Anchor"; diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index 617714e5..9cab0ff5 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -1,6 +1,7 @@ +import { createGridSnapModifier } from "../../../services/Drag/modifiers/GridSnapModifier"; import { BlockState } from "../../../store/block/Block"; import { BlockListStore } from "../../../store/block/BlocksList"; -import { DragModifiers, isMetaKeyEvent } from "../../../utils/functions"; +import { isMetaKeyEvent } from "../../../utils/functions"; import { ESelectionStrategy } from "../../../utils/types/types"; import { GraphComponent } from "../GraphComponent"; import { TGraphLayerContext } from "../layers/graphLayer/GraphLayer"; @@ -69,7 +70,6 @@ export class Blocks extends GraphComponent { const selectedBlocksComponents: Block[] = selectedBlocksStates.map((block) => block.getViewComponent()); this.context.graph.getGraphLayer().captureEvents(blockInstance); - const gridSnap = DragModifiers.gridSnap(10); // Получаем начальную позицию основного блока (который инициировал драг) const mainBlockState = blockInstance.connectedState; @@ -83,9 +83,6 @@ export class Blocks extends GraphComponent { block.onDragStart(dragEvent); } }, - beforeUpdate: (dragInfo) => { - dragInfo.selectModifier(gridSnap.name); - }, onDragUpdate: (dragEvent, dragInfo) => { const blocks = dragInfo.context.selectedBlocks as Block[]; for (const block of blocks) { @@ -102,7 +99,9 @@ export class Blocks extends GraphComponent { }, event as MouseEvent, { - positionModifiers: [gridSnap], + positionModifiers: [ + createGridSnapModifier({ gridSize: this.context.constants.block.SNAPPING_GRID_SIZE, stage: "drop" }), + ], initialEntityPosition: initialEntityPosition, context: { enableGridSnap: true, diff --git a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts index 135a06f8..7822e763 100644 --- a/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts +++ b/src/components/canvas/layers/connectionLayer/ConnectionLayer.ts @@ -1,8 +1,9 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; -import { DragHandler } from "../../../../services/DragController"; -import { DragInfo } from "../../../../services/DragInfo"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; +import { MagneticModifier } from "../../../../services/Drag/modifiers/MagneticModifier"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; -import { AnchorState } from "../../../../store/anchor/Anchor"; +import { AnchorState, EAnchorType } from "../../../../store/anchor/Anchor"; import { BlockState, TBlockId } from "../../../../store/block/Block"; import { isBlock, isShiftKeyEvent } from "../../../../utils/functions"; import { render } from "../../../../utils/renderers/render"; @@ -11,6 +12,7 @@ import { Point, TPoint } from "../../../../utils/types/shapes"; import { ESelectionStrategy } from "../../../../utils/types/types"; import { Anchor } from "../../../canvas/anchors"; import { Block } from "../../../canvas/blocks/Block"; +import { GraphComponent } from "../../GraphComponent"; type TIcon = { path: string; @@ -34,6 +36,10 @@ type ConnectionLayerProps = LayerProps & { point?: TIcon; drawLine?: DrawLineFunction; isConnectionAllowed?: (sourceComponent: BlockState | AnchorState) => boolean; + /** + * Distance threshold for block magnetism. Defaults to 50 pixels. + */ + magnetismDistance?: number; }; declare module "../../../../graphEvents" { @@ -127,6 +133,20 @@ export class ConnectionLayer extends Layer< protected enabled: boolean; private declare eventAborter: AbortController; + // eslint-disable-next-line new-cap + protected magneticModifier = MagneticModifier({ + magnetismDistance: 200, + resolvePosition: (element: GraphComponent) => { + if (element instanceof Block) { + return element.getConnectionPoint("in"); + } + if (element instanceof Anchor) { + return element.getPosition(); + } + return null; + }, + }); + constructor(props: ConnectionLayerProps) { super({ canvas: { @@ -197,10 +217,10 @@ export class ConnectionLayer extends Layer< if (!event || !target || !this.root?.ownerDocument) { return; } + const useBlocksAnchors = this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors"); if ( this.enabled && - ((this.context.graph.rootStore.settings.getConfigFlag("useBlocksAnchors") && target instanceof Anchor) || - (isShiftKeyEvent(event) && isBlock(target))) + ((useBlocksAnchors && target instanceof Anchor) || (isShiftKeyEvent(event) && isBlock(target))) ) { // Get the source component state const sourceComponent = target.connectedState; @@ -220,15 +240,34 @@ export class ConnectionLayer extends Layer< onDragUpdate: (dUpdateEvent: MouseEvent, dragInfo: DragInfo) => { this.onMoveNewConnection( dUpdateEvent, - new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number) + dragInfo.adjustedEntityPosition, + dragInfo.context?.closestTarget as Block | Anchor ); }, onDragEnd: (dEndEvent: MouseEvent, dragInfo: DragInfo) => { - this.onEndNewConnection(new Point(dragInfo.lastCameraX as number, dragInfo.lastCameraY as number)); + this.onEndNewConnection(dragInfo.adjustedEntityPosition, dragInfo.context?.closestTarget as Block | Anchor); }, }; - this.context.graph.dragController.start(connectionHandler, event); + this.magneticModifier.setParams({ + magnetismDistance: this.props.magnetismDistance || 80, + targets: useBlocksAnchors ? [Anchor] : [Block], + filter: (element: GraphComponent) => { + if (element === target) { + return false; + } + if (useBlocksAnchors) { + if (target instanceof Anchor && element instanceof Anchor) { + // Anchors with same type can't be connected (IN and IN or OUT and OUT) + return target.connectedState.state.type !== element.connectedState.state.type; + } + } + }, + }); + + this.context.graph.dragController.start(connectionHandler, event, { + positionModifiers: [this.magneticModifier], + }); } }; @@ -354,10 +393,8 @@ export class ConnectionLayer extends Layer< this.performRender(); } - private onMoveNewConnection(event: MouseEvent, point: Point) { - const newTargetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); - - // Используем мировые координаты вместо координат canvas + private onMoveNewConnection(event: MouseEvent, point: Point, newTargetComponent?: Block | Anchor) { + // Update connection state with adjusted position from magnetism this.connectionState = { ...this.connectionState, tx: point.x, @@ -372,6 +409,7 @@ export class ConnectionLayer extends Layer< } // Only process if the target has changed or if there was no previous target + // Also ensure we're not targeting the source component if ( (!this.target || this.target.connectedState !== newTargetComponent.connectedState) && newTargetComponent.connectedState !== this.sourceComponent @@ -398,12 +436,11 @@ export class ConnectionLayer extends Layer< } } - private onEndNewConnection(point: Point) { + private onEndNewConnection(point: Point, targetComponent?: Block | Anchor) { if (!this.sourceComponent) { return; } - const targetComponent = this.context.graph.getElementOverPoint(point, [Block, Anchor]); this.connectionState = { sx: 0, sy: 0, diff --git a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts index 5bee2ec6..64750f84 100644 --- a/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts +++ b/src/components/canvas/layers/newBlockLayer/NewBlockLayer.ts @@ -1,6 +1,6 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; -import { DragHandler } from "../../../../services/DragController"; -import { DragInfo } from "../../../../services/DragInfo"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { BlockState } from "../../../../store/block/Block"; import { getXY, isAltKeyEvent, isBlock } from "../../../../utils/functions"; diff --git a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts index 49619081..91f08742 100644 --- a/src/components/canvas/layers/selectionLayer/SelectionLayer.ts +++ b/src/components/canvas/layers/selectionLayer/SelectionLayer.ts @@ -1,6 +1,6 @@ import { GraphMouseEvent, extractNativeGraphMouseEvent } from "../../../../graphEvents"; -import { DragHandler } from "../../../../services/DragController"; -import { DragInfo } from "../../../../services/DragInfo"; +import { DragHandler } from "../../../../services/Drag/DragController"; +import { DragInfo } from "../../../../services/Drag/DragInfo"; import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; import { selectBlockList } from "../../../../store/block/selectors"; import { isBlock, isMetaKeyEvent } from "../../../../utils/functions"; diff --git a/src/graph.ts b/src/graph.ts index 12a7ff62..0589003b 100644 --- a/src/graph.ts +++ b/src/graph.ts @@ -10,7 +10,7 @@ import { SelectionLayer } from "./components/canvas/layers/selectionLayer/Select import { TGraphColors, TGraphConstants, initGraphColors, initGraphConstants } from "./graphConfig"; import { GraphEventParams, GraphEventsDefinitions } from "./graphEvents"; import { scheduler } from "./lib/Scheduler"; -import { DragController } from "./services/DragController"; +import { DragController } from "./services/Drag/DragController"; import { HitTest } from "./services/HitTest"; import { Layer } from "./services/Layer"; import { Layers } from "./services/LayersService"; diff --git a/src/index.ts b/src/index.ts index 229c2848..232fffb1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,15 +6,14 @@ export type { TGraphColors, TGraphConstants } from "./graphConfig"; export { type UnwrapGraphEventsDetail } from "./graphEvents"; export * from "./plugins"; export { ECameraScaleLevel } from "./services/camera/CameraService"; -export { DragController, type DragHandler, type DragControllerConfig } from "./services/DragController"; +export { DragController, type DragHandler, type DragControllerConfig } from "./services/Drag/DragController"; export { DragInfo, type PositionModifier, type DragContext, type ModifierSuggestion, type DragStage, -} from "./services/DragInfo"; -export { DragModifiers } from "./utils/functions"; +} from "./services/Drag/DragInfo"; export * from "./services/Layer"; export * from "./store"; export { EAnchorType } from "./store/anchor/Anchor"; diff --git a/src/services/Drag/DragController.ts b/src/services/Drag/DragController.ts new file mode 100644 index 00000000..0303dae7 --- /dev/null +++ b/src/services/Drag/DragController.ts @@ -0,0 +1,295 @@ +import { Graph } from "../../graph"; +import { ESchedulerPriority, scheduler } from "../../lib/Scheduler"; +import { dragListener } from "../../utils/functions/dragListener"; +import { EVENTS } from "../../utils/types/events"; + +import { DragInfo, PositionModifier } from "./DragInfo"; + +/** + * Interface for components that can be dragged + */ +export interface DragHandler { + /** + * Called before position update to select a modifier + * @param dragInfo - Stateful model with drag information + */ + beforeUpdate?(dragInfo: DragInfo): void; + + /** + * Called when dragging starts + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragStart(event: MouseEvent, dragInfo: DragInfo): void; + + /** + * Called when position is updated during dragging + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragUpdate(event: MouseEvent, dragInfo: DragInfo): void; + + /** + * Called when dragging ends + * @param event - Mouse event + * @param dragInfo - Stateful model with drag information + */ + onDragEnd(event: MouseEvent, dragInfo: DragInfo): void; +} + +/** + * Configuration for DragController + */ +export interface DragControllerConfig { + /** Enable automatic camera movement when approaching edges */ + enableEdgePanning?: boolean; + /** Position modifiers for coordinate correction during dragging */ + positionModifiers?: PositionModifier[]; + /** Additional context to pass to modifiers */ + context?: Record; + /** Initial position of the dragged entity in camera space */ + initialEntityPosition?: { x: number; y: number }; +} + +/** + * Centralized controller for managing component dragging + */ +export class DragController { + private graph: Graph; + + private currentDragHandler?: DragHandler; + + private isDragging = false; + + private lastMouseEvent?: MouseEvent; + + private dragInfo: DragInfo; + + private updateScheduler?: () => void; + + constructor(graph: Graph) { + this.graph = graph; + } + + /** + * Starts the dragging process for the specified component + * @param component - Component that will be dragged + * @param event - Initial mouse event + * @param config - Drag configuration + * @returns void + */ + public start(component: DragHandler, event: MouseEvent, config: DragControllerConfig = {}): void { + if (this.isDragging) { + // eslint-disable-next-line no-console + console.warn("DragController: attempt to start dragging while already dragging"); + return; + } + + this.currentDragHandler = component; + this.isDragging = true; + this.lastMouseEvent = event; + + // Create DragInfo with modifiers, context and initialize + this.dragInfo = new DragInfo( + this.graph, + config.positionModifiers || [], + config.context, + config.initialEntityPosition + ); + this.dragInfo.init(event); + + if (config.enableEdgePanning ?? true) { + const defaultConfig = this.graph.graphConstants.camera.EDGE_PANNING; + + this.graph.getGraphLayer().enableEdgePanning({ + speed: defaultConfig.SPEED, + edgeSize: defaultConfig.EDGE_SIZE, + }); + + // Start periodic component updates for synchronization with camera movement + this.startContinuousUpdate(); + } + + component.onDragStart(event, this.dragInfo); + + this.startDragListener(event); + } + + /** + * Updates the dragging state + * @param event - Mouse event + * @returns void + */ + public update(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + this.lastMouseEvent = event; + + // Update DragInfo state + this.dragInfo.update(event); + + // Analyze position modifiers + this.dragInfo.analyzeSuggestions(); + + // Give opportunity to select modifier in beforeUpdate + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Default strategy - by distance + this.dragInfo.selectDefault(); + } + + this.currentDragHandler.onDragUpdate(event, this.dragInfo); + } + + /** + * Ends the dragging process + * @param event - Mouse event + * @returns void + */ + public end(event: MouseEvent): void { + if (!this.isDragging || !this.currentDragHandler) { + return; + } + + // TODO: Need to pass EventedComponent instead of DragController + // this.graph.getGraphLayer().releaseCapture(); + + // Stop continuous updates + this.stopContinuousUpdate(); + + // Disable edge panning + this.graph.getGraphLayer().disableEdgePanning(); + + // Complete the process in DragInfo (sets stage to 'drop') + this.dragInfo.end(event); + + // Analyze modifiers at 'drop' stage + this.dragInfo.analyzeSuggestions(); + + // Give opportunity to select modifier at drop stage + if (this.currentDragHandler.beforeUpdate) { + this.currentDragHandler.beforeUpdate(this.dragInfo); + } else { + // Default strategy - by distance + this.dragInfo.selectDefault(); + } + + // Call onDragUpdate with final positions (at 'drop' stage) + this.currentDragHandler.onDragUpdate(event, this.dragInfo); + + // Then call the drag end handler + this.currentDragHandler.onDragEnd(event, this.dragInfo); + + // Reset state + this.currentDragHandler = undefined; + this.isDragging = false; + this.lastMouseEvent = undefined; + this.dragInfo.reset(); + } + + /** + * Checks if dragging is currently in progress + * @returns true if dragging is in progress + */ + public isDragInProgress(): boolean { + return this.isDragging; + } + + /** + * Gets the current dragged component + * @returns current DragHandler or undefined + */ + public getCurrentDragHandler(): DragHandler | undefined { + return this.currentDragHandler; + } + + /** + * Gets current drag information + * @returns DragInfo instance (always available) + */ + public getCurrentDragInfo(): DragInfo { + return this.dragInfo; + } + + /** + * Starts continuous component updates for synchronization with camera movement + * @returns void + */ + private startContinuousUpdate(): void { + if (this.updateScheduler) { + return; + } + + const update = () => { + if (!this.isDragging || !this.currentDragHandler || !this.lastMouseEvent) { + return; + } + + // Create synthetic mouse event with current coordinates + // This allows components to update their position during camera movement + // even when physical mouse movement doesn't occur + const syntheticEvent = new MouseEvent("mousemove", { + clientX: this.lastMouseEvent.clientX, + clientY: this.lastMouseEvent.clientY, + bubbles: false, + cancelable: false, + }); + + // Copy pageX/pageY manually since they're not in MouseEventInit + Object.defineProperty(syntheticEvent, "pageX", { value: this.lastMouseEvent.pageX }); + Object.defineProperty(syntheticEvent, "pageY", { value: this.lastMouseEvent.pageY }); + + // Update DragInfo state for synthetic event + this.dragInfo.update(this.lastMouseEvent); + + this.currentDragHandler.onDragUpdate(syntheticEvent, this.dragInfo); + }; + + // Use medium priority for updates to synchronize with camera movement + this.updateScheduler = scheduler.addScheduler({ performUpdate: update }, ESchedulerPriority.MEDIUM); + } + + /** + * Stops continuous updates + * @returns void + */ + private stopContinuousUpdate(): void { + if (this.updateScheduler) { + this.updateScheduler(); + this.updateScheduler = undefined; + } + } + + /** + * Starts dragListener to track mouse events + * @param _initialEvent - Initial mouse event (unused) + * @returns void + */ + private startDragListener(_initialEvent: MouseEvent): void { + const ownerDocument = this.graph.getGraphCanvas().ownerDocument; + + dragListener(ownerDocument) + .on(EVENTS.DRAG_START, (event: MouseEvent) => { + this.lastMouseEvent = event; + }) + .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { + this.update(event); + }) + .on(EVENTS.DRAG_END, (event: MouseEvent) => { + this.end(event); + }); + } + + /** + * Forcibly ends current dragging (e.g., during unmounting) + * @returns void + */ + public forceEnd(): void { + if (this.isDragging && this.lastMouseEvent) { + this.end(this.lastMouseEvent); + } + } +} diff --git a/src/services/DragInfo.ts b/src/services/Drag/DragInfo.ts similarity index 53% rename from src/services/DragInfo.ts rename to src/services/Drag/DragInfo.ts index 77100c20..577b2106 100644 --- a/src/services/DragInfo.ts +++ b/src/services/Drag/DragInfo.ts @@ -1,27 +1,27 @@ -import { Graph } from "../graph"; -import { Point } from "../utils/types/shapes"; +import { Graph } from "../../graph"; +import { Point } from "../../utils/types/shapes"; /** - * Стадии жизненного цикла перетаскивания + * Drag lifecycle stages */ export type DragStage = "start" | "dragging" | "drop"; /** - * Интерфейс для модификатора позиции при перетаскивании + * Interface for position modifier during dragging */ export interface PositionModifier { name: string; priority: number; - /** Проверяет, применим ли модификатор для данной позиции */ + /** Checks if the modifier is applicable for the given position */ applicable: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => boolean; - /** Предлагает новую позицию (ленивое вычисление) */ + /** Suggests a new position (lazy evaluation) */ suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => Point | null; } /** - * Контекст для модификаторов перетаскивания + * Context for drag modifiers */ export interface DragContext { graph: Graph; @@ -31,46 +31,46 @@ export interface DragContext { } /** - * Предложение модификатора с ленивым вычислением + * Modifier suggestion with lazy evaluation */ export interface ModifierSuggestion { name: string; priority: number; distance: number | null; - /** Получает предложенную позицию (с кэшированием) */ + /** Gets the suggested position (with caching) */ getSuggestedPosition(): Point | null; - /** @private Ленивая функция вычисления */ + /** @private Lazy calculation function */ _suggester: () => Point | null; - /** @private Кэш позиции */ + /** @private Position cache */ _cachedPosition?: Point | null; } /** - * Statefull модель для хранения информации о процессе перетаскивания - * Использует ленивые вычисления через getter-ы для оптимальной производительности + * Stateful model for storing drag process information + * Uses lazy calculations through getters for optimal performance */ export class DragInfo { protected initialEvent: MouseEvent | null = null; protected currentEvent: MouseEvent | null = null; - // Кэш для координат камеры + // Cache for camera coordinates private _startCameraPoint: Point | null = null; private _currentCameraPoint: Point | null = null; - // Система модификаторов позиции + // Position modifier system private modifiers: PositionModifier[] = []; private suggestions: ModifierSuggestion[] = []; private selectedModifier: string | null = null; private contextCache: DragContext | null = null; private customContext: Record; - // Стадия перетаскивания + // Drag stage private currentStage: DragStage = "start"; - // Позиция перетаскиваемой сущности + // Position of the dragged entity private entityStartPosition: Point | null = null; private mouseToEntityOffset: Point | null = null; @@ -89,7 +89,7 @@ export class DragInfo { } /** - * Сбрасывает состояние DragInfo + * Resets DragInfo state * @returns void */ public reset(): void { @@ -100,30 +100,30 @@ export class DragInfo { this.suggestions = []; this.selectedModifier = null; this.contextCache = null; - this.currentStage = "start"; // Возвращаем к начальной стадии - // Кастомный контекст не сбрасываем, так как он задается при создании DragInfo + this.currentStage = "start"; // Return to initial stage + // Don't reset custom context as it's set during DragInfo creation } /** - * Получает текущую стадию перетаскивания + * Gets the current drag stage */ public get stage(): DragStage { return this.currentStage; } /** - * Инициализирует начальное состояние перетаскивания - * @param event - Начальное событие мыши + * Initializes the initial drag state + * @param event - Initial mouse event * @returns void */ public init(event: MouseEvent): void { this.initialEvent = event; this.currentEvent = event; - this._startCameraPoint = null; // Будет вычислен лениво + this._startCameraPoint = null; // Will be calculated lazily this._currentCameraPoint = null; - this.currentStage = "start"; // Устанавливаем стадию инициализации + this.currentStage = "start"; // Set initialization stage - // Вычисляем offset между мышью и сущностью при инициализации + // Calculate offset between mouse and entity during initialization if (this.entityStartPosition) { const mouseStartPoint = this.graph.getPointInCameraSpace(event); this.mouseToEntityOffset = new Point( @@ -138,73 +138,73 @@ export class DragInfo { } /** - * Обновляет текущее состояние перетаскивания - * @param event - Текущее событие мыши + * Updates the current drag state + * @param event - Current mouse event * @returns void */ public update(event: MouseEvent): void { this.currentEvent = event; - this._currentCameraPoint = null; // Сбрасываем кэш для перевычисления - this.currentStage = "dragging"; // Устанавливаем стадию активного перетаскивания - this.contextCache = null; // Сбрасываем кэш контекста для обновления stage + this._currentCameraPoint = null; // Reset cache for recalculation + this.currentStage = "dragging"; // Set active drag stage + this.contextCache = null; // Reset context cache to update stage } /** - * Завершает процесс перетаскивания - * @param event - Финальное событие мыши + * Ends the drag process + * @param event - Final mouse event * @returns void */ public end(event: MouseEvent): void { this.currentEvent = event; - this._currentCameraPoint = null; // Финальное обновление - this.currentStage = "drop"; // Устанавливаем стадию завершения - this.contextCache = null; // Сбрасываем кэш контекста для обновления stage + this._currentCameraPoint = null; // Final update + this.currentStage = "drop"; // Set completion stage + this.contextCache = null; // Reset context cache to update stage } /** - * Обновляет кастомный контекст во время операции перетаскивания - * @param newContext - Новые данные контекста для объединения с существующими + * Updates custom context during drag operation + * @param newContext - New context data to merge with existing * @returns void */ public updateContext(newContext: Record): void { this.customContext = { ...this.customContext, ...newContext }; - this.contextCache = null; // Сбрасываем кэш контекста для перевычисления + this.contextCache = null; // Reset context cache for recalculation } - // === ЛЕНИВЫЕ ГЕТТЕРЫ ДЛЯ ЭКРАННЫХ КООРДИНАТ === + // === LAZY GETTERS FOR SCREEN COORDINATES === /** - * Начальные координаты X в экранном пространстве + * Initial X coordinates in screen space */ public get startX(): number { return this.initialEvent?.clientX ?? 0; } /** - * Начальные координаты Y в экранном пространстве + * Initial Y coordinates in screen space */ public get startY(): number { return this.initialEvent?.clientY ?? 0; } /** - * Текущие координаты X в экранном пространстве + * Current X coordinates in screen space */ public get lastX(): number { return this.currentEvent?.clientX ?? this.startX; } /** - * Текущие координаты Y в экранном пространстве + * Current Y coordinates in screen space */ public get lastY(): number { return this.currentEvent?.clientY ?? this.startY; } - // === ЛЕНИВЫЕ ГЕТТЕРЫ ДЛЯ КООРДИНАТ КАМЕРЫ === + // === LAZY GETTERS FOR CAMERA COORDINATES === /** - * Начальные координаты в пространстве камеры + * Initial coordinates in camera space */ protected get startCameraPoint(): Point { if (!this._startCameraPoint && this.initialEvent) { @@ -214,7 +214,7 @@ export class DragInfo { } /** - * Текущие координаты в пространстве камеры + * Current coordinates in camera space */ protected get currentCameraPoint(): Point { if (!this._currentCameraPoint && this.currentEvent) { @@ -224,37 +224,37 @@ export class DragInfo { } /** - * Начальная координата X в пространстве камеры + * Initial X coordinate in camera space */ public get startCameraX(): number { return this.startCameraPoint.x; } /** - * Начальная координата Y в пространстве камеры + * Initial Y coordinate in camera space */ public get startCameraY(): number { return this.startCameraPoint.y; } /** - * Текущая координата X в пространстве камеры + * Current X coordinate in camera space */ public get lastCameraX(): number { return this.currentCameraPoint.x; } /** - * Текущая координата Y в пространстве камеры + * Current Y coordinate in camera space */ public get lastCameraY(): number { return this.currentCameraPoint.y; } - // === ВЫЧИСЛЯЕМЫЕ СВОЙСТВА === + // === COMPUTED PROPERTIES === /** - * Разность координат в экранном пространстве + * Coordinate difference in screen space */ public get screenDelta(): { x: number; y: number } { return { @@ -264,7 +264,7 @@ export class DragInfo { } /** - * Разность координат в пространстве камеры + * Coordinate difference in camera space */ public get worldDelta(): { x: number; y: number } { return { @@ -274,7 +274,7 @@ export class DragInfo { } /** - * Расстояние перетаскивания в экранном пространстве + * Drag distance in screen space */ public get screenDistance(): number { const delta = this.screenDelta; @@ -282,7 +282,7 @@ export class DragInfo { } /** - * Расстояние перетаскивания в пространстве камеры + * Drag distance in camera space */ public get worldDistance(): number { const delta = this.worldDelta; @@ -290,7 +290,7 @@ export class DragInfo { } /** - * Направление перетаскивания в пространстве камеры + * Drag direction in camera space */ public get worldDirection(): "horizontal" | "vertical" | "diagonal" | "none" { const delta = this.worldDelta; @@ -306,16 +306,16 @@ export class DragInfo { } /** - * Проверяет, является ли перетаскивание микросдвигом - * @param threshold - Порог расстояния в пикселях (по умолчанию 5) - * @returns true если расстояние меньше порога + * Checks if dragging is a micro-movement + * @param threshold - Distance threshold in pixels (default 5) + * @returns true if distance is less than threshold */ public isMicroDrag(threshold = 5): boolean { return this.worldDistance < threshold; } /** - * Продолжительность перетаскивания в миллисекундах + * Drag duration in milliseconds */ public get duration(): number { if (!this.initialEvent || !this.currentEvent) return 0; @@ -323,7 +323,7 @@ export class DragInfo { } /** - * Скорость перетаскивания в пикселях в миллисекунду + * Drag velocity in pixels per millisecond */ public get velocity(): { vx: number; vy: number } { const duration = this.duration; @@ -337,37 +337,37 @@ export class DragInfo { } /** - * Исходное событие мыши + * Initial mouse event */ public get initialMouseEvent(): MouseEvent | null { return this.initialEvent; } /** - * Текущее событие мыши + * Current mouse event */ public get currentMouseEvent(): MouseEvent | null { return this.currentEvent; } /** - * Проверяет, инициализирован ли DragInfo + * Checks if DragInfo is initialized */ public get isInitialized(): boolean { return this.initialEvent !== null; } /** - * Проверяет, есть ли движение с момента инициализации + * Checks if there's movement since initialization */ public get hasMovement(): boolean { return this.currentEvent !== this.initialEvent; } - // === СИСТЕМА МОДИФИКАТОРОВ ПОЗИЦИИ === + // === POSITION MODIFIER SYSTEM === /** - * Анализирует все модификаторы и создает предложения + * Analyzes all modifiers and creates suggestions * @returns void */ public analyzeSuggestions(): void { @@ -376,7 +376,7 @@ export class DragInfo { return; } - // Используем позицию сущности для модификаторов, а не позицию мыши + // Use entity position for modifiers, not mouse position const entityPos = this.currentEntityPosition; const context = this.getDragContext(); @@ -386,17 +386,17 @@ export class DragInfo { } /** - * Создает ленивое предложение модификатора - * @param modifier - Модификатор позиции - * @param pos - Исходная позиция - * @param ctx - Контекст перетаскивания - * @returns Предложение с ленивым вычислением + * Creates a lazy modifier suggestion + * @param modifier - Position modifier + * @param pos - Initial position + * @param ctx - Drag context + * @returns Suggestion with lazy evaluation */ private createSuggestion(modifier: PositionModifier, pos: Point, ctx: DragContext): ModifierSuggestion { return { name: modifier.name, priority: modifier.priority, - distance: null, // Ленивое вычисление + distance: null, // Lazy evaluation _suggester: () => modifier.suggest(pos, this, ctx), _cachedPosition: undefined, @@ -410,7 +410,7 @@ export class DragInfo { } /** - * Выбирает модификатор по приоритету (первый с наименьшим приоритетом) + * Selects modifier by priority (first with lowest priority) * @returns void */ public selectByPriority(): void { @@ -419,7 +419,7 @@ export class DragInfo { } /** - * Выбирает модификатор по расстоянию (ближайший к исходной позиции) + * Selects modifier by distance (closest to original position) * @returns void */ public selectByDistance(): void { @@ -434,8 +434,8 @@ export class DragInfo { } /** - * Выбирает модификатор с помощью кастомной функции - * @param selector - Функция выбора модификатора + * Selects modifier using custom function + * @param selector - Modifier selection function * @returns void */ public selectByCustom(selector: (suggestions: ModifierSuggestion[]) => string | null): void { @@ -443,8 +443,8 @@ export class DragInfo { } /** - * Выбирает конкретный модификатор по имени - * @param name - Имя модификатора + * Selects specific modifier by name + * @param name - Modifier name * @returns void */ public selectModifier(name: string): void { @@ -454,7 +454,7 @@ export class DragInfo { } /** - * Выбирает модификатор по умолчанию (по расстоянию) + * Selects default modifier (by distance) * @returns void */ public selectDefault(): void { @@ -462,9 +462,9 @@ export class DragInfo { } /** - * Вычисляет расстояние от исходной до предложенной позиции - * @param suggestion - Предложение модификатора - * @returns Расстояние в пикселях + * Calculates distance from original to suggested position + * @param suggestion - Modifier suggestion + * @returns Distance in pixels */ private calculateDistance(suggestion: ModifierSuggestion): number { const original = new Point(this.lastCameraX, this.lastCameraY); @@ -476,25 +476,25 @@ export class DragInfo { } /** - * Проверяет, применим ли модификатор с указанным именем - * @param modifierName - Имя модификатора - * @returns true если модификатор применим + * Checks if modifier with specified name is applicable + * @param modifierName - Modifier name + * @returns true if modifier is applicable */ public isApplicable(modifierName: string): boolean { return this.suggestions.some((s) => s.name === modifierName); } /** - * Проверяет, применен ли модификатор с указанным именем - * @param modifierName - Имя модификатора - * @returns true если модификатор применен + * Checks if modifier with specified name is applied + * @param modifierName - Modifier name + * @returns true if modifier is applied */ public isModified(modifierName: string): boolean { return this.selectedModifier === modifierName; } /** - * Получает скорректированную позицию с учетом примененного модификатора + * Gets adjusted position considering applied modifier */ public get adjustedPosition(): Point { if (!this.selectedModifier) { @@ -508,41 +508,41 @@ export class DragInfo { } /** - * Получает скорректированную координату X + * Gets adjusted X coordinate */ public get adjustedCameraX(): number { return this.adjustedPosition.x; } /** - * Получает скорректированную координату Y + * Gets adjusted Y coordinate */ public get adjustedCameraY(): number { return this.adjustedPosition.y; } - // === ПОЗИЦИЯ СУЩНОСТИ === + // === ENTITY POSITION === /** - * Начальная позиция сущности + * Initial entity position */ public get entityStartX(): number { return this.entityStartPosition?.x ?? 0; } /** - * Начальная позиция сущности + * Initial entity position */ public get entityStartY(): number { return this.entityStartPosition?.y ?? 0; } /** - * Текущая позиция сущности (без модификаторов) + * Current entity position (without modifiers) */ public get currentEntityPosition(): Point { if (!this.entityStartPosition || !this.mouseToEntityOffset) { - // Fallback к позиции мыши если нет данных о сущности + // Fallback to mouse position if no entity data return new Point(this.lastCameraX, this.lastCameraY); } @@ -551,7 +551,7 @@ export class DragInfo { } /** - * Скорректированная позиция сущности с учетом модификаторов + * Adjusted entity position considering modifiers */ public get adjustedEntityPosition(): Point { if (!this.selectedModifier) { @@ -565,22 +565,22 @@ export class DragInfo { } /** - * Скорректированная X координата сущности + * Adjusted entity X coordinate */ public get adjustedEntityX(): number { return this.adjustedEntityPosition.x; } /** - * Скорректированная Y координата сущности + * Adjusted entity Y coordinate */ public get adjustedEntityY(): number { return this.adjustedEntityPosition.y; } /** - * Дельта между начальной и скорректированной позицией сущности - * Используется для применения той же дельты к другим сущностям + * Delta between initial and adjusted entity position + * Used to apply the same delta to other entities */ public get adjustedWorldDelta(): { x: number; y: number } { if (!this.entityStartPosition) { @@ -595,10 +595,10 @@ export class DragInfo { } /** - * Применяет скорректированную дельту к произвольной начальной позиции - * @param startX - Начальная X координата сущности - * @param startY - Начальная Y координата сущности - * @returns Новая позиция с примененной дельтой + * Applies adjusted delta to arbitrary starting position + * @param startX - Initial entity X coordinate + * @param startY - Initial entity Y coordinate + * @returns New position with applied delta */ public applyAdjustedDelta(startX: number, startY: number): { x: number; y: number } { const delta = this.adjustedWorldDelta; @@ -608,11 +608,11 @@ export class DragInfo { }; } - // === КОНТЕКСТ ПЕРЕТАСКИВАНИЯ === + // === DRAG CONTEXT === /** - * Получает контекст перетаскивания (с кэшированием) - * @returns Контекст перетаскивания + * Gets drag context (with caching) + * @returns Drag context */ private getDragContext(): DragContext { if (!this.contextCache) { @@ -622,8 +622,8 @@ export class DragInfo { } /** - * Создает простой контекст перетаскивания - * @returns Базовый контекст с дополнительными данными от пользователя + * Creates simple drag context + * @returns Basic context with additional user data */ private createSimpleContext(): DragContext { const mousePos = new Point(this.lastCameraX, this.lastCameraY); @@ -631,11 +631,11 @@ export class DragInfo { return { graph: this.graph, - currentPosition: mousePos, // Позиция мыши (для совместимости) - currentEntityPosition: entityPos, // Позиция сущности + currentPosition: mousePos, // Mouse position (for compatibility) + currentEntityPosition: entityPos, // Entity position entityStartPosition: this.entityStartPosition, - stage: this.currentStage, // Текущая стадия перетаскивания - // Добавляем пользовательский контекст + stage: this.currentStage, // Current drag stage + // Add user context ...this.customContext, }; } diff --git a/src/services/Drag/modifiers/GridSnapModifier.ts b/src/services/Drag/modifiers/GridSnapModifier.ts new file mode 100644 index 00000000..e88ccc77 --- /dev/null +++ b/src/services/Drag/modifiers/GridSnapModifier.ts @@ -0,0 +1,92 @@ +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragStage, PositionModifier } from "../DragInfo"; + +/** + * Configuration for the grid snap modifier. + */ +export type GridSnapModifierConfig = { + /** Size of the grid in pixels. Positions will snap to multiples of this value. */ + gridSize: number; + /** Drag stage when this modifier should be active (e.g., 'dragging', 'drop'). */ + stage: DragStage; +}; + +/** + * Extended drag context for grid snap modifier operations. + */ +type GridSnapModifierContext = DragContext & { + /** Whether grid snapping is enabled. Can be used to temporarily disable snapping. */ + enableGridSnap: boolean; + /** Override grid size from context. If provided, takes precedence over config gridSize. */ + gridSize?: number; +}; + +/** + * Creates a grid snap position modifier that aligns dragged elements to a regular grid. + * + * This modifier snaps positions to the nearest grid intersection based on the specified + * grid size. It can be configured to activate only during specific drag stages (e.g., + * only on drop for clean final positioning, or during dragging for real-time feedback). + * + * @example + * ```typescript + * // Snap to 20px grid only when dropping + * const dropGridSnap = createGridSnapModifier({ + * gridSize: 20, + * stage: 'drop' + * }); + * + * // Real-time snapping during drag with 25px grid + * const realtimeGridSnap = createGridSnapModifier({ + * gridSize: 25, + * stage: 'dragging' + * }); + * + * // Using with dynamic grid size from context + * dragController.start(handler, event, { + * positionModifiers: [dropGridSnap], + * context: { + * enableGridSnap: !event.ctrlKey, // Disable with Ctrl key + * gridSize: zoomLevel > 2 ? 10 : 20 // Smaller grid when zoomed in + * } + * }); + * ``` + * + * @param params - Configuration for the grid snap modifier + * @returns A position modifier that provides grid snapping functionality + */ +export const createGridSnapModifier = (params: GridSnapModifierConfig): PositionModifier => ({ + name: "grid-snap", + priority: 10, + + /** + * Determines if grid snapping should be applied for the current drag state. + * + * @param _pos - Current position (unused) + * @param dragInfo - Current drag information + * @param ctx - Drag context with grid snap settings + * @returns true if grid snapping should be applied + */ + applicable: (_pos, dragInfo, ctx: GridSnapModifierContext) => { + // Apply only if there's actual movement (not micro-movement) + const isEnabled = ctx.enableGridSnap !== false; // Enabled by default + return !dragInfo.isMicroDrag() && isEnabled && ctx.stage === params.stage; + }, + + /** + * Calculates the grid-snapped position. + * + * @param pos - Current position to snap to grid + * @param _dragInfo - Current drag information (unused) + * @param ctx - Drag context that may override grid size + * @returns Position snapped to the nearest grid intersection + */ + suggest: (pos, _dragInfo, ctx: GridSnapModifierContext) => { + // Grid size can be overridden through context + const effectiveGridSize = ctx.gridSize || params.gridSize; + return new Point( + Math.round(pos.x / effectiveGridSize) * effectiveGridSize, + Math.round(pos.y / effectiveGridSize) * effectiveGridSize + ); + }, +}); diff --git a/src/services/Drag/modifiers/MagneticModifier.ts b/src/services/Drag/modifiers/MagneticModifier.ts new file mode 100644 index 00000000..b71872ef --- /dev/null +++ b/src/services/Drag/modifiers/MagneticModifier.ts @@ -0,0 +1,169 @@ +import { GraphComponent } from "../../../components/canvas/GraphComponent"; +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragInfo } from "../DragInfo"; + +/** + * Configuration for the magnetic modifier. + */ +export type MagneticModifierConfig = { + /** Distance threshold for magnetism in pixels. Elements within this distance will trigger magnetism. */ + magnetismDistance: number; + /** Array of component types to search for magnetism. If not provided, searches all components. */ + targets?: Constructor[]; + /** + * Function to resolve the snap position of an element. + * Should return null/undefined if the element should not provide a snap position. + * @param element - The element to resolve position for + * @returns Position coordinates or null if not applicable + */ + resolvePosition?: (element: GraphComponent) => { x: number; y: number } | null; + /** + * Function to filter which elements can be snap targets. + * @param element - The element to test + * @returns true if element should be considered for magnetism + */ + filter?: (element: GraphComponent) => boolean; +}; + +/** + * Extended drag context for magnetic modifier operations. + */ +type MagneticModifierContext = DragContext & { + magneticModifier: { + magnetismDistance: number; + targets?: Constructor[]; + resolvePosition?: (element: GraphComponent) => { x: number; y: number } | null; + filter?: (element: GraphComponent) => boolean; + }; +}; + +/** + * Creates a magnetic position modifier that snaps dragged elements to nearby targets. + * + * This modifier searches for elements within a specified distance and snaps the dragged + * position to the closest valid target. It uses viewport-based element filtering for + * optimal performance and supports custom target types, position resolution, and filtering. + * + * @example + * ```typescript + * // Basic magnetism to blocks and anchors + * const magneticModifier = MagneticModifier({ + * magnetismDistance: 50, + * targets: [Block, Anchor], + * resolvePosition: (element) => { + * if (element instanceof Block) return element.getConnectionPoint("in"); + * if (element instanceof Anchor) return element.getPosition(); + * return null; + * } + * }); + * + * // Anchor-to-anchor magnetism with type filtering + * const anchorMagnetism = MagneticModifier({ + * magnetismDistance: 30, + * targets: [Anchor], + * filter: (element) => element instanceof Anchor && element.type !== sourceAnchor.type + * }); + * ``` + * + * @param params - Configuration for the magnetic modifier + * @returns A position modifier that provides magnetism functionality + */ +export const MagneticModifier = (params: MagneticModifierConfig) => { + let config = params; + return { + name: "magnetic", + priority: 10, + + /** + * Updates the modifier configuration with new parameters. + * @param nextParams - Partial configuration to merge with existing config + * @returns void + */ + setParams: (nextParams: Partial) => { + config = Object.assign({}, config, nextParams); + }, + + /** + * Determines if the magnetic modifier should be applied for the current drag state. + * + * @param pos - Current position being evaluated + * @param dragInfo - Current drag information + * @param ctx - Drag context containing stage and other metadata + * @returns true if magnetism should be applied + */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: MagneticModifierContext) => { + // Only apply during dragging and drop stages, not during start + if (ctx.stage === "start") return false; + + // Don't apply for micro-movements to prevent jitter + if (dragInfo.isMicroDrag()) return false; + + return true; + }, + + /** + * Calculates the magnetic snap position based on nearby elements. + * + * Searches for target elements within the magnetism distance and returns + * the position of the closest valid target. Updates the drag context with + * the found target for use by other systems. + * + * @param pos - Current position to evaluate for magnetism + * @param dragInfo - Current drag information + * @param ctx - Drag context containing graph and other metadata + * @returns Modified position if a target is found, otherwise original position + */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: MagneticModifierContext) => { + const distance = config.magnetismDistance; + + // Create a search rectangle around the current position + const searchRect = { + x: pos.x - distance, + y: pos.y - distance, + width: distance * 2, + height: distance * 2, + }; + + // Get elements within the search area based on useBlockAnchors setting + let elementsInRect = ctx.graph.getElementsOverRect(searchRect, [...config.targets]); + + if (config.filter) { + elementsInRect = elementsInRect.filter(config.filter); + } + + let closestTarget: GraphComponent | null = null; + let closestDistance = distance; + + // Check all found elements (only blocks or only anchors based on settings) + elementsInRect.forEach((element) => { + const position = config.resolvePosition?.(element); + if (!position) { + return; + } + + const dist = Math.sqrt(Math.pow(pos.x - position.x, 2) + Math.pow(pos.y - position.y, 2)); + + if (dist < closestDistance) { + closestTarget = element; + closestDistance = dist; + } + }); + + // Update context with closest target for use in drag handlers + dragInfo.updateContext({ + closestTarget, + }); + + // If we found a nearby target, snap to it + if (closestTarget) { + const position = config.resolvePosition?.(closestTarget); + if (position) { + return new Point(position.x, position.y); + } + } + + // No snapping suggestion - return original position + return pos; + }, + }; +}; diff --git a/src/services/DragController.ts b/src/services/DragController.ts deleted file mode 100644 index 4c7766e2..00000000 --- a/src/services/DragController.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { Graph } from "../graph"; -import { ESchedulerPriority, scheduler } from "../lib/Scheduler"; -import { dragListener } from "../utils/functions/dragListener"; -import { EVENTS } from "../utils/types/events"; - -import { DragInfo, PositionModifier } from "./DragInfo"; - -/** - * Интерфейс для компонентов, которые могут быть перетаскиваемыми - */ -export interface DragHandler { - /** - * Вызывается перед обновлением позиции для выбора модификатора - * @param dragInfo - Statefull модель с информацией о перетаскивании - */ - beforeUpdate?(dragInfo: DragInfo): void; - - /** - * Вызывается при начале перетаскивания - * @param event - Событие мыши - * @param dragInfo - Statefull модель с информацией о перетаскивании - */ - onDragStart(event: MouseEvent, dragInfo: DragInfo): void; - - /** - * Вызывается при обновлении позиции во время перетаскивания - * @param event - Событие мыши - * @param dragInfo - Statefull модель с информацией о перетаскивании - */ - onDragUpdate(event: MouseEvent, dragInfo: DragInfo): void; - - /** - * Вызывается при завершении перетаскивания - * @param event - Событие мыши - * @param dragInfo - Statefull модель с информацией о перетаскивании - */ - onDragEnd(event: MouseEvent, dragInfo: DragInfo): void; -} - -/** - * Конфигурация для DragController - */ -export interface DragControllerConfig { - /** Включить автоматическое движение камеры при приближении к границам */ - enableEdgePanning?: boolean; - /** Модификаторы позиции для коррекции координат во время перетаскивания */ - positionModifiers?: PositionModifier[]; - /** Дополнительный контекст для передачи в модификаторы */ - context?: Record; - /** Начальная позиция перетаскиваемой сущности в пространстве камеры */ - initialEntityPosition?: { x: number; y: number }; -} - -/** - * Централизованный контроллер для управления перетаскиванием компонентов - */ -export class DragController { - private graph: Graph; - - private currentDragHandler?: DragHandler; - - private isDragging = false; - - private lastMouseEvent?: MouseEvent; - - private dragInfo: DragInfo; - - private updateScheduler?: () => void; - - constructor(graph: Graph) { - this.graph = graph; - } - - /** - * Начинает процесс перетаскивания для указанного компонента - * @param component - Компонент, который будет перетаскиваться - * @param event - Исходное событие мыши - * @param config - Конфигурация перетаскивания - * @returns void - */ - public start(component: DragHandler, event: MouseEvent, config: DragControllerConfig = {}): void { - if (this.isDragging) { - // eslint-disable-next-line no-console - console.warn("DragController: attempt to start dragging while already dragging"); - return; - } - - this.currentDragHandler = component; - this.isDragging = true; - this.lastMouseEvent = event; - - // Создаем DragInfo с модификаторами, контекстом и инициализируем - this.dragInfo = new DragInfo( - this.graph, - config.positionModifiers || [], - config.context, - config.initialEntityPosition - ); - this.dragInfo.init(event); - - if (config.enableEdgePanning ?? true) { - const defaultConfig = this.graph.graphConstants.camera.EDGE_PANNING; - - this.graph.getGraphLayer().enableEdgePanning({ - speed: defaultConfig.SPEED, - edgeSize: defaultConfig.EDGE_SIZE, - }); - - // Запускаем периодическое обновление компонента для синхронизации с движением камеры - this.startContinuousUpdate(); - } - - component.onDragStart(event, this.dragInfo); - - this.startDragListener(event); - } - - /** - * Обновляет состояние перетаскивания - * @param event - Событие мыши - * @returns void - */ - public update(event: MouseEvent): void { - if (!this.isDragging || !this.currentDragHandler) { - return; - } - - this.lastMouseEvent = event; - - // Обновляем состояние DragInfo - this.dragInfo.update(event); - - // Анализируем модификаторы позиции - this.dragInfo.analyzeSuggestions(); - - // Даем возможность выбрать модификатор в beforeUpdate - if (this.currentDragHandler.beforeUpdate) { - this.currentDragHandler.beforeUpdate(this.dragInfo); - } else { - // Дефолтная стратегия - по расстоянию - this.dragInfo.selectDefault(); - } - - this.currentDragHandler.onDragUpdate(event, this.dragInfo); - } - - /** - * Завершает процесс перетаскивания - * @param event - Событие мыши - * @returns void - */ - public end(event: MouseEvent): void { - if (!this.isDragging || !this.currentDragHandler) { - return; - } - - // TODO: Нужно передать EventedComponent вместо DragController - // this.graph.getGraphLayer().releaseCapture(); - - // Останавливаем непрерывное обновление - this.stopContinuousUpdate(); - - // Отключаем edge panning - this.graph.getGraphLayer().disableEdgePanning(); - - // Завершаем процесс в DragInfo (устанавливает стадию 'drop') - this.dragInfo.end(event); - - // Анализируем модификаторы на стадии 'drop' - this.dragInfo.analyzeSuggestions(); - - // Даем возможность выбрать модификатор на стадии drop - if (this.currentDragHandler.beforeUpdate) { - this.currentDragHandler.beforeUpdate(this.dragInfo); - } else { - // Дефолтная стратегия - по расстоянию - this.dragInfo.selectDefault(); - } - - // Вызываем onDragUpdate с финальными позициями (на стадии 'drop') - this.currentDragHandler.onDragUpdate(event, this.dragInfo); - - // Затем вызываем обработчик завершения перетаскивания - this.currentDragHandler.onDragEnd(event, this.dragInfo); - - // Сбрасываем состояние - this.currentDragHandler = undefined; - this.isDragging = false; - this.lastMouseEvent = undefined; - this.dragInfo.reset(); - } - - /** - * Проверяет, происходит ли в данный момент перетаскивание - * @returns true если происходит перетаскивание - */ - public isDragInProgress(): boolean { - return this.isDragging; - } - - /** - * Получает текущий перетаскиваемый компонент - * @returns текущий DragHandler или undefined - */ - public getCurrentDragHandler(): DragHandler | undefined { - return this.currentDragHandler; - } - - /** - * Получает текущую информацию о перетаскивании - * @returns экземпляр DragInfo (всегда доступен) - */ - public getCurrentDragInfo(): DragInfo { - return this.dragInfo; - } - - /** - * Запускает непрерывное обновление компонента для синхронизации с движением камеры - * @returns void - */ - private startContinuousUpdate(): void { - if (this.updateScheduler) { - return; - } - - const update = () => { - if (!this.isDragging || !this.currentDragHandler || !this.lastMouseEvent) { - return; - } - - // Создаем синтетическое событие мыши с текущими координатами - // Это позволяет компонентам обновлять свою позицию при движении камеры - // даже когда физическое движение мыши не происходит - const syntheticEvent = new MouseEvent("mousemove", { - clientX: this.lastMouseEvent.clientX, - clientY: this.lastMouseEvent.clientY, - bubbles: false, - cancelable: false, - }); - - // Копируем pageX/pageY вручную, так как в MouseEventInit их нет - Object.defineProperty(syntheticEvent, "pageX", { value: this.lastMouseEvent.pageX }); - Object.defineProperty(syntheticEvent, "pageY", { value: this.lastMouseEvent.pageY }); - - // Обновляем состояние DragInfo для синтетического события - this.dragInfo.update(this.lastMouseEvent); - - this.currentDragHandler.onDragUpdate(syntheticEvent, this.dragInfo); - }; - - // Используем средний приоритет для обновлений чтобы синхронизироваться с движением камеры - this.updateScheduler = scheduler.addScheduler({ performUpdate: update }, ESchedulerPriority.MEDIUM); - } - - /** - * Останавливает непрерывное обновление - * @returns void - */ - private stopContinuousUpdate(): void { - if (this.updateScheduler) { - this.updateScheduler(); - this.updateScheduler = undefined; - } - } - - /** - * Запускает dragListener для отслеживания событий мыши - * @param _initialEvent - Начальное событие мыши (не используется) - * @returns void - */ - private startDragListener(_initialEvent: MouseEvent): void { - const ownerDocument = this.graph.getGraphCanvas().ownerDocument; - - dragListener(ownerDocument) - .on(EVENTS.DRAG_START, (event: MouseEvent) => { - this.lastMouseEvent = event; - }) - .on(EVENTS.DRAG_UPDATE, (event: MouseEvent) => { - this.update(event); - }) - .on(EVENTS.DRAG_END, (event: MouseEvent) => { - this.end(event); - }); - } - - /** - * Принудительно завершает текущее перетаскивание (например, при размонтировании) - * @returns void - */ - public forceEnd(): void { - if (this.isDragging && this.lastMouseEvent) { - this.end(this.lastMouseEvent); - } - } -} diff --git a/src/utils/functions/index.ts b/src/utils/functions/index.ts index 13e400bb..744069b4 100644 --- a/src/utils/functions/index.ts +++ b/src/utils/functions/index.ts @@ -1,5 +1,5 @@ import { Block } from "../../components/canvas/blocks/Block"; -import type { DragStage, PositionModifier } from "../../services/DragInfo"; +import type { DragStage, PositionModifier } from "../../services/Drag/DragInfo"; import { BlockState, TBlockId } from "../../store/block/Block"; import { ECanChangeBlockGeometry } from "../../store/settings"; import { EVENTS_DETAIL, SELECTION_EVENT_TYPES } from "../types/events"; @@ -256,63 +256,3 @@ export function computeCssVariable(name: string) { // Re-export scheduler utilities export { schedule, debounce, throttle } from "../utils/schedule"; - -// === POSITION MODIFIERS === - -/** - * Утилиты для создания стандартных модификаторов позиции - * - * Новая архитектура поддерживает позицию сущности вместо позиции мыши: - * - `adjustedEntityPosition` - скорректированная позиция сущности (блока) - * - `initialEntityPosition` - начальная позиция сущности - * - Модификаторы работают с позицией сущности, а не курсора - * - * @example - * dragController.start(handler, event, { - * positionModifiers: [DragModifiers.gridSnap(20)], - * initialEntityPosition: { x: block.x, y: block.y }, // Позиция блока - * context: { - * enableGridSnap: true, - * gridSize: 15, // Переопределяем размер сетки - * selectedBlocks: [block1, block2] // любые данные - * } - * }); - * - * // В обработчике для одного блока: - * onDragUpdate(event, dragInfo) { - * const newPos = dragInfo.adjustedEntityPosition; // Позиция основного блока - * mainBlock.updatePosition(newPos.x, newPos.y); - * } - * - * // Для множественного выбора (каждый блок использует свою начальную позицию): - * onDragUpdate(event, dragInfo) { - * const newPos = dragInfo.applyAdjustedDelta(this.startX, this.startY); - * thisBlock.updatePosition(newPos.x, newPos.y); - * } - */ -export const DragModifiers = { - /** - * Создает модификатор для привязки к сетке - * @param gridSize - Размер ячейки сетки в пикселях - * @returns Модификатор gridSnap - */ - gridSnap: (gridSize: number, stage: DragStage = "drop"): PositionModifier => ({ - name: "grid-snap", - priority: 10, - - applicable: (pos, dragInfo, ctx) => { - // Применяем только если есть движение (не микросдвиг) - const isEnabled = ctx.enableGridSnap !== false; // По умолчанию включен - return !dragInfo.isMicroDrag() && isEnabled && ctx.stage === stage; - }, - - suggest: (pos, _dragInfo, ctx) => { - // Можно переопределить размер сетки через контекст - const effectiveGridSize = (ctx.gridSize as number) || gridSize; - return new Point( - Math.round(pos.x / effectiveGridSize) * effectiveGridSize, - Math.round(pos.y / effectiveGridSize) * effectiveGridSize - ); - }, - }), -}; From e3f4f74a0da56793cfce37e8524fdb008042da78 Mon Sep 17 00:00:00 2001 From: draedful Date: Fri, 1 Aug 2025 12:14:06 +0300 Subject: [PATCH 09/10] ... --- src/components/canvas/blocks/Blocks.ts | 23 ++ .../Drag/modifiers/MagneticBorderModifier.ts | 283 ++++++++++++++++++ src/services/Drag/modifiers/index.ts | 15 + 3 files changed, 321 insertions(+) create mode 100644 src/services/Drag/modifiers/MagneticBorderModifier.ts create mode 100644 src/services/Drag/modifiers/index.ts diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index 9cab0ff5..ed30beca 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -1,4 +1,5 @@ import { createGridSnapModifier } from "../../../services/Drag/modifiers/GridSnapModifier"; +import { MagneticBorderModifier } from "../../../services/Drag/modifiers/MagneticBorderModifier"; import { BlockState } from "../../../store/block/Block"; import { BlockListStore } from "../../../store/block/BlocksList"; import { isMetaKeyEvent } from "../../../utils/functions"; @@ -83,6 +84,9 @@ export class Blocks extends GraphComponent { block.onDragStart(dragEvent); } }, + beforeUpdate: (dragInfo) => { + dragInfo.selectByPriority(); + }, onDragUpdate: (dragEvent, dragInfo) => { const blocks = dragInfo.context.selectedBlocks as Block[]; for (const block of blocks) { @@ -101,6 +105,25 @@ export class Blocks extends GraphComponent { { positionModifiers: [ createGridSnapModifier({ gridSize: this.context.constants.block.SNAPPING_GRID_SIZE, stage: "drop" }), + // eslint-disable-next-line new-cap + MagneticBorderModifier({ + magnetismDistance: "auto", + targets: [Block], + enabledBorders: ["top", "right", "bottom", "left"], + filter: (element) => { + return element !== blockInstance; + }, + resolveBounds: (element) => { + if (element instanceof Block) { + return { + x: element.state.x, + y: element.state.y, + width: element.state.width, + height: element.state.height, + }; + } + }, + }), ], initialEntityPosition: initialEntityPosition, context: { diff --git a/src/services/Drag/modifiers/MagneticBorderModifier.ts b/src/services/Drag/modifiers/MagneticBorderModifier.ts new file mode 100644 index 00000000..5f537df0 --- /dev/null +++ b/src/services/Drag/modifiers/MagneticBorderModifier.ts @@ -0,0 +1,283 @@ +import { GraphComponent } from "../../../components/canvas/GraphComponent"; +import { Point } from "../../../utils/types/shapes"; +import { DragContext, DragInfo } from "../DragInfo"; + +/** + * Configuration for the magnetic border modifier. + */ +export type MagneticBorderModifierConfig = { + /** + * Distance threshold for magnetism in pixels, or 'auto' to use camera viewport. + * - number: Elements within this distance will trigger magnetism + * - 'auto': All elements in camera viewport will be considered for magnetism + */ + magnetismDistance: number | "auto"; + /** Array of component types to search for magnetism. If not provided, searches all components. */ + targets?: Constructor[]; + /** + * Function to resolve the bounding box of an element. + * Should return null/undefined if the element should not provide a bounding box. + * @param element - The element to resolve bounding box for + * @returns Bounding box coordinates or null if not applicable + */ + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null; + /** + * Function to filter which elements can be snap targets. + * @param element - The element to test + * @returns true if element should be considered for magnetism + */ + filter?: (element: GraphComponent) => boolean; + /** + * Which borders to consider for snapping. + * @default ['top', 'right', 'bottom', 'left'] + */ + enabledBorders?: Array<"top" | "right" | "bottom" | "left">; +}; + +/** + * Extended drag context for magnetic border modifier operations. + */ +type MagneticBorderModifierContext = DragContext & { + magneticBorderModifier: { + magnetismDistance: number | "auto"; + targets?: Constructor[]; + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null; + filter?: (element: GraphComponent) => boolean; + enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + }; +}; + +/** + * Represents a border of a bounding box. + */ +type BorderInfo = { + element: GraphComponent; + border: "top" | "right" | "bottom" | "left"; + point: Point; + distance: number; +}; + +/** + * Calculates the closest point on infinite lines extending through rectangle borders. + * Unlike border snapping, this projects the point onto infinite lines that pass through the borders. + * @param point - The point to find the closest line projection for + * @param bounds - The bounding box of the rectangle + * @param enabledBorders - Which border lines to consider + * @returns Array of line projection information sorted by distance + */ +function getClosestBorderLines( + point: Point, + bounds: { x: number; y: number; width: number; height: number }, + enabledBorders: Array<"top" | "right" | "bottom" | "left"> = ["top", "right", "bottom", "left"] +): Array<{ border: "top" | "right" | "bottom" | "left"; point: Point; distance: number }> { + const { x, y, width, height } = bounds; + const linePoints: Array<{ border: "top" | "right" | "bottom" | "left"; point: Point; distance: number }> = []; + + // Top border line (horizontal line y = bounds.y) + if (enabledBorders.includes("top")) { + const linePoint = new Point(point.x, y); // Project point onto horizontal line + const distance = Math.abs(point.y - y); // Distance is just the Y difference + linePoints.push({ border: "top", point: linePoint, distance }); + } + + // Right border line (vertical line x = bounds.x + width) + if (enabledBorders.includes("right")) { + const linePoint = new Point(x + width, point.y); // Project point onto vertical line + const distance = Math.abs(point.x - (x + width)); // Distance is just the X difference + linePoints.push({ border: "right", point: linePoint, distance }); + } + + // Bottom border line (horizontal line y = bounds.y + height) + if (enabledBorders.includes("bottom")) { + const linePoint = new Point(point.x, y + height); // Project point onto horizontal line + const distance = Math.abs(point.y - (y + height)); // Distance is just the Y difference + linePoints.push({ border: "bottom", point: linePoint, distance }); + } + + // Left border line (vertical line x = bounds.x) + if (enabledBorders.includes("left")) { + const linePoint = new Point(x, point.y); // Project point onto vertical line + const distance = Math.abs(point.x - x); // Distance is just the X difference + linePoints.push({ border: "left", point: linePoint, distance }); + } + + return linePoints.sort((a, b) => a.distance - b.distance); +} + +/** + * Creates a magnetic border modifier that snaps dragged elements to infinite lines extending through element borders. + * + * This modifier searches for elements within a specified distance (or camera viewport in world + * coordinates for 'auto' mode) and snaps the dragged position to the closest infinite line that + * passes through element borders. Unlike border snapping which limits to the actual border edges, + * this projects onto infinite lines for perfect alignment. It uses viewport-based element filtering + * for optimal performance and supports custom target types, bounding box resolution, and border line filtering. + * + * @example + * ```typescript + * // Basic line magnetism to block borders with distance threshold + * const lineMagnetism = MagneticBorderModifier({ + * magnetismDistance: 20, + * targets: [Block], + * resolveBounds: (element) => { + * if (element instanceof Block) { + * return { + * x: element.state.x, + * y: element.state.y, + * width: element.state.width, + * height: element.state.height + * }; + * } + * return null; + * } + * }); + * + * // Auto mode: snap to all visible blocks in camera viewport (world coordinates) + * const globalLineMagnetism = MagneticBorderModifier({ + * magnetismDistance: "auto", // Use entire camera viewport in world coordinates + * targets: [Block], + * resolveBounds: (element) => element.getBounds() + * }); + * + * // Snap only to horizontal lines (through top/bottom borders) + * const horizontalLineSnap = MagneticBorderModifier({ + * magnetismDistance: 15, + * targets: [Block], + * enabledBorders: ["top", "bottom"], + * resolveBounds: (element) => element.getBounds() + * }); + * + * // Auto mode with vertical lines only + * const globalVerticalAlign = MagneticBorderModifier({ + * magnetismDistance: "auto", + * targets: [Block], + * enabledBorders: ["left", "right"], + * resolveBounds: (element) => element.getBounds() + * }); + * ``` + * + * @param params - Configuration for the magnetic border modifier + * @returns A position modifier that provides border line magnetism functionality + */ +export const MagneticBorderModifier = (params: MagneticBorderModifierConfig) => { + let config = params; + return { + name: "magneticBorder", + priority: 8, // Slightly lower priority than point magnetism + + /** + * Updates the modifier configuration with new parameters. + * @param nextParams - Partial configuration to merge with existing config + * @returns void + */ + setParams: (nextParams: Partial) => { + config = Object.assign({}, config, nextParams); + }, + + /** + * Determines if the magnetic border modifier should be applied for the current drag state. + * + * @param pos - Current position being evaluated + * @param dragInfo - Current drag information + * @param ctx - Drag context containing stage and other metadata + * @returns true if border magnetism should be applied + */ + applicable: (pos: Point, dragInfo: DragInfo, ctx: MagneticBorderModifierContext) => { + // Only apply during dragging and drop stages, not during start + if (ctx.stage === "start") return false; + + // Don't apply for micro-movements to prevent jitter + if (dragInfo.isMicroDrag()) return false; + + return true; + }, + + /** + * Calculates the magnetic snap position based on infinite lines through element borders. + * + * Searches for target elements within the magnetism distance and returns + * the projected position on the closest infinite line passing through element borders. + * Updates the drag context with the found target information for use by other systems. + * + * @param pos - Current position to evaluate for magnetism + * @param dragInfo - Current drag information + * @param ctx - Drag context containing graph and other metadata + * @returns Modified position if a border line is found, otherwise original position + */ + suggest: (pos: Point, dragInfo: DragInfo, ctx: MagneticBorderModifierContext) => { + const enabledBorders = config.enabledBorders || ["right", "left"]; + const isAutoMode = config.magnetismDistance === "auto"; + + // Determine search area and maximum distance + let searchRect: { x: number; y: number; width: number; height: number }; + let maxDistance: number; + + let elementsInRect = []; + + if (isAutoMode) { + elementsInRect = ctx.graph.getElementsInViewport(config.targets ? [...config.targets] : []); + // In auto mode, allow infinite distance within viewport + maxDistance = Infinity; + } else { + // Distance mode: create search rectangle around current position + const distance = config.magnetismDistance as number; + searchRect = { + x: pos.x - distance, + y: pos.y - distance, + width: distance * 2, + height: distance * 2, + }; + elementsInRect = ctx.graph.getElementsOverRect(searchRect, config.targets ? [...config.targets] : []); + maxDistance = distance; + } + + // Get elements within the search area + + if (config.filter) { + elementsInRect = elementsInRect.filter(config.filter); + } + + let closestBorder: BorderInfo | null = null; + let closestDistance = maxDistance; + + // Check all found elements for their borders + elementsInRect.forEach((element) => { + const bounds = config.resolveBounds?.(element); + if (!bounds) { + return; + } + + // Get closest border lines for this element + const borderLines = getClosestBorderLines(pos, bounds, enabledBorders); + + // Check if any border line is closer than our current closest + for (const borderLine of borderLines) { + if (borderLine.distance < closestDistance) { + closestBorder = { + element, + border: borderLine.border, + point: borderLine.point, + distance: borderLine.distance, + }; + closestDistance = borderLine.distance; + } + } + }); + + // Update context with closest border information for use in drag handlers + dragInfo.updateContext({ + closestBorder, + closestBorderElement: closestBorder?.element, + closestBorderSide: closestBorder?.border, + }); + + // If we found a nearby border, snap to it + if (closestBorder) { + return closestBorder.point; + } + + // No snapping suggestion - return original position + return pos; + }, + }; +}; diff --git a/src/services/Drag/modifiers/index.ts b/src/services/Drag/modifiers/index.ts new file mode 100644 index 00000000..7b330821 --- /dev/null +++ b/src/services/Drag/modifiers/index.ts @@ -0,0 +1,15 @@ +/** + * Position modifiers for the drag system. + * + * These modifiers can be used to modify dragged positions in real-time, + * providing features like grid snapping, magnetism to elements, and border alignment. + */ + +export { createGridSnapModifier } from "./GridSnapModifier"; +export type { GridSnapModifierConfig } from "./GridSnapModifier"; + +export { MagneticModifier } from "./MagneticModifier"; +export type { MagneticModifierConfig } from "./MagneticModifier"; + +export { MagneticBorderModifier } from "./MagneticBorderModifier"; +export type { MagneticBorderModifierConfig } from "./MagneticBorderModifier"; \ No newline at end of file From 96a55c020aacedb849ef4dfa762088242224d2ee Mon Sep 17 00:00:00 2001 From: draedful Date: Sun, 3 Aug 2025 16:10:19 +0300 Subject: [PATCH 10/10] feat(Drag): AligmentLayer experiment --- src/components/canvas/blocks/Blocks.ts | 20 +- .../alignmentLayer/AlignmentLinesLayer.ts | 435 +++++++++++++++++ .../canvas/layers/alignmentLayer/README.md | 161 +++++++ .../canvas/layers/alignmentLayer/index.ts | 1 + src/examples/DragMiddlewareExample.ts | 85 ++++ src/graphEvents.ts | 4 + src/index.ts | 3 + src/services/Drag/DragController.ts | 13 +- src/services/Drag/DragInfo.ts | 92 +++- .../Drag/modifiers/MagneticBorderModifier.ts | 289 ++++++++++-- .../alignmentLayer/alignmentLayer.stories.tsx | 437 ++++++++++++++++++ 11 files changed, 1478 insertions(+), 62 deletions(-) create mode 100644 src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts create mode 100644 src/components/canvas/layers/alignmentLayer/README.md create mode 100644 src/components/canvas/layers/alignmentLayer/index.ts create mode 100644 src/examples/DragMiddlewareExample.ts create mode 100644 src/stories/examples/alignmentLayer/alignmentLayer.stories.tsx diff --git a/src/components/canvas/blocks/Blocks.ts b/src/components/canvas/blocks/Blocks.ts index ed30beca..3724e12e 100644 --- a/src/components/canvas/blocks/Blocks.ts +++ b/src/components/canvas/blocks/Blocks.ts @@ -105,28 +105,10 @@ export class Blocks extends GraphComponent { { positionModifiers: [ createGridSnapModifier({ gridSize: this.context.constants.block.SNAPPING_GRID_SIZE, stage: "drop" }), - // eslint-disable-next-line new-cap - MagneticBorderModifier({ - magnetismDistance: "auto", - targets: [Block], - enabledBorders: ["top", "right", "bottom", "left"], - filter: (element) => { - return element !== blockInstance; - }, - resolveBounds: (element) => { - if (element instanceof Block) { - return { - x: element.state.x, - y: element.state.y, - width: element.state.width, - height: element.state.height, - }; - } - }, - }), ], initialEntityPosition: initialEntityPosition, context: { + dragEntity: blockInstance, enableGridSnap: true, selectedBlocks: selectedBlocksComponents, }, diff --git a/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts b/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts new file mode 100644 index 00000000..f6f1464f --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/AlignmentLinesLayer.ts @@ -0,0 +1,435 @@ +import { TComponentState } from "../../../../lib/Component"; +import { DragContext, DragInfo, DragModifier, IDragMiddleware } from "../../../../services/Drag/DragInfo"; +import { + MagneticBorderModifier, + MagneticBorderModifierConfig, +} from "../../../../services/Drag/modifiers/MagneticBorderModifier"; +import { Layer, LayerContext, LayerProps } from "../../../../services/Layer"; +import { GraphComponent } from "../../GraphComponent"; +import { Block } from "../../blocks/Block"; + +/** Border information from magnetic modifier context */ +interface BorderInfo { + element: GraphComponent; + border: "top" | "right" | "bottom" | "left"; + point: { x: number; y: number }; + distance: number; +} + +/** Extended drag context with magnetic border information */ +interface MagneticDragContext extends DragContext { + allBorderLines?: BorderInfo[]; + selectedBorders?: BorderInfo[]; + dragEntity?: GraphComponent; +} + +/** + * Configuration for the alignment lines layer + */ +export interface AlignmentLinesLayerProps extends LayerProps { + /** Configuration for the magnetic border modifier */ + magneticBorderConfig?: Partial; + /** Style configuration for alignment lines */ + lineStyle?: { + /** Color for snap lines (lines that trigger snapping) */ + snapColor?: string; + /** Color for guide lines (lines that don't trigger snapping) */ + guideColor?: string; + /** Line width */ + width?: number; + /** Dash pattern for snap lines */ + snapDashPattern?: number[]; + /** Dash pattern for guide lines */ + guideDashPattern?: number[]; + }; +} + +/** + * State for storing alignment line information + */ +interface AlignmentLinesState extends TComponentState { + /** Array of snap lines (lines that trigger snapping) */ + snapLines: Array<{ + /** Line type - horizontal or vertical */ + type: "horizontal" | "vertical"; + /** Position coordinate (y for horizontal, x for vertical) */ + position: number; + /** Visual bounds for the line */ + bounds: { + start: number; + end: number; + }; + }>; + /** Array of guide lines (lines that show potential alignment but don't snap) */ + guideLines: Array<{ + /** Line type - horizontal or vertical */ + type: "horizontal" | "vertical"; + /** Position coordinate (y for horizontal, x for vertical) */ + position: number; + /** Visual bounds for the line */ + bounds: { + start: number; + end: number; + }; + }>; + /** Whether alignment lines are currently visible */ + visible: boolean; +} + +/** + * Layer that displays alignment lines when dragging blocks with magnetic border snapping + */ +export class AlignmentLinesLayer + extends Layer< + AlignmentLinesLayerProps, + LayerContext & { canvas: HTMLCanvasElement; ctx: CanvasRenderingContext2D }, + AlignmentLinesState + > + implements IDragMiddleware +{ + /** Current drag modifier instance */ + private magneticModifier: DragModifier | null = null; + + /** Configuration for magnetic border behavior */ + private readonly magneticConfig: MagneticBorderModifierConfig; + + constructor(props: AlignmentLinesLayerProps) { + super({ + canvas: { + zIndex: 15, // Above selection layer but below new block layer + classNames: ["alignment-lines-layer", "no-pointer-events"], + transformByCameraPosition: true, + ...props.canvas, + }, + ...props, + }); + + // Default magnetic border configuration + this.magneticConfig = { + magnetismDistance: 50, // Show guide lines up to 50px + snapThreshold: 15, // Snap only within 15px + enabledBorders: ["top", "right", "bottom", "left"], + allowMultipleSnap: true, // Allow snapping to both horizontal and vertical lines + targets: [Block], + resolveBounds: (element: GraphComponent) => { + if (element instanceof Block) { + const state = element.state; + return { + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }; + } + return null; + }, + filter: (element: GraphComponent, dragInfo: DragInfo, ctx: DragContext) => { + // Don't snap to self and filter non-block elements + return element instanceof Block && element !== ctx.dragEntity; + }, + ...props.magneticBorderConfig, + }; + + // Initialize state + this.setState({ + snapLines: [], + guideLines: [], + visible: false, + }); + + const canvas = this.getCanvas(); + const ctx = canvas.getContext("2d"); + if (!ctx) { + throw new Error("Unable to get 2D rendering context"); + } + + this.setContext({ + canvas, + ctx, + camera: props.camera, + constants: this.props.graph.graphConstants, + colors: this.props.graph.graphColors, + graph: this.props.graph, + }); + } + + /** + * Provides drag modifier for magnetic border snapping with line visualization + * @returns Drag modifier for border snapping with alignment lines + */ + public dragModifier(): DragModifier { + if (!this.magneticModifier) { + // Create the magnetic border modifier + // eslint-disable-next-line new-cap + const baseMagneticModifier = MagneticBorderModifier(this.magneticConfig); + + // Extend it with line visualization + this.magneticModifier = { + ...baseMagneticModifier, + name: "alignmentLinesLayer", + onApply: (dragInfo: DragInfo, ctx: DragContext) => { + // Update alignment lines based on closest border + this.updateAlignmentLines(dragInfo, ctx); + }, + }; + } + + return this.magneticModifier; + } + + /** + * Called after layer initialization + * Sets up event listeners for drag events + * @returns void + */ + protected afterInit(): void { + super.afterInit(); + + // Subscribe to drag events to manage modifier and visualization + this.context.graph.on("drag-start", (event) => { + const { dragInfo } = event.detail; + + // Add our modifier if we're dragging a Block + if (this.isDraggingBlock(dragInfo)) { + dragInfo.addModifier(this.dragModifier()); + this.setState({ visible: true }); + } + }); + + this.context.graph.on("drag-update", (event) => { + const { dragInfo } = event.detail; + + // Update visualization if our modifier is applied + if (dragInfo.isApplied(this.dragModifier())) { + this.updateAlignmentLines(dragInfo, dragInfo.context as DragContext); + this.performRender(); + } + }); + + this.context.graph.on("drag-end", (event) => { + const { dragInfo } = event.detail; + + // Clean up: remove modifier and hide lines + dragInfo.removeModifier(this.dragModifier().name); + this.setState({ + snapLines: [], + guideLines: [], + visible: false, + }); + this.performRender(); + }); + } + + /** + * Checks if we're dragging a block + * @param dragInfo - Current drag information + * @returns true if dragging a Block component + */ + private isDraggingBlock(dragInfo: DragInfo): boolean { + return "dragEntity" in dragInfo.context && dragInfo.context.dragEntity instanceof Block; + } + + /** + * Updates alignment lines based on current drag state + * @param dragInfo - Current drag information + * @param ctx - Drag context containing border information + * @returns void + */ + private updateAlignmentLines(dragInfo: DragInfo, ctx: DragContext): void { + const snapLines: AlignmentLinesState["snapLines"] = []; + const guideLines: AlignmentLinesState["guideLines"] = []; + + // Get border information from context + const magneticCtx = ctx as MagneticDragContext; + const allBorderLines = magneticCtx.allBorderLines || []; + const selectedBorders = magneticCtx.selectedBorders || []; + + // Get current dragged block bounds for line extension + const draggedEntity = magneticCtx.dragEntity; + let draggedBounds = null; + if (draggedEntity instanceof Block) { + const state = draggedEntity.state; + draggedBounds = { + x: state.x, + y: state.y, + width: state.width, + height: state.height, + }; + } + + // Convert selected borders to snap lines + selectedBorders.forEach((borderInfo) => { + const { border, point, element } = borderInfo; + + // Get bounds of the target element for line extension + const targetBounds = this.magneticConfig.resolveBounds?.(element); + if (!targetBounds) return; + + const line = this.createLineFromBorder(border, point, targetBounds, draggedBounds); + if (line) { + snapLines.push(line); + } + }); + + // Convert all other borders to guide lines (excluding those already used for snapping) + const selectedBorderIds = new Set(selectedBorders.map((b) => `${this.getElementId(b.element)}-${b.border}`)); + + allBorderLines.forEach((borderInfo) => { + const { border, point, element } = borderInfo; + const borderId = `${this.getElementId(element)}-${border}`; + + // Skip if this border is already used for snapping + if (selectedBorderIds.has(borderId)) return; + + // Get bounds of the target element for line extension + const targetBounds = this.magneticConfig.resolveBounds?.(element); + if (!targetBounds) return; + + const line = this.createLineFromBorder(border, point, targetBounds, draggedBounds); + if (line) { + guideLines.push(line); + } + }); + + // Update state with new lines + this.setState({ + snapLines, + guideLines, + visible: snapLines.length > 0 || guideLines.length > 0, + }); + } + + /** + * Gets a unique identifier for a GraphComponent + * @param element - The graph component + * @returns Unique string identifier for the element + */ + private getElementId(element: GraphComponent): string { + // Use a combination of constructor name and unique string as identifier + // Since we don't have a reliable id property, we use the object's string representation + return `${element.constructor.name}_${Object.prototype.toString.call(element)}`; + } + + /** + * Creates a line object from border information + * @param border - Border type (top, right, bottom, left) + * @param point - Point on the border line + * @param targetBounds - Bounds of the target element + * @param draggedBounds - Bounds of the dragged element (can be null) + * @returns Line object or null if border type is unsupported + */ + private createLineFromBorder( + border: "top" | "right" | "bottom" | "left", + point: { x: number; y: number }, + targetBounds: { x: number; y: number; width: number; height: number }, + draggedBounds: { x: number; y: number; width: number; height: number } | null + ) { + const padding = 20; + + if (border === "top" || border === "bottom") { + // Horizontal line + const lineY = point.y; + + // Calculate line bounds - extend beyond both elements + let startX = Math.min(targetBounds.x, draggedBounds?.x ?? point.x); + let endX = Math.max( + targetBounds.x + targetBounds.width, + (draggedBounds?.x ?? point.x) + (draggedBounds?.width ?? 0) + ); + + // Add padding + startX -= padding; + endX += padding; + + return { + type: "horizontal" as const, + position: lineY, + bounds: { start: startX, end: endX }, + }; + } else if (border === "left" || border === "right") { + // Vertical line + const lineX = point.x; + + // Calculate line bounds - extend beyond both elements + let startY = Math.min(targetBounds.y, draggedBounds?.y ?? point.y); + let endY = Math.max( + targetBounds.y + targetBounds.height, + (draggedBounds?.y ?? point.y) + (draggedBounds?.height ?? 0) + ); + + // Add padding + startY -= padding; + endY += padding; + + return { + type: "vertical" as const, + position: lineX, + bounds: { start: startY, end: endY }, + }; + } + + return null; + } + + /** + * Renders alignment lines on canvas + * @returns void + */ + protected render(): void { + super.render(); + + const state = this.state; + if (!state.visible || (state.snapLines.length === 0 && state.guideLines.length === 0)) { + return; + } + + const ctx = this.context.ctx; + const lineStyle = this.props.lineStyle || {}; + + // Configure common line properties + ctx.lineWidth = lineStyle.width || 1; + + // Draw guide lines first (less prominent) + if (state.guideLines.length > 0) { + ctx.strokeStyle = lineStyle.guideColor || "#E0E0E0"; // Light gray + ctx.setLineDash(lineStyle.guideDashPattern || [3, 3]); // Subtle dashes + + state.guideLines.forEach((line) => { + ctx.beginPath(); + + if (line.type === "horizontal") { + ctx.moveTo(line.bounds.start, line.position); + ctx.lineTo(line.bounds.end, line.position); + } else { + ctx.moveTo(line.position, line.bounds.start); + ctx.lineTo(line.position, line.bounds.end); + } + + ctx.stroke(); + }); + } + + // Draw snap lines (more prominent) + if (state.snapLines.length > 0) { + ctx.strokeStyle = lineStyle.snapColor || "#007AFF"; // Bright blue + ctx.setLineDash(lineStyle.snapDashPattern || [5, 5]); // More prominent dashes + + state.snapLines.forEach((line) => { + ctx.beginPath(); + + if (line.type === "horizontal") { + ctx.moveTo(line.bounds.start, line.position); + ctx.lineTo(line.bounds.end, line.position); + } else { + ctx.moveTo(line.position, line.bounds.start); + ctx.lineTo(line.position, line.bounds.end); + } + + ctx.stroke(); + }); + } + + // Reset line dash + ctx.setLineDash([]); + } +} diff --git a/src/components/canvas/layers/alignmentLayer/README.md b/src/components/canvas/layers/alignmentLayer/README.md new file mode 100644 index 00000000..29e17ef1 --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/README.md @@ -0,0 +1,161 @@ +# Alignment Lines Layer + +Слой для отображения линий выравнивания при перетаскивании блоков с использованием магнитного прилипания к границам. + +## Описание + +`AlignmentLinesLayer` - это визуальный слой, который показывает пунктирные линии выравнивания когда пользователь перетаскивает блоки. Линии появляются когда блок приближается к границам других блоков и показывают точки выравнивания. + +## Особенности + +- ✅ Интеграция с `MagneticBorderModifier` +- ✅ Автоматическое обнаружение блоков в области видимости +- ✅ Настраиваемый стиль линий (цвет, толщина, пунктир) +- ✅ Поддержка всех четырех направлений выравнивания +- ✅ Динамическое добавление/удаление модификаторов через события +- ✅ Оптимизированная отрисовка только во время перетаскивания + +## Использование + +### Базовое использование + +```typescript +import { Graph } from "@gravity-ui/graph"; +import { AlignmentLinesLayer } from "@gravity-ui/graph"; + +const graph = new Graph(container, { + layers: [ + // ... другие слои + [AlignmentLinesLayer, {}], // Базовая конфигурация + ], +}); +``` + +### Расширенная конфигурация + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + magnetismDistance: "auto", // или число в пикселях + enabledBorders: ["top", "right", "bottom", "left"], + targets: [Block], // типы компонентов для выравнивания + }, + lineStyle: { + color: "#007AFF", + width: 1, + dashPattern: [5, 5], + }, + }, +] +``` + +## Конфигурация + +### AlignmentLinesLayerProps + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `magneticBorderConfig` | `Partial` | - | Конфигурация магнитного прилипания | +| `lineStyle` | `LineStyleConfig` | - | Стиль отображения линий | + +### MagneticBorderModifierConfig + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `magnetismDistance` | `number \| "auto"` | `"auto"` | Расстояние магнетизма в пикселях или "auto" для всего viewport | +| `enabledBorders` | `Array<"top" \| "right" \| "bottom" \| "left">` | `["top", "right", "bottom", "left"]` | Включенные границы для выравнивания | +| `targets` | `Constructor[]` | `[Block]` | Типы компонентов для поиска | +| `resolveBounds` | `(element) => Bounds \| null` | - | Функция получения границ элемента | +| `filter` | `(element) => boolean` | - | Фильтр элементов для выравнивания | + +### LineStyleConfig + +| Свойство | Тип | По умолчанию | Описание | +|----------|-----|--------------|----------| +| `color` | `string` | `"#007AFF"` | Цвет линий | +| `width` | `number` | `1` | Толщина линий в пикселях | +| `dashPattern` | `number[]` | `[5, 5]` | Паттерн пунктирной линии | + +## Примеры + +### Только горизонтальное выравнивание + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + enabledBorders: ["top", "bottom"], + }, + lineStyle: { + color: "#FF3B30", + width: 2, + }, + }, +] +``` + +### Ограниченное расстояние магнетизма + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + magnetismDistance: 50, // 50 пикселей + }, + lineStyle: { + color: "#34C759", + dashPattern: [3, 3], + }, + }, +] +``` + +### Кастомная фильтрация элементов + +```typescript +[ + AlignmentLinesLayer, + { + magneticBorderConfig: { + filter: (element) => { + // Выравнивание только с видимыми блоками определенного типа + return element instanceof Block && + element.isVisible() && + element.getState().type === "process"; + }, + }, + }, +] +``` + +## Архитектура + +Слой реализует интерфейс `IDragMiddleware` и использует систему событий: + +1. **drag-start** - добавляет модификатор и активирует визуализацию +2. **drag-update** - обновляет линии выравнивания на основе текущей позиции +3. **drag-end** - убирает модификатор и скрывает линии + +### Алгоритм работы + +1. При начале перетаскивания блока добавляется `MagneticBorderModifier` +2. Модификатор анализирует ближайшие элементы и находит точки выравнивания +3. Данные о выравнивании передаются через контекст drag события +4. Слой извлекает информацию о границах и строит линии выравнивания +5. Линии отрисовываются на canvas с настроенным стилем +6. При завершении перетаскивания все очищается + +## Производительность + +- Отрисовка происходит только во время активного перетаскивания +- Используется кеширование вычислений в `MagneticBorderModifier` +- Линии рисуются с учетом текущего масштаба камеры +- Автоматическая очистка при завершении операции + +## Интеграция + +Слой полностью интегрирован с системой событий графа и может использоваться совместно с другими слоями и модификаторами без конфликтов. \ No newline at end of file diff --git a/src/components/canvas/layers/alignmentLayer/index.ts b/src/components/canvas/layers/alignmentLayer/index.ts new file mode 100644 index 00000000..c5499efd --- /dev/null +++ b/src/components/canvas/layers/alignmentLayer/index.ts @@ -0,0 +1 @@ +export { AlignmentLinesLayer, type AlignmentLinesLayerProps } from "./AlignmentLinesLayer"; \ No newline at end of file diff --git a/src/examples/DragMiddlewareExample.ts b/src/examples/DragMiddlewareExample.ts new file mode 100644 index 00000000..26157d7c --- /dev/null +++ b/src/examples/DragMiddlewareExample.ts @@ -0,0 +1,85 @@ +import { Layer } from "../services/Layer"; +import { Graph } from "../graph"; +import { Block } from "../components/canvas/blocks/Block"; +import { IDragMiddleware, DragModifier, DragInfo, DragContext } from "../services/Drag/DragInfo"; +import { Point } from "../utils/types/shapes"; + +/** + * Example implementation of a layer that provides drag middleware + * similar to the user's request + */ +export class ExampleLayer extends Layer implements IDragMiddleware { + private lines: Array<{ x: number; y: number; width: number; height: number }> = []; + + public dragModifier(): DragModifier { + return { + name: "exampleLayer", + priority: 8, + applicable: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => { + // Check if we're dragging a Block and if there are nearby borders + return ctx.stage === "dragging" && "dragEntity" in ctx && ctx.dragEntity instanceof Block; + }, + suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => { + // Example: snap to grid or borders + const snappedX = Math.round(pos.x / 20) * 20; + const snappedY = Math.round(pos.y / 20) * 20; + return new Point(snappedX, snappedY); + }, + onApply: (dragInfo: DragInfo, ctx: DragContext) => { + // Update visual indicators when this modifier is applied + console.log("Example modifier applied during drag"); + + // In real implementation, you might update visual state here + if ("closestBorder" in ctx && Array.isArray(ctx.closestBorder)) { + this.lines = ctx.closestBorder.map((border: any) => ({ + x: border.point.x, + y: border.point.y, + width: 10, + height: 10, + })); + } + }, + }; + } + + public afterInit(): void { + // Subscribe to drag events to manage modifiers dynamically + this.context.graph.on("drag-start", (event) => { + const { dragInfo } = event.detail; + + // Add our modifier if we're dragging a Block + if (dragInfo.context.dragEntity instanceof Block) { + dragInfo.addModifier(this.dragModifier()); + } + }); + + this.context.graph.on("drag-update", (event) => { + const { dragInfo } = event.detail; + + // Check if our modifier is applied and update visual state + if (dragInfo.isApplied(this.dragModifier())) { + // Update visual indicators based on current drag state + console.log("Our modifier is currently applied"); + + // In real implementation, you might call setState or similar + // this.setState({ lines: this.lines }); + } + }); + + this.context.graph.on("drag-end", (event) => { + const { dragInfo } = event.detail; + + // Clean up: remove our modifier after drag ends + dragInfo.removeModifier(this.dragModifier().name); + + // Clear visual indicators + this.lines = []; + // this.setState({ lines: [] }); + }); + } + + // Example of how to access the current lines state + public getLines(): Array<{ x: number; y: number; width: number; height: number }> { + return this.lines; + } +} \ No newline at end of file diff --git a/src/graphEvents.ts b/src/graphEvents.ts index bd6e69e4..3c596db8 100644 --- a/src/graphEvents.ts +++ b/src/graphEvents.ts @@ -2,6 +2,7 @@ import { EventedComponent } from "./components/canvas/EventedComponent/EventedCo import { GraphState } from "./graph"; import { TGraphColors, TGraphConstants } from "./graphConfig"; import { TCameraState } from "./services/camera/CameraService"; +import { DragInfo } from "./services/Drag/DragInfo"; export type GraphMouseEvent = CustomEvent<{ target?: EventedComponent; @@ -41,6 +42,9 @@ export interface GraphEventsDefinitions extends BaseGraphEventDefinition { "constants-changed": (event: CustomEvent<{ constants: TGraphConstants }>) => void; "colors-changed": (event: CustomEvent<{ colors: TGraphColors }>) => void; "state-change": (event: CustomEvent<{ state: GraphState }>) => void; + "drag-start": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; + "drag-update": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; + "drag-end": (event: CustomEvent<{ dragInfo: DragInfo }>) => void; } const graphMouseEvents = ["mousedown", "click", "dblclick", "mouseenter", "mousemove", "mouseleave"]; diff --git a/src/index.ts b/src/index.ts index 232fffb1..3d089994 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ export { DragController, type DragHandler, type DragControllerConfig } from "./s export { DragInfo, type PositionModifier, + type DragModifier, + type IDragMiddleware, type DragContext, type ModifierSuggestion, type DragStage, @@ -32,4 +34,5 @@ export * from "./components/canvas/groups"; export * from "./components/canvas/layers/newBlockLayer/NewBlockLayer"; export * from "./components/canvas/layers/connectionLayer/ConnectionLayer"; +export * from "./components/canvas/layers/alignmentLayer"; export * from "./lib/Component"; diff --git a/src/services/Drag/DragController.ts b/src/services/Drag/DragController.ts index 0303dae7..b0828bfc 100644 --- a/src/services/Drag/DragController.ts +++ b/src/services/Drag/DragController.ts @@ -3,7 +3,7 @@ import { ESchedulerPriority, scheduler } from "../../lib/Scheduler"; import { dragListener } from "../../utils/functions/dragListener"; import { EVENTS } from "../../utils/types/events"; -import { DragInfo, PositionModifier } from "./DragInfo"; +import { DragInfo, DragModifier, PositionModifier } from "./DragInfo"; /** * Interface for components that can be dragged @@ -44,7 +44,7 @@ export interface DragControllerConfig { /** Enable automatic camera movement when approaching edges */ enableEdgePanning?: boolean; /** Position modifiers for coordinate correction during dragging */ - positionModifiers?: PositionModifier[]; + positionModifiers?: (PositionModifier | DragModifier)[]; /** Additional context to pass to modifiers */ context?: Record; /** Initial position of the dragged entity in camera space */ @@ -112,6 +112,9 @@ export class DragController { component.onDragStart(event, this.dragInfo); + // Emit drag-start event + this.graph.emit("drag-start", { dragInfo: this.dragInfo }); + this.startDragListener(event); } @@ -142,6 +145,9 @@ export class DragController { } this.currentDragHandler.onDragUpdate(event, this.dragInfo); + + // Emit drag-update event + this.graph.emit("drag-update", { dragInfo: this.dragInfo }); } /** @@ -183,6 +189,9 @@ export class DragController { // Then call the drag end handler this.currentDragHandler.onDragEnd(event, this.dragInfo); + // Emit drag-end event + this.graph.emit("drag-end", { dragInfo: this.dragInfo }); + // Reset state this.currentDragHandler = undefined; this.isDragging = false; diff --git a/src/services/Drag/DragInfo.ts b/src/services/Drag/DragInfo.ts index 577b2106..4c96a07a 100644 --- a/src/services/Drag/DragInfo.ts +++ b/src/services/Drag/DragInfo.ts @@ -20,6 +20,22 @@ export interface PositionModifier { suggest: (pos: Point, dragInfo: DragInfo, ctx: DragContext) => Point | null; } +/** + * Extended drag modifier with additional lifecycle hooks + */ +export interface DragModifier extends PositionModifier { + /** Called when modifier is applied during dragging */ + onApply?: (dragInfo: DragInfo, ctx: DragContext) => void; +} + +/** + * Interface for layers and components that can provide drag modifiers + */ +export interface IDragMiddleware { + /** Returns a drag modifier for this middleware */ + dragModifier(): DragModifier; +} + /** * Context for drag modifiers */ @@ -61,9 +77,10 @@ export class DragInfo { private _currentCameraPoint: Point | null = null; // Position modifier system - private modifiers: PositionModifier[] = []; + private modifiers: (PositionModifier | DragModifier)[] = []; private suggestions: ModifierSuggestion[] = []; private selectedModifier: string | null = null; + private appliedModifiers: Set = new Set(); private contextCache: DragContext | null = null; private customContext: Record; @@ -76,7 +93,7 @@ export class DragInfo { constructor( protected graph: Graph, - modifiers: PositionModifier[] = [], + modifiers: (PositionModifier | DragModifier)[] = [], customContext?: Record, initialEntityPosition?: { x: number; y: number } ) { @@ -99,6 +116,7 @@ export class DragInfo { this._currentCameraPoint = null; this.suggestions = []; this.selectedModifier = null; + this.appliedModifiers.clear(); this.contextCache = null; this.currentStage = "start"; // Return to initial stage // Don't reset custom context as it's set during DragInfo creation @@ -409,6 +427,24 @@ export class DragInfo { }; } + /** + * Applies a modifier by name + * @param name - Modifier name + * @returns void + * @private + */ + private applyModifier(name: string | null): void { + if (name) { + this.appliedModifiers.add(name); + + // Call onApply for DragModifier if available + const modifier = this.modifiers.find((m) => m.name === name); + if (modifier && "onApply" in modifier && modifier.onApply) { + modifier.onApply(this, this.getDragContext()); + } + } + } + /** * Selects modifier by priority (first with lowest priority) * @returns void @@ -416,6 +452,7 @@ export class DragInfo { public selectByPriority(): void { const best = this.suggestions.sort((a, b) => a.priority - b.priority)[0]; this.selectedModifier = best?.name || null; + this.applyModifier(this.selectedModifier); } /** @@ -431,6 +468,7 @@ export class DragInfo { .sort((a, b) => a.distance - b.distance); this.selectedModifier = withDistances[0]?.name || null; + this.applyModifier(this.selectedModifier); } /** @@ -440,6 +478,7 @@ export class DragInfo { */ public selectByCustom(selector: (suggestions: ModifierSuggestion[]) => string | null): void { this.selectedModifier = selector(this.suggestions); + this.applyModifier(this.selectedModifier); } /** @@ -450,6 +489,7 @@ export class DragInfo { public selectModifier(name: string): void { if (this.suggestions.some((s) => s.name === name)) { this.selectedModifier = name; + this.applyModifier(name); } } @@ -639,4 +679,52 @@ export class DragInfo { ...this.customContext, }; } + + // === DYNAMIC MODIFIER MANAGEMENT === + + /** + * Adds a modifier dynamically during the drag process + * @param modifier - The modifier to add + * @returns void + */ + public addModifier(modifier: PositionModifier | DragModifier): void { + // Check if modifier with this name already exists + const existingIndex = this.modifiers.findIndex((m) => m.name === modifier.name); + if (existingIndex !== -1) { + // Replace existing modifier + this.modifiers[existingIndex] = modifier; + } else { + // Add new modifier + this.modifiers.push(modifier); + } + + // Clear suggestions cache to recalculate with new modifier + this.suggestions = []; + } + + /** + * Removes a modifier by name + * @param modifierName - Name of the modifier to remove + * @returns void + */ + public removeModifier(modifierName: string): void { + const index = this.modifiers.findIndex((m) => m.name === modifierName); + if (index !== -1) { + this.modifiers.splice(index, 1); + // Clear suggestions cache to recalculate without removed modifier + this.suggestions = []; + // Remove from applied modifiers if present + this.appliedModifiers.delete(modifierName); + } + } + + /** + * Checks if a modifier is currently applied + * @param modifier - The modifier to check (can be name or modifier object) + * @returns true if modifier is applied + */ + public isApplied(modifier: string | PositionModifier | DragModifier): boolean { + const modifierName = typeof modifier === "string" ? modifier : modifier.name; + return this.appliedModifiers.has(modifierName); + } } diff --git a/src/services/Drag/modifiers/MagneticBorderModifier.ts b/src/services/Drag/modifiers/MagneticBorderModifier.ts index 5f537df0..2d75f3ee 100644 --- a/src/services/Drag/modifiers/MagneticBorderModifier.ts +++ b/src/services/Drag/modifiers/MagneticBorderModifier.ts @@ -12,6 +12,12 @@ export type MagneticBorderModifierConfig = { * - 'auto': All elements in camera viewport will be considered for magnetism */ magnetismDistance: number | "auto"; + /** + * Distance threshold for actual snapping in pixels. + * If not provided, uses magnetismDistance value. + * Must be <= magnetismDistance when both are numbers. + */ + snapThreshold?: number; /** Array of component types to search for magnetism. If not provided, searches all components. */ targets?: Constructor[]; /** @@ -26,12 +32,17 @@ export type MagneticBorderModifierConfig = { * @param element - The element to test * @returns true if element should be considered for magnetism */ - filter?: (element: GraphComponent) => boolean; + filter?: (element: GraphComponent, dragInfo: DragInfo, ctx: DragContext) => boolean; /** * Which borders to consider for snapping. * @default ['top', 'right', 'bottom', 'left'] */ enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + /** + * Whether to allow snapping to multiple borders simultaneously. + * @default false + */ + allowMultipleSnap?: boolean; }; /** @@ -40,10 +51,12 @@ export type MagneticBorderModifierConfig = { type MagneticBorderModifierContext = DragContext & { magneticBorderModifier: { magnetismDistance: number | "auto"; + snapThreshold?: number; targets?: Constructor[]; resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null; filter?: (element: GraphComponent) => boolean; enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + allowMultipleSnap?: boolean; }; }; @@ -57,6 +70,188 @@ type BorderInfo = { distance: number; }; +/** + * Calculates the minimum distance from any border of a dragged block to an infinite line. + * @param currentPos - Current position (top-left corner) where the block would be placed + * @param blockSize - Size of the dragged block (width and height) + * @param lineInfo - Information about the infinite line + * @returns Minimum distance from any border of the block to the line + */ +function getDistanceFromBlockBordersToLine( + currentPos: Point, + blockSize: { width: number; height: number }, + lineInfo: { border: "top" | "right" | "bottom" | "left"; point: Point } +): number { + const { border, point } = lineInfo; + + if (border === "top" || border === "bottom") { + // Horizontal line - check distance from top and bottom borders of dragged block + const topDistance = Math.abs(currentPos.y - point.y); + const bottomDistance = Math.abs(currentPos.y + blockSize.height - point.y); + return Math.min(topDistance, bottomDistance); + } else { + // Vertical line - check distance from left and right borders of dragged block + const leftDistance = Math.abs(currentPos.x - point.x); + const rightDistance = Math.abs(currentPos.x + blockSize.width - point.x); + return Math.min(leftDistance, rightDistance); + } +} + +/** + * Calculates the correct position for the dragged block when snapping to a line. + * Determines which border of the block should snap to the line and calculates + * the corresponding top-left position of the block. + * @param currentPos - Current position (top-left corner) of the block + * @param blockSize - Size of the block (width and height) + * @param lineInfo - Information about the line to snap to + * @returns New position for the block's top-left corner + */ +function calculateSnapPosition( + currentPos: Point, + blockSize: { width: number; height: number }, + lineInfo: { border: "top" | "right" | "bottom" | "left"; point: Point } +): Point { + const { border, point } = lineInfo; + + if (border === "top" || border === "bottom") { + // Horizontal line - determine which border (top or bottom) should snap + const topDistance = Math.abs(currentPos.y - point.y); + const bottomDistance = Math.abs(currentPos.y + blockSize.height - point.y); + + if (topDistance <= bottomDistance) { + // Top border snaps to line + return new Point(currentPos.x, point.y); + } else { + // Bottom border snaps to line + return new Point(currentPos.x, point.y - blockSize.height); + } + } else { + // Vertical line - determine which border (left or right) should snap + const leftDistance = Math.abs(currentPos.x - point.x); + const rightDistance = Math.abs(currentPos.x + blockSize.width - point.x); + + if (leftDistance <= rightDistance) { + // Left border snaps to line + return new Point(point.x, currentPos.y); + } else { + // Right border snaps to line + return new Point(point.x - blockSize.width, currentPos.y); + } + } +} + +/** + * Processes a single element to collect its border lines and snap candidates. + * @param element - The graph component to process + * @param pos - Current cursor position + * @param enabledBorders - Which borders to consider + * @param snapThreshold - Distance threshold for snapping + * @param draggedSize - Size of the dragged element (optional) + * @param resolveBounds - Function to resolve element bounds + * @returns Object containing all border lines and snap candidates + */ +function processElementBorderLines( + element: GraphComponent, + pos: Point, + enabledBorders: Array<"top" | "right" | "bottom" | "left">, + snapThreshold: number, + draggedSize: { width: number; height: number } | null, + resolveBounds?: (element: GraphComponent) => { x: number; y: number; width: number; height: number } | null +): { allLines: BorderInfo[]; snapCandidates: BorderInfo[] } { + const bounds = resolveBounds?.(element); + if (!bounds) { + return { allLines: [], snapCandidates: [] }; + } + + const allLines: BorderInfo[] = []; + const snapCandidates: BorderInfo[] = []; + const borderLines = getClosestBorderLines(pos, bounds, enabledBorders); + + for (const borderLine of borderLines) { + const borderInfo: BorderInfo = { + element, + border: borderLine.border, + point: borderLine.point, + distance: borderLine.distance, + }; + + allLines.push(borderInfo); + + // Check if any border of the dragged block is close to this infinite line + let shouldSnap = false; + if (draggedSize) { + // Use the current position (pos) to calculate where the block would be + const blockToLineDistance = getDistanceFromBlockBordersToLine(pos, draggedSize, { + border: borderLine.border, + point: borderLine.point, + }); + shouldSnap = blockToLineDistance <= snapThreshold; + } else { + // Fallback to original point-based logic if no dragged size + shouldSnap = borderLine.distance <= snapThreshold; + } + + if (shouldSnap) { + snapCandidates.push(borderInfo); + } + } + + return { allLines, snapCandidates }; +} + +/** + * Calculates the final position after applying snapping to selected borders. + * @param selectedBorders - Array of borders to snap to + * @param draggedSize - Size of the dragged element (optional) + * @param pos - Current position + * @returns Final position after snapping + */ +function calculateFinalPosition( + selectedBorders: BorderInfo[], + draggedSize: { width: number; height: number } | null, + pos: Point +): Point { + if (selectedBorders.length === 0) { + return pos; // No snapping + } + + let newPos = pos; + + if (draggedSize) { + // Use smart positioning that considers which border of the block should snap + for (const border of selectedBorders) { + const snapPos = calculateSnapPosition(newPos, draggedSize, { + border: border.border, + point: border.point, + }); + + if (border.border === "top" || border.border === "bottom") { + // Update Y coordinate from horizontal line snap + newPos = new Point(newPos.x, snapPos.y); + } else { + // Update X coordinate from vertical line snap + newPos = new Point(snapPos.x, newPos.y); + } + } + } else { + // Fallback to original logic if no dragged size + let newX = pos.x; + let newY = pos.y; + + for (const border of selectedBorders) { + if (border.border === "top" || border.border === "bottom") { + newY = border.point.y; + } else if (border.border === "left" || border.border === "right") { + newX = border.point.x; + } + } + + newPos = new Point(newX, newY); + } + + return newPos; +} + /** * Calculates the closest point on infinite lines extending through rectangle borders. * Unlike border snapping, this projects the point onto infinite lines that pass through the borders. @@ -207,77 +402,93 @@ export const MagneticBorderModifier = (params: MagneticBorderModifierConfig) => suggest: (pos: Point, dragInfo: DragInfo, ctx: MagneticBorderModifierContext) => { const enabledBorders = config.enabledBorders || ["right", "left"]; const isAutoMode = config.magnetismDistance === "auto"; + const allowMultipleSnap = config.allowMultipleSnap || false; - // Determine search area and maximum distance - let searchRect: { x: number; y: number; width: number; height: number }; - let maxDistance: number; + // Get snap threshold - defaults to magnetismDistance if not provided + const snapThreshold = isAutoMode ? Infinity : config.snapThreshold ?? (config.magnetismDistance as number); + // Get elements within search area let elementsInRect = []; if (isAutoMode) { elementsInRect = ctx.graph.getElementsInViewport(config.targets ? [...config.targets] : []); - // In auto mode, allow infinite distance within viewport - maxDistance = Infinity; } else { // Distance mode: create search rectangle around current position const distance = config.magnetismDistance as number; - searchRect = { + const searchRect = { x: pos.x - distance, y: pos.y - distance, width: distance * 2, height: distance * 2, }; elementsInRect = ctx.graph.getElementsOverRect(searchRect, config.targets ? [...config.targets] : []); - maxDistance = distance; } - // Get elements within the search area - if (config.filter) { - elementsInRect = elementsInRect.filter(config.filter); + elementsInRect = elementsInRect.filter((element) => config.filter(element, dragInfo, ctx)); } - let closestBorder: BorderInfo | null = null; - let closestDistance = maxDistance; + // Get dragged element size for border distance calculations + const draggedElement = (ctx as MagneticBorderModifierContext & { dragEntity?: GraphComponent }).dragEntity; + const draggedBounds = draggedElement ? config.resolveBounds?.(draggedElement) : null; + const draggedSize = draggedBounds ? { width: draggedBounds.width, height: draggedBounds.height } : null; + + // Collect infinite border lines from all found elements + const allBorderLines: BorderInfo[] = []; + // Collect lines that are close enough for actual snapping + const snapCandidates: BorderInfo[] = []; // Check all found elements for their borders elementsInRect.forEach((element) => { - const bounds = config.resolveBounds?.(element); - if (!bounds) { - return; - } + const result = processElementBorderLines( + element, + pos, + enabledBorders, + snapThreshold, + draggedSize, + config.resolveBounds + ); + allBorderLines.push(...result.allLines); + snapCandidates.push(...result.snapCandidates); + }); + + // Sort candidates by distance + allBorderLines.sort((a, b) => a.distance - b.distance); + snapCandidates.sort((a, b) => a.distance - b.distance); - // Get closest border lines for this element - const borderLines = getClosestBorderLines(pos, bounds, enabledBorders); - - // Check if any border line is closer than our current closest - for (const borderLine of borderLines) { - if (borderLine.distance < closestDistance) { - closestBorder = { - element, - border: borderLine.border, - point: borderLine.point, - distance: borderLine.distance, - }; - closestDistance = borderLine.distance; + // Find the best borders to snap to + let selectedBorders: BorderInfo[] = []; + + if (snapCandidates.length > 0) { + if (allowMultipleSnap) { + // Group by axis (horizontal/vertical) and take closest from each + const horizontalBorders = snapCandidates.filter((b) => b.border === "top" || b.border === "bottom"); + const verticalBorders = snapCandidates.filter((b) => b.border === "left" || b.border === "right"); + + if (horizontalBorders.length > 0) { + selectedBorders.push(horizontalBorders[0]); // Closest horizontal } + if (verticalBorders.length > 0) { + selectedBorders.push(verticalBorders[0]); // Closest vertical + } + } else { + // Take only the single closest border + selectedBorders = [snapCandidates[0]]; } - }); + } - // Update context with closest border information for use in drag handlers + // Update context with border information for visualization + const closestBorder = allBorderLines.length > 0 ? allBorderLines[0] : null; dragInfo.updateContext({ closestBorder, closestBorderElement: closestBorder?.element, closestBorderSide: closestBorder?.border, + allBorderLines, // All lines for visualization + selectedBorders, // Lines that are actually snapped to }); - // If we found a nearby border, snap to it - if (closestBorder) { - return closestBorder.point; - } - - // No snapping suggestion - return original position - return pos; + // Calculate and return final position + return calculateFinalPosition(selectedBorders, draggedSize, pos); }, }; }; diff --git a/src/stories/examples/alignmentLayer/alignmentLayer.stories.tsx b/src/stories/examples/alignmentLayer/alignmentLayer.stories.tsx new file mode 100644 index 00000000..e70c89f4 --- /dev/null +++ b/src/stories/examples/alignmentLayer/alignmentLayer.stories.tsx @@ -0,0 +1,437 @@ +import React, { useCallback, useEffect } from "react"; + +import { ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryObj } from "@storybook/react"; + +import { TBlock } from "../../../components/canvas/blocks/Block"; +import { AlignmentLinesLayer } from "../../../components/canvas/layers/alignmentLayer"; +import { Graph } from "../../../graph"; +import { GraphBlock, GraphCanvas, useGraph, useLayer } from "../../../react-components"; +import { TConnection } from "../../../store/connection/ConnectionState"; +import { ECanChangeBlockGeometry } from "../../../store/settings"; + +import "@gravity-ui/uikit/styles/styles.css"; + +// Sample data for demonstration +const sampleBlocks: TBlock[] = [ + { + is: "Block" as const, + id: "block-1", + x: 100, + y: 100, + width: 150, + height: 80, + selected: false, + name: "Block 1", + anchors: [], + }, + { + is: "Block" as const, + id: "block-2", + x: 300, + y: 200, + width: 150, + height: 80, + selected: false, + name: "Block 2", + anchors: [], + }, + { + is: "Block" as const, + id: "block-3", + x: 500, + y: 100, + width: 150, + height: 80, + selected: false, + name: "Block 3", + anchors: [], + }, + { + is: "Block" as const, + id: "block-4", + x: 200, + y: 300, + width: 150, + height: 80, + selected: false, + name: "Block 4", + anchors: [], + }, +]; + +const sampleConnections: TConnection[] = []; + +interface AlignmentStoryProps { + magnetismDistance?: number | "auto"; + snapThreshold?: number; + allowMultipleSnap?: boolean; + enabledBorders?: Array<"top" | "right" | "bottom" | "left">; + snapColor?: string; + guideColor?: string; + lineWidth?: number; + snapDashPattern?: number[]; + guideDashPattern?: number[]; +} + +const AlignmentStory = ({ + magnetismDistance = 200, // Увеличиваем для демонстрации бесконечных линий + snapThreshold = 15, // Прилипание только при близком расстоянии + allowMultipleSnap = true, + enabledBorders = ["top", "right", "bottom", "left"], + snapColor = "#007AFF", + guideColor = "#E0E0E0", + lineWidth = 1, + snapDashPattern = [5, 5], + guideDashPattern = [3, 3], +}: AlignmentStoryProps) => { + const renderBlock = useCallback((graph: Graph, block: TBlock) => { + return ( + +
+ {block.name} +
+
+ ); + }, []); + + const { graph } = useGraph({ + settings: { + canChangeBlockGeometry: ECanChangeBlockGeometry.ALL, + canDragCamera: true, + canZoomCamera: true, + }, + }); + + useLayer(graph, AlignmentLinesLayer, { + magneticBorderConfig: { + magnetismDistance, + snapThreshold, + allowMultipleSnap, + enabledBorders, + }, + lineStyle: { + snapColor, + guideColor, + width: lineWidth, + snapDashPattern, + guideDashPattern, + }, + }); + + useEffect(() => { + if (graph) { + graph.setEntities({ + blocks: sampleBlocks, + connections: sampleConnections, + }); + graph.start(); + } + }, [graph]); + + return ( +
+
+ 💡 Инструкция: Перетащите блок любой границей к линии. Прилипание работает для всех границ + блока (верх, низ, лево, право) +
+ +
+ ); +}; + +const GraphApp = (props: AlignmentStoryProps) => { + return ( + + + + ); +}; + +const meta: Meta = { + title: "Examples/Alignment Layer", + component: GraphApp, + parameters: { + layout: "fullscreen", + }, + argTypes: { + magnetismDistance: { + control: { type: "select" }, + options: ["auto", 100, 200, 300, 500], + description: "Радиус поиска блоков для создания бесконечных линий", + }, + snapThreshold: { + control: { type: "range", min: 5, max: 50, step: 5 }, + description: "Расстояние до линии для срабатывания прилипания", + }, + allowMultipleSnap: { + control: { type: "boolean" }, + description: "Разрешить прилипание к нескольким линиям одновременно", + }, + enabledBorders: { + control: { type: "check" }, + options: ["top", "right", "bottom", "left"], + description: "Включенные границы для выравнивания", + }, + snapColor: { + control: { type: "color" }, + description: "Цвет линий прилипания (активное выравнивание)", + }, + guideColor: { + control: { type: "color" }, + description: "Цвет вспомогательных линий (потенциальное выравнивание)", + }, + lineWidth: { + control: { type: "range", min: 1, max: 5, step: 1 }, + description: "Толщина линий в пикселях", + }, + snapDashPattern: { + control: { type: "object" }, + description: "Паттерн пунктира для линий прилипания", + }, + guideDashPattern: { + control: { type: "object" }, + description: "Паттерн пунктира для вспомогательных линий", + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +/** + * Демонстрирует бесконечные линии выравнивания с прилипанием по расстоянию до линии. + * Серые линии видны всегда, синие появляются при приближении к линии. + */ +export const DefaultAlignmentLines: Story = { + args: { + magnetismDistance: 200, // Большое расстояние для показа бесконечных линий + snapThreshold: 15, // Прилипание только при близком расстоянии к линии + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#007AFF", + guideColor: "#E0E0E0", + lineWidth: 1, + snapDashPattern: [5, 5], + guideDashPattern: [3, 3], + }, + parameters: { + docs: { + description: { + story: ` +Система бесконечных линий выравнивания с прилипанием любой границы блока. + +**Ключевые особенности:** +1. **Бесконечные линии выравнивания**: + - Серые линии показывают все возможные направления выравнивания + - Линии видны для всех блоков в радиусе magnetismDistance (200px) + - Линии продолжаются бесконечно, не ограничиваются размерами блоков + +2. **Прилипание всех границ блока**: + - Прилипание работает для любой границы: верх, низ, лево, право + - Система автоматически определяет ближайшую границу блока к линии + - Прилипание происходит при приближении любой границы на snapThreshold (15px) + +3. **Множественное прилипание**: + - Одновременное прилипание к горизонтальной и вертикальной линиям + - Позволяет точно выравнивать блоки по углам используя разные границы + +**Как использовать:** +1. Перетащите блок - увидите серые бесконечные линии от всех блоков поблизости +2. Приблизьте ЛЮБУЮ границу блока к серой линии - она станет синей и сработает прилипание +3. Попробуйте выровнять правую границу одного блока с левой границей другого +4. Попробуйте выровнять нижнюю границу с верхней границей другого блока + +**Преимущества:** +- Интуитивное поведение: любая граница блока может прилипнуть к линии +- Точное управление: блок позиционируется правильно в зависимости от прилипающей границы +- Универсальность: работает для всех сценариев выравнивания блоков + `, + }, + }, + }, +}; + +/** + * Демонстрирует плавное управление с разными порогами прилипания + */ +export const SmoothSnapControl: Story = { + args: { + magnetismDistance: 80, + snapThreshold: 10, + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#FF3B30", + guideColor: "#FFE5E5", + lineWidth: 2, + snapDashPattern: [8, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Пример с плавным управлением: +- Большое расстояние обнаружения (80px) - рано показывает вспомогательные линии +- Малый порог прилипания (10px) - требует точного позиционирования +- Красные линии прилипания на розовом фоне вспомогательных линий +- Идеально для точного позиционирования элементов + `, + }, + }, + }, +}; + +/** + * Пример с жестким прилипанием (как в старой версии) + */ +export const StrictSnapping: Story = { + args: { + magnetismDistance: 30, + snapThreshold: 30, // Равно magnetismDistance - прилипание сразу + allowMultipleSnap: false, + enabledBorders: ["top", "bottom"], + snapColor: "#34C759", + guideColor: "#34C759", // Тот же цвет - нет разделения + lineWidth: 2, + snapDashPattern: [10, 5], + guideDashPattern: [10, 5], + }, + parameters: { + docs: { + description: { + story: ` +Эмуляция старого поведения (без плавного управления): +- snapThreshold = magnetismDistance (30px) - прилипание сразу при обнаружении +- allowMultipleSnap = false - только одна линия +- Только горизонтальное выравнивание +- Одинаковый стиль для всех линий + `, + }, + }, + }, +}; + +/** + * Демонстрирует прилипание всех границ блока к линиям + */ +export const AllBordersSnappingDemo: Story = { + args: { + magnetismDistance: 300, + snapThreshold: 20, // Немного больший порог для удобства демонстрации + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#34C759", + guideColor: "#D1D1D6", + lineWidth: 2, + snapDashPattern: [6, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Демонстрация прилипания всех границ блока: + +**Эксперименты:** +1. **Левая граница**: Перетащите блок так, чтобы его левая сторона приблизилась к вертикальной линии +2. **Правая граница**: Перетащите блок так, чтобы его правая сторона приблизилась к вертикальной линии +3. **Верхняя граница**: Перетащите блок так, чтобы его верх приблизился к горизонтальной линии +4. **Нижняя граница**: Перетащите блок так, чтобы его низ приблизился к горизонтальной линии +5. **Углы**: Попробуйте выровнять углы блоков - сработает двойное прилипание + +**Обратите внимание:** +- Блок правильно позиционируется в зависимости от того, какая граница прилипает +- snapThreshold = 20px для удобства демонстрации +- Зеленые линии четко показывают момент срабатывания прилипания + `, + }, + }, + }, +}; + +/** + * Демонстрирует разницу между бесконечными линиями и прилипанием по расстоянию + */ +export const InfiniteLinesDemo: Story = { + args: { + magnetismDistance: 400, // Очень большой радиус для демонстрации + snapThreshold: 10, // Малое расстояние прилипания + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#FF3B30", + guideColor: "#C7C7CC", + lineWidth: 1, + snapDashPattern: [8, 4], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Демонстрация концепции бесконечных линий: +- magnetismDistance = 400px - показывает линии от всех блоков на экране +- snapThreshold = 10px - очень точное прилипание только вблизи линий +- Серые пунктирные линии показывают все возможности выравнивания +- Красные линии появляются только при точном наведении на линию +- Попробуйте двигать блок по всему экрану - увидите все линии выравнивания + `, + }, + }, + }, +}; + +/** + * Демонстрирует множественное прилипание к углу + */ +export const MultipleSnapDemo: Story = { + args: { + magnetismDistance: 60, + snapThreshold: 20, + allowMultipleSnap: true, + enabledBorders: ["top", "right", "bottom", "left"], + snapColor: "#AF52DE", + guideColor: "#F5E5FF", + lineWidth: 1, + snapDashPattern: [6, 3], + guideDashPattern: [2, 2], + }, + parameters: { + docs: { + description: { + story: ` +Пример множественного прилипания: +- Попробуйте выровнять блок по углу другого блока +- Увидите одновременно горизонтальную и вертикальную линии прилипания +- Фиолетовые линии на светло-фиолетовом фоне +- Идеально для точного позиционирования в сетке + `, + }, + }, + }, +};