Files
Endoleap/src/components/canvas/transformable-node.tsx
T

83 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<CanvasNode> & { children: ReactNode; className?: string }) {
const { id, data, selected, width, height, children, className = "" } = props;
const editing = useContext(NodeEditing);
const flow = useReactFlow<CanvasNode>();
const update = useUpdateNodeInternals();
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";
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<HTMLElement>, 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<HTMLElement>) {
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<HTMLElement>) {
event.stopPropagation(); gesture.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
}
return <>
<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 === "icon" ? 80 : plain ? 240 : 260), height: height || (data.kind === "icon" ? 80 : plain ? 72 : 200), transform: `rotate(${rotation}deg)` }}>
{children}
{capabilities.resizable && <button type="button" className={`${styles.cornerResize} nodrag nopan`} tabIndex={selected && !rotating ? 0 : -1}
aria-hidden={!selected || rotating} aria-label="拖动右下角调整大小" title="拖动调整大小"
onPointerDown={(event) => start(event, "bottom-right")} onPointerMove={move} onPointerUp={finish} onPointerCancel={finish}>
<Grip size={14} />
</button>}
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
</div>
</>;
}