likes
comments
collection
share

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

作者站长头像
站长
· 阅读数 9

🌈介绍

基于 vue3.x + CompositionAPI + typescript + vite 的可拖拽、缩放、旋转的组件

  • 拖拽&区域拖拽
  • 支持缩放
  • 旋转
  • 网格拖拽缩放

在线示例

源码地址

上一节分享了元素的组合与拆分,今天的主要任务是完成编辑器的右键菜单操作栏和数据导入导出等功能。

右键菜单操作栏

最终效果

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

常见的操作有删除、复制、创建副本、置顶、置底、组合、取消组合、锁定/解锁、上移、下移等

在开始讲具体功能前,我们先来创建一个$contentmenu方法。当在画布右键时调用显示菜单

第一步准备组件模板

这个组件的主要功能是根据传入的配置对象在指定位置弹出一个右键菜单,并且可以通过点击菜单项执行相应的回调函数。

<template>
  <div>
    <div ref="triggerRef" class="es-trigger" :style="triggerStyle"></div>
    <div
      ref="menuRef"
      v-show="state.visible"
      class="es-contentmenu"
      :style="style"
      @click.stop
      @mousedown.stop
    >
      <ul v-if="state.option.items">
        <li v-for="item in state.option.items" @click="handleItemClick(item)">{{ item.label }}</li>
      </ul>
    </div>
  </div>
</template>

<script setup lang='ts'>
import { ref, computed, onMounted, reactive, onBeforeUnmount, PropType } from 'vue'
import { computePosition, flip, shift, offset } from '@floating-ui/dom'
import { MenuItem, MenuOption } from './index'
const props = defineProps({
  option: {
    type: Object as PropType<MenuOption>,
    default: () => ({})
  }
})
const triggerRef = ref()
const menuRef = ref()

const state = reactive({
  option: props.option,
  visible: false,
  top: 0,
  left: 0
})

// 菜单的位置
const style = computed(() => ({ left: state.left + 'px', top: state.top + 'px' }))
// 触发器的位置
const triggerStyle = computed(() => ({ left: state.option.clientX + 'px', top: state.option.clientY + 'px' }))

// floating-ui 中间件
const middleware = [shift(), flip(), offset(10)]

const open = (option: Record<string, any>) => {
  state.option = option
  state.visible = true
  // 每次打开计算最新位置
  computePosition(triggerRef.value, menuRef.value, { middleware }).then(data => {
    state.left = data.x
    state.top = data.y
  })
}
const close = () => {
  state.visible = false
}

// 点击菜单项
const handleItemClick = (item: MenuItem) => {
  state.option.onClick && state.option.onClick(item)
  close()
}

onMounted(() => {
  document.addEventListener('mousedown', close)
})

onBeforeUnmount(() => {
  document.removeEventListener('mousedown', close)
})

defineExpose({
  open,
  close
})
</script>

解析:

  1. 首先我们要准备两个元素,触发器(trigger)和菜单容器
  2. styletriggerStyle 是计算属性,根据状态对象的值计算右键菜单和触发器的位置
  3. 提供open和close方法,每次调用 open 执行 floating-ui 的 computePosition 函数传入需要的参数

上面的 floating-ui 不是必须项,可以自己选择是否使用

封装方法 $contextmenu

import { useEditorContainer } from '@/hooks'
import { VNode, createVNode, render } from 'vue'
import Menu from './Menu.vue'

export type ActionType = 'remove' | 'copy' | 'paste' | 'duplicate' | 'top' | 'bottom' | 'bottom' | 'group' | 'ungroup' | 'selectAll' | 'lock' | 'moveUp' | 'moveDown'

export type MenuItem = {
  label: string,
  action: ActionType
}

export type MenuOption = {
  clientX?: number
  clientY?: number
  items?: MenuItem[]
  onClick?: (item: MenuItem) => void
}

