首页 星云 工具 资源 星选 资讯 热门工具
:

PDF转图片 完全免费 小红书视频下载 无水印 抖音视频下载 无水印 数字星空

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

编程知识
2024年09月26日 17:05

本章分享一下如何使用 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源码

示例地址

From:https://www.cnblogs.com/xachary/p/18433456
本文地址: http://www.shuzixingkong.net/article/2325
0评论
提交 加载更多评论
其他文章 C# WebSocket Servers -- Fleck、SuperSocket、TouchSocke
最近在维护老项目,感觉内存一直都有问题,定位到问题是WebSocketServer的问题,了解了 Fleck、SuperSocket、TouchSocke 等开源项目 ,这里记录一下。可能今后都不会用些轮子了,.net5、.net6、.net7、.net8 项目已经集成了WebSocket,只要 a
C语言数据类型、变量的输入和输出、进制转换
scanf标准函数可以从键盘得到数字并记录到存储区里,为了使用这个标准函数需要包含stdio.h这个头文件 在scanf函数调用语句里应该使用存储区的地址表示存储区;双引号里使用占位符表示存储区的类型, 在scanf函数调用语句里尽量不要写不是占位符的内容,如果用户输入的格式和程序要求的格式不同 程
博客园终身会员小福利,送华为云服务器
最近我们和华为云总经销商浙江杭云网络科技有限公司达成了合作,准备从10月开始做一些华为云的代理业务,增加园子的收入来源。 在做这个业务之前,先给园子的终身会员送点华为云服务器作为小福利,这次只申请到100台,先到先得,送完为止。 赠送的云服务器配置如下: 终身VIP会员 :送1核2G1M华为云服务器
博客园终身会员小福利,送华为云服务器
仅需6步,实现虚拟物体在现实世界的精准放置
增强现实(AR)技术作为一种将数字信息和现实场景融合的创新技术,近年来得到了快速发展,并在多个应用领域展现出其独特的魅力。比如在教育行业,老师可以通过虚拟现实场景生动直观地帮助学生理解抽象概念;在旅游行业,AR技术还能虚拟历史文化场景、虚拟导航等,为游客提供更加沉浸的互动体验。 然而,对于应用来说,
仅需6步,实现虚拟物体在现实世界的精准放置 仅需6步,实现虚拟物体在现实世界的精准放置 仅需6步,实现虚拟物体在现实世界的精准放置
深度DFS 和 广度BFS搜索算法学习
目录广度优先的动态图深度优先的动态图广度和深度的具体步骤深度和广度的应用场景 图的两种遍历方式: 深度优先遍历(DFS——Depth First Search) 广度优先遍历(BFS——Breath First Search) 图的遍历算法里,处理临时数据,依赖两个抽象数据结构: 栈 队列 广度优先
深度DFS 和 广度BFS搜索算法学习 深度DFS 和 广度BFS搜索算法学习 深度DFS 和 广度BFS搜索算法学习
华为GaussDB数据库(单机版)在ARM环境下的安装指南
一、软件版本 机器配置:8核16G,CPU: Huawei Kunpeng 920 2.9GHz 操作系统:EulerOS 2.8 64bit with ARM 数据库版本:GaussDB Kernel 505.1.0 build 44f4fa53 二、部署流程 2.1 新建用户 ① 以omm用户为
华为GaussDB数据库(单机版)在ARM环境下的安装指南 华为GaussDB数据库(单机版)在ARM环境下的安装指南 华为GaussDB数据库(单机版)在ARM环境下的安装指南
VulnStack-红日靶机二
红日靶机二 环境搭建 只需要把虚拟机的 host-only(仅主机)网卡改为 10.10.10.0 网段,如下配置 把 NAT 网卡,改为 192.168.96.0 网段,如下 首先恢复到 v1.3 快照 让后点击放弃,放弃后再开机,用其他用户 .\de1ay:1qaz@WSX 凭证登陆,密码过期修
VulnStack-红日靶机二 VulnStack-红日靶机二 VulnStack-红日靶机二
手把手教你建【货币】一题的网络流模型
现在已知如下问题,并告诉你这题可以用网络流来解决,你该怎么做,该怎么建出网络流的模型? 一些前提: 显然可以发现绝不可能走横向向左的边,但可能走竖向向上的边(如下图) 那么图其实就是这样的:问从 \(s\) 到 \(t\) 的最小花费 如果没有那 \(m\) 条限制,我们直接跑最短路就行了,加上这些
手把手教你建【货币】一题的网络流模型 手把手教你建【货币】一题的网络流模型 手把手教你建【货币】一题的网络流模型