Integrate Canvas V2 subgraphs, editable edges and AI runs

This commit is contained in:
2026-11-06 19:59:56 +08:00
parent 3478daeccb
commit 05d96abd0e
35 changed files with 1626 additions and 443 deletions
+48 -18
View File
@@ -1,57 +1,62 @@
"use client";
import { createContext, useContext, useEffect, useRef, type PointerEvent, type ReactNode } from "react";
import { useReactFlow, useUpdateNodeInternals, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, RotateCw } from "lucide-react";
import { useReactFlow, useUpdateNodeInternals, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
import { Grip, Maximize2, RotateCw } from "lucide-react";
import { elementCapabilities, type TextStyle } from "@/lib/canvas-elements";
import type { CanvasNode, NodeDirection } from "@/lib/canvas";
import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry";
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
import { NodeStyleToolbar } from "./node-style-toolbar";
import { MembershipBadge } from "./frame-feedback";
import { ContentHandles } from "./content-handles";
import styles from "./canvas.module.css";
export const NodeEditing = createContext({
begin: () => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
workspace: "", schema: 1 as 1 | 2,
begin: (_id?: string) => {}, select: (_id: string) => {}, edit: (_id: string, _text: string) => {},
branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {},
transform: (_id: string, _transform: NodeTransform) => {},
style: (_id: string, _style: TextStyle, _fontSize?: number) => {},
rename: (_id: string, _title: string) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode }) => {},
frame: (_id: string, _patch: { title?: string; labelMode?: import("@/lib/canvas-elements").FrameLabelMode; presentation?: import("@/lib/canvas-v2").FramePresentation }) => {},
rotating: null as string | null, rotate: (_id: string | null) => {},
resizing: null as string | null, resize: (_id: string | null) => {},
});
type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" };
type Gesture = { node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; target: HTMLElement; pointerId: number };
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useContext(NodeEditing);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
const { zoom } = useViewport();
const frame = useRef<HTMLDivElement>(null);
const gesture = useRef<Gesture | null>(null);
const rotation = data.rotation || 0;
const plain = data.kind === "plain-text" || data.kind === "icon" || data.kind === "plain-image";
const capabilities = elementCapabilities(data.kind);
const resizing = capabilities.resizable && editing.resizing === id && selected;
const rotating = capabilities.rotatable && editing.rotating === id && selected;
useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]);
useEffect(() => {
if (!rotating) return;
if (!rotating && !resizing) return;
const outside = (event: globalThis.PointerEvent) => {
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"]')) editing.rotate(null);
if (!frame.current?.contains(event.target as Node) && !(event.target as Element).closest?.('[data-rotate-toggle="' + id + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
};
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); };
const escape = (event: KeyboardEvent) => { if (event.key === "Escape") { cancelGesture(); editing.rotate(null); editing.resize(null); } };
window.addEventListener("pointerdown", outside, true);
window.addEventListener("keydown", escape);
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
}, [rotating, id, editing]);
}, [rotating, resizing, id, editing]);
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
if (event.button !== 0) return;
event.preventDefault(); event.stopPropagation();
const node = flow.getNode(id);
if (!node) return;
editing.begin();
editing.begin(id);
if (!node.selected) editing.select(id);
gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
gesture.current = { node, direction, target: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
event.currentTarget.setPointerCapture(event.pointerId);
}
function move(event: PointerEvent<HTMLElement>) {
@@ -61,7 +66,13 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
const { node, start, direction } = gesture.current;
editing.transform(id, direction === "rotate"
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }));
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined));
}
function cancelGesture() {
const current = gesture.current; if (!current) return;
gesture.current = null;
editing.transform(id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
if (current.target.hasPointerCapture(current.pointerId)) current.target.releasePointerCapture(current.pointerId);
}
function finish(event: PointerEvent<HTMLElement>) {
event.stopPropagation(); gesture.current = null;
@@ -71,17 +82,36 @@ export function TransformableNode(props: NodeProps<CanvasNode> & { children: Rea
{!props.hideStyleToolbar && <NodeStyleToolbar {...props} />}
<div ref={frame} className={`${styles.transformFrame} ${selected ? styles.selectedFrame : ""} ${plain ? styles.plainFrame : ""} ${rotating ? styles.rotatingFrame + " nodrag nopan" : ""} ${className}`}
onPointerDownCapture={rotating ? (event) => start(event, "rotate") : undefined}
onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}
onLostPointerCapture={() => { gesture.current = null; }}
style={{ width: width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
onLostPointerCapture={cancelGesture}
style={{ zoom: data.viewScale || 1, width: (width || (data.kind === "frame" ? 640 : data.kind === "plain-image" ? 320 : data.kind === "icon" ? 80 : plain ? 240 : 260)) / (data.viewScale || 1), height: ( height || (data.kind === "frame" ? 400 : data.kind === "plain-image" ? 240 : data.kind === "icon" ? 80 : plain ? 72 : 200)) / (data.viewScale || 1), transform: `rotate(${rotation}deg)` }}>
{children}
{editing.schema === 2 && ["plain-text", "plain-image"].includes(data.kind) && <ContentHandles selected={selected} />}
{data.kind !== "frame" && <MembershipBadge id={id} frameId={data.frameId} selected={selected} />}
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
{capabilities.resizable && !resizing && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={rotating ? -1 : 0}
aria-hidden={rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}>
<Grip size={14} />
</button>}
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
const left = direction.includes("left") ? "0%" : direction.includes("right") ? "100%" : "50%";
const top = direction.includes("top") ? "0%" : direction.includes("bottom") ? "100%" : "50%";
return <button type="button" key={direction} className={`${styles.resizeHandle} nodrag nopan`} data-resize-handle={direction} aria-label={`拉伸 ${direction}`} title="拖动拉伸 · Shift 固定比例" style={{ left, top, width: 10 / (zoom * (data.viewScale || 1)), height: 10 / (zoom * (data.viewScale || 1)), borderWidth: 1 / (zoom * (data.viewScale || 1)), cursor: direction === "top" || direction === "bottom" ? "ns-resize" : direction === "left" || direction === "right" ? "ew-resize" : direction === "top-left" || direction === "bottom-right" ? "nwse-resize" : "nesw-resize" }}
onPointerDown={(event) => start(event, direction)} onPointerMove={move} onPointerUp={finish} onPointerCancel={cancelGesture}
onKeyDown={(event) => {
const delta = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] }[event.key]; if (!delta) return;
event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return;
editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
editing.transform(id, resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey));
}} />;
})}
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
</div>
</>;
}
export function ResizeToggle({ id }: { id: string }) {
const editing = useContext(NodeEditing);
return <button type="button" data-resize-toggle={id} aria-label="八向拉伸模式" title="八向拉伸 · Shift 固定比例" aria-pressed={editing.resizing === id} onClick={() => editing.resize(editing.resizing === id ? null : id)}><Maximize2 size={16} /></button>;
}