前端使用 Konva 实现可视化设计器(12)- 连接线 - 直线
这一章实现的连接线,目前仅支持直线连接,为了能够不影响原有的其它功能,尝试了2、3个实现思路,最终实测这个实现方式目前来说最为合适了。
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~

相关定义
- 连接点

记录了连接点相关信息,并不作为素材而存在,仅记录信息,即导出导入的时候,并不会出现所谓的连接点节点。
它存放在节点身上,因此导出、导入自然而然就可以持久化了。
src/Render/draws/LinkDraw.ts
// 连接点
export interface LinkDrawPoint {
id: string
groupId: string
visible: boolean
pairs: LinkDrawPair[]
x: number
y: number
}
- 连接对

一个连接点,记录从该点出发的多条连接线信息,作为连接对信息存在。
src/Render/draws/LinkDraw.ts
// 连接对
export interface LinkDrawPair {
id: string
from: {
groupId: string
pointId: string
}
to: {
groupId: string
pointId: string
}
}
- 连接点(锚点)

它是拖入素材的时候生成的真实节点,归属于所在的节点中,存在却不可见,关键作用是同步连接点真实坐标,尤其是节点发生 transform 时候,必须依赖它获得 transform 后连接点变化。
src/Render/handlers/DragOutsideHandlers.ts
// 略
drop: (e: GlobalEventHandlersEventMap['drop']) => {
// 略
const points = [
// 左
{ x: 0, y: group.height() / 2 },
// 右
{
x: group.width(),
y: group.height() / 2
},
// 上
{ x: group.width() / 2, y: 0 },
// 下
{
x: group.width() / 2,
y: group.height()
}
]
// 连接点信息
group.setAttrs({
points: points.map(
(o) =>
({
...o,
id: nanoid(),
groupId: group.id(),
visible: true,
pairs: []
}) as LinkDrawPoint
)
})
// 连接点(锚点)
for (const point of group.getAttr('points') ?? []) {
group.add(
new Konva.Circle({
name: 'link-anchor',
id: point.id,
x: point.x,
y: point.y,
radius: this.render.toStageValue(1),
stroke: 'rgba(0,0,255,1)',
strokeWidth: this.render.toStageValue(2),
visible: false
})
)
}
group.on('mouseenter', () => {
// 显示 连接点
this.render.linkTool.pointsVisible(true, group)
})
// hover 框(多选时才显示)
group.add(
new Konva.Rect({
id: 'hoverRect',
width: image.width(),
height: image.height(),
fill: 'rgba(0,255,0,0.3)',
visible: false
})
)
group.on('mouseleave', () => {
// 隐藏 连接点
this.render.linkTool.pointsVisible(false, group)
// 隐藏 hover 框
group.findOne('#hoverRect')?.visible(false)
})
// 略
}
// 略
- 连接线

根据连接点信息,绘制的线条,也不作为素材而存在,导出导入的时候,也不会出现所谓的连接点节点。不过,在导出图片、SVG和用于预览框的时候,会直接利用线条节点导出、显示。
src/Render/tools/ImportExportTool.ts
// 略
/**
* 获得显示内容
* @param withLink 是否包含线条
* @returns
*/
getView(withLink: boolean = false) {
// 复制画布
const copy = this.render.stage.clone()
// 提取 main layer 备用
const main = copy.find('#main')[0] as Konva.Layer
const cover = copy.find('#cover')[0] as Konva.Layer
// 暂时清空所有 layer
copy.removeChildren()
// 提取节点
let nodes = main.getChildren((node) => {
return !this.render.ignore(node)
})
if (withLink) {
nodes = nodes.concat(
cover.getChildren((node) => {
return node.name() === Draws.LinkDraw.name
})
)
}
// 略
}
// 略
src/Render/draws/PreviewDraw.ts
override draw() {
// 略
const main = this.render.stage.find('#main')[0] as Konva.Layer
const cover = this.render.stage.find('#cover')[0] as Konva.Layer
// 提取节点
const nodes = [
...main.getChildren((node) => {
return !this.render.ignore(node)
}),
// 补充连线
...cover.getChildren((node) => {
return node.name() === Draws.LinkDraw.name
})
]
// 略
}
- 连接线(临时)

