前端使用 Konva 实现可视化设计器(23)- 绘制曲线、属性面板

前端使用 Konva 实现可视化设计器(23)- 绘制曲线、属性面板本章分享一下如何使用 Konva 绘制基础图形 曲线 以及属性面板的基本实现思路 希望大家继续关注和支持哈 多求 5 个 Stars 谢谢 konva

大家好,欢迎来到IT知识分享网。

本章分享一下如何使用 Konva 绘制基础图形:曲线,以及属性面板的基本实现思路,希望大家继续关注和支持哈(多求 5 个 Stars 谢谢)!

请大家动动小手,给我一个免费的 Star 吧~

大家如果发现了 Bug,欢迎来提 Issue 哟~

github源码

gitee源码

示例地址

绘制曲线

先上效果!

在这里插入图片描述

这里其实取巧了哈,基本就是在绘制折线的基础上,给 Konva.Line 添加一个关键的属性 tension 即可,参照官方示例:

在这里插入图片描述

未来,在属性面板中,可以调节 tension 的值,基本可以实现绘制一些简单的曲线。

属性面板

早些时候,已经有小伙伴问,外部如何动态调整 Konva 内部各对象的一些特性,这里以页面的背景色、全局线条和填充颜色,及其素材各自的线条和填充颜色为例,分享一个基本可行实现思路是如何的。

这里以 svg 素材为例,可以调整 svg 素材的线条、填充颜色。

基本交互

在这里插入图片描述

UI

这里简单粗暴一些,使用 naive-ui 的组件组装一下就可以了:

<!-- src/App.vue --> <n-tabs type="line" size="small" animated v-model:value="tabCurrent"> <n-tab-pane name="page" tab="页面"> <n-form ref="formRef" :model="pageSettingsModel" :rules="{}" label-placement="top" size="small" v-if="pageSettingsModel"> <n-form-item label="背景色" path="background"> <n-color-picker v-model:value="pageSettingsModelBackground" @update:show="(v: boolean) => { pageSettingsModel && !v && (pageSettingsModelBackground = pageSettingsModel.background) }" :actions="['clear', 'confirm']" show-preview @confirm="(v: string) => { pageSettingsModel && (pageSettingsModel.background = v) }" @clear="pageSettingsModel && (pageSettingsModel.background = Render.PageSettingsDefault.background)"></n-color-picker> </n-form-item> <n-form-item label="线条颜色" path="stroke"> <n-color-picker v-model:value="pageSettingsModelStroke" @update:show="(v: boolean) => { pageSettingsModel && !v && (pageSettingsModelStroke = pageSettingsModel.stroke) }" :actions="['clear', 'confirm']" show-preview @confirm="(v: string) => { pageSettingsModel && (pageSettingsModel.stroke = v) }" @clear="pageSettingsModel && (pageSettingsModel.stroke = Render.AssetSettingsDefault.stroke)"></n-color-picker> </n-form-item> <n-form-item label="填充颜色" path="fill"> <n-color-picker v-model:value="pageSettingsModelFill" @update:show="(v: boolean) => { pageSettingsModel && !v && (pageSettingsModelFill = pageSettingsModel.fill) }" :actions="['clear', 'confirm']" show-preview @confirm="(v: string) => { pageSettingsModel && (pageSettingsModel.fill = v) }" @clear="pageSettingsModel && (pageSettingsModel.fill = Render.AssetSettingsDefault.fill)"></n-color-picker> </n-form-item> </n-form> </n-tab-pane> <n-tab-pane name="asset" tab="素材" :disabled="assetCurrent === void 0"> <n-form ref="formRef" :model="assetSettingsModel" :rules="{}" label-placement="top" size="small" v-if="assetSettingsModel"> <n-form-item label="线条颜色" path="stroke" v-if="assetCurrent?.attrs.imageType === Types.ImageType.svg"> <n-color-picker v-model:value="assetSettingsModelStorke" @update:show="(v: boolean) => { assetSettingsModel && !v && (assetSettingsModelStorke = assetSettingsModel.stroke) }" :actions="['clear', 'confirm']" show-preview @confirm="(v: string) => { assetSettingsModel && (assetSettingsModel.stroke = v) }" @clear="assetSettingsModel && (assetSettingsModel.stroke = '#000')"></n-color-picker> </n-form-item> <n-form-item label="填充颜色" path="fill" v-if="assetCurrent?.attrs.imageType === Types.ImageType.svg"> <n-color-picker v-model:value="assetSettingsModelFill" @update:show="(v: boolean) => { assetSettingsModel && !v && (assetSettingsModelFill = assetSettingsModel.fill) }" :actions="['clear', 'confirm']" show-preview @confirm="(v: string) => { assetSettingsModel && (assetSettingsModel.fill = v) }" @clear="assetSettingsModel && (assetSettingsModel.fill = '#000')"></n-color-picker> </n-form-item> </n-form> </n-tab-pane> </n-tabs> 