let vm: VNode | null = null
export function $contextmenu(option: MenuOption) {
  if (!vm) {
    const { container: globalContainer } = useEditorContainer()
    const container = document.createElement('div')
    vm = createVNode(Menu, { option })

    // 将组件渲染成真实节点
    render(vm, container)

    globalContainer.appendChild(container.firstElementChild!)
  }

  const { open } = vm.component!.exposed!
  open(option)
}
  • useEditorContainer 实现如下,该函数的主要作用就是创建一个全局容器并添加到body中,我们可以将动态弹窗统一放到这个容器中
let cachedContainer: HTMLElement
const selector = `es-editor-container-1996`

type EditorContainerType = {
  container: HTMLElement
  selector: string
}

export const useEditorContainer = (): EditorContainerType => {
  if (!cachedContainer && !document.querySelector(`#${selector}`)) {
    const container = document.createElement('div')
    container.id = selector
    cachedContainer = container
    document.body.appendChild(container)
  }

  return {
    container: cachedContainer,
    selector
  }
}

调用方式

<div class="es-editor" @contextmenu.prevent="onEditorContextMenu"></div>
<script setup lang='ts'>
import { $contextmenu } from '@/components/common'
function onEditorContextMenu(e: MouseEvent) {
  const { clientX, clientY }  = e
  $contextmenu({
    clientX,
    clientY,
    items: [
      { action: 'remove', label: '删除' },
      { action: 'copy', label: '复制' },
      { action: 'duplicate', label: '创建副本' },
      { action: 'top', label: '置顶' },
      { action: 'bottom', label: '置底' },
      { action: 'moveUp', label: '上移一层' },
      { action: 'moveDown', label: '下移一层' }
    ],
    onClick({ action }) {
      console.log(action)
    }
  })
}
</script>

效果如下:

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

可以看到,当下方的可视区域显示不下的时候就会显示在上方。这就是floating-ui的效果

具体actions实现

<template>
  <div class="es-container">
    <div
      ref="editorRef"
      class="es-editor"
      @contextmenu.prevent="onEditorContextMenu"
    >
      <Drager
        v-for="item in data.elements"
        v-bind="item"
        rotatable
        @contextmenu.stop="onContextmenu($event, item)"
        @click.stop
        @mousedown.stop
      >
        <component
          :is="item.component!"
          v-bind="item.props"
          :style="{
            ...item.style,
            width: '100%',
            height: '100%'
          }"
        >
          {{ item.text }}
        </component>
      </Drager>
      <GridRect />
    </div>
  </div>
</template>

<script setup lang='ts'>
import { ref } from 'vue'
import GridRect from '@/components/editor/GridRect.vue'
import Drager from 'es-drager'
import { EditorType } from '@/components/types'
import { useId } from '@/utils'
import { useActions } from '@/hooks'
const data = ref<EditorType>({
  elements: [{
    id: useId(),
    component: 'div',
    width: 100,
    height: 100,
    left: 100,
    top: 100,
    text: 'div1',
    style: { backgroundColor: '#fff2cc', border: '2px solid #d6b656' }
  },
  {
    id: useId(),
    component: 'div',
    width: 100,
    height: 100,
    left: 300,
    top: 150,
    text: 'div2',
    style: {backgroundColor: '#f8cecc', border: '2px solid #b85450'}
  }]
})
const editorRef = ref<HTMLElement | null>(null)
const {
  onEditorContextMenu,
  onContextmenu
} = useActions(data, editorRef)
</script>

<style lang='scss' scoped>
.es-container {
  width: 800px;
  height: 600px;
  position: absolute;
  left: 50%;
  top: 40%;
  transform: translate(-50%, -50%);
  .es-editor {
    position: relative;
    width: 800px;
    height: 600px;
  }
}
</style>

模板中使用了 useActions 钩子,接收 data 和画布的ref,返回 onEditorContextMenu(画布右键) 和 onContextmenu(元素右键)。注意元素右键要阻止冒泡

看看 useActions 的具体实现

