/** * 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。 * 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。 */ "use client"; import { useContext, useEffect, useRef, useState } from "react"; import { FoldVertical, UnfoldVertical, ExternalLink } from "lucide-react"; import { Handle, NodeToolbar, Position, useViewport, type NodeProps } from "@xyflow/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import type { CanvasNode } from "@/lib/canvas"; import { TransformableNode, ResizeToggle } from "./transformable-node"; import { FrameFeedback } from "./frame-feedback"; import { FrameLabelMenu } from "./frame-label-menu"; import { ContentHandles } from "./content-handles"; import {useCanvasOverlay} from "./canvas-overlays"; import {useScopedNodeEditing} from "./node-editing"; import styles from "./canvas.module.css"; /** directAnchors用于新版分区四向连接点;子画布ports则按输入/输出方向单独布局。 * 分区使用自己的设置浮层,因此隐藏TransformableNode的通用样式栏,仍保留公共变换行为。 */ export function FrameNode(props: NodeProps & { directAnchors?: boolean }) { const { directAnchors = false, ...nodeProps } = props; const { id, data, selected } = nodeProps; // 设置浮层注册到当前视图的overlay索引,让Portal中的控件也能正确参与焦点与输入路由。 const overlay=useCanvasOverlay(id,"frame-settings"); const editing = useScopedNodeEditing(id,data.__headlessTarget); const feedback = useContext(FrameFeedback); // 反馈来自当前拖动预计算,只统计跨分区的进入/离开;这两个计数不是持久成员列表。 const incoming = feedback.drops.filter((drop) => drop.to === id && drop.from !== id).length; const outgoing = feedback.drops.filter((drop) => drop.from === id && drop.to !== id).length; const { zoom } = useViewport(); const reduce = useReducedMotion(); // 名称是受控输入草稿;外部data.title变化时同步覆盖,实际提交发生在blur。 const [title, setTitle] = useState(data.title); useEffect(() => setTitle(data.title), [data.title]); // Escape恢复模型标题并标记跳过随后的blur保存;Enter排除输入法组合态后通过blur统一提交。 const cancelBlur = useRef(false); const mode = data.labelMode || "fixed"; /** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */ function saveTitle() { const name = title.trim() || "未命名分区"; if (name !== data.title) editing.frame(id, { title: name }); setTitle(name); } // 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。 // 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。 // 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。 // fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。 // 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。 return <> {selected && event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> setTitle(event.target.value)} onBlur={() => { if (cancelBlur.current) cancelBlur.current = false; else saveTitle(); }} onKeyDown={(event) => { if (event.key === "Enter" && !event.nativeEvent.isComposing) { event.currentTarget.blur(); } if (event.key === "Escape") { cancelBlur.current = true; setTitle(data.title); event.currentTarget.blur(); } }} /> editing.frame(id, { labelMode })} /> {data.subgraph && <> } } {data.subgraph && data.presentation?.collapsed && event.stopPropagation()}>{data.title}打开子画布} {mode !== "hidden" &&
{data.title}
} {(incoming > 0 || outgoing > 0) &&
{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}
} {(["top", "right", "bottom", "left"] as const).map((side) =>
)} {directAnchors && } {data.ports?.map((port) => { // 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。 const peers = data.ports!.filter((item) => item.direction === port.direction); return ; })} ; }