起点鼠标按下 -> 拖动显示线条 -> 终点鼠标释放 -> 产生连接信息 LinkDrawPoint. LinkDrawPair
// 连接线(临时)
export interface LinkDrawState {
linkingLine: {
group: Konva.Group
circle: Konva.Circle
line: Konva.Line
} | null
}
代码文件
新增几个关键的代码文件:
src/Render/draws/LinkDraw.ts
根据 连接点.链接对 绘制 连接点、连接线,及其相关的事件处理
它的绘制顺序,应该放在绘制 比例尺、预览框之前。
src/Render/handlers/LinkHandlers.ts
根据 连接线(临时)信息,绘制/移除 连接线(临时)
src/Render/tools/LinkTool.ts
移除连接线,控制 连接点 的显示/隐藏
移除连接线,实际上就是移除其 连接对 信息
// 略
export class LinkTool {
// 略
pointsVisible(visible: boolean, group?: Konva.Group) {
if (group) {
this.pointsVisibleEach(visible, group)
} else {
const groups = this.render.layer.find('.asset') as Konva.Group[]
for (const group of groups) {
this.pointsVisibleEach(visible, group)
}
}
// 更新连线
this.render.draws[Draws.LinkDraw.name].draw()
// 更新预览
this.render.draws[Draws.PreviewDraw.name].draw()
}
remove(line: Konva.Line) {
const { groupId, pointId, pairId } = line.getAttrs()
if (groupId && pointId && pairId) {
const group = this.render.layer.findOne(`#${groupId}`) as Konva.Group
if (group) {
const points = (group.getAttr('points') ?? []) as LinkDrawPoint[]
const point = points.find((o) => o.id === pointId)
if (point) {
const pairIndex = (point.pairs ?? ([] as LinkDrawPair[])).findIndex(
(o) => o.id === pairId
)
if (pairIndex > -1) {
point.pairs.splice(pairIndex, 1)
group.setAttr('points', points)
// 更新连线
this.render.draws[Draws.LinkDraw.name].draw()
// 更新预览
this.render.draws[Draws.PreviewDraw.name].draw()
}
}
}
}
}
}
关键逻辑
- 绘制 连接线(临时)