import { $contextmenu, ActionType, MenuItem } from '@/components/common'
import { ComponentType, EditorType } from '@/components/types'
import { cancelGroup, deepCopy, makeGroup, useId } from '@/utils'
import { computed, Ref } from 'vue'
type ActionMethods = {
  [key in ActionType]?: (element: ComponentType, ...args: any[]) => void
}
export function useActions(
  data: Ref<EditorType>,
  editorRef: Ref<HTMLElement | null>
) {
  const editorRect = computed(() => {
    return editorRef.value?.getBoundingClientRect() || {} as DOMRect
  })
  // 当前右键元素
  let currentMenudownElement: ComponentType | null = null
  // 复制元素
  let copySnapshot: ComponentType | null = null

  // 获取指定元素的索引
  const getIndex = (element: ComponentType | null) => {
    if (!element) return -1
    return data.value.elements.findIndex(item => item.id === element.id)
  }

  // 交换两个元素
  const swap = (i: number, j: number) => {
    [data.value.elements[i], data.value.elements[j]] = [data.value.elements[j], data.value.elements[i]]
  }
  
  // 添加元素
  const addElement = (element: ComponentType | null) => {
    if (!element) return
    // 拷贝一份
    const newElement = deepCopy(element)
    // 修改id
    newElement.id = useId()
    data.value.elements.push(newElement)
  }
  const actions: ActionMethods = {
    remove() { // 删除
      const index = getIndex(currentMenudownElement)
      if (index > -1) data.value.elements.splice(index, 1)
    },
    cut(element) { // 剪切
      copySnapshot = element
      actions.remove!(element)
    },
    copy(element) { // 拷贝
      copySnapshot = element
    },
    duplicate(element) { // 创建副本
      const newElement = deepCopy(element)
      // 偏移left和top避免重叠
      newElement.left += 10
      newElement.top += 10
      addElement(newElement)
    },
    top(element) {
      // 获取当前元素索引
      const index = getIndex(element)
      // 将该索引的元素删除
      const [topElement] = data.value.elements.splice(index, 1)
      // 添加到末尾
      data.value.elements.push(topElement)
    },
    bottom(element) {
      // 获取当前元素索引
      const index = getIndex(element)
      // 将该索引的元素删除
      const [topElement] = data.value.elements.splice(index, 1)
      // 添加到开头
      data.value.elements.unshift(topElement)
    },
    group() { // 组合
      data.value.elements = makeGroup(data.value.elements, editorRect.value)
    },
    ungroup() { // 拆分
      data.value.elements = cancelGroup(data.value.elements, editorRect.value)
    },
    paste(_, clientX: number, clientY: number){ // 粘贴
      if (!copySnapshot) return
      const element = deepCopy(copySnapshot)
      // 计算粘贴位置
      element.left = clientX - editorRect.value!.left
      element.top = clientY - editorRect.value!.top
  
      addElement(element)
    },
    selectAll() { // 全选
      data.value.elements.forEach(item => item.selected = true)
    },
    lock(element) { // 锁定/解锁
      const index = getIndex(element)
      data.value.elements[index].disabled = !data.value.elements[index].disabled
    },
    moveUp(element) { // 上移
      // 获取当前元素索引
      const index = getIndex(element)
      // 不能超过边界
      if (index >= data.value.elements.length - 1) {
        return 
      }

      swap(index, index + 1)
    },
    moveDown(element) { // 下移
      // 获取当前元素索引
      const index = getIndex(element)
      // 不能超过边界
      if (index <= 0) {
        return 
      }

      swap(index, index - 1)
    }
  }

  // 元素右键菜单
  const onContextmenu = (e: MouseEvent, item: ComponentType) => {
    e.preventDefault()
    const { clientX, clientY }  = e
    currentMenudownElement = deepCopy(item)
    
    const selectedElements = data.value.elements.filter(item => item.selected)
    const actionItems: MenuItem[] = [
      { action: 'remove', label: '删除' },
      { action: 'cut', label: '剪切' },
      { action: 'copy', label: '复制' },
      { action: 'duplicate', label: '创建副本' },
      { action: 'top', label: '置顶' },
      { action: 'bottom', label: '置底' },
      { action: 'moveUp', label: '上移一层' },
      { action: 'moveDown', label: '下移一层' },
    ]
    if (!item.group && selectedElements.length > 1) {
      // 如果不是组合元素并且有多个选中元素,则显示组合操作
      actionItems.push({ action: 'group', label: '组合' })
    } else {
      // 显示取消组合操作
      item.group && actionItems.push({ action: 'ungroup', label: '取消组合' })
    }

    const isLocked = currentMenudownElement!.disabled
    const lockAction: MenuItem = { action: 'lock', label: '锁定 / 解锁' }
    if (!isLocked) {
      actionItems.push(lockAction)
    }
    $contextmenu({
      clientX,
      clientY,
      items: !isLocked ? actionItems : [lockAction], // 如果是锁定元素只显示解锁操作
      onClick: ({ action }) => {
        if (actions[action]) {
          actions[action]!(currentMenudownElement!)
        }
      }
    })
  }

  // 画布右键菜单
  const onEditorContextMenu = (e: MouseEvent) => {
    const { clientX, clientY }  = e
    $contextmenu({
      clientX,
      clientY,
      items: [
        { action: 'paste', label: '在这粘贴' },
        { action: 'selectAll', label: '全选' },
      ],
      onClick({ action }) {
        if (action === 'paste') {
          actions.paste!(currentMenudownElement!, clientX, clientY)
        } else {
          actions[action] && actions[action]!(currentMenudownElement!)
        }
      }
    })
  }

  return {
    onContextmenu,
    onEditorContextMenu
  }
}

