A LeaferJS connector (edge) component with arrows, multiple route types, labels, and collaboration helpers.
npm install @scory02/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
``bash`
pnpm add leafer-connector leafer-editor
> leafer-editor 为 peerDependency,需要业务侧安装并锁定版本。
最小输入参数只有:app + from/to。
`ts
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]);
`
`ts`
const edge = new Connector(app, {
from: a,
to: b,
routeType: "orthogonal",
cornerRadius: 16,
});
!Preview
`ts`
const edge = new Connector(app, {
from: a,
to: b,
routeType: "bezier",
bezierCurvature: 0.6,
routeOptions: {
// 0:尽量保持贝塞尔;140:近距离自动降级正交更稳定
bezierFallbackDistance: 0,
},
});
!Preview
`ts`
const edge = new Connector(app, {
from: a,
to: b,
routeType: "straight",
});
!Preview
你可以在组件算出默认结果后,通过 onDraw 覆盖:
- 覆盖 points(world 坐标):组件会基于 points 重新生成圆角路径并更新 label 中点
- 覆盖 path(world 坐标 SVG path):支持 M/L/C/Q/Z(绝对坐标)。若只覆盖 path 不覆盖 points,label 会沿用默认中点
`ts
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 };
},
});
`
!Preview
`ts`
const edge = new Connector(app, {
from: a,
to: b,
label: { text: "Hello", editable: true },
});
!Preview
`ts`
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 | 否 | - | 文案样式(fill/fontSize/boxStyle/padding 等) |labelOnDoubleClick
| | boolean | 否 | true | 是否允许双击连线打开/创建 label |
| 字段 | 类型 | 必填 | 默认值 | 说明 |
| --- | --- | --- | --- | --- |
| 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)
- bezierCurvature?: number:曲率/张力(越大曲线“张开”越明显)routeOptions?.bezierFallbackDistance?: number
- :当 routeType="bezier" 时,若两端 padding 点距离小于该值(或节点重叠),可降级为正交圆角 0
- 默认 :尽量保持贝塞尔140
- 推荐 :近距离更稳定、避免回勾
- stroke?: stringstrokeWidth?: number
- dashPattern?: number[]
- :虚线,例如 [6, 4]startArrow?: IArrowStyle
- endArrow?: IArrowStyle
- (默认 "triangle")
- scaleMode?: "world" | "pixel" "world"
- :跟随画布缩放(默认)"pixel"
- :保持像素大小(线宽/箭头不随 zoom 变化)arrowBaseScale?: number
- :箭头基准缩放(配合 pixel 模式更常用)
- label?: { text?: string; editable?: boolean; style?: PartiallabelOnDoubleClick?: 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 文本变化
opt1/opt2 的字段与优先级(从高到低):
1. linkPoint?: IPointData(world 坐标):固定连接点(最高优先级)side?: "top" | "right" | "bottom" | "left" | "auto"
2. + percent?: number:在某条边上按比例取点
其它:
- padding?: number / margin?: number:单端覆盖percent
- 默认 0.5(边中点)
你可以把 Connector 状态写入 Yjs(或其它 CRDT),并在远端恢复:
`ts
// 1) 序列化:需要提供 node -> id
const state = edge.getState((node) => String((node as any).id));
// 2) 恢复:需要提供 id -> node
edge.setState(state, (id) => nodeById.get(id));
`
如果你希望 label 变化 能自动产出 “可同步的 diff”,可以用 onChange:
`ts`
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 排序稳定序列化,避免误触发 onChangefrom/to
- 避免重复绑定:同一节点多次被设置为 时,内部会去重绑定拖拽监听RenderEvent.END
- label 变更会自动合并:输入过程中可能产生高频 ,内部会合并到同一微任务批次再触发回调
- event(默认):仅拖拽/交互触发 update(),性能最好render
- :每帧 RenderEvent.END 强制 update(),适合协同/程序频繁更新坐标manual
- :你自己控制刷新(可调用 connector.invalidate() 或 connector.update())
协同场景建议开启节流减少压力:
`ts`
const edge = new Connector(app, {
from: a,
to: b,
updateMode: "render",
renderThrottleMs: 16,
});
你可以用 routeOptions 调整正交 smart-route 的取舍(更偏好绕开/更偏好 S-route 等):
`ts`
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" 时生效。
- 你可能没有设置 routeType: "bezier"(默认是 "orthogonal")routeOptions.bezierFallbackDistance
- 或者你显式把 设置成较大值(例如 140),导致近距离自动降级为正交圆角
本组件内部会把路由计算与绘制统一在 Connector 的 local 坐标,并在 onDraw 回调里对外提供 world 坐标,已避免常见的坐标系漂移问题。 onDraw
如果你在 返回自定义 path,务必返回 world 坐标 path(组件会自动转回 local)。
- 导出:Connector 以及相关类型(见 src/types.ts)
本包默认输出 双产物:
- ESM:dist/esmdist/cjs
- CJS:(.cjs 后缀)
可选:Rollup 生产 bundle(更适合做体积检查/发布前 smoke test):
`bash`
pnpm run bundle:rollup
产物会输出到 dist/bundle/(含 .min.)。
发布前:
`bash`
pnpm run build
npm publish
- 本包是 ESM(type: module),发布时会输出到 dist/(含类型声明)leafer-editor` 作为 peerDependency,需要由业务侧安装与锁定版本
-