65 lines
5.8 KiB
TypeScript
65 lines
5.8 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 { NodeEditing, TransformableNode, ResizeToggle } from "./transformable-node";
|
|
import { FrameFeedback } from "./frame-feedback";
|
|
import { FrameLabelMenu } from "./frame-label-menu";
|
|
import { ContentHandles } from "./content-handles";
|
|
import styles from "./canvas.module.css";
|
|
|
|
export function FrameNode(props: NodeProps<CanvasNode> & { directAnchors?: boolean }) {
|
|
const { directAnchors = false, ...nodeProps } = props;
|
|
const { id, data, selected } = nodeProps;
|
|
const editing = useContext(NodeEditing);
|
|
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();
|
|
const [title, setTitle] = useState(data.title);
|
|
useEffect(() => setTitle(data.title), [data.title]);
|
|
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);
|
|
}
|
|
return <>
|
|
<NodeToolbar style={{ zIndex: 100, pointerEvents: "auto" }} isVisible={true} position={Position.Top} offset={45}>
|
|
<AnimatePresence>{selected && <motion.div 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} />
|
|
{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>}</AnimatePresence>
|
|
</NodeToolbar>
|
|
<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) => {
|
|
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>
|
|
</>;
|
|
}
|