"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 { elementCapabilities, type TextStyle } from "@/lib/canvas-elements"; import type { CanvasNode, NodeDirection } from "@/lib/canvas"; import { resizeFromPointer, rotationFromPointer, type NodeTransform } from "@/lib/canvas-geometry"; import { NodeStyleToolbar } from "./node-style-toolbar"; import styles from "./canvas.module.css"; export const NodeEditing = createContext({ begin: () => {}, edit: (_id: string, _text: string) => {}, branch: (_id: string, _direction: NodeDirection, _point: XYPosition) => {}, transform: (_id: string, _transform: NodeTransform) => {}, style: (_id: string, _style: TextStyle, _fontSize?: number) => {}, rotating: null as string | null, rotate: (_id: string | null) => {}, }); type Gesture = { node: CanvasNode; start: XYPosition; direction: "bottom-right" | "rotate" }; export function TransformableNode(props: NodeProps & { children: ReactNode; className?: string }) { const { id, data, selected, width, height, children, className = "" } = props; const editing = useContext(NodeEditing); const flow = useReactFlow(); const update = useUpdateNodeInternals(); const frame = useRef(null); const gesture = useRef(null); const rotation = data.rotation || 0; const plain = data.kind === "plain-text" || data.kind === "icon"; const capabilities = elementCapabilities(data.kind); const rotating = capabilities.rotatable && editing.rotating === id && selected; useEffect(() => { update(id); }, [id, rotation, width, height, selected, update]); useEffect(() => { if (!rotating) 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); }; const escape = (event: KeyboardEvent) => { if (event.key === "Escape") editing.rotate(null); }; window.addEventListener("pointerdown", outside, true); window.addEventListener("keydown", escape); return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); }; }, [rotating, id, editing]); function start(event: PointerEvent, direction: Gesture["direction"]) { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); const node = flow.getNode(id); if (!node) return; editing.begin(); gesture.current = { node, direction, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) }; event.currentTarget.setPointerCapture(event.pointerId); } function move(event: PointerEvent) { if (!gesture.current) return; event.stopPropagation(); const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }); 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 })); } function finish(event: PointerEvent) { event.stopPropagation(); gesture.current = null; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); } return <>
start(event, "rotate") : undefined} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish} onLostPointerCapture={() => { gesture.current = null; }} style={{ width: width || (data.kind === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}> {children} {capabilities.resizable && } {rotating && {Math.round(rotation)}° · 拖动旋转,点击外部退出}
; }