由于注释很详细,代码也不复杂,这里就简单说一下:

  • currentMenudownElement: 存储当前右键点击的元素。copySnapshot: 存储复制的元素的快照。

  • 所有操作方法统一放在 actions 对象中,里面的方法也都是常见的数组操作

  • onContextmenu(e, item): 处理元素的右键菜单

    1. 每次右键保存当前元素
    2. 创建默认 actionItems
    3. 判断是否是组合元素,如果有多个元素选中显示组合,如果是组合元素显示拆分
    4. 判断元素是否锁定,非锁定添加到 actionItems 中,否则只显示锁定/解锁操作
    5. 当点击菜单项的时候触发 onClick,根据 action 调用对应的方法即可
    6. 组合与拆分的具体细节可以参考前一篇文章
  • onEditorContextMenu(e): 处理画布的右键菜单,提供粘贴和全选操作。

什么。。。还有键盘事件,有了上面的 actions,键盘事件就...

还是写写吧!(●ˇ∀ˇ●)

键盘事件

还是在 useActions 这个 hook 里

  • 定义键盘映射表
// 键盘映射表
const keyboardMap = {
  ['ctrl+x']: 'cut',
  ['ctrl+c']: 'copy',
  ['ctrl+v']: 'paste',
  ['Delete']: 'remove',
  ['ctrl+a']: 'selectAll',
  ['ctrl+d']: 'duplicate'
}
  • 添加事件监听
// 监听键盘事件
const onKeydown = (e: KeyboardEvent) => {
  const { ctrlKey, key } = e
  // 拼凑按下的键
  const keyArr = []
  if (ctrlKey) keyArr.push('ctrl')
  keyArr.push(key)
  const keyStr = keyArr.join('+')
  // 获取操作
  const action = (keyboardMap as any)[keyStr]! as ActionType
  // 如果actions中有具体的操作则执行
  if (actions[action]) {
    e.preventDefault()
    // 找到当前选中的元素
    currentMenudownElement = data.value.elements.find(item => item.selected) || null
    actions[action]!(currentMenudownElement!)
  }
}

onMounted(() => {
  window.addEventListener('keydown', onKeydown)
})

onBeforeMount(() => {
  window.removeEventListener('keydown', onKeydown)
})

解析:

  1. 准备 keyboardMap 键盘映射表(这里只有4个),值需要与 actions 的方法对应,可以更多
  2. onKeydown 中获取到按下的键/组合,拼接成规定的格式
  3. 判断是否 actions 中有对应的方法,有则执行
  4. 注意:在右键的时候我们能知道是哪个元素,但在键盘事件里,只能查找当前选中的元素

