From b00a0e9ae16e5d22cbd9a935c4bd849bc35fa403 Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:42:36 +0800 Subject: [PATCH] fix: hook --- .../schedules/hooks/useCanvasNodeDrag.ts | 105 ++++++++++++++++++ .../schedules/hooks/useContextMenuDismiss.ts | 30 +++++ 2 files changed, 135 insertions(+) create mode 100644 frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts create mode 100644 frontend/app/features/schedules/hooks/useContextMenuDismiss.ts diff --git a/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts new file mode 100644 index 0000000..dc35032 --- /dev/null +++ b/frontend/app/features/schedules/hooks/useCanvasNodeDrag.ts @@ -0,0 +1,105 @@ +import { useRef, type PointerEvent as ReactPointerEvent } from "react"; +import { useSchedulesStore } from "../state/schedulesStore"; +import type { ScheduleNode } from "../../../services/api"; + +type DragState = { + nodeId: string; + pointerId: number; + startClientX: number; + startClientY: number; + originX: number; + originY: number; + moved: boolean; +}; + +/** + * 画布节点拖拽编排。内部持有瞬时 dragRef,避免每次拖拽都触发 store 重渲染。 + * 拖拽过程中直接改 schedule.nodes 的 position;松手时把 rounded 后的位置写到 store 的 + * setPositionDraft 模块级 Map,刷新不丢。 + */ +export function useCanvasNodeDrag({ + busy, + linkSourceId, +}: { + busy: string | null | undefined; + linkSourceId: string | null; +}): { + startDrag: (event: ReactPointerEvent, node: ScheduleNode) => void; + moveDrag: (event: ReactPointerEvent) => void; + finishDrag: (event: ReactPointerEvent) => void; +} { + const dragRef = useRef(null); + const setSchedule = useSchedulesStore((s) => s.setSchedule); + const setSelectedNodeId = useSchedulesStore((s) => s.setSelectedNodeId); + const setSelectedEdgeId = useSchedulesStore((s) => s.setSelectedEdgeId); + + const startDrag = ( + event: ReactPointerEvent, + node: ScheduleNode, + ): void => { + if (event.button !== 0 || busy || linkSourceId) return; + const target = event.target as HTMLElement; + if (target.closest("button")) return; + event.currentTarget.setPointerCapture(event.pointerId); + dragRef.current = { + nodeId: node.node_id, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + originX: node.position_x, + originY: node.position_y, + moved: false, + }; + setSelectedNodeId(node.node_id); + setSelectedEdgeId(null); + }; + + const moveDrag = (event: ReactPointerEvent): void => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + const deltaX = event.clientX - drag.startClientX; + const deltaY = event.clientY - drag.startClientY; + if (Math.abs(deltaX) + Math.abs(deltaY) > 3) drag.moved = true; + setSchedule((current) => { + if (!current) return current; + return { + ...current, + nodes: current.nodes.map((item) => + item.node_id === drag.nodeId + ? { + ...item, + position_x: Math.max(10, drag.originX + deltaX), + position_y: Math.max(10, drag.originY + deltaY), + } + : item, + ), + }; + }); + }; + + const finishDrag = (event: ReactPointerEvent): void => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) return; + dragRef.current = null; + if (!drag.moved) return; + const current = useSchedulesStore.getState().schedule; + const node = current?.nodes.find((item) => item.node_id === drag.nodeId); + if (!current || !node) return; + const position = { + position_x: Math.round(node.position_x), + position_y: Math.round(node.position_y), + }; + useSchedulesStore.getState().setPositionDraft(node.node_id, position); + setSchedule((value) => { + if (!value) return value; + return { + ...value, + nodes: value.nodes.map((item) => + item.node_id === node.node_id ? { ...item, ...position } : item, + ), + }; + }); + }; + + return { startDrag, moveDrag, finishDrag }; +} \ No newline at end of file diff --git a/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts b/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts new file mode 100644 index 0000000..672092d --- /dev/null +++ b/frontend/app/features/schedules/hooks/useContextMenuDismiss.ts @@ -0,0 +1,30 @@ +import { useEffect } from "react"; +import { useSchedulesStore } from "../state/schedulesStore"; + +/** + * 监听全局 pointerdown / blur / resize / scroll / Escape,关闭当前打开的右键菜单。 + * 菜单的 open/close 生命周期归菜单自己管——页面不再持有这段 effect。 + */ +export function useContextMenuDismiss(): void { + const contextMenu = useSchedulesStore((s) => s.contextMenu); + const setContextMenu = useSchedulesStore((s) => s.setContextMenu); + useEffect(() => { + if (!contextMenu) return undefined; + const close = (): void => setContextMenu(null); + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key === "Escape") close(); + }; + window.addEventListener("pointerdown", close); + window.addEventListener("blur", close); + window.addEventListener("resize", close); + window.addEventListener("scroll", close, true); + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("pointerdown", close); + window.removeEventListener("blur", close); + window.removeEventListener("resize", close); + window.removeEventListener("scroll", close, true); + window.removeEventListener("keydown", onKeyDown); + }; + }, [contextMenu, setContextMenu]); +} \ No newline at end of file