src/Render/draws/LinkDraw.ts
起点鼠标按下 'mousedown' -> 略 -> 终点鼠标释放 'mouseup'
// 略
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
this.clear()
// stage 状态
const stageState = this.render.getStageState()
const groups = this.render.layer.find('.asset') as Konva.Group[]
const points = groups.reduce((ps, group) => {
return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
}, [] as LinkDrawPoint[])
const pairs = points.reduce((ps, point) => {
return ps.concat(point.pairs ? point.pairs : [])
}, [] as LinkDrawPair[])
// 略
// 连接点
for (const point of points) {
const group = groups.find((o) => o.id() === point.groupId)
// 非 选择中
if (group && !group.getAttr('selected')) {
const anchor = this.render.layer.findOne(`#${point.id}`)
if (anchor) {
const circle = new Konva.Circle({
id: point.id,
groupId: group.id(),
x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
radius: this.render.toStageValue(this.option.size),
stroke: 'rgba(255,0,0,0.2)',
strokeWidth: this.render.toStageValue(1),
name: 'link-point',
opacity: point.visible ? 1 : 0
})
// 略
circle.on('mousedown', () => {
this.render.selectionTool.selectingClear()
const pos = this.render.stage.getPointerPosition()
if (pos) {
// 临时 连接线 画
this.state.linkingLine = {
group: group,
circle: circle,
line: new Konva.Line({
name: 'linking-line',
points: _.flatten([
[circle.x(), circle.y()],
[
this.render.toStageValue(pos.x - stageState.x),
this.render.toStageValue(pos.y - stageState.y)
]
]),
stroke: 'blue',
strokeWidth: 1
})
}
this.layer.add(this.state.linkingLine.line)
}
})
// 略
}
}
}
}
src/Render/handlers/LinkHandlers.ts
拖动显示线条、移除 连接线(临时)
从起点到鼠标当前位置
handlers = {
stage: {
mouseup: () => {
const linkDrawState = (this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state
// 临时 连接线 移除
linkDrawState.linkingLine?.line.remove()
linkDrawState.linkingLine = null
},
mousemove: () => {
const linkDrawState = (this.render.draws[Draws.LinkDraw.name] as Draws.LinkDraw).state
const pos = this.render.stage.getPointerPosition()
if (pos) {
// stage 状态
const stageState = this.render.getStageState()
// 临时 连接线 画
if (linkDrawState.linkingLine) {
const { circle, line } = linkDrawState.linkingLine
line.points(
_.flatten([
[circle.x(), circle.y()],
[
this.render.toStageValue(pos.x - stageState.x),
this.render.toStageValue(pos.y - stageState.y)
]
])
)
}
}
}
}
}
- 产生连接信息
src/Render/draws/LinkDraw.ts
// 略
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
this.clear()
// stage 状态
const stageState = this.render.getStageState()
const groups = this.render.layer.find('.asset') as Konva.Group[]
const points = groups.reduce((ps, group) => {
return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
}, [] as LinkDrawPoint[])
const pairs = points.reduce((ps, point) => {
return ps.concat(point.pairs ? point.pairs : [])
}, [] as LinkDrawPair[])
// 略
// 连接点
for (const point of points) {
const group = groups.find((o) => o.id() === point.groupId)
// 非 选择中
if (group && !group.getAttr('selected')) {
const anchor = this.render.layer.findOne(`#${point.id}`)
if (anchor) {
const circle = new Konva.Circle({
id: point.id,
groupId: group.id(),
x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
radius: this.render.toStageValue(this.option.size),
stroke: 'rgba(255,0,0,0.2)',
strokeWidth: this.render.toStageValue(1),
name: 'link-point',
opacity: point.visible ? 1 : 0
})
// 略
circle.on('mouseup', () => {
if (this.state.linkingLine) {
const line = this.state.linkingLine
// 不同连接点
if (line.circle.id() !== circle.id()) {
const toGroup = groups.find((o) => o.id() === circle.getAttr('groupId'))
if (toGroup) {
const fromPoints = (
Array.isArray(line.group.getAttr('points')) ? line.group.getAttr('points') : []
) as LinkDrawPoint[]
const fromPoint = fromPoints.find((o) => o.id === line.circle.id())
if (fromPoint) {
const toPoints = (
Array.isArray(toGroup.getAttr('points')) ? toGroup.getAttr('points') : []
) as LinkDrawPoint[]
const toPoint = toPoints.find((o) => o.id === circle.id())
if (toPoint) {
if (Array.isArray(fromPoint.pairs)) {
fromPoint.pairs = [
...fromPoint.pairs,
{
id: nanoid(),
from: {
groupId: line.group.id(),
pointId: line.circle.id()
},
to: {
groupId: circle.getAttr('groupId'),
pointId: circle.id()
}
}
]
}
// 更新历史
this.render.updateHistory()
this.draw()
// 更新预览
this.render.draws[Draws.PreviewDraw.name].draw()
}
}
}
}
// 临时 连接线 移除
this.state.linkingLine?.line.remove()
this.state.linkingLine = null
}
})
this.group.add(circle)
}
// 略
}
}
}
}
- 绘制 连接线

