@scory02/leafer-connector
v0.1.4
Published
A LeaferJS connector (edge) component with arrows, multiple route types, labels, and collaboration helpers.
Maintainers
Readme
leafer-connector
基于 LeaferJS(通过 leafer-editor)实现的连接线(Connector / Edge)组件,面向“白板 / 流程图 / 节点图”场景。
你可以把它理解成:给两个 IUI 节点自动画出一条“像流程图工具一样”的连线,并支持 label、协同等能力。
能力概览
- 连接 2 个节点:
from/to: IUI - 端点模型:
padding / margin / side(auto) / percent / linkPoint - 路由类型:
orthogonal / bezier / straight / custom - 样式:
stroke / strokeWidth / dashPattern / startArrow / endArrow - 缩放策略:
scaleMode: world | pixel(线宽/箭头是否随 zoom 缩放) - 交互:双击连线创建/编辑 label(label 永远在路径中点)
- 协同/程序更新:
updateMode="render"+renderThrottleMs - 状态同步:
getState/setState+onChange/onLabelChange输出 diff
安装
pnpm add leafer-connector leafer-editor
leafer-editor为 peerDependency,需要业务侧安装并锁定版本。
快速开始(最小可用)
最小输入参数只有:app + from/to。
import { App, Rect } from "leafer-editor";
import { Connector } from "leafer-connector";
const app = new App({ view: container, editor: {} });
const a = new Rect({ x: 100, y: 100, width: 200, height: 160, fill: "#32cd79", draggable: true });
const b = new Rect({ x: 520, y: 280, width: 220, height: 160, fill: "#3b82f6", draggable: true });
const edge = new Connector(app, { from: a, to: b });
app.tree.add([a, b, edge]);路由示例
正交(orthogonal)
const edge = new Connector(app, {
from: a,
to: b,
routeType: "orthogonal",
cornerRadius: 16,
});
贝塞尔(bezier)
const edge = new Connector(app, {
from: a,
to: b,
routeType: "bezier",
bezierCurvature: 0.6,
routeOptions: {
// 0:尽量保持贝塞尔;140:近距离自动降级正交更稳定
bezierFallbackDistance: 0,
},
});
直线(straight)
const edge = new Connector(app, {
from: a,
to: b,
routeType: "straight",
});
自定义(custom + onDraw)
你可以在组件算出默认结果后,通过 onDraw 覆盖:
- 覆盖
points(world 坐标):组件会基于 points 重新生成圆角路径并更新 label 中点 - 覆盖
path(world 坐标 SVG path):支持M/L/C/Q/Z(绝对坐标)。若只覆盖 path 不覆盖 points,label 会沿用默认中点
const edge = new Connector(app, {
from: a,
to: b,
routeType: "custom",
onDraw: ({ s, e, defaultResult }) => {
// 1) 直接用默认结果
// return
// 2) 覆盖 points(world)
// return { points: [s.linkPoint, s.paddingPoint, e.paddingPoint, e.linkPoint] }
// 3) 覆盖 path(world,M/L/C/Q/Z)
return { path: defaultResult.path };
},
});
label(连线文字)示例
默认 label(有背景遮挡)
const edge = new Connector(app, {
from: a,
to: b,
label: { text: "Hello", editable: true },
});
自定义 label 样式
const edge = new Connector(app, {
from: a,
to: b,
label: {
text: "关系",
style: {
fill: "#fff",
fontSize: 12,
fontFamily: "Arial",
fontWeight: "bold",
padding: [2, 6],
boxStyle: { fill: "#00000099", cornerRadius: 6 },
},
},
});参数总览(表格)
这一节把所有入参集中在一个地方,方便快速查阅(字段名使用“点号路径”表示嵌套结构)。
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| from | IUI | 是 | - | 起点节点 |
| to | IUI | 是 | - | 终点节点 |
| routeType | "orthogonal" \| "bezier" \| "straight" \| "custom" | 否 | "orthogonal" | 路由类型 |
| padding | number | 否 | 20 | 出线段长度(从 linkPoint 沿法线外扩) |
| margin | number | 否 | 0 | 连接点与节点边界间距(让线不贴边) |
| cornerRadius | number | 否 | 16 | 正交/智能路由的圆角半径 |
| opt1 | TargetOption | 否 | - | 起点单端端点策略(覆盖全局) |
| opt2 | TargetOption | 否 | - | 终点单端端点策略(覆盖全局) |
| bezierCurvature | number | 否 | 0.6 | bezier 曲率/张力(越大越“张开”) |
| routeOptions | { ... } | 否 | - | smart-route 参数(会做深合并,未传字段会用默认值) |
| routeOptions.avoidPadding | number | 否 | margin | 避障 padding:将需要避开的 bounds 外扩多少(local) |
| routeOptions.intersectionPenalty | number | 否 | 1e6 | 线段与避障矩形相交的惩罚分(越大越“绕开”) |
| routeOptions.longStraightRatio | number | 否 | 0.65 | 长直线惩罚阈值(maxSegment/total > ratio 开始惩罚) |
| routeOptions.longStraightWeight | number | 否 | 2000 | 长直线惩罚权重 |
| routeOptions.enableSRoutes | boolean | 否 | true | 是否生成 S-route(两次转折)候选 |
| routeOptions.bezierFallbackDistance | number | 否 | 0 | bezier 近距离降级阈值(小于该值或节点重叠可降级为正交圆角) |
| onDraw | ({ s, e, defaultResult }) => Partial<{ points; path }> \| void | 否 | - | 自定义绘制(入参/出参 points/path 都是 world 坐标) |
| updateMode | "event" \| "render" \| "manual" | 否 | "event" | 自动更新模式(协同场景建议用 render) |
| renderThrottleMs | number | 否 | 16 | render 模式节流(ms) |
| getNodeId | (node: IUI) => string | 否 | - | 协同序列化:node -> id(用于 getState/onChange) |
| onChange | ({ reason, prev, next, diff, changedKeys }) => void | 否 | - | 统一变更回调(reason: "label" \| "setState") |
| onLabelChange | ({ oldText, newText }) => void | 否 | - | label 文本变化回调 |
| stroke | string | 否 | "#ffffff" | 线条颜色 |
| strokeWidth | number | 否 | 2 | 线宽 |
| dashPattern | number[] | 否 | - | 虚线,例如 [6, 4] |
| startArrow | IArrowStyle | 否 | - | 起点箭头样式 |
| endArrow | IArrowStyle | 否 | "triangle" | 终点箭头样式 |
| scaleMode | "world" \| "pixel" | 否 | "world" | 缩放策略(pixel:线宽/箭头保持像素大小) |
| arrowBaseScale | number | 否 | 1 | 箭头基准缩放(配合 pixel 更常用) |
| label | { ... } | 否 | - | 连线文字配置(存在时可显示/编辑) |
| label.text | string | 否 | - | 初始文字(空/空白会被视为不创建 label) |
| label.editable | boolean | 否 | - | 是否允许编辑(打开 inner editor) |
| label.style | Partial<ITextInputData> | 否 | - | 文案样式(fill/fontSize/boxStyle/padding 等) |
| labelOnDoubleClick | boolean | 否 | true | 是否允许双击连线打开/创建 label |
TargetOption(用于 opt1/opt2)表格
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| optX.side | "top" \| "right" \| "bottom" \| "left" \| "auto" | 否 | "auto" | 固定连接面,或自动择优 |
| optX.percent | number | 否 | 0.5 | 连接点在该面的比例(0~1,0.5=边中点) |
| optX.padding | number | 否 | - | 单端 padding(覆盖全局 padding) |
| optX.margin | number | 否 | - | 单端 margin(覆盖全局 margin) |
| optX.linkPoint | IPointData | 否 | - | 固定连接点(world,优先级最高) |
必填
from: IUI:起点节点to: IUI:终点节点
端点与路由
routeType?: "orthogonal" | "bezier" | "straight" | "custom""orthogonal":正交折线 + 圆角(smart-route)"bezier":smooth-step 风格曲线(在节点很近/重叠时可选降级为正交)"straight":直线(仍会包含 linkPoint/paddingPoint 的出线段)"custom":默认给一个可用结果,但你应通过onDraw覆盖
padding?: number:出线段长度(从 linkPoint 沿法线外扩)margin?: number:连接点与节点边界的间距(让线不要贴边)cornerRadius?: number:正交圆角半径opt1?: TargetOption/opt2?: TargetOption:单端覆盖(见下方 TargetOption)
Bezier
bezierCurvature?: number:曲率/张力(越大曲线“张开”越明显)routeOptions?.bezierFallbackDistance?: number:当routeType="bezier"时,若两端 padding 点距离小于该值(或节点重叠),可降级为正交圆角- 默认
0:尽量保持贝塞尔 - 推荐
140:近距离更稳定、避免回勾
- 默认
样式
stroke?: stringstrokeWidth?: numberdashPattern?: number[]:虚线,例如[6, 4]startArrow?: IArrowStyleendArrow?: IArrowStyle(默认"triangle")
缩放策略
scaleMode?: "world" | "pixel""world":跟随画布缩放(默认)"pixel":保持像素大小(线宽/箭头不随 zoom 变化)
arrowBaseScale?: number:箭头基准缩放(配合 pixel 模式更常用)
label(连线文字)
label?: { text?: string; editable?: boolean; style?: Partial<ITextInputData> }labelOnDoubleClick?: boolean:是否允许双击连线打开/创建 label(默认 true)
提示:如果你不传
style.boxStyle/padding,组件会给 label 自动加半透明背景遮挡线条,保证可读。
更新模式(协同/性能)
updateMode?: "event" | "render" | "manual"event:仅交互事件触发update()(性能最好,默认)render:每帧RenderEvent.END触发(适合协同/程序改变坐标)manual:完全手动
renderThrottleMs?: number:render模式节流,推荐16~33
协同同步
getNodeId?: (node: IUI) => string:用于getStateonChange?: ({ reason, prev, next, diff, changedKeys }) => void:结构变化统一回调(用于写入 Yjs diff)onLabelChange?: ({ oldText, newText }) => void:label 文本变化
TargetOption(单端端点策略)
opt1/opt2 的字段与优先级(从高到低):
linkPoint?: IPointData(world 坐标):固定连接点(最高优先级)side?: "top" | "right" | "bottom" | "left" | "auto"+percent?: number:在某条边上按比例取点
其它:
padding?: number/margin?: number:单端覆盖percent默认0.5(边中点)
协同:序列化/恢复(getState / setState)
你可以把 Connector 状态写入 Yjs(或其它 CRDT),并在远端恢复:
// 1) 序列化:需要提供 node -> id
const state = edge.getState((node) => String((node as any).id));
// 2) 恢复:需要提供 id -> node
edge.setState(state, (id) => nodeById.get(id));协同:onChange / onLabelChange
如果你希望 label 变化 能自动产出 “可同步的 diff”,可以用 onChange:
const edge = new Connector(app, {
from: a,
to: b,
getNodeId: (node) => String((node as any).id),
onChange: ({ reason, diff }) => {
// 把 diff 写入 Yjs
console.log(reason, diff);
},
onLabelChange: ({ oldText, newText }) => {
console.log(oldText, newText);
},
});协同/性能注意事项:
- diff 对比是稳定的:内部对对象字段做 key 排序稳定序列化,避免误触发
onChange - 避免重复绑定:同一节点多次被设置为
from/to时,内部会去重绑定拖拽监听 - label 变更会自动合并:输入过程中可能产生高频
RenderEvent.END,内部会合并到同一微任务批次再触发回调
性能与更新模式(updateMode)
event(默认):仅拖拽/交互触发update(),性能最好render:每帧RenderEvent.END强制update(),适合协同/程序频繁更新坐标manual:你自己控制刷新(可调用connector.invalidate()或connector.update())
render 模式节流
协同场景建议开启节流减少压力:
const edge = new Connector(app, {
from: a,
to: b,
updateMode: "render",
renderThrottleMs: 16,
});routeOptions(smart-route 参数)
你可以用 routeOptions 调整正交 smart-route 的取舍(更偏好绕开/更偏好 S-route 等):
const edge = new Connector(app, {
from: a,
to: b,
routeType: "orthogonal",
routeOptions: {
avoidPadding: 12,
intersectionPenalty: 1e6,
longStraightRatio: 0.65,
longStraightWeight: 2000,
enableSRoutes: true,
},
});
bezierFallbackDistance仅在routeType="bezier"时生效。
常见问题(FAQ)
1) 为什么我设置了 bezier,看起来还是折线?
- 你可能没有设置
routeType: "bezier"(默认是"orthogonal") - 或者你显式把
routeOptions.bezierFallbackDistance设置成较大值(例如 140),导致近距离自动降级为正交圆角
2) 为什么平移画布后连线会“漂移”?
本组件内部会把路由计算与绘制统一在 Connector 的 local 坐标,并在 onDraw 回调里对外提供 world 坐标,已避免常见的坐标系漂移问题。
如果你在 onDraw 返回自定义 path,务必返回 world 坐标 path(组件会自动转回 local)。
API 导出
- 导出:
Connector以及相关类型(见src/types.ts)
构建与发布
本包默认输出 双产物:
- ESM:
dist/esm - CJS:
dist/cjs(.cjs后缀)
可选:Rollup 生产 bundle(更适合做体积检查/发布前 smoke test):
pnpm run bundle:rollup产物会输出到 dist/bundle/(含 *.min.*)。
发布前:
pnpm run build
npm publish备注
- 本包是 ESM(
type: module),发布时会输出到dist/(含类型声明) leafer-editor作为 peerDependency,需要由业务侧安装与锁定版本
