- useCanvasNodeDrag.moveDrag 改 ref 直改 DOM,松手才写 store,避免拖拽期间反复重渲染 SchedulePage - runs 轮询改 setTimeout 链式 + 移除 runs 依赖,避免每 1.5s 重建 interval - SchedulePage 拆 PositionDraftBadge / RunNowButton,删除冗余的 runs/runsLoading/positionDraftCount 订阅 - 拆 ScheduleEdge 组件 + React.memo + useMemo,切换 edge 选中时不再重算所有 edgePath - 删除 schedule.css 中引用未定义 keyframes 的 modal-in/spin 死动画 - 删除 .schedule-edge-line transition、.schedule-run-result-backdrop backdrop-filter;补 .artifact-card transition Refs: kaneo #19-#24
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
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;
|
|
currentX: number;
|
|
currentY: number;
|
|
moved: boolean;
|
|
nodeEl: HTMLDivElement;
|
|
};
|
|
|
|
/**
|
|
* 画布节点拖拽编排。内部持有瞬时 dragRef + 节点 DOM 引用,避免每次拖拽都触发 store
|
|
* 重渲染:pointermove 期间直接改节点的 inline left/top(只动 DOM,不动 store);
|
|
* 松手时把 rounded 后的位置一次性写入 store 的 setPositionDraft 模块级 Map(草稿
|
|
* 持久化)+ setSchedule(让 React 知道最终位置),刷新不丢。
|
|
*/
|
|
export function useCanvasNodeDrag({
|
|
busy,
|
|
linkSourceId,
|
|
}: {
|
|
busy: string | null | undefined;
|
|
linkSourceId: string | null;
|
|
}): {
|
|
startDrag: (event: ReactPointerEvent<HTMLDivElement>, node: ScheduleNode) => void;
|
|
moveDrag: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
finishDrag: (event: ReactPointerEvent<HTMLDivElement>) => void;
|
|
} {
|
|
const dragRef = useRef<DragState | null>(null);
|
|
const setSchedule = useSchedulesStore((s) => s.setSchedule);
|
|
const setSelectedNodeId = useSchedulesStore((s) => s.setSelectedNodeId);
|
|
const setSelectedEdgeId = useSchedulesStore((s) => s.setSelectedEdgeId);
|
|
|
|
const startDrag = (
|
|
event: ReactPointerEvent<HTMLDivElement>,
|
|
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,
|
|
currentX: node.position_x,
|
|
currentY: node.position_y,
|
|
moved: false,
|
|
nodeEl: event.currentTarget,
|
|
};
|
|
setSelectedNodeId(node.node_id);
|
|
setSelectedEdgeId(null);
|
|
};
|
|
|
|
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>): 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;
|
|
const x = Math.max(10, drag.originX + deltaX);
|
|
const y = Math.max(10, drag.originY + deltaY);
|
|
drag.currentX = x;
|
|
drag.currentY = y;
|
|
// 只写 DOM,不动 store;拖拽期间 SchedulePage 不会因此重渲染。
|
|
drag.nodeEl.style.left = `${x}px`;
|
|
drag.nodeEl.style.top = `${y}px`;
|
|
};
|
|
|
|
const finishDrag = (event: ReactPointerEvent<HTMLDivElement>): void => {
|
|
const drag = dragRef.current;
|
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
dragRef.current = null;
|
|
if (!drag.moved) return;
|
|
const position = {
|
|
position_x: Math.round(drag.currentX),
|
|
position_y: Math.round(drag.currentY),
|
|
};
|
|
// 先记草稿(持久化),再把最终位置同步进 store 让节点 div 的 inline style 与
|
|
// schedule.nodes 一致(下次重渲染/保存时不会出现位置回弹)。
|
|
useSchedulesStore.getState().setPositionDraft(drag.nodeId, position);
|
|
setSchedule((value) => {
|
|
if (!value) return value;
|
|
return {
|
|
...value,
|
|
nodes: value.nodes.map((item) =>
|
|
item.node_id === drag.nodeId ? { ...item, ...position } : item,
|
|
),
|
|
};
|
|
});
|
|
};
|
|
|
|
return { startDrag, moveDrag, finishDrag };
|
|
} |