本章主要实现素材的嵌套(生成阶段)这意味着可以拖入画布的对象,不只是图片素材,还可以是嵌套的图片和图形。在未来的章节中,应该可以实现素材成组/解散的效果。
最近难以抽出时间继续本示例更新,以至于拖到今天才更新这一章…
请大家动动小手,给我一个免费的 Star 吧~
大家如果发现了 Bug,欢迎来提 Issue 哟~
github源码
gitee源码
示例地址
一些调整和优化
1、原本分散在各处的不同层的 draw 方法调用,现在基本上统一调用 render 的 redraw 方法,简化代码逻辑(暂未发现明显的性能问题)。
2、修复右键无法删除连接线的问题,主要是 stage 的 contextmenu 事件实测 target 无法得到指向的 Line 实例,目前使用 Konva.Util.haveIntersection 处理,以下是该逻辑的代码片段:
// src/Render/draws/ContextmenuDraw.tsconst linkGroup = this.render.layerCover.find(`.${Draws.LinkDraw.name}`)[0] as Konva.Group// 右键目标可能为 连接线let lineSelection: Konva.Node | null = nullif (linkGroup) {const linkLines = linkGroup.find('.link-line')for (const line of linkLines) {if (Konva.Util.haveIntersection({ ...pos, width: 1, height: 1 }, line.getClientRect())) {// 右键目标为 连接线lineSelection = linebreak}}}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.tscalcNodeRotationInfo(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: stringavatar?: 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 = urla.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 = Infinityfor (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] - minStartXpoints[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].childrenfor (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 导出 jsonconst 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 = trueif (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) {// 获取可视节点和 layerconst 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!勾勾手指~
源码
gitee源码
示例地址