ETJava Beta | Java    注册   登录
  • 搜索:
  • 前端使用 Konva 实现可视化设计器(23)- 绘制曲线、属性面板

    发表于      阅读(1)     博客类别:Crawler     转自:https://www.cnblogs.com/xachary/p/18433456
    如有侵权 请联系我们删除  (页面底部联系我们)  

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

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

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

    github源码

    gitee源码

    示例地址

    绘制曲线

    先上效果!

    image

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

    image

    未来,在属性面板中,可以调节 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 分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
    
    // 略
    
      // 加载 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源码

    示例地址