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

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

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

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

github源码

gitee源码

示例地址

绘制曲线

先上效果!

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

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

属性面板

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

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

基本交互

UI

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

html 复制代码
<!-- 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>

魔改一下组件样式:

css 复制代码
/* 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;
      }

组件和表单的控制:

javascript 复制代码
// 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 到值。

javascript 复制代码
// 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。

javascript 复制代码
// 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,用于模拟页面背景的:

javascript 复制代码
// 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
        })
      )

// 略

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

javascript 复制代码
// 恢复
  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 素材的时候,也处理一遍:

javascript 复制代码
// 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 素材,会基于 页面的线条、填充值,所以拖入的时候也要处理一下:

javascript 复制代码
// 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源码

示例地址

相关推荐
加班是不可能的,除非双倍日工资3 小时前
css预编译器实现星空背景图
前端·css·vue3
wyiyiyi4 小时前
【Web后端】Django、flask及其场景——以构建系统原型为例
前端·数据库·后端·python·django·flask
gnip4 小时前
vite和webpack打包结构控制
前端·javascript
excel4 小时前
在二维 Canvas 中模拟三角形绕 X、Y 轴旋转
前端
阿华的代码王国5 小时前
【Android】RecyclerView复用CheckBox的异常状态
android·xml·java·前端·后端
一条上岸小咸鱼5 小时前
Kotlin 基本数据类型(三):Booleans、Characters
android·前端·kotlin
Jimmy5 小时前
AI 代理是什么,其有助于我们实现更智能编程
前端·后端·ai编程
ZXT5 小时前
promise & async await总结
前端
Jerry说前后端5 小时前
RecyclerView 性能优化:从原理到实践的深度优化方案
android·前端·性能优化
画个太阳作晴天5 小时前
A12预装app
linux·服务器·前端