魔改一下组件样式:

/* src/App.vue */ :deep(.n-tabs-nav-scroll-content) { 
    box-shadow: 0 -1px 0 0 rgb(230, 230, 230) inset; border-bottom-color: rgb(230, 230, 230) !important; } :deep(.n-tabs-tab-pad) { 
    width: 16px; } 

组件和表单的控制:

// src/App.vue // 略 function init() { 
    if (boardElement.value && stageElement.value) { 
    resizer.init(boardElement.value, { 
    resize: async (x, y, width, height) => { 
    if (render === null) { 
    // 初始化渲染 render = new Render(stageElement.value!, { 
    width, height, // showBg: true, showRuler: true, showRefLine: true, attractResize: true, attractBg: true, showPreview: true, attractNode: true, }) // 同步页面设置 pageSettingsModel.value = render.getPageSettings() await nextTick() ready.value = true } render.resize(width, height) // 同步页面设置 render.on('page-settings-change', (settings: Types.PageSettings) => { 
    pageSettingsModelInnerChange.value = true pageSettingsModel.value = settings }) render.on('selection-change', (nodes: Konva.Node[]) => { 
    if (nodes.length === 0) { 
    // 清空选择 assetCurrent.value = undefined assetSettingsModel.value = undefined tabCurrent.value = 'page' } else if (nodes.length === 1) { 
    // 单选 assetCurrent.value = nodes[0] assetSettingsModel.value = render!.getAssetSettings(nodes[0]) tabCurrent.value = 'asset' } else { 
    // 多选 assetCurrent.value = undefined assetSettingsModel.value = undefined tabCurrent.value = 'page' } }) } }) } } // 略 // 当前 tab const tabCurrent = ref('page') // 页面设置 const pageSettingsModel: Ref<Types.PageSettings | undefined> = ref() const pageSettingsModelInnerChange = ref(false) const pageSettingsModelBackground = ref('') const pageSettingsModelStroke = ref('') const pageSettingsModelFill = ref('') // 当前素材 const assetCurrent: Ref<Konva.Node | undefined> = ref() // 素材设置 const assetSettingsModel: Ref<Types.AssetSettings | undefined> = ref() const assetSettingsModelInnerChange = ref(false) const assetSettingsModelStorke = ref('') const assetSettingsModelFill = ref('') watch(() => pageSettingsModel.value, () => { 
    if (pageSettingsModel.value) { 
    pageSettingsModelBackground.value = pageSettingsModel.value.background pageSettingsModelStroke.value = pageSettingsModel.value.stroke pageSettingsModelFill.value = pageSettingsModel.value.fill if (ready.value && !pageSettingsModelInnerChange.value) { 
    render?.setPageSettings(pageSettingsModel.value) } } pageSettingsModelInnerChange.value = false }, { 
    deep: true }) watch(() => assetSettingsModel.value, () => { 
    if (assetSettingsModel.value && assetCurrent.value) { 
    assetSettingsModelStorke.value = assetSettingsModel.value.stroke assetSettingsModelFill.value = assetSettingsModel.value.fill if (ready.value && !assetSettingsModelInnerChange.value) { 
    render?.setAssetSettings(assetCurrent.value, assetSettingsModel.value) } } assetSettingsModelInnerChange.value = false }, { 
    deep: true }) 

这里有几个小细节:

  • 颜色选择器 confirm 确认

没有直接用 v-model 绑定表单的颜色值,而定义了一些类似 xxxSettingsModelYyy 变量,原因是约束修改颜色必须通过 confirm 按钮才能使其颜色生效,需要一些变量作为缓存。

因此也多了一些初始化和同步赋值逻辑,看起来凌乱一些。

  • Tab自动切换

默认显示页面属性面板,选择单个素材(暂时只实现 svg 素材),切换至素材属性面板,清空选择则回到页面属性面板。

  • watch 逻辑锁

在监听 pageSettingsModel 的时候,需要判断 pageSettingsModelInnerChange 的状态,解决了因为 Render 的 上一步、下一步、导入 等操作,触发 page-settings-change 事件(自定义事件,后面细说),会改变 pageSettingsModel 的值,以此防止 重复的 setPageSettings(后面细说) 逻辑。

类型、事件定义

属性面板 与 Render 属性同步,主要靠的是自定义事件,原有的 selection-change 事件,可以解决判断当前应该处理页面属性还是素材属性;需要新增一个 page-settings-change 事件,获知因为 Render 的 上一步、下一步、导入 等操作,需要更新 pageSettingsModel 到值。

// src/Render/types.ts // 略 export type RenderEvents = { 
    ['history-change']: { 
    records: string[]; index: number } ['selection-change']: Konva.Node[] ['debug-change']: boolean ['link-type-change']: LinkType ['scale-change']: number ['loading']: boolean ['graph-type-change']: GraphType | undefined // 新增 ['page-settings-change']: PageSettings } // 略 / * 页面设置 */ export interface PageSettings { 
    background: string stroke: string fill: string } / * 素材设置 */ export interface AssetSettings { 
    stroke: string fill: string } 

属性默认值、获取属性值、设置属性值

这里是通过把页面属性、素材属性分别存放在 stage 和 素材group 的 attrs 中,pageSettings 和 assetSettings。

// src/Render/index.ts // 略 // 页面设置 默认值 static PageSettingsDefault: Types.PageSettings = { 
    background: 'transparent', stroke: 'rgb(0,0,0)', fill: 'rgb(0,0,0)' } // 获取页面设置 getPageSettings(): Types.PageSettings { 
    return this.stage.attrs.pageSettings ?? { 
    ...Render.PageSettingsDefault } } // 更新页面设置 setPageSettings(settings: Types.PageSettings) { 
    this.stage.setAttr('pageSettings', settings) // 更新背景 this.updateBackground() // 更新历史 this.updateHistory() // console.log(this.stage.attrs) } // 获取背景 getBackground() { 
    return this.draws[Draws.BgDraw.name].layer.findOne( `.${ 
     Draws.BgDraw.name}__background` ) as Konva.Rect } // 更新背景 updateBackground() { 
    const background = this.getBackground() if (background) { 
    background.fill(this.getPageSettings().background ?? 'transparent') } this.draws[Draws.BgDraw.name].draw() this.draws[Draws.PreviewDraw.name].draw() } // 素材设置 默认值 static AssetSettingsDefault: Types.AssetSettings = { 
    stroke: '', fill: '' } // 获取素材设置 getAssetSettings(asset?: Konva.Node): Types.AssetSettings { 
    const base = asset?.attrs.assetSettings ?? { 
    ...Render.AssetSettingsDefault } return { 
    // 特定 ...base, // 继承全局 stroke: base.stroke || this.getPageSettings().stroke, fill: base.fill || this.getPageSettings().fill } } // 设置 svgXML 样式(部分) setSvgXMLSettings(xml: string, settings: Types.AssetSettings) { 
    const reg = /<(circle|ellipse|line|path|polygon|rect|text|textPath|tref|tspan)[^>/]*\/?>/g const shapes = xml.match(reg) const regStroke = / stroke="([^"]*)"/ const regFill = / fill="([^"]*)"/ for (const shape of shapes ?? []) { 
    let result = shape if (settings.stroke) { 
    if (regStroke.test(shape)) { 
    result = result.replace(regStroke, ` stroke="${ 
     settings.stroke}"`) } else { 
    result = result.replace(/(<[^>/]*)(\/?>)/, `$1 stroke="${ 
     settings.stroke}" $2`) } } if (settings.fill) { 
    if (regFill.test(shape)) { 
    result = result.replace(regFill, ` fill="${ 
     settings.fill}"`) } else { 
    result = result.replace(/(<[^>/]*)(\/?>)/, `$1 fill="${ 
     settings.fill}" $2`) } } xml = xml.replace(shape, result) } return xml } // 更新素材设置 async setAssetSettings(asset: Konva.Node, settings: Types.AssetSettings) { 
    asset.setAttr('assetSettings', settings) if (asset instanceof Konva.Group) { 
    const node = asset.children[0] as Konva.Shape if (node instanceof Konva.Image) { 
    if (node.attrs.svgXML) { 
    const n = await this.assetTool.loadSvgXML( this.setSvgXMLSettings(node.attrs.svgXML, settings) ) node.parent?.add(n) node.remove() node.destroy() n.zIndex(0) } } } this.draws[Draws.BgDraw.name].draw() this.draws[Draws.GraphDraw.name].draw() this.draws[Draws.LinkDraw.name].draw() this.draws[Draws.PreviewDraw.name].draw() } 

