这一章实现的连接线,目前仅支持直线连接,为了能够不影响原有的其它功能,尝试了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!勾勾手指~