請大家動動小手,給我一個免費的 Star 吧~
這一章處理一下複製、貼上、刪除、畫布歸位、層次調整,透過右鍵選單控制。
github原始碼
gitee原始碼
示例地址
複製貼上
複製貼上(透過快捷鍵)
// 複製暫存
pasteCache: Konva.Node[] = [];
// 貼上次數(用於定義新節點的偏移距離)
pasteCount = 1;
// 複製
pasteStart() {
this.pasteCache = this.render.selectionTool.selectingNodes.map((o) => {
const copy = o.clone();
// 恢復透明度、可互動
copy.setAttrs({
listening: true,
opacity: copy.attrs.lastOpacity ?? 1,
});
// 清空狀態
copy.setAttrs({
nodeMousedownPos: undefined,
lastOpacity: undefined,
lastZIndex: undefined,
selectingZIndex: undefined,
});
return copy;
});
this.pasteCount = 1;
}
// 貼上
pasteEnd() {
if (this.pasteCache.length > 0) {
this.render.selectionTool.selectingClear();
this.copy(this.pasteCache);
this.pasteCount++;
}
}
快捷鍵處理:
keydown: (e: GlobalEventHandlersEventMap['keydown']) => {
if (e.ctrlKey) {
if (e.code === Types.ShutcutKey.C) {
this.render.copyTool.pasteStart() // 複製
} else if (e.code === Types.ShutcutKey.V) {
this.render.copyTool.pasteEnd() // 貼上
}
}
}
}
邏輯比較簡單,可以關注程式碼中的註釋。
複製貼上(右鍵)
/**
* 複製貼上
* @param nodes 節點陣列
* @param skip 跳過檢查
* @returns 複製的元素
*/
copy(nodes: Konva.Node[]) {
const arr: Konva.Node[] = [];
for (const node of nodes) {
if (node instanceof Konva.Transformer) {
// 複製已選擇
const backup = [...this.render.selectionTool.selectingNodes];
this.render.selectionTool.selectingClear();
this.copy(backup);
} else {
// 複製未選擇
const copy = node.clone();
// 使新節點產生偏移
copy.setAttrs({
x: copy.x() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
y: copy.y() + this.render.toStageValue(this.render.bgSize) * this.pasteCount,
});
// 插入新節點
this.render.layer.add(copy);
// 選中複製內容
this.render.selectionTool.select([...this.render.selectionTool.selectingNodes, copy]);
}
}
return arr;
}
邏輯比較簡單,可以關注程式碼中的註釋。
刪除
處理方法:
// 移除元素
remove(nodes: Konva.Node[]) {
for (const node of nodes) {
if (node instanceof Konva.Transformer) {
// 移除已選擇的節點
this.remove(this.selectionTool.selectingNodes);
// 清除選擇
this.selectionTool.selectingClear();
} else {
// 移除未選擇的節點
node.remove();
}
}
}
事件處理:
keydown: (e: GlobalEventHandlersEventMap['keydown']) => {
if (e.ctrlKey) {
// 略
} else if (e.code === Types.ShutcutKey.刪除) {
this.render.remove(this.render.selectionTool.selectingNodes)
}
}
畫布歸位
邏輯比較簡單,恢復畫布比例和偏移量:
// 恢復位置大小
positionZoomReset() {
this.render.stage.setAttrs({
scale: { x: 1, y: 1 }
})
this.positionReset()
}
// 恢復位置
positionReset() {
this.render.stage.setAttrs({
x: this.render.rulerSize,
y: this.render.rulerSize
})
// 更新背景
this.render.draws[Draws.BgDraw.name].draw()
// 更新比例尺
this.render.draws[Draws.RulerDraw.name].draw()
// 更新參考線
this.render.draws[Draws.RefLineDraw.name].draw()
}
稍微說明一下,初始位置需要考慮比例尺的大小。
層次調整
關於層次的調整,相對比較晦澀。
一些輔助方法
獲取需要處理的節點,主要是處理 transformer 內部的節點:
// 獲取移動節點
getNodes(nodes: Konva.Node[]) {
const targets: Konva.Node[] = []
for (const node of nodes) {
if (node instanceof Konva.Transformer) {
// 已選擇的節點
targets.push(...this.render.selectionTool.selectingNodes)
} else {
// 未選擇的節點
targets.push(node)
}
}
return targets
}
獲得計算所需的最大、最小 zIndex:
// 最大 zIndex
getMaxZIndex() {
return Math.max(
...this.render.layer
.getChildren((node) => {
return !this.render.ignore(node)
})
.map((o) => o.zIndex())
)
}
// 最小 zIndex
getMinZIndex() {
return Math.min(
...this.render.layer
.getChildren((node) => {
return !this.render.ignore(node)
})
.map((o) => o.zIndex())
)
}
記錄選擇之前的 zIndex
由於被選擇的節點會被臨時置頂,會影響節點層次的調整,所以選擇之前需要記錄一下選擇之前的 zIndex:
// 更新 zIndex 快取
updateLastZindex(nodes: Konva.Node[]) {
for (const node of nodes) {
node.setAttrs({
lastZIndex: node.zIndex()
})
}
}
處理 transformer 的置頂影響
透過 transformer 選擇的時候,所選節點的層次已經被置頂。
所以調整時需要有個步驟:
- 記錄已經被 transformer 影響的每個節點的 zIndex(其實就是記錄置頂狀態)
- 調整節點的層次
- 恢復被 transformer 選擇的節點的 zIndex(其實就是恢復置頂狀態)
舉例子:
現在有節點:
A/1 B/2 C/3 D/4 E/5 F/6 G/7
記錄選擇 C D E 之前的 lastZIndex:C/3 D/4 E/5
選擇後,“臨時置頂” C D E:
A/1 B/2 F/3 G/4 C/5 D/6 E/7
此時置底了 C D E,由於上面記錄了選擇之前的 lastZIndex,直接計算 lastZIndex,變成 C/1 D/2 E/3
在 selectingClear 的時候,會根據 lastZIndex 讓 zIndex 的調整生效:
逐步變化:
0、A/1 B/2 F/3 G/4 C/5 D/6 E/7 改變 C/5 -> C/1
1、C/1 A/2 B/3 F/4 G/5 D/6 E/7 改變 D/6 -> D/2
2、C/1 D/2 A/3 B/4 F/5 G/6 E/7 改變 E/7 -> E/3
3、C/1 D/2 E/3 A/4 B/5 F/6 G/7 完成調整因為 transformer 的存在,調整完還要恢復原來的“臨時置頂”:
A/1 B/2 F/3 G/4 C/5 D/6 E/7
下面是記錄選擇之前的 zIndex 狀態、恢復調整之後的 zIndex 狀態的方法:
// 記錄選擇期間的 zIndex
updateSelectingZIndex(nodes: Konva.Node[]) {
for (const node of nodes) {
node.setAttrs({
selectingZIndex: node.zIndex()
})
}
}
// 恢復選擇期間的 zIndex
resetSelectingZIndex(nodes: Konva.Node[]) {
nodes.sort((a, b) => a.zIndex() - b.zIndex())
for (const node of nodes) {
node.zIndex(node.attrs.selectingZIndex)
}
}
關於 zIndex 的調整
主要分兩種情況:已選的節點、未選的節點
- 已選:如上面所說,調整之餘,還要處理 transformer 的置頂影響
- 未選:直接調整即可
// 上移
up(nodes: Konva.Node[]) {
// 最大zIndex
const maxZIndex = this.getMaxZIndex()
const sorted = this.getNodes(nodes).sort((a, b) => b.zIndex() - a.zIndex())
// 上移
let lastNode: Konva.Node | null = null
if (this.render.selectionTool.selectingNodes.length > 0) {
this.updateSelectingZIndex(sorted)
for (const node of sorted) {
if (
node.attrs.lastZIndex < maxZIndex &&
(lastNode === null || node.attrs.lastZIndex < lastNode.attrs.lastZIndex - 1)
) {
node.setAttrs({
lastZIndex: node.attrs.lastZIndex + 1
})
}
lastNode = node
}
this.resetSelectingZIndex(sorted)
} else {
// 直接調整
for (const node of sorted) {
if (
node.zIndex() < maxZIndex &&
(lastNode === null || node.zIndex() < lastNode.zIndex() - 1)
) {
node.zIndex(node.zIndex() + 1)
}
lastNode = node
}
this.updateLastZindex(sorted)
}
}
直接舉例子(忽略 transformer 的置頂影響):
現在有節點:
A/1 B/2 C/3 D/4 E/5 F/6 G/7,上移 D F
執行一次:
移動F,A/1 B/2 C/3 D/4 E/5 G/6 F/7
移動D,A/1 B/2 C/3 E/4 D/5 G/6 F/7
再執行一次:
移動F,已經到頭了,不變,A/1 B/2 C/3 E/4 D/5 G/6 F/7
移動D,A/1 B/2 C/3 E/4 G/5 D/6 F/7
再執行一次:
移動F,已經到尾了,不變,A/1 B/2 C/3 E/4 G/5 D/6 F/7
移動D,已經貼著 F 了,為了保持 D F 的相對順序,也不變,A/1 B/2 C/3 E/4 G/5 D/6 F/7
結束
// 下移
down(nodes: Konva.Node[]) {
// 最小 zIndex
const minZIndex = this.getMinZIndex()
const sorted = this.getNodes(nodes).sort((a, b) => a.zIndex() - b.zIndex())
// 下移
let lastNode: Konva.Node | null = null
if (this.render.selectionTool.selectingNodes.length > 0) {
this.updateSelectingZIndex(sorted)
for (const node of sorted) {
if (
node.attrs.lastZIndex > minZIndex &&
(lastNode === null || node.attrs.lastZIndex > lastNode.attrs.lastZIndex + 1)
) {
node.setAttrs({
lastZIndex: node.attrs.lastZIndex - 1
})
}
lastNode = node
}
this.resetSelectingZIndex(sorted)
} else {
// 直接調整
for (const node of sorted) {
if (
node.zIndex() > minZIndex &&
(lastNode === null || node.zIndex() > lastNode.zIndex() + 1)
) {
node.zIndex(node.zIndex() - 1)
}
lastNode = node
}
this.updateLastZindex(sorted)
}
}
直接舉例子(忽略 transformer 的置頂影響):
現在有節點:
A/1 B/2 C/3 D/4 E/5 F/6 G/7,下移 B D
執行一次:
移動B,B/1 A/2 C/3 D/4 E/5 F/6 G/7
移動D,B/1 A/2 D/3 C/4 E/5 F/6 G/7
再執行一次:
移動B,已經到頭了,不變,B/1 A/2 D/3 C/4 E/5 F/6 G/7
移動D,B/1 D/2 A/3 C/4 E/5 F/6 G/7
再執行一次:
移動B,已經到頭了,不變,B/1 D/2 A/3 C/4 E/5 F/6 G/7
移動D,已經貼著 B 了,為了保持 B D 的相對順序,也不變,B/1 D/2 A/3 C/4 E/5 F/6 G/7
結束
// 置頂
top(nodes: Konva.Node[]) {
// 最大 zIndex
let maxZIndex = this.getMaxZIndex()
const sorted = this.getNodes(nodes).sort((a, b) => b.zIndex() - a.zIndex())
if (this.render.selectionTool.selectingNodes.length > 0) {
// 先選中再調整
this.updateSelectingZIndex(sorted)
// 置頂
for (const node of sorted) {
node.setAttrs({
lastZIndex: maxZIndex--
})
}
this.resetSelectingZIndex(sorted)
} else {
// 直接調整
for (const node of sorted) {
node.zIndex(maxZIndex)
}
this.updateLastZindex(sorted)
}
}
從高到低,逐個移動,每次移動遞減 1
// 置底
bottom(nodes: Konva.Node[]) {
// 最小 zIndex
let minZIndex = this.getMinZIndex()
const sorted = this.getNodes(nodes).sort((a, b) => a.zIndex() - b.zIndex())
if (this.render.selectionTool.selectingNodes.length > 0) {
// 先選中再調整
this.updateSelectingZIndex(sorted)
// 置底
for (const node of sorted) {
node.setAttrs({
lastZIndex: minZIndex++
})
}
this.resetSelectingZIndex(sorted)
} else {
// 直接調整
for (const node of sorted) {
node.zIndex(minZIndex)
}
this.updateLastZindex(sorted)
}
}
從低到高,逐個移動,每次移動遞增 1
調整 zIndex 的思路比較個性化,所以晦澀。要符合 konva 的 zIndex 特定,且達到目的,演算法可以自行調整。
右鍵選單
事件處理
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
this.state.lastPos = this.render.stage.getPointerPosition()
if (e.evt.button === Types.MouseButton.左鍵) {
if (!this.state.menuIsMousedown) {
// 沒有按下選單,清除選單
this.state.target = null
this.draw()
}
} else if (e.evt.button === Types.MouseButton.右鍵) {
// 右鍵按下
this.state.right = true
}
},
mousemove: () => {
if (this.state.target && this.state.right) {
// 拖動畫布時(右鍵),清除選單
this.state.target = null
this.draw()
}
},
mouseup: () => {
this.state.right = false
},
contextmenu: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['contextmenu']>) => {
const pos = this.render.stage.getPointerPosition()
if (pos && this.state.lastPos) {
// 右鍵目標
if (pos.x === this.state.lastPos.x || pos.y === this.state.lastPos.y) {
this.state.target = e.target
} else {
this.state.target = null
}
this.draw()
}
},
wheel: () => {
// 畫布縮放時,清除選單
this.state.target = null
this.draw()
}
邏輯說明都在註釋裡了,主要處理的是右鍵選單出現的位置,以及出現和消失的時機,最後是右鍵的目標。
override draw() {
this.clear()
if (this.state.target) {
// 選單陣列
const menus: Array<{
name: string
action: (e: Konva.KonvaEventObject<MouseEvent>) => void
}> = []
if (this.state.target === this.render.stage) {
// 空白處
menus.push({
name: '恢復位置',
action: () => {
this.render.positionTool.positionReset()
}
})
menus.push({
name: '恢復大小位置',
action: () => {
this.render.positionTool.positionZoomReset()
}
})
} else {
// 未選擇:真實節點,即素材的容器 group
// 已選擇:transformer
const target = this.state.target.parent
// 目標
menus.push({
name: '複製',
action: () => {
if (target) {
this.render.copyTool.copy([target])
}
}
})
menus.push({
name: '刪除',
action: () => {
if (target) {
this.render.remove([target])
}
}
})
menus.push({
name: '置頂',
action: () => {
if (target) {
this.render.zIndexTool.top([target])
}
}
})
menus.push({
name: '上一層',
action: () => {
if (target) {
this.render.zIndexTool.up([target])
}
}
})
menus.push({
name: '下一層',
action: () => {
if (target) {
this.render.zIndexTool.down([target])
}
}
})
menus.push({
name: '置底',
action: () => {
if (target) {
this.render.zIndexTool.bottom([target])
}
}
})
}
// stage 狀態
const stageState = this.render.getStageState()
// 繪製右鍵選單
const group = new Konva.Group({
name: 'contextmenu',
width: stageState.width,
height: stageState.height
})
let top = 0
// 選單每項高度
const lineHeight = 30
const pos = this.render.stage.getPointerPosition()
if (pos) {
for (const menu of menus) {
// 框
const rect = new Konva.Rect({
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y + top - stageState.y),
width: this.render.toStageValue(100),
height: this.render.toStageValue(lineHeight),
fill: '#fff',
stroke: '#999',
strokeWidth: this.render.toStageValue(1),
name: 'contextmenu'
})
// 標題
const text = new Konva.Text({
x: this.render.toStageValue(pos.x - stageState.x),
y: this.render.toStageValue(pos.y + top - stageState.y),
text: menu.name,
name: 'contextmenu',
listening: false,
fontSize: this.render.toStageValue(16),
fill: '#333',
width: this.render.toStageValue(100),
height: this.render.toStageValue(lineHeight),
align: 'center',
verticalAlign: 'middle'
})
group.add(rect)
group.add(text)
// 選單事件
rect.on('click', (e) => {
if (e.evt.button === Types.MouseButton.左鍵) {
// 觸發事件
menu.action(e)
// 移除選單
this.group.removeChildren()
this.state.target = null
}
e.evt.preventDefault()
e.evt.stopPropagation()
})
rect.on('mousedown', (e) => {
if (e.evt.button === Types.MouseButton.左鍵) {
this.state.menuIsMousedown = true
// 按下效果
rect.fill('#dfdfdf')
}
e.evt.preventDefault()
e.evt.stopPropagation()
})
rect.on('mouseup', (e) => {
if (e.evt.button === Types.MouseButton.左鍵) {
this.state.menuIsMousedown = false
}
})
rect.on('mouseenter', (e) => {
if (this.state.menuIsMousedown) {
rect.fill('#dfdfdf')
} else {
// hover in
rect.fill('#efefef')
}
e.evt.preventDefault()
e.evt.stopPropagation()
})
rect.on('mouseout', () => {
// hover out
rect.fill('#fff')
})
rect.on('contextmenu', (e) => {
e.evt.preventDefault()
e.evt.stopPropagation()
})
top += lineHeight - 1
}
}
this.group.add(group)
}
}
邏輯也不復雜,根據右鍵的目標分配相應的選單項
空白處:恢復位置、大小
節點:複製、刪除、上移、下移、置頂、置底
繪製右鍵選單
右鍵的目標有二種情況:空白處、單個/多選節點。
接下來,計劃實現下面這些功能:
- 實時預覽窗
- 匯出、匯入
- 對齊效果
- 等等。。。
是不是值得更多的 Star 呢?勾勾手指~
原始碼
gitee原始碼
示例地址