src/Render/draws/LinkDraw.ts
这里就是利用了上面提到的 连接点(锚点),通过它的 absolutePosition 获得真实位置。
// 略
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
this.clear()
// stage 状态
const stageState = this.render.getStageState()
const groups = this.render.layer.find('.asset') as Konva.Group[]
const points = groups.reduce((ps, group) => {
return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
}, [] as LinkDrawPoint[])
const pairs = points.reduce((ps, point) => {
return ps.concat(point.pairs ? point.pairs : [])
}, [] as LinkDrawPair[])
// 连接线
for (const pair of pairs) {
const fromGroup = groups.find((o) => o.id() === pair.from.groupId)
const fromPoint = points.find((o) => o.id === pair.from.pointId)
const toGroup = groups.find((o) => o.id() === pair.to.groupId)
const toPoint = points.find((o) => o.id === pair.to.pointId)
if (fromGroup && toGroup && fromPoint && toPoint) {
const fromAnchor = this.render.layer.findOne(`#${fromPoint.id}`)
const toAnchor = this.render.layer.findOne(`#${toPoint.id}`)
if (fromAnchor && toAnchor) {
const line = new Konva.Line({
name: 'link-line',
// 用于删除连接线
groupId: fromGroup.id(),
pointId: fromPoint.id,
pairId: pair.id,
//
points: _.flatten([
[
this.render.toStageValue(fromAnchor.absolutePosition().x - stageState.x),
this.render.toStageValue(fromAnchor.absolutePosition().y - stageState.y)
],
[
this.render.toStageValue(toAnchor.absolutePosition().x - stageState.x),
this.render.toStageValue(toAnchor.absolutePosition().y - stageState.y)
]
]),
stroke: 'red',
strokeWidth: 2
})
this.group.add(line)
// 连接线 hover 效果
line.on('mouseenter', () => {
line.stroke('rgba(255,0,0,0.6)')
document.body.style.cursor = 'pointer'
})
line.on('mouseleave', () => {
line.stroke('red')
document.body.style.cursor = 'default'
})
}
}
}
// 略
}
}
- 绘制 连接点
src/Render/draws/LinkDraw.ts
// 略
export class LinkDraw extends Types.BaseDraw implements Types.Draw {
// 略
override draw() {
this.clear()
// stage 状态
const stageState = this.render.getStageState()
const groups = this.render.layer.find('.asset') as Konva.Group[]
const points = groups.reduce((ps, group) => {
return ps.concat(Array.isArray(group.getAttr('points')) ? group.getAttr('points') : [])
}, [] as LinkDrawPoint[])
const pairs = points.reduce((ps, point) => {
return ps.concat(point.pairs ? point.pairs : [])
}, [] as LinkDrawPair[])
// 略
// 连接点
for (const point of points) {
const group = groups.find((o) => o.id() === point.groupId)
// 非 选择中
if (group && !group.getAttr('selected')) {
const anchor = this.render.layer.findOne(`#${point.id}`)
if (anchor) {
const circle = new Konva.Circle({
id: point.id,
groupId: group.id(),
x: this.render.toStageValue(anchor.absolutePosition().x - stageState.x),
y: this.render.toStageValue(anchor.absolutePosition().y - stageState.y),
radius: this.render.toStageValue(this.option.size),
stroke: 'rgba(255,0,0,0.2)',
strokeWidth: this.render.toStageValue(1),
name: 'link-point',
opacity: point.visible ? 1 : 0
})
// hover 效果
circle.on('mouseenter', () => {
circle.stroke('rgba(255,0,0,0.5)')
circle.opacity(1)
document.body.style.cursor = 'pointer'
})
circle.on('mouseleave', () => {
circle.stroke('rgba(255,0,0,0.2)')
circle.opacity(0)
document.body.style.cursor = 'default'
})
// 略
}
}
}
}
- 复制
有几个关键:
- 更新 id,包括:节点、连接点、锚点、连接对
- 重新绑定相关事件
src/Render/tools/CopyTool.ts
// 略
export class CopyTool {
// 略
/**
* 复制粘贴
* @param nodes 节点数组
* @param skip 跳过检查
* @returns 复制的元素
*/
copy(nodes: Konva.Node[]) {
const clones: Konva.Group[] = []
for (const node of nodes) {
if (node instanceof Konva.Transformer) {
// 复制已选择
const backup = [...this.render.selectionTool.selectingNodes]
this.render.selectionTool.selectingClear()
this.copy(backup)
return
} else {
// 复制未选择(先记录,后处理)
clones.push(node.clone())
}
}
// 处理克隆节点
// 新旧 id 映射
const groupIdChanges: { [index: string]: string } = {}
const pointIdChanges: { [index: string]: string } = {}
// 新 id、新事件
for (const copy of clones) {
const gid = nanoid()
groupIdChanges[copy.id()] = gid
copy.id(gid)
const pointsClone = _.cloneDeep(copy.getAttr('points') ?? [])
copy.setAttr('points', pointsClone)
for (const point of pointsClone) {
const pid = nanoid()
pointIdChanges[point.id] = pid
const anchor = copy.findOne(`#${point.id}`)
anchor?.id(pid)
point.id = pid
point.groupId = copy.id()
point.visible = false
}
copy.off('mouseenter')
copy.on('mouseenter', () => {
// 显示 连接点
this.render.linkTool.pointsVisible(true, copy)
})
copy.off('mouseleave')
copy.on('mouseleave', () => {
// 隐藏 连接点
this.render.linkTool.pointsVisible(false, copy)
// 隐藏 hover 框
copy.findOne('#hoverRect')?.visible(false)
})
// 使新节点产生偏移
copy.setAttrs({
x: copy.x() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
y: copy.y() + this.render.toStageValue(this.render.bgSize) * this.pasteCount
})
}
// pairs 新 id
for (const copy of clones) {
const points = copy.getAttr('points') ?? []
for (const point of points) {
for (const pair of point.pairs) {
// id 换新
pair.id = nanoid()
pair.from.groupId = groupIdChanges[pair.from.groupId]
pair.from.pointId = pointIdChanges[pair.from.pointId]
pair.to.groupId = groupIdChanges[pair.to.groupId]
pair.to.pointId = pointIdChanges[pair.to.pointId]
}
}
}
// 略
}
}
接下来,计划实现下面这些功能:
- 连接线 - 折线(头疼)
- 等等。。。
More Stars please!勾勾手指~
前端使用 Konva 实现可视化设计器(12)- 连接线 - 直线的更多相关文章
- 惊闻企业Web应用生成平台 活字格 V4.0 免费了,不单可视化设计器免费,服务器也免费!
官网消息: 针对活字格开发者,新版本完全免费!您可下载活字格 Web 应用生成平台 V4.0 Updated 1,方便的创建各类 Web 应用系统,任意部署,永不过期. 我之前学习过活字格,也曾经向用 ...
- (原创)【B4A】一步一步入门02:可视化界面设计器、控件的使用
一.前言 上篇 (原创)[B4A]一步一步入门01:简介.开发环境搭建.HelloWorld 中我们创建了默认的项目,现在我们来看一下B4A项目的构成,以及如何所见即所得的设计界面,并添加和使用自带的 ...
- Windows Phone 十二、设计器同步
在设计阶段为页面添加数据源 Blend或者VS的可视化设计器会跑我们的代码,然后来显示出来,当我们Build之后,设计器会进入页面的构造函数,调用InitializeComponent();方法来将U ...
- WinForms项目升级.Net Core 3.0之后,没有WinForm设计器?
目录 .NET Conf 2019 Window Forms 设计器 .NET Conf 2019 2019 9.23-9.25召开了 .NET Conf 2019 大会,大会宣布了 .Net Cor ...
- ActiveReports 9 新功能:可视化查询设计器(VQD)介绍
在最新发布的ActiveReports 9报表控件中添加了多项新功能,以帮助你在更短的时间里创建外观绚丽.功能强大的报表系统,本文将重点介绍可视化数据查询设计器,无需手动编写任何SQL语句,主要内容如 ...
- VS2015 android 设计器不能可视化问题解决。
近期安装了VS2015,体验了一下android 的开发,按模板创建执行了个,试下效果非常不错.也能够可视化设计.但昨天再次打开或创建一个android程序后,设计界面直接不能显示,显示错误:(可能是 ...
- 可视化流程设计——流程设计器演示(基于Silverlight)
上一篇文章<通用流程设计>对鄙人写的通用流程做了一定的介绍,并奉上了相关源码.但一个好的流程设计必少不了流程设计器的支持,本文将针对<通用流程设计>中的流程的设计器做一个简单的 ...
- 解析大型.NET ERP系统核心组件 查询设计器 报表设计器 窗体设计器 工作流设计器 任务计划设计器
企业管理软件包含一些公共的组件,这些基础的组件在每个新项目立项阶段就必须考虑.核心的稳定不变功能,方便系统开发与维护,也为系统二次开发提供了诸多便利.比如通用权限管理系统,通用附件管理,通用查询等组件 ...
- F2工作流引擎之-纯JS Web在线可拖拽的流程设计器(八)
Web纯JS流程设计器无需编程,完全是通过鼠标拖.拉.拽的方式来完成,支持串行.并行.分支.异或分支.M取N路分支.会签.聚合.多重聚合.退回.传阅.转交,都可以非常方便快捷地实现,管理员 ...
- 纯JS Web在线可拖拽的流程设计器
F2工作流引擎之-纯JS Web在线可拖拽的流程设计器 Web纯JS流程设计器无需编程,完全是通过鼠标拖.拉.拽的方式来完成,支持串行.并行.分支.异或分支.M取N路分支.会签.聚合.多重聚合.退回. ...
随机推荐
- Deep Learning on Graphs: A Survey第五章自动编码论文总结
论文地址:https://arxiv.org/pdf/1812.04202.pdf 最近老师让我们读的一片论文,已经开组会讲完了,我负责的是第五章,图的自动编码,现在再总结一遍,便于后者研读.因为这篇 ...
- Pytorch-tensor的感知机,链式法则
1.单层感知机 单层感知机的主要步骤: 1.对数据进行一个权重的累加求和,求得∑ 2.将∑经过一个激活函数Sigmoid,得出值O 3.再将O,经过一个损失函数mse_loss,得出值loss 4.根 ...
- 动手实现自己的http服务器【精简版】
1 package v2; 2 3 import java.io.IOException; 4 import java.io.OutputStream; 5 import java.io.PrintS ...
- HarmonyOS NEXT应用开发案例——自定义TabBar
介绍 本示例主要介绍了TabBar中间页面如何实现有一圈圆弧外轮廓以及TabBar页签被点击之后会改变图标显示,并有一小段动画效果. 效果图预览 使用说明: 依次点击tabBar页面,除了社区图标之外 ...
- 无缝衔接 gRPC 与 dubbo-go
最近我们 dubbo-go 社区里面,呼声很大的一个 feature 就是对 gRPC 的支持.在某位大佬的不懈努力之下,终于弄出来了. 今天我就给大家分析一下大佬是怎么连接 dubbo-go 和 g ...
- Serverless Devs 的官网是如何通过 Serverless Devs 部署的
简介: 只有自己吃自己的狗粮,自己做的东西才不"".Serverless Devs 自发展之处到现在,已经经历了几个月的时间,在这几个月,Serverless Devs 的成长是迅 ...
- 唯品会:在 Flink 容器化与平台化上的建设实践
简介: 唯品会 Flink 的容器化实践应用,Flink SQL 平台化建设,以及在实时数仓和实验平台上的应用案例. 转自dbaplus社群公众号作者:王康,唯品会数据平台高级开发工程师 自 2017 ...
- 一文总结Java\JDK 17发布的新特性
简介: JDK 17已经于2021年3月16日如期发布.本文介绍JDK 17新特性.JDK 17于2021年9月14日正式发布(General-Availability Release).JDK 1 ...
- [Contract] openzeppelin/cli 开发, 部署, 升级智能合约
Install Dependency $ npm init $ npm install @openzeppelin/cli Setup project $ npx openzeppelin init ...
- Java面试题:你知道Spring的IOC吗?那么,它为什么这么重要呢?
Spring的IOC(控制反转)是一种设计模式,它允许开发者将对象的创建和管理交给Spring框架来完成.在Spring中,IOC允许开发者将对象依赖关系从代码中分离出来,从而使代码更加灵活.可重用和 ...