json导入导出/插入图片

导入导出和 $contextmenu 一个套路,我们想通过方法调用,例如 $dialog({}) 或者 $upload({}) 的方式

导出

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

Dialog 模板

<template>
  <ElDialog
    v-model="state.visible"
    v-bind="state.option"
    draggable
  >
    <div id="esEditor"></div>

    <template #footer>
      <ElButton @click="close">取消</ElButton>
      <ElButton type="primary" @click="handleConfirm">保存编辑</ElButton>
      <ElButton type="primary" @click="handleExport">导出JSON</ElButton>
    </template>
  </ElDialog>
</template>

<script setup lang='ts'>
import { ElButton, ElDialog, dayjs} from 'element-plus'
import { nextTick, reactive } from 'vue'
import ace from 'ace-builds'
import 'ace-builds/src-min-noconflict/theme-one_dark'
import 'ace-builds/src-min-noconflict/mode-json5'
const props = defineProps({
  option: {
    type: Object,
    default: () => ({})
  }
})

const state = reactive({
  option: props.option,
  visible: false
})
let editor: ace.Ace.Editor

const open = (option: Record<string, any>) => {
  state.option = option
  state.visible = true

  nextTick(() => {
    editor = ace.edit('esEditor', {
      maxLines: 34,
      minLines: 34,
      fontSize: 14,
      theme: 'ace/theme/one_dark',
      mode: 'ace/mode/json5',
      tabSize: 4,
      readOnly: false
    })

    editor.setValue(JSON.stringify(JSON.parse(state.option.content), null, 4))
  })
}
// 关闭弹窗
const close = () => {
  state.visible = false
}

const handleConfirm = () => {
  const { confirm } = state.option
  confirm && confirm(editor && editor.getValue())
}

// 点击导出json
const handleExport = () => {
  if (!editor) return
  // 创建a标签
  const link = document.createElement('a')
  // 生成文件名称
  const filename = dayjs().format('YYYY-MM-DD') + '-es-drager.json'
  link.download = filename
  // 创建blob
  const blob = new Blob([editor.getValue()])
  // 创建临时url
  const href = URL.createObjectURL(blob)
  link.href = href
  // 调用click
  link.click()
  // 销毁
  URL.revokeObjectURL(href)
}

defineExpose({
  open,
  close
})
</script>

ace-editor 的具体使用请参考官网,其它的都是常规操作

  1. handleConfirm:点击确认调用 confirm 回调并把最新数据传递过去
  2. handleExport:使用a标签的方式,你们比我熟
  3. open 方法中由于页面上还没有id为 esEditor 这个元素,因此在 nextTick 里创建的 editor

$dialog 方法

import { useEditorContainer } from '@/hooks'
import { VNode, createVNode, render } from 'vue'
import Dialog from './Dialog.vue'


let vm: VNode | null = null
export function $dialog(option: Object) {
  if (!vm) {
    // 手动挂载组件
    const { container: globalContainer } = useEditorContainer()
    const container = document.createElement('div')
    vm = createVNode(Dialog, { option })

    // 将组件渲染成真实节点
    render(vm, container)

    globalContainer.appendChild(container.firstElementChild!)
  }

  const { open } = vm.component!.exposed!
  open(option)
}

导入json&插入图片(上传文件)

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

准备上传模板 upload.vue

<template>
  <input ref="inpurRef" type="file" :accept="state.option?.accept" class="es-upload" @change="handleChange">
</template>

<script setup lang='ts'>
import { ref, onMounted, reactive, PropType } from 'vue'
import { UploadOption } from './index'
const acceptMap = {
  json: '.json',
  image: 'image/*'
}
const props = defineProps({
  option: {
    type: Object as PropType<UploadOption>,
    required: true
  }
})
const state = {
  option: props.option
}
const inpurRef = ref()