这里素材的线条、填充默认值,是会继承页面的线条、填充值的,就是说,拖入的素材线条、填充值,会按当前页面的值初始化。

getBackground

这里获取的背景是一个放在网格线同 Layer 的 Rect,用于模拟页面背景的:

// src/Render/draws/BgDraw.ts // 略 group.add( new Konva.Rect({ 
    name: `${ 
     this.constructor.name}__background`, x: this.render.toStageValue(-stageState.x + this.render.rulerSize), y: this.render.toStageValue(-stageState.y + this.render.rulerSize), width: this.render.toStageValue(stageState.width), height: this.render.toStageValue(stageState.height), listening: false, fill: this.render.getPageSettings().background }) ) // 略 

这里说“模拟”的意思是,背景最后是在 导入、导出 的时候才真正的处理:

// 恢复 async restore(json: string, silent = false) { 
    try { 
    // 略 // 往 main layer 插入新节点 this.render.layer.add(...nodes) // 同步页面设置 this.render.stage.setAttr('pageSettings', stage.attrs.pageSettings) this.render.emit('page-settings-change', this.render.getPageSettings()) // 更新背景 this.render.updateBackground() // 略 } catch (e) { 
    console.error(e) } finally { 
    // 略 } } // 略 // 获取元素图片 getAssetImage(pixelRatio = 1, bgColor?: string) { 
    // 略 bg.setAttrs({ 
    x: -copy.x(), y: -copy.y(), width: copy.width(), height: copy.height(), fill: bgColor ?? this.render.getPageSettings().background }) // 略 } // 略 // 获取Svg async getSvg() { 
    // 略 // 获得 svg let rawSvg = c2s.getSerializedSvg() console.log(rawSvg) // 添加背景 rawSvg = rawSvg.replace( /(<defs\/><g><rect fill=")([^"]+)(")/, `$1${ 
     this.render.getPageSettings().background}$3` ) // 略 } // 略 } // 略 / * 获得元素(用于另存为元素) * @returns Konva.Stage */ getAsset() { 
    const copy = this.getAssetView() // 添加背景 const background = this.render.getBackground() background.width(copy.width()) background.height(copy.height()) copy.children[0].add(background) background.moveToBottom() // 略 } 

分别说说处理的思路:

  • 导出图片

在 toDataURL 之前在添加背景 Rect。

  • 导出 svg

这里的思路是,通过正则表达式替换 svg xml 内容,修改上面提到的 背景 Rect 对应的 svg xml rect 结构。

  • 导出素材 json

虽然这里也是添加背景 Rect,不同之处是,该层与其他素材同级,像似一个内部素材。

  • 导入 json

通过 stage 的 attrs 中 pageSettings 属性记录,通过事件 page-settings-change 恢复外部表单 model 的值。并同时更新 背景 Rect 的颜色。

setAssetSettings、setSvgXMLSettings

可以看到这里看起来明显有点复杂,由于素材 svg 最终是以 Konva.Image 的方式加载的,所以唯一可以影响显示的线条、填充颜色,只能在加载之前,通过替换 svg xml 实现。

替换 svg xml 思路比较简单粗暴,就是把可能的节点 circle|ellipse|line|path|polygon|rect|text|textPath|tref|tspan,识别提取出来,进行 stroke、fill 的替换/插入。

恢复加载 svg 素材的时候,也处理一遍:

// src/Render/tools/AssetTool.ts // 略 // 加载 svg async loadSvg(src: string) { 
    const svgXML = await (await fetch(src)).text() return this.loadSvgXML(this.render.setSvgXMLSettings(svgXML, this.render.getAssetSettings())) } // 略 

上面说到,拖入的 svg 素材,会基于 页面的线条、填充值,所以拖入的时候也要处理一下:

// src/Render/handlers/DragOutsideHandlers.ts // 略 drop: (e: GlobalEventHandlersEventMap['drop']) => { 
    // 略 let group = null // 默认连接点 let points: Types.AssetInfoPoint[] = [] // 图片素材 if (target instanceof Konva.Image) { 
    group = new Konva.Group({ 
    id: nanoid(), width: target.width(), height: target.height(), name: 'asset', assetType: Types.AssetType.Image, draggable: true, imageType: type !== 'json' ? type === Types.ImageType.svg ? Types.ImageType.svg : type === Types.ImageType.gif ? Types.ImageType.gif : Types.ImageType.other : undefined }) this.render.setAssetSettings(group, this.render.getAssetSettings()) // 略 } else { 
    // json 素材 // 略 } // 略 }) } } } // 略 

说到这里,基本实现了页面属性、素材属性及其继承关系的实现(还有很多优化空间)啦!

Thanks watching~

More Stars please!勾勾手指~

源码

gitee源码

示例地址

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/135188.html

(0)
上一篇 2025-07-06 16:20
下一篇 2025-07-06 16:26

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信