前端使用 Konva 实现可视化设计器(17)- 素材嵌套 - 生成阶段
本章主要实现素材的嵌套(生成阶段)这意味着可以拖入画布的对象,不只是图片素材,还可以是嵌套的图片和图形。在未来的章节中,应该可以实现素材成组/解散的效果。
最近难以抽出时间继续本示例更新,以至于拖到今天才更新这一章...
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~
一些调整和优化
1、原本分散在各处的不同层的 draw 方法调用,现在基本上统一调用 render 的 redraw 方法,简化代码逻辑(暂未发现明显的性能问题)。
2、修复右键无法删除连接线的问题,主要是 stage 的 contextmenu 事件实测 target 无法得到指向的 Line 实例,目前使用 Konva.Util.haveIntersection 处理,以下是该逻辑的代码片段:
// src/Render/draws/ContextmenuDraw.ts
const linkGroup = this.render.layerCover.find(
`.${Draws.LinkDraw.name}`
)[0] as Konva.Group
// 右键目标可能为 连接线
let lineSelection: Konva.Node | null = null
if (linkGroup) {
const linkLines = linkGroup.find('.link-line')
for (const line of linkLines) {
if (
Konva.Util.haveIntersection({ ...pos, width: 1, height: 1 }, line.getClientRect())
) {
// 右键目标为 连接线
lineSelection = line
break
}
}
}
if (pos.x === this.state.lastPos.x || pos.y === this.state.lastPos.y) {
// 右键 连接线/其它目标
this.state.target = lineSelection ?? e.target
} else {
this.state.target = null
}
3、原来的对齐逻辑没有考虑目标被 rotate 后的情况,现已经修复支持了,实现方式还是使用三角函数计算:
红框是 rotate 后的占用区域,通过旋转角度就可以计算该区域的宽高,left、right、top、bottom 也是用于计算占用区域的 x,y 坐标:
// src/Render/tools/AlignTool.ts
calcNodeRotationInfo(node: Konva.Node) {
const rotate = node.rotation()
const offsetLeft = node.height() * Math.sin((rotate * Math.PI) / 180)
const offsetRight = node.width() * Math.cos((rotate * Math.PI) / 180)
const offsetTop = node.height() * Math.cos((rotate * Math.PI) / 180)
const offsetBottom = node.width() * Math.sin((rotate * Math.PI) / 180)
const width = Math.abs(offsetLeft) + Math.abs(offsetRight)
const height = Math.abs(offsetTop) + Math.abs(offsetBottom)
let x = node.x()
if ((rotate >= 0 && rotate < 90) || (rotate >= -360 && rotate < -270)) {
x = x - Math.abs(offsetLeft)
} else if ((rotate >= 90 && rotate < 180) || (rotate >= -270 && rotate < -180)) {
x = x - width
} else if ((rotate >= 180 && rotate < 270) || (rotate >= -180 && rotate < -90)) {
x = x - Math.abs(offsetRight)
} else if ((rotate >= 270 && rotate < 360) || (rotate >= -90 && rotate < 0)) {
// 无需处理
}
let y = node.y()
if ((rotate >= 0 && rotate < 90) || (rotate >= -360 && rotate < -270)) {
// 无需处理
} else if ((rotate >= 90 && rotate < 180) || (rotate >= -270 && rotate < -180)) {
y = y - Math.abs(offsetTop)
} else if ((rotate >= 180 && rotate < 270) || (rotate >= -180 && rotate < -90)) {
y = y - height
} else if ((rotate >= 270 && rotate < 360) || (rotate >= -90 && rotate < 0)) {
y = y - Math.abs(offsetBottom)
}
return {
x,
y,
width,
height
}
}
进入正题
调整资源的定义
由于嵌套之后的素材,不是图片,需要额外一个封面图片 avatar,用于左侧菜单的显示:
// src/Render/types.ts
export interface AssetInfo {
url: string
avatar?: string // 子素材需要额外的封面
points?: Array<AssetInfoPoint>
}
增加示例数据:
// src/App.vue
const assetsModules: Array<Types.AssetInfo> = [
{ "url": "./json/1.json", avatar: './json/1.png' },
{ "url": "./json/2.json", avatar: './json/2.png' },
{ "url": "./json/3.json", avatar: './json/3.png' },
{ "url": "./json/4.json", avatar: './json/4.png' },
// 略
}
上面的示例数据是嵌套生成,稍后再细说实现。生成一个嵌套素材,分 2 步骤:
<button @click="onSaveAsset">另存为元素</button>
<button @click="onSaveAssetPNG">另存为元素图片</button>
另存为元素,输出的就是 json 文件;另存为元素图片,就是上面的封面 avatar。
// src/App.vue
// 另存为元素
function onSaveAsset() {
if (render) {
const a = document.createElement('a')
const event = new MouseEvent('click')
a.download = 'asset.json'
a.href = window.URL.createObjectURL(new Blob([render.importExportTool.getAsset()]))
a.dispatchEvent(event)
a.remove()
}
}
// 另存为元素图片
function onSaveAssetPNG() {
if (render) {
// 3倍尺寸、白色背景
const url = render.importExportTool.getAssetImage(3, '#ffffff')
const a = document.createElement('a')
const event = new MouseEvent('click')
a.download = 'image'
a.href = url
a.dispatchEvent(event)
a.remove()
}
}
关键就是方法 getAsset、getAssetImage,实现逻辑后面细说。
则,左侧菜单素材,优先显示 avatar 封面:
<img :src="item.avatar || item.url" />
将当前多个素材,组合成单个素材
上面提到了 getAsset,它是生成满足需求的 json 内容的,依赖关系:
getAsset(新) <- getAssetView(新) <- getView(已有)
已经存在的 getView 方法,已经用于 导出 json、另存为图片、另存为 svg,此次仅做了一些优化,它的作用基本上依然是:
1、clone 当前 stage
2、移除被认为应该 ignore 的节点
3、如需要,重新加入连接线的 Line 实例(操作过程中,连接线是实时绘制的)
4、计算并处理节点(们)占用的区域
5、返回处理好的 clone
基于 getView,为了组合多个素材,需要实现一个 getAssetView:
// src/Render/tools/ImportExportTool.ts
/**
* 获得显示内容(用于另存为元素)
* @returns Konva.Stage
*/
getAssetView() {
const copy = this.getView(true)
const children = copy.getChildren()[0].getChildren()
const nodes: Konva.Stage | Konva.Layer | Konva.Group | Konva.Node[] = [...children]
let minX = Infinity,
maxX = -Infinity,
minY = Infinity,
maxY = -Infinity,
minStartX = Infinity,
minStartY = Infinity
for (const node of nodes) {
if (node instanceof Konva.Group) {
if (node.x() < minX) {
minX = node.x()
}
if (node.x() + node.width() > maxX) {
maxX = node.x() + node.width()
}
if (node.y() < minY) {
minY = node.y()
}
if (node.y() + node.height() > maxY) {
maxY = node.y() + node.height()
}
if (node.x() < minStartX) {
minStartX = node.x()
}
if (node.y() < minStartY) {
minStartY = node.y()
}
// 移除辅助元素
if (node instanceof Konva.Group) {
const clickMask = node.findOne('#click-mask')
if (clickMask) {
clickMask.destroy()
}
}
} else if (node instanceof Konva.Line && node.name() === 'link-line') {
// 连线占用空间
const points = node.points()
for (let i = 0; i < points.length; i += 2) {
const [x, y] = [points[i], points[i + 1]]
if (x < minX) {
minX = x - 1
}
if (x > maxX) {
maxX = x + 1
}
if (y < minY) {
minY = y - 1
}
if (y > maxY) {
maxY = y + 1
}
if (x < minStartX) {
minStartX = x - 1
}
if (y < minStartY) {
minStartY = y - 1
}
}
}
}
for (const node of nodes) {
if (node instanceof Konva.Group) {
node.x(node.x() - minStartX)
node.y(node.y() - minStartY)
} else if (node instanceof Konva.Line && node.name() === 'link-line') {
const points = node.points()
for (let i = 0; i < points.length; i += 2) {
points[i] = points[i] - minStartX
points[i + 1] = points[i + 1] - minStartY
}
node.points(points)
}
}
copy.x(0)
copy.y(0)
copy.width(maxX - minX)
copy.height(maxY - minY)
return copy
}
区别于 getView,计算占用区域是有差异的(绿色:getView,红色:getAssetView):
因为,经过组合的素材,是包含连接线 Line 的实例的,所以还要考虑连接线的占用区域:
最后,导出之前,把所有内容,都进行一次移动,整体移动至 0,0 点。
接着是实现 getAsset:
// src/Render/tools/ImportExportTool.ts
/**
* 获得元素(用于另存为元素)
* @returns Konva.Stage
*/
getAsset() {
const copy = this.getAssetView()
const json = copy.toJSON()
const obj = JSON.parse(json)
const assets = obj.children[0].children
for (const asset of assets) {
if (asset.attrs.name === 'asset') {
asset.attrs.name = 'sub-asset'
}
if (asset.attrs.selected) {
asset.attrs.selected = false
}
}
this.render.linkTool.jsonIdCover(assets)
// 通过 stage api 导出 json
const result = JSON.stringify({
...obj.children[0],
className: 'Group',
attrs: {
width: copy.width(),
height: copy.height(),
x: 0,
y: 0
}
})
copy.destroy()
return result
}
主要逻辑有 2 个:
1、把 getAssetView 处理好的 stage 导出为 json。
2、把组合前多个 asset 的 name 改为 sub-asset,意为“子素材”,与组合后的 asset 区别开。
3、把组合前多个 asset 的内部 id 刷新一遍,通过 jsonIdCover 方法。
// src/Render/tools/LinkTool.ts
// 刷新 json 的 id、事件
jsonIdCover(assets: any[]) {
let deepAssets = [...assets]
const idMap = new Map()
while (deepAssets.length > 0) {
const asset = deepAssets.shift()
if (asset) {
if (Array.isArray(asset.attrs.points)) {
for (const point of asset.attrs.points) {
if (Array.isArray(point.pairs)) {
for (const pair of point.pairs) {
if (pair.from.groupId && !idMap.has(pair.from.groupId)) {
idMap.set(pair.from.groupId, 'g:' + nanoid())
}
if (pair.to.groupId && !idMap.has(pair.to.groupId)) {
idMap.set(pair.to.groupId, 'g:' + nanoid())
}
if (pair.from.pointId && !idMap.has(pair.from.pointId)) {
idMap.set(pair.from.pointId, 'p:' + nanoid())
}
if (pair.to.pointId && !idMap.has(pair.to.pointId)) {
idMap.set(pair.to.pointId, 'p:' + nanoid())
}
}
}
if (point.id) {
if (!idMap.has(point.id)) {
idMap.set(point.id, 'p:' + nanoid())
}
}
if (point.groupId) {
if (!idMap.has(point.groupId)) {
idMap.set(point.groupId, 'g:' + nanoid())
}
}
}
}
if (asset.attrs.id) {
if (!idMap.has(asset.attrs.id)) {
idMap.set(asset.attrs.id, 'n:' + nanoid())
}
}
if (Array.isArray(asset.children)) {
deepAssets.push(...asset.children)
}
}
}
deepAssets = [...assets]
while (deepAssets.length > 0) {
const asset = deepAssets.shift()
if (asset) {
if (idMap.has(asset.attrs.id)) {
asset.attrs.id = idMap.get(asset.attrs.id)
}
if (Array.isArray(asset.attrs.points)) {
for (const point of asset.attrs.points) {
if (Array.isArray(point.pairs)) {
for (const pair of point.pairs) {
pair.disabled = true
if (pair.id) {
pair.id = 'pr:' + nanoid()
}
if (idMap.has(pair.from.groupId)) {
pair.from.groupId = idMap.get(pair.from.groupId)
}
if (idMap.has(pair.to.groupId)) {
pair.to.groupId = idMap.get(pair.to.groupId)
}
if (idMap.has(pair.from.pointId)) {
pair.from.pointId = idMap.get(pair.from.pointId)
}
if (idMap.has(pair.to.pointId)) {
pair.to.pointId = idMap.get(pair.to.pointId)
}
}
}
if (idMap.has(point.id)) {
const anchor = asset.children.find((o: any) => o.attrs.id === point.id)
point.id = idMap.get(point.id)
if (anchor) {
anchor.attrs.id = point.id
}
}
if (idMap.has(point.groupId)) {
point.groupId = idMap.get(point.groupId)
}
}
}
if (Array.isArray(asset.children)) {
deepAssets.push(...asset.children)
}
}
}
}
jsonIdCover 对 json 结构进行一次广度优先遍历,把所有 asset 的 id(groupId)刷新一遍,连带关联的 point、pair、anchor 的 id、groupId 要同步更新。
特别地,将 pair.disabled 记录为 true,原因是,在组合的时候,连接线直接当作 Line 实例也包含进来了,在保留该 pair 记录的同时,标记后,在后续的 Link draw 的时候会忽略这些 pair。
此时,另存为元素的 json 已经处理好,可以导出了。
组合后的素材封面
生成封面,也是依赖 getAssetView 处理好的 stage 克隆,基本与原来的 getImage 一样:
// src/Render/tools/ImportExportTool.ts
// 获取元素图片
getAssetImage(pixelRatio = 1, bgColor?: string) {
// 获取可视节点和 layer
const copy = this.getAssetView()
// 背景层
const bgLayer = new Konva.Layer()
// 背景矩形
const bg = new Konva.Rect({
listening: false
})
bg.setAttrs({
x: -copy.x(),
y: -copy.y(),
width: copy.width(),
height: copy.height(),
fill: bgColor
})
// 添加背景
bgLayer.add(bg)
// 插入背景
const children = copy.getChildren()
copy.removeChildren()
copy.add(bgLayer)
copy.add(children[0], ...children.slice(1))
const url = copy.toDataURL({ pixelRatio })
copy.destroy()
// 通过 stage api 导出图片
return url
}
通过 getAsset、getAssetImage 就可以获得组合后的素材的 json 文件、封面图片了。
生成的示例,可以参考左侧菜单新增的 4 个素材,放在静态目录 public/json 中。
示例 3 = 示例 1 + 示例 2;示例 4 = 示例 3 + 其他,嵌套了多层。
More Stars please!勾勾手指~
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?