這一章實現的連線線,目前僅支援直線連線,為了能夠不影響原有的其它功能,嘗試了2、3個實現思路,最終實測這個實現方式目前來說最為合適了。
請大家動動小手,給我一個免費的 Star 吧~
大家如果發現了 Bug,歡迎來提 Issue 喲~
github原始碼
gitee原始碼
示例地址
相關定義
- 連線點
記錄了連線點相關資訊,並不作為素材而存在,僅記錄資訊,即匯出匯入的時候,並不會出現所謂的連線點節點。
它存放在節點身上,因此匯出、匯入自然而然就可以持久化了。
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!勾勾手指~
原始碼
gitee原始碼
示例地址