const open = (option: UploadOption) => {
  state.option = option
  let accept = (acceptMap as any)[option.resultType]
  if (option.accept) {
    accept = option.accept
  }
  inpurRef.value.setAttribute('accept', accept)
  inpurRef.value.click()
}

const handleChange = async (e: Event) => {
  if (!state.option || !state.option.onChange) return

  const { resultType, onChange } = state.option
  let result: any = e
  const file: File = (e.target as any).files[0]
  // 如果是json或text使用readAsText读取
  if (['json', 'text'].includes(resultType)) {
    result = await readFile(file)
  } else if (resultType === 'image') {
    // 按照base64读取
    result = await readFile(file, resultType)
  }

  // 调用onChange回调并把数据传递过去
  onChange(result)
  inpurRef.value.value = ''
}


/**
 * 读取文件的文本内容
 */
const readFile = (file: File, type: string = 'text') => {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.addEventListener('load', (e) => {
      const result = e.target!.result || '{}'
      resolve(result)
    })

    if (type === 'text') {
      reader.readAsText(file)
    } else {
      reader.readAsDataURL(file)
    }
  })
}

onMounted(() => {
  open(props.option)
})

defineExpose({
  open
})
</script>

<style lang='scss' scoped>
.es-upload {
  display: none;
}
</style>

  1. 每次调用 open 方法自动触发 input 的点击事件打开文件选择框
  2. change 事件中根据传递的类型处理文件,也可以不处理。处理完毕后调用 onChange 回调把处理后的数据传递过去

$upload 方法

import { useEditorContainer } from '@/hooks'
import { VNode, createVNode, render } from 'vue'
import Upload from './Upload.vue'
type ResultType = 'text' | 'json' | 'image' | 'custom'
export type UploadOption = {
  resultType: ResultType
  accept?: string
  onChange?: (e: any) => void
}
let vm: VNode | null = null
export function $upload(option: UploadOption) {
  if (!vm) {
    // 手动挂载组件
    const { container: globalContainer } = useEditorContainer()
    const container = document.createElement('div')
    vm = createVNode(Upload, { option })

    // 将组件渲染成真实节点
    render(vm, container)

    globalContainer.appendChild(container.firstElementChild!)
  } else {
    // 第一次组件挂载会打开,后面调用open打开文件选择框
    const { open } = vm.component!.exposed!
    open(option)
  }
}

和上面一样的套路,这里就不展开了!

使用

const tools: ToolType[] = [
  { label: '导出', handler: () => {
    $dialog({
      title: '导出',
      content: JSON.stringify(data.value),
      confirm(text: string) {
        data.value = JSON.parse(text)
      }
    })
  }},
  { label: '导入', handler: () => {
    $upload({
      resultType: 'json',
      onChange(text: string) {
        data.value = JSON.parse(text)
      }
    })
  }},
  { label: '插入图片', handler: () => {
    $upload({
      resultType: 'image',
      onChange(e: string) {
        const newElement: ComponentType = {
          id: useId(),
          component: 'img',
          props: { src: e, width: 160, onLoad(e: Event) {
            // 图片加载完毕,得到原始宽高
            const { naturalHeight, naturalWidth } = e.target as any
            const cur = data.value.elements.find(item => item.id === newElement.id)!

            cur.width = naturalWidth
            cur.height = naturalHeight
          }}
        }

        data.value.elements.push(newElement)
      }
    })
  }}
]
<el-button v-for="item in tools" type="primary" @click="item.handler">{{ item.label }}</el-button>
  1. 导入和导出处理很简单,直接赋值最新数据即可
  2. 插入图片需要单独处理,需要等到图片加载完毕后获取到图片原始宽高

插入图片效果

可拖拽、缩放、旋转组件之 - 菜单操作栏、json数据导入导出

最后

本文主要介绍了编辑器里常见的右键菜单操作栏、导入导出、插入图片等功能。为了方便调用,这几个功能都封装成方法调用的方式。相信大家应该也熟悉这种封装方式了吧

查看完整代码请滑到顶部有 github 链接地址和在线示例

  • 相关文章