47 lines
6.9 KiB
TypeScript
47 lines
6.9 KiB
TypeScript
/**
|
|
* 连线的受控样式与曲线工具栏。接收当前data及回调,编辑外观、动画、传播开关和曲线点,不直接持有文档状态。
|
|
*/
|
|
"use client";
|
|
import { EdgeToolbar } from "@xyflow/react";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import type { EdgeData, EdgeMarker, MarkerShape } from "@/lib/canvas-v2";
|
|
import styles from "./canvas.module.css";
|
|
|
|
/** x/y是工具栏锚点;count与knotMode来自外层曲线编辑器。add/remove/changeKnotMode委托外层选择具体曲线点。
|
|
* EdgeToolbar只负责屏幕定位,外层仅在选中连线时挂载本组件。 */
|
|
export function EdgeToolbarControls({ id, x, y, data, change, add, remove, count, knotMode, changeKnotMode }: { id: string; x: number; y: number; data: EdgeData; change: (data: EdgeData) => void; add: () => void; remove: () => void; count: number; knotMode?: string; changeKnotMode: (mode: "corner" | "smooth" | "symmetric") => void }) {
|
|
const a = data.appearance || {}, animation = data.animation || {};
|
|
/** 合并appearance子对象并保留其它连线数据;箭头/箭尾各自更新时仍需在事件内保留其嵌套字段。 */
|
|
// 合并基于本次 render 捕获的 data;change 必须由外层正确采用,不能把旧回调当成读取最新文档的入口。
|
|
const appearance = (patch: NonNullable<EdgeData["appearance"]>) => change({ ...data, appearance: { ...a, ...patch } });
|
|
/** 动画属性单独合并,调整一个设置不覆盖已有方向、效果等其它字段。 */
|
|
const animate = (patch: NonNullable<EdgeData["animation"]>) => change({ ...data, animation: { ...animation, ...patch } });
|
|
/** 箭头与箭尾共用的六种形状;实际SVG绘制由CanvasEdge中的Marker实现。 */
|
|
const markers: [MarkerShape, string][] = [["none", "无"], ["open-arrow", "开放箭头"], ["triangle", "三角箭头"], ["circle", "圆形"], ["diamond", "菱形"], ["square", "方形"]];
|
|
// 宽度校验0.5—20,端部尺寸4—32,动画时长250—30000ms;透明度范围由range控件给出。
|
|
// head/tail分别合并type/size/filled;传播开关将execution替换为当前enabled对象。
|
|
// 无曲线点时禁用模式和删除,达到64点时禁用添加;外层仍负责相应操作的实际合法性。
|
|
// 每个控件即时调用回调,不负责 history 分组或持久化;数值范围检查也不要求步长整数倍。
|
|
// 所有指针/键盘冒泡止于工具栏,避免颜色或输入操作同时触发画布编辑。
|
|
return <EdgeToolbar alignY="bottom" edgeId={id} x={x} y={y} isVisible style={{ zIndex: 115, pointerEvents: "auto" }}><div role="toolbar" aria-label="连线编辑" className={`${styles.edgeToolbar} nodrag nopan nowheel`}
|
|
onPointerDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}>
|
|
<label>颜色<input aria-label="连线颜色" type="color" value={a.color || "#aa968b"} onChange={(event) => appearance({ color: event.target.value })} /></label>
|
|
<label>粗细<input aria-label="连线粗细" type="number" min={.5} max={20} step={.5} value={a.width ?? 1.5} onChange={(event) => { const value = +event.target.value; if (value >= .5 && value <= 20) appearance({ width: value }); }} /></label>
|
|
<label>线条<select aria-label="线条类型" value={a.line || "solid"} onChange={(event) => appearance({ line: event.target.value as typeof a.line })}><option value="solid">实线</option><option value="dashed">虚线</option><option value="dotted">点线</option></select></label>
|
|
{(["head", "tail"] as const).map((end) => <label key={end}>{end === "head" ? "箭头" : "箭尾"}<select aria-label={end === "head" ? "箭头类型" : "箭尾类型"} value={a[end]?.type || "none"} onChange={(event) => appearance({ [end]: { ...a[end], type: event.target.value as EdgeMarker["type"] } })}>{markers.map(([id, label]) => <option key={id} value={id}>{label}</option>)}</select>
|
|
<input aria-label={end === "head" ? "箭头大小" : "箭尾大小"} type="number" min={4} max={32} value={a[end]?.size || 12} onChange={(event) => { const size = +event.target.value; if (size >= 4 && size <= 32) appearance({ [end]: { ...a[end], type: a[end]?.type || "none", size } }); }} />
|
|
<input aria-label={end === "head" ? "填充箭头" : "填充箭尾"} type="checkbox" checked={a[end]?.filled ?? true} onChange={(event) => appearance({ [end]: { ...a[end], type: a[end]?.type || "none", filled: event.target.checked } })} /></label>)}
|
|
<label>透明度<input aria-label="连线透明度" type="range" min={.05} max={1} step={.05} value={a.opacity ?? 1} onChange={(event) => appearance({ opacity: +event.target.value })} /></label>
|
|
<label>动画<select aria-label="动画播放时机" value={animation.mode || "off"} onChange={(event) => animate({ mode: event.target.value as typeof animation.mode })}><option value="off">关闭</option><option value="always">始终播放</option><option value="executing">仅执行时</option></select></label>
|
|
<label>效果<select aria-label="连线动画效果" value={animation.effect || "flow"} onChange={(event) => animate({ effect: event.target.value as typeof animation.effect })}><option value="flow">流光</option><option value="dash">流动虚线</option><option value="pulse">脉冲</option></select></label>
|
|
<label>方向<select aria-label="动画方向" value={animation.direction || "forward"} onChange={(event) => animate({ direction: event.target.value as typeof animation.direction })}><option value="forward">正向</option><option value="reverse">反向</option></select></label>
|
|
<label>时长<input aria-label="动画时长" title="毫秒" type="number" min={250} max={30000} step={250} value={animation.durationMs || 1600} onChange={(event) => { const durationMs = +event.target.value; if (durationMs >= 250 && durationMs <= 30000) animate({ durationMs }); }} /></label>
|
|
<label>光色<input aria-label="动画颜色" type="color" value={animation.color || "#ffffff"} onChange={(event) => animate({ color: event.target.value })} /></label>
|
|
<label><input aria-label="参与下游传播" type="checkbox" checked={data.execution?.enabled !== false} onChange={(event) => change({ ...data, execution: { enabled: event.target.checked } })} />传递</label>
|
|
<label>曲线点<select aria-label="曲线点控制模式" disabled={!count} value={knotMode || "smooth"} onChange={(event) => changeKnotMode(event.target.value as "corner" | "smooth" | "symmetric")}><option value="corner">独立控制柄</option><option value="smooth">平滑</option><option value="symmetric">镜像</option></select></label>
|
|
<button type="button" aria-label="添加曲线点" title="也可双击连线添加曲线点" disabled={count >= 64} onClick={add}><Plus size={14} /></button>
|
|
<button type="button" aria-label="删除选中曲线点" disabled={!count} onClick={remove}><Trash2 size={14} /></button>
|
|
<small>拖动锚点或控制柄 · 双击添加 · Shift 镜像控制柄</small>
|
|
</div></EdgeToolbar>;
|
|
}
|