0


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

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

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

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

github源码

gitee源码

示例地址

绘制曲线

先上效果!

在这里插入图片描述

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

在这里插入图片描述

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

属性面板

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

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

基本交互

在这里插入图片描述

UI

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

<!-- src/App.vue --><n-tabstype="line"size="small"animatedv-model:value="tabCurrent"><n-tab-panename="page"tab="页面"><n-formref="formRef":model="pageSettingsModel":rules="{}"label-placement="top"size="small"v-if="pageSettingsModel"><n-form-itemlabel="背景色"path="background"><n-color-pickerv-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-itemlabel="线条颜色"path="stroke"><n-color-pickerv-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-itemlabel="填充颜色"path="fill"><n-color-pickerv-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-panename="asset"tab="素材":disabled="assetCurrent === void 0"><n-formref="formRef":model="assetSettingsModel":rules="{}"label-placement="top"size="small"v-if="assetSettingsModel"><n-form-itemlabel="线条颜色"path="stroke"v-if="assetCurrent?.attrs.imageType === Types.ImageType.svg"><n-color-pickerv-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-itemlabel="填充颜色"path="fill"v-if="assetCurrent?.attrs.imageType === Types.ImageType.svg"><n-color-pickerv-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// 略functioninit(){if(boardElement.value && stageElement.value){
    resizer.init(boardElement.value,{resize:async(x, y, width, height)=>{if(render ===null){// 初始化渲染
          render =newRender(stageElement.value!,{
            width,
            height,//
            showBg:true,
            showRuler:true,
            showRefLine:true,
            attractResize:true,
            attractBg:true,
            showPreview:true,
            attractNode:true,})// 同步页面设置
          pageSettingsModel.value = render.getPageSettings()awaitnextTick()

          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'}elseif(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'}})}})}}// 略// 当前 tabconst 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
}// 略/**
 * 页面设置
 */exportinterfacePageSettings{
  background: string
  stroke: string
  fill: string
}/**
 * 素材设置
 */exportinterfaceAssetSettings{
  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 {returnthis.stage.attrs.pageSettings ??{...Render.PageSettingsDefault }}// 更新页面设置setPageSettings(settings: Types.PageSettings){this.stage.setAttr('pageSettings', settings)// 更新背景this.updateBackground()// 更新历史this.updateHistory()// console.log(this.stage.attrs)}// 获取背景getBackground(){returnthis.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)[^>/]*\/?>/gconst 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
  }// 更新素材设置asyncsetAssetSettings(asset: Konva.Node, settings: Types.AssetSettings){
    asset.setAttr('assetSettings', settings)if(asset instanceofKonva.Group){const node = asset.children[0]as Konva.Shape
      if(node instanceofKonva.Image){if(node.attrs.svgXML){const n =awaitthis.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(newKonva.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
        }))// 略

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

// 恢复asyncrestore(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
    })// 略}// 略// 获取SvgasyncgetSvg(){// 略// 获得 svglet 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 分4步:
1、通过 attrs 取出 svgXml 的值;
2、通过正则表达式替换/插入线条、填充颜色值;
3、生成新的 Image 替换原来的 Image;
4、恢复新的 Image 的 zIndex(置顶);

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

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

// src/Render/tools/AssetTool.ts// 略// 加载 svgasyncloadSvg(src: string){const svgXML =await(awaitfetch(src)).text()returnthis.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 instanceofKonva.Image){
                group =newKonva.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://blog.csdn.net/xachary2/article/details/142553520
版权归原作者 @xachary 所有, 如有侵权,请联系我们删除。

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

还没有评论