157 lines
14 KiB
TypeScript
157 lines
14 KiB
TypeScript
/**
|
||
* 节点共用的几何交互容器:绘制旋转后的内容、选择样式、拉伸手柄及模式提示,协调一次手势的预览/提交/取消。
|
||
* 拖动节点位置仍由外层ReactFlow处理;此处主要处理旋转与尺寸变化,通过NodeEditing而非直接写文档。
|
||
*/
|
||
"use client";
|
||
|
||
import { useEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||
import { useReactFlow, useViewport, type NodeProps, type XYPosition } from "@xyflow/react";
|
||
import { Grip, Maximize2, RotateCw } from "lucide-react";
|
||
import { elementCapabilities } from "@/lib/canvas-elements";
|
||
import type { CanvasNode } from "@/lib/canvas";
|
||
import { resizeFromPointer, rotationFromPointer, type NodeTransform, type ResizeDirection } from "@/lib/canvas-geometry";
|
||
import type { EditingLease } from "./editing-lease";
|
||
import { NodeStyleToolbar } from "./node-style-toolbar";
|
||
import { MembershipBadge } from "./frame-feedback";
|
||
import { ContentHandles } from "./content-handles";
|
||
import {ownsPointerCapture,acquirePointerCapture,releasePointerCapture} from "@/lib/canvas-bindings/pointer-capture";
|
||
import {canvasKeyContext,routeCanvasKey} from "@/lib/canvas-bindings/input-router";
|
||
import styles from "./canvas.module.css";
|
||
import {useNodeInternalsUpdate} from "./node-internals";
|
||
|
||
export {NodeEditing} from "./node-editing";
|
||
import {useScopedNodeEditing} from "./node-editing";
|
||
/** 保存手势起始节点快照与画布坐标,防止每帧以已预览结果为基准累积误差;pointerId/capture限定事件所有权。
|
||
* 新版lease绑定具体交互,旧版则使用上下文的begin/transform/finish/cancel适配。 */
|
||
type Gesture = { lease?: EditingLease<NodeTransform>; node: CanvasNode; start: XYPosition; direction: ResizeDirection | "rotate"; capture: HTMLElement; pointerId: number };
|
||
|
||
/** children决定内容显示,hideStyleToolbar允许分区使用专属设置栏;className补充类型外观。
|
||
* 能否缩放/旋转由elementCapabilities和当前选中/模式共同决定,不在每个节点组件重复实现。 */
|
||
export function TransformableNode(props: NodeProps<CanvasNode> & { children: ReactNode; className?: string; hideStyleToolbar?: boolean }) {
|
||
const { id, data, selected, width, height, children, className = "" } = props;
|
||
const editing = useScopedNodeEditing(id,data.__headlessTarget);
|
||
const flow = useReactFlow<CanvasNode>();
|
||
const update = useNodeInternalsUpdate(id);
|
||
const { zoom } = useViewport();
|
||
const frame = useRef<HTMLDivElement>(null);
|
||
const gesture = useRef<Gesture | null>(null);
|
||
// 用ref让只挂载一次的window blur监听调用最新cancelGesture;失焦和卸载均取消尚未结束的交互。
|
||
const interrupt=useRef(()=>{});interrupt.current=()=>cancelGesture();
|
||
useEffect(()=>{const blur=()=>interrupt.current();window.addEventListener('blur',blur);return()=>{window.removeEventListener('blur',blur);interrupt.current();};},[]);
|
||
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;
|
||
// 几何、旋转或选中变化后请求批量更新ReactFlow连接点内部数据,避免端点仍留在旧位置。
|
||
useEffect(() => { update(); }, [id, rotation, width, height, selected, update]);
|
||
// 仅在变换模式启用时监听外部点击与Escape;退出/切换模式拆除监听。
|
||
useEffect(() => {
|
||
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 + '"], [data-resize-toggle="' + id + '"]')) { editing.rotate(null); editing.resize(null); }
|
||
};
|
||
/** 按统一输入路由判断Escape是否归工具,尊重文本/组合输入与已处理事件;认领后取消手势并退出模式。 */
|
||
const escape = (event: KeyboardEvent) => {
|
||
if(event.key!=="Escape")return;
|
||
const claim=routeCanvasKey({key:event.key,composing:event.isComposing,defaultPrevented:event.defaultPrevented,...canvasKeyContext(event)},{tool:()=>{cancelGesture();editing.rotate(null);editing.resize(null);return true;}});
|
||
if(claim==='tool'){event.preventDefault();event.stopPropagation();}
|
||
};
|
||
window.addEventListener("pointerdown", outside, true);
|
||
window.addEventListener("keydown", escape);
|
||
return () => { window.removeEventListener("pointerdown", outside, true); window.removeEventListener("keydown", escape); };
|
||
}, [rotating, resizing, id, editing]);
|
||
|
||
/** 主指针左键启动;旋转跳过文本输入目标,重复指针不得接管已有手势,另一指针到来先取消。
|
||
* 取得起始节点和租约后捕获指针;捕获失败按取消路径恢复,成功才阻止默认行为与冒泡。 */
|
||
function start(event: PointerEvent<HTMLElement>, direction: Gesture["direction"]) {
|
||
if (event.button !== 0||event.defaultPrevented) return;
|
||
if(direction==='rotate'&&event.target instanceof Element&&event.target.closest('input,textarea,[contenteditable]:not([contenteditable="false"]),[role="textbox"]'))return;
|
||
if(gesture.current){if(gesture.current.pointerId!==event.pointerId)cancelGesture();return;}
|
||
if(event.isPrimary===false)return;
|
||
const node = flow.getNode(id);
|
||
if (!node) return;
|
||
// false表示动作层拒绝启动;缺少专用beginTransform时回退旧版begin,随后按需选中节点。
|
||
const lease = editing.beginTransform?.(id); if (lease === false) return;
|
||
if (!editing.beginTransform) editing.begin(id);
|
||
if (!node.selected) editing.select(id);
|
||
gesture.current = { lease: lease && typeof lease === 'object' ? lease : undefined, node, direction, capture: event.currentTarget, pointerId: event.pointerId, start: flow.screenToFlowPosition({ x: event.clientX, y: event.clientY }) };
|
||
if(!acquirePointerCapture(event.currentTarget,event.pointerId)){cancelGesture();return;}
|
||
event.preventDefault();event.stopPropagation();
|
||
}
|
||
/** 只接受持有捕获的指针,把屏幕坐标变为画布坐标,再基于起始快照算旋转或尺寸变化。
|
||
* 旋转Shift吸附由geometry函数处理;八向拉伸模式把Shift传入比例约束,普通角落拖动沿用默认行为。 */
|
||
function move(event: PointerEvent<HTMLElement>) {
|
||
if(!gesture.current)return;event.stopPropagation();
|
||
if (!ownsPointerCapture(gesture.current,event)) return;
|
||
const current = flow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||
const { node, start, direction } = gesture.current!;
|
||
const change = direction === "rotate"
|
||
? { rotation: rotationFromPointer(node, start, current, event.shiftKey) }
|
||
: resizeFromPointer(node, direction, { x: current.x - start.x, y: current.y - start.y }, resizing ? event.shiftKey : undefined);
|
||
// 新版只预览当前lease,旧适配发transform;历史单元何时发布由Provider控制。
|
||
if (gesture.current!.lease) gesture.current!.lease!.preview(change); else editing.transform(id, change);
|
||
}
|
||
/** 无事件参数可由失焦/卸载强制取消;有事件时先校验捕获归属,避免其它指针干扰。
|
||
* 优先lease.cancel,其次cancelTransform;最旧接口缺少取消钩子时把起始几何写回,最后释放捕获。 */
|
||
function cancelGesture(event?:{pointerId:number;type?:string;target?:unknown;stopPropagation?():void}) {
|
||
if(event&&gesture.current)event.stopPropagation?.();
|
||
if(event&&!ownsPointerCapture(gesture.current,event))return;
|
||
const current = gesture.current; if (!current) return;
|
||
gesture.current = null;
|
||
if (current.lease) current.lease.cancel(); else if (editing.cancelTransform) editing.cancelTransform(); else editing.transform(current.node.id, current.direction === "rotate" ? { rotation: current.node.data.rotation || 0 } : { position: current.node.position, width: current.node.width, height: current.node.height });
|
||
releasePointerCapture(current.capture,current.pointerId);
|
||
}
|
||
/** 抬起时先采用最后一个指针位置,再清手势引用并提交;随后释放捕获不会误取消已结束交互。 */
|
||
function finish(event: PointerEvent<HTMLElement>) {
|
||
if(!gesture.current)return;event.stopPropagation();
|
||
if (!ownsPointerCapture(gesture.current,event)) return; const current = gesture.current!; move(event); gesture.current = null;
|
||
if (current.lease) current.lease.commit(); else editing.finishTransform?.();
|
||
releasePointerCapture(current.capture,current.pointerId);
|
||
}
|
||
// 容器的CSS zoom使用viewScale,宽高同时除以viewScale,保持投影尺寸与内部显示坐标对应。
|
||
// schema=2给纯文本/纯图片补四向连接点,非分区显示成员徽标;普通尺寸调整用右下角,模式内显示八向手柄。
|
||
// 八向手柄的尺寸与边框反向抵消zoom和viewScale,使屏幕上大小稳定;方向决定位置及cursor。
|
||
return <>
|
||
{!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={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 && !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={cancelGesture}>
|
||
<Grip size={14} />
|
||
</button>}
|
||
{resizing && (["top-left", "top", "top-right", "right", "bottom-right", "bottom", "bottom-left", "left"] as const).map((direction) => {
|
||
// 从方向名推导0%/50%/100%锚点:边中点只占一个方向,角点同时包含横纵方向。
|
||
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) => {
|
||
// 键盘每次方向键形成一次完整变换:默认一个viewScale单位,Ctrl/Meta放大十倍,Shift固定比例。
|
||
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;
|
||
const lease = editing.beginTransform?.(id); if (lease === false) return; if (!editing.beginTransform) editing.begin(id); const scale = (data.viewScale || 1) * (event.ctrlKey || event.metaKey ? 10 : 1);
|
||
const change = resizeFromPointer(node, direction, { x: delta[0] * scale, y: delta[1] * scale }, event.shiftKey);
|
||
if (lease && typeof lease === 'object') { lease.preview(change); lease.commit(); } else { editing.transform(id, change); editing.finishTransform?.(); }
|
||
}} />;
|
||
})}
|
||
{resizing && <span className={styles.rotationHint}>拖动八向手柄 · Shift 固定比例 · Esc 退出</span>}
|
||
{rotating && <span className={styles.rotationHint}><RotateCw size={12} /> {Math.round(rotation)}° · 拖动旋转,点击外部退出</span>}
|
||
</div>
|
||
</>;
|
||
}
|
||
|
||
/** 受控拉伸模式开关;aria-pressed反映当前节点是否处于模式,data标记供外部点击判定排除自身。 */
|
||
export function ResizeToggle({ id,renderedTarget }: { id: string;renderedTarget?:import("@/lib/canvas-bindings/compat-types").RenderedTarget }) {
|
||
const editing = useScopedNodeEditing(id,renderedTarget);
|
||
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>;
|
||
}
|