89 lines
8.3 KiB
TypeScript
89 lines
8.3 KiB
TypeScript
/**
|
|
* 分区节点:展示名称、边框、成员拖入/移出反馈,以及子画布折叠与概览设置。
|
|
* 局部状态只存名称输入草稿;保存与分区属性修改都交给作用域编辑接口,成员归属不由本组件直接变更。
|
|
*/
|
|
"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<CanvasNode> & { 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";
|
|
/** 去除首尾空白,空名称回退“未命名分区”;只有与模型不同才派发修改,再归一化输入显示。 */
|
|
// 这里只 trim/补默认名,maxLength 是输入控件限制;UTF-8 字节和数据合法性仍由命令/格式层负责。
|
|
function saveTitle() {
|
|
const name = title.trim() || "未命名分区";
|
|
if (name !== data.title) editing.frame(id, { title: name });
|
|
setTitle(name);
|
|
}
|
|
// title effect 无论是否正编辑都跟随外部值,外部改名可能替换未提交草稿;局部保存后也不等待远端 ACK。
|
|
// 选中时显示设置栏,动画尊重减少动态效果偏好;栏内事件停止冒泡,避免触发画布快捷键或拖动。
|
|
// 折叠切换前按viewScale还原当前尺寸,存入原状态对应的expandedSize/collapsedSize供后续恢复。
|
|
// 概览比例输入限定5%—400%,只更新presentation.contentScale;链接单独打开子画布。
|
|
// 折叠按钮只提交 presentation 候选,不直接隐藏/删除后代;实际尺寸恢复和投影由动作实现处理。
|
|
// fixed名称与拖放提示用1/(zoom*viewScale)抵消缩放,scaled跟随画布,hidden不绘制名称。
|
|
// 所有端口都用 source 类型配合外层连接模式;方向差异由 Start/End 能力开关表达。
|
|
// 四侧边框提供拖动命中区域;ports按同方向均匀分布,输出在右侧可起连线,输入在左侧可收连线。
|
|
return <>
|
|
<AnimatePresence>{selected && <NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
|
<motion.div ref={overlay} className={`${styles.styleToolbar} nodrag nopan nowheel`} role="toolbar" aria-label="分区设置"
|
|
initial={{ opacity: 0, y: reduce ? 0 : 5 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: reduce ? 0 : 5 }} transition={{ duration: reduce ? 0 : 0.14 }}
|
|
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
|
<input className={styles.frameNameInput} aria-label="分区名称" value={title} maxLength={300} onChange={(event) => 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(); } }} />
|
|
<FrameLabelMenu value={mode} change={(labelMode) => editing.frame(id, { labelMode })} />
|
|
<ResizeToggle id={id} renderedTarget={data.__headlessTarget} />
|
|
{data.subgraph && <><button type="button" aria-label={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} title={data.presentation?.collapsed ? "展开子画布" : "收起子画布"} onClick={() => {
|
|
const collapsed = !data.presentation?.collapsed;
|
|
const currentSize = { width: (props.width || 640) / (data.viewScale || 1), height: (props.height || 400) / (data.viewScale || 1) };
|
|
const presentation = { ...data.presentation, collapsed, [collapsed ? "expandedSize" : "collapsedSize"]: currentSize };
|
|
editing.frame(id, { presentation });
|
|
}}>{data.presentation?.collapsed ? <UnfoldVertical size={16} /> : <FoldVertical size={16} />}</button>
|
|
<label className={styles.subgraphScale} title="子画布概览比例,不修改内容尺寸"><input aria-label="子画布概览比例" type="number" min={5} max={400} step={5} value={Math.round((data.presentation?.contentScale || 1) * 100)} onChange={(event) => { const scale = +event.target.value / 100; if (scale >= .05 && scale <= 4) editing.frame(id, { presentation: { ...data.presentation, contentScale: scale } }); }} />%</label>
|
|
<a href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} aria-label="打开子画布" title="打开子画布"><ExternalLink size={15} /></a></>}
|
|
|
|
</motion.div>
|
|
</NodeToolbar>}</AnimatePresence>
|
|
<TransformableNode {...nodeProps} hideStyleToolbar className={`${styles.frameNode} ${incoming ? styles.frameDropTarget : ""} ${outgoing ? styles.frameDropExit : ""}`}>
|
|
{data.subgraph && data.presentation?.collapsed && <a className={styles.collapsedSubgraph} href={`/dashboard/${encodeURIComponent(editing.workspace)}/${data.subgraph.boardId}`} onPointerDown={(event) => event.stopPropagation()}><FoldVertical size={22} /><span>{data.title}</span><small>打开子画布</small></a>}
|
|
{mode !== "hidden" && <div className={styles.frameLabel} style={{ scale: mode === "fixed" ? 1 / (zoom * (data.viewScale || 1)) : 1 }} title={data.title}>{data.title}</div>}
|
|
{(incoming > 0 || outgoing > 0) && <div className={styles.frameDropHint} style={{ scale: 1 / (zoom * (data.viewScale || 1)) }}>{incoming > 0 ? `松开加入 · ${incoming} 项` : `松开移出 · ${outgoing} 项`}</div>}
|
|
{(["top", "right", "bottom", "left"] as const).map((side) => <div key={side} className={`${styles.frameBorderHit} ${styles["frameBorder_" + side]}`} title="拖动分区,已有成员一起移动" />)}
|
|
{directAnchors && <ContentHandles selected={!!selected} />}
|
|
{data.ports?.map((port) => {
|
|
// 只在同方向端口之间均分高度,index+1与length+1让首尾都留出边距。
|
|
const peers = data.ports!.filter((item) => item.direction === port.direction);
|
|
return <Handle key={port.id} id={`port:${port.id}`} type="source" position={port.direction === "input" ? Position.Left : Position.Right} className={styles.framePort} style={{ top: `${(peers.indexOf(port) + 1) / (peers.length + 1) * 100}%` }} isConnectableStart={selected && port.direction === "output"} isConnectableEnd={port.direction === "input"} title={port.direction === "input" ? "子画布输入" : "子画布输出"} />;
|
|
})}
|
|
</TransformableNode>
|
|
</>;
|
|
}
|