前端使用 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!勾勾手指~