github/gitee Star 終於有幾個了!
從這章開始,難度算是(或者說細節較多)升級,是不是值得更多的 Star 呢?!
繼續求 Star ,希望大家多多一鍵三連,十分感謝大家的支援~
創作不易,Star 50 個,創作加速!
github原始碼
gitee原始碼
示例地址
選擇框
準備工作
想要拖動一個元素,可以考慮使用節點的 draggable 屬性。
不過,想要拖動多個元素,可以使用 transformer,官網也是簡單的示例 Basic demo。
按設計思路統一透過 transformer 移動/縮放所選,也意味著,元素要先選後動。
先準備一個 group、transformer、selectRect:
// 多選器層
groupTransformer: Konva.Group = new Konva.Group()
// 多選器
transformer: Konva.Transformer = new Konva.Transformer({
shouldOverdrawWholeArea: true,
borderDash: [4, 4],
padding: 1,
rotationSnaps: [0, 45, 90, 135, 180, 225, 270, 315, 360]
})
// 選擇框
selectRect: Konva.Rect = new Konva.Rect({
id: 'selectRect',
fill: 'rgba(0,0,255,0.1)',
visible: false
})
先說 transformer,設定 shouldOverdrawWholeArea 為了選擇所選的空白處也能拖動;rotationSnaps 就是官方提供的 rotate 時的磁貼互動。
然後,selectRect 就是選擇框,參考的就是上面提到的 Basic demo。
最後,上面的 group 比較特別,它承載了上面的 transformer 和 selectRect,且置於第一章提到的 layerCover。
// 輔助層 - 頂層
this.groupTransformer.add(this.transformer)
this.groupTransformer.add(this.selectRect)
this.layerCover.add(this.groupTransformer)
selectRect 不應該被“互動”,所以加個排查判斷:
// 忽略非素材
ignore(node: Konva.Node) {
// 素材有各自根 group
const isGroup = node instanceof Konva.Group
return !isGroup || node.id() === 'selectRect' || this.ignoreDraw(node)
}
選擇
準備一些狀態變數:
// selectRect 拉動的開始和結束座標
selectRectStartX = 0
selectRectStartY = 0
selectRectEndX = 0
selectRectEndY = 0
// 是否正在使用 selectRect
selecting = false
選擇開始,處理 stage 的 mousedown 事件:
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
// 略
if (e.target === this.render.stage) {
// 點選空白處
// 清除選擇
// 外部也需要此操作,統一放在 selectionTool中
// 後面會提到
this.render.selectionTool.selectingClear()
// 選擇框
if (e.evt.button === Types.MouseButton.左鍵) {
const pos = this.render.stage.getPointerPosition()
if (pos) {
// 初始化狀態值
this.selectRectStartX = pos.x
this.selectRectStartY = pos.y
this.selectRectEndX = pos.x
this.selectRectEndY = pos.y
}
// 初始化大小
this.render.selectRect.width(0)
this.render.selectRect.height(0)
// 開始選擇
this.selecting = true
}
} else if (parent instanceof Konva.Transformer) {
// transformer 點選事件交給 transformer 自己的 handler
} else if (parent instanceof Konva.Group) {
// 略
}
}
接著,處理 stage 的 mousemove 事件:
mousemove: () => {
// stage 狀態
const stageState = this.render.getStageState()
// 選擇框
if (this.selecting) {
// 選擇區域中
const pos = this.render.stage.getPointerPosition()
if (pos) {
// 選擇移動後的座標
this.selectRectEndX = pos.x
this.selectRectEndY = pos.y
}
// 調整【選擇框】的位置和大小
this.render.selectRect.setAttrs({
visible: true, // 顯示
x: this.render.toStageValue(
Math.min(this.selectRectStartX, this.selectRectEndX) - stageState.x
),
y: this.render.toStageValue(
Math.min(this.selectRectStartY, this.selectRectEndY) - stageState.y
),
width: this.render.toStageValue(Math.abs(this.selectRectEndX - this.selectRectStartX)),
height: this.render.toStageValue(Math.abs(this.selectRectEndY - this.selectRectStartY))
})
}
}
稍微說一下,調整【選擇框】的位置和大小,關於 toStageValue 可以看看上一章。 width 和 height 比較好理解,開始位置 和 結束位置 相減就可以得出。
x 和 y,需從 開始位置 和 結束位置 選數值小的作為【選擇框】的 rect 起點,最後要扣除 stage 的視覺位移,畢竟它們是放在 stage 裡面的,就是 相對位置 和 視覺位置 的轉換。
結束選擇,處理 stage 的 mouseup 事件:
mouseup: () => {
// 選擇框
// 重疊計算
const box = this.render.selectRect.getClientRect()
if (box.width > 0 && box.height > 0) {
// 區域有面積
// 獲取所有圖形
const shapes = this.render.layer.getChildren((node) => {
return !this.render.ignore(node)
})
// 提取重疊部分
const selected = shapes.filter((shape) =>
// 關鍵 api
Konva.Util.haveIntersection(box, shape.getClientRect())
)
// 多選
// 統一放在 selectionTool中,對外暴露 api
this.render.selectionTool.select(selected)
}
// 重置
this.render.selectRect.setAttrs({
visible: false, // 隱藏
x: 0,
y: 0,
width: 0,
height: 0
})
// 選擇區域結束
this.selecting = false
}
【選擇框】的主要處理的事件就是這些,接著,看看關鍵的 selectionTool.selectingClear、selectionTool.select,直接上程式碼:
// 選擇節點
select(nodes: Konva.Node[]) {
// 選之前,清一下
this.selectingClear()
if (nodes.length > 0) {
// 用於撐開 transformer
// 如果到這一章就到此為止,是不需要selectingNodesArea 這個 group
// 賣個關子,留著後面解釋
this.selectingNodesArea = new Konva.Group({
visible: false,
listening: false
})
// 最大zIndex
const maxZIndex = Math.max(
...this.render.layer
.getChildren((node) => {
return !this.render.ignore(node)
})
.map((o) => o.zIndex())
)
// 記錄狀態
for (const node of nodes) {
node.setAttrs({
nodeMousedownPos: node.position(), // 後面用於移動所選
lastOpacity: node.opacity(), // 選中時,下面會使其變透明,記錄原有的透明度
lastZIndex: node.zIndex() // 記錄原有的層次,後面暫時提升所選節點的層次
})
}
// 設定透明度、提升層次、不可互動
for (const node of nodes.sort((a, b) => a.zIndex() - b.zIndex())) {
const copy = node.clone()
this.selectingNodesArea.add(copy)
node.setAttrs({
listening: false,
opacity: node.opacity() * 0.8,
zIndex: maxZIndex
})
}
// 選中的節點
this.selectingNodes = nodes
// 放進 transformer 所在的層
this.render.groupTransformer.add(this.selectingNodesArea)
// 選中的節點,放進 transformer
this.render.transformer.nodes([...this.selectingNodes, this.selectingNodesArea])
}
}
// 清空已選
selectingClear() {
// 清空選擇
this.render.transformer.nodes([])
// 移除 selectingNodesArea
this.selectingNodesArea?.remove()
this.selectingNodesArea = null
// 恢復透明度、層次、可互動
for (const node of this.selectingNodes.sort(
(a, b) => a.attrs.lastZIndex - b.attrs.lastZIndex
)) {
node.setAttrs({
listening: true,
opacity: node.attrs.lastOpacity ?? 1,
zIndex: node.attrs.lastZIndex
})
}
// 清空狀態
for (const node of this.selectingNodes) {
node.setAttrs({
nodeMousedownPos: undefined,
lastOpacity: undefined,
lastZIndex: undefined,
selectingZIndex: undefined
})
}
// 清空選擇節點
this.selectingNodes = []
}
值得一提,Konva 關於 zIndex 的處理比較特別,始終從 1 到 N,意味著,改變一個節點的 zIndex,將影響其他節點的 zIndex,舉個例子,假如有下面節點,數字就是對應的 zIndex:
a-1、b-2、c-3、d-4
此時我改 b 到 4(最大 zIndex),即 b-4,此時 c、d 會自動適應 zIndex,變成:
a-1、c-2、d-3、b-4
所以,上面需要兩次的 this.selectingNodes.sort 處理,舉個例子:
a/1、b/2、c/3、d/4,此時我選中 b 和 c
先置頂 b,即 a-1、c-2、d-3、b-4
後置頂 c,即 a-1、d-2、b-3、c-4
這樣就可以保證原來 b 和 c 的相對位置的基礎上,置頂 b 和 c
這樣,透過【選擇框】多選目標的互動就完成了。
點選
處理【未選擇】節點
除了用【選擇框】,也可以透過 ctrl + 點選 選擇節點。
回到 stage 的 mousedown 事件處理:
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
const parent = e.target.getParent()
if (e.target === this.render.stage) {
// 略
} else if (parent instanceof Konva.Transformer) {
// transformer 點選事件交給 transformer 自己的 handler
} else if (parent instanceof Konva.Group) {
if (e.evt.button === Types.MouseButton.左鍵) {
if (!this.render.ignore(parent) && !this.render.ignoreDraw(e.target)) {
if (e.evt.ctrlKey) {
// 新增多選
this.render.selectionTool.select([
...this.render.selectionTool.selectingNodes,
parent
])
} else {
// 單選
this.render.selectionTool.select([parent])
}
}
} else {
this.render.selectionTool.selectingClear()
}
}
}
這裡比較簡單,就是處理一下已選的陣列。
處理【已選擇】節點
// 記錄初始狀態
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
const anchor = this.render.transformer.getActiveAnchor()
if (!anchor) {
// 非變換
if (e.evt.ctrlKey) {
// 選擇
if (this.render.selectionTool.selectingNodesArea) {
const pos = this.render.stage.getPointerPosition()
if (pos) {
const keeps: Konva.Node[] = []
const removes: Konva.Node[] = []
// 從高到低,逐個判斷 已選節點 和 滑鼠點選位置 是否重疊
let finded = false
for (const node of this.render.selectionTool.selectingNodes.sort(
(a, b) => b.zIndex() - a.zIndex()
)) {
if (
!finded &&
Konva.Util.haveIntersection(node.getClientRect(), {
...pos,
width: 1,
height: 1
})
) {
// 記錄需要移除選擇的節點
removes.unshift(node)
finded = true
} else {
keeps.unshift(node)
}
}
if (removes.length > 0) {
// 取消選擇
this.render.selectionTool.select(keeps)
} else {
// 從高到低,逐個判斷 未選節點 和 滑鼠點選位置 是否重疊
let finded = false
const adds: Konva.Node[] = []
for (const node of this.render.layer
.getChildren()
.filter((node) => !this.render.ignore(node))
.sort((a, b) => b.zIndex() - a.zIndex())) {
if (
!finded &&
Konva.Util.haveIntersection(node.getClientRect(), {
...pos,
width: 1,
height: 1
})
) {
// 記錄需要增加選擇的節點
adds.unshift(node)
finded = true
}
}
if (adds.length > 0) {
// 新增選擇
this.render.selectionTool.select([
...this.render.selectionTool.selectingNodes,
...adds
])
}
}
}
}
} else {
// 略
}
} else {
// 略
}
}
效果:
移動節點
準備工作
相關狀態變數:
// 拖動前的位置
transformerMousedownPos: Konva.Vector2d = { x: 0, y: 0 }
// 拖動偏移
groupImmediateLocOffset: Konva.Vector2d = { x: 0, y: 0 }
相關方法,處理 transformer 事件中會使用到:
// 透過偏移量(selectingNodesArea)移動【目標節點】
selectingNodesPositionByOffset(offset: Konva.Vector2d) {
for (const node of this.render.selectionTool.selectingNodes) {
const x = node.attrs.nodeMousedownPos.x + offset.x
const y = node.attrs.nodeMousedownPos.y + offset.y
node.x(x)
node.y(y)
}
const area = this.render.selectionTool.selectingNodesArea
if (area) {
area.x(area.attrs.areaMousedownPos.x + offset.x)
area.y(area.attrs.areaMousedownPos.y + offset.y)
}
}
// 重置【目標節點】的 nodeMousedownPos
selectingNodesPositionReset() {
for (const node of this.render.selectionTool.selectingNodes) {
node.attrs.nodeMousedownPos.x = node.x()
node.attrs.nodeMousedownPos.y = node.y()
}
}
// 重置 transformer 狀態
transformerStateReset() {
// 記錄 transformer pos
this.transformerMousedownPos = this.render.transformer.position()
}
// 重置 selectingNodesArea 狀態
selectingNodesAreaReset() {
this.render.selectionTool.selectingNodesArea?.setAttrs({
areaMousedownPos: {
x: 0,
y: 0
}
})
}
// 重置
reset() {
this.transformerStateReset()
this.selectingNodesPositionReset()
this.selectingNodesAreaReset()
}
主要透過處理 transformer 的事件:
transformend: () => {
// 變換結束
// 重置狀態
this.reset()
},
//
dragstart: () => {
this.render.selectionTool.selectingNodesArea?.setAttrs({
areaMousedownPos: this.render.selectionTool.selectingNodesArea?.position()
})
},
// 拖動
dragmove: () => {
// 拖動中
this.selectingNodesPositionByOffset(this.groupImmediateLocOffset)
},
dragend: () => {
// 拖動結束
this.selectingNodesPositionByOffset(this.groupImmediateLocOffset)
// 重置狀態
this.reset()
}
還有這:
// 記錄初始狀態
mousedown: (e: Konva.KonvaEventObject<GlobalEventHandlersEventMap['mousedown']>) => {
const anchor = this.render.transformer.getActiveAnchor()
if (!anchor) {
// 非變換
if (e.evt.ctrlKey) {
// 略
} else {
if (this.render.selectionTool.selectingNodesArea) {
// 拖動前
// 重置狀態
this.reset()
}
}
} else {
// 變換前
// 重置狀態
this.reset()
}
}
還要處理 transformer 的配置 dragBoundFunc,從它獲得 groupImmediateLocOffset 偏移量:
// 拖動中
dragBoundFunc: (pos: Konva.Vector2d) => {
// transform pos 偏移
const transformPosOffsetX = pos.x - this.transformerMousedownPos.x
const transformPosOffsetY = pos.y - this.transformerMousedownPos.y
// group loc 偏移
this.groupImmediateLocOffset = {
x: this.render.toStageValue(transformPosOffsetX),
y: this.render.toStageValue(transformPosOffsetY)
}
return pos
// 接著到 dragmove 事件處理
}
接下來,計劃實現下面這些功能:
- 放大縮小所選的“磁貼效果”(基於網格)
- 拖動所選的“磁貼效果”(基於網格)
- 節點層次單個、批次調整
- 鍵盤複製、貼上
- 等等。。。
是不是更加有趣呢?是不是值得更多的 Star 呢?勾勾手指~
原始碼
gitee原始碼
示例地址