Browse Source

feat: 添加右键菜单、层级移动

jiaxing.liao 2 weeks ago
parent
commit
453d5fac19

+ 4 - 4
src/renderer/src/lvgl-widgets/button-matrix/ButtonMatrix.vue

@@ -19,7 +19,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, type CSSProperties } from 'vue'
 import defaultStyle from './style.json'
 
 const props = defineProps<{
@@ -30,7 +30,7 @@ const props = defineProps<{
   state?: string
 }>()
 
-const getProps = computed(() => {
+const getProps = computed((): Record<string, CSSProperties> => {
   const styles = props.styles
   let mainStyle = styles.find((item) => item.state === props.state && item.part.name === 'main')
   let itemsStyle = styles.find((item) => item.state === props.state && item.part.name === 'items')
@@ -47,7 +47,7 @@ const getProps = computed(() => {
       ?.find((item) => item.partName === 'items')
       ?.state.find((item) => item.state === props.state)?.style
   }
-  console.log(mainStyle, itemsStyle)
+
   return {
     rootStyle: {
       boxSizing: 'border-box',
@@ -98,7 +98,7 @@ const getProps = computed(() => {
       textAlign: itemsStyle?.text?.align,
       textDecoration: itemsStyle?.text?.strike ? 'line-through' : itemsStyle?.text?.underline,
       /* x 偏移量 | y 偏移量 | 阴影模糊半径 | 阴影扩散半径 | 阴影颜色 */
-      boxShodow: itemsStyle?.box
+      boxShadow: itemsStyle?.box
         ? `${itemsStyle.shadow?.offsetX}px ${itemsStyle.shadow?.offsetY}px ${itemsStyle.shadow?.width}px ${itemsStyle.shadow?.spread}px ${itemsStyle.shadow?.color}`
         : 'none'
     },

+ 212 - 2
src/renderer/src/store/modules/action.ts

@@ -1,5 +1,15 @@
+import { ref } from 'vue'
 import { defineStore } from 'pinia'
+
+import { bfsWalk } from 'simple-mind-map/src/utils'
+import { moveToPosition } from '@/utils'
+import { klona } from 'klona'
+import { v4 } from 'uuid'
+
 import { useProjectStore } from '@/store/modules/project'
+import { useAppStore } from './app'
+
+import type { BaseWidget } from '@/types/baseWidget'
 
 type AlignType =
   | 'left'
@@ -14,6 +24,8 @@ type AlignType =
   | 'hspace'
 export const useActionStore = defineStore('action', () => {
   const projectStore = useProjectStore()
+  const appStore = useAppStore()
+  const clipboard = ref<BaseWidget[]>()
 
   /**
    * 对齐组件
@@ -152,11 +164,209 @@ export const useActionStore = defineStore('action', () => {
    * 层级调整
    * @param type 层级调整类型
    */
-  const onLevel = (type: 'up' | 'down' | 'top' | 'bottom') => {}
+  const onLevel = (type: 'up' | 'down' | 'top' | 'bottom') => {
+    let widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+
+    bfsWalk(projectStore.activePage, (child) => {
+      // 对子节点遍历排序
+      child?.children?.forEach((item, index) => {
+        if (widgetIds.includes(item.id)) {
+          switch (type) {
+            case 'up': {
+              if (index > 0) {
+                moveToPosition(child.children, index, index - 1)
+              }
+              break
+            }
+            case 'down': {
+              if (index < child.children.length - 1) {
+                moveToPosition(child.children, index, index + 1)
+              }
+              break
+            }
+            case 'top': {
+              if (index > 0) {
+                moveToPosition(child.children, index, 0)
+              }
+              break
+            }
+            case 'bottom': {
+              if (index < child.children.length - 1) {
+                moveToPosition(child.children, index, child.children.length - index)
+              }
+              break
+            }
+          }
+          // 跑完一轮 隐藏位置调整的
+          widgetIds = widgetIds.filter((id) => id !== item.id)
+        }
+      })
+    })
+  }
+
+  /**
+   * 删除页面
+   * @param pageId 页面ID
+   */
+  const onDeletePage = (pageId: string) => {
+    projectStore.project?.screens.forEach((screen) => {
+      screen.pages = screen.pages.filter((page) => page.id !== pageId)
+    })
+  }
+
+  /**
+   * 根据控件ID删除控件
+   * @param widgetId 控件ID
+   */
+  const onDeleteById = (widgetId: string) => {
+    projectStore.project?.screens.forEach((screen) => {
+      bfsWalk(screen.pages, (child) => {
+        const index = child?.children?.findIndex((item) => item.id === widgetId) ?? -1
+        if (index !== -1) {
+          child.children.splice(index, 1)
+        }
+      })
+    })
+  }
+
+  /**
+   * 删除控件
+   */
+  const onDelete = () => {
+    const widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+    if (!widgetIds.length) return
+
+    projectStore.project?.screens.forEach((screen) => {
+      bfsWalk(screen.pages, (child) => {
+        const index = child?.children?.findIndex((item) => widgetIds.includes(item.id)) ?? -1
+        if (index !== -1) {
+          child.children.splice(index, 1)
+        }
+      })
+    })
+  }
+
+  /**
+   * 复制
+   *
+   */
+  const onCopy = () => {
+    clipboard.value = klona(projectStore.activeWidgets)
+  }
+
+  /**
+   * 复用
+   */
+  const onCopyFrom = () => {
+    const widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+    if (!widgetIds.length) return
+
+    bfsWalk(projectStore.activePage, (child) => {
+      const obj = child?.children?.find((item) => widgetIds.includes(item.id))
+      if (obj) {
+        const newWidget = klona(obj)
+        newWidget.id = v4()
+        if (!newWidget.isCopy) {
+          newWidget.isCopy = true
+          projectStore.project?.widgets?.push(newWidget)
+        }
+        obj.copyFrom = newWidget.id
+        obj.isCopy = true
+        // 复制一份复用的
+        child.children.unshift({
+          ...newWidget,
+          id: v4()
+        })
+      }
+    })
+  }
+
+  /**
+   * 锁定/解锁
+   */
+  const onLock = (lock: boolean) => {
+    const widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+    if (!widgetIds.length) return
+
+    bfsWalk(projectStore.activePage, (child) => {
+      const obj = child?.children?.find((item) => widgetIds.includes(item.id))
+      if (obj) {
+        obj.locked = lock
+      }
+    })
+  }
+
+  /**
+   * 粘贴
+   */
+  const onPaste = () => {
+    if (!clipboard.value?.length) return
+
+    const list = projectStore.activeWidgets?.[0]?.children || projectStore.activePage?.children
+    clipboard.value.forEach((obj) => {
+      obj.x += 10
+      obj.y += 10
+      const newWidget = klona(obj)
+      list.unshift({
+        ...newWidget,
+        id: v4(),
+        events: []
+      })
+    })
+  }
+
+  /**
+   * 剪切
+   */
+  const onCut = () => {
+    const widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+    if (!widgetIds.length) return
+    clipboard.value = []
+    bfsWalk(projectStore.activePage, (child) => {
+      const index = child?.children?.findIndex((item) => widgetIds.includes(item.id)) ?? -1
+      if (index != -1) {
+        clipboard.value?.push(child.children[index])
+        child.children.splice(index, 1)
+      }
+    })
+  }
+
+  /**
+   * 隐藏/显示
+   */
+  const onHidden = (hidden: boolean) => {
+    const widgetIds = projectStore.activeWidgets.map((widget) => widget.id)
+    if (!widgetIds.length) return
+
+    bfsWalk(projectStore.activePage, (child) => {
+      const obj = child?.children?.find((item) => widgetIds.includes(item.id))
+      if (obj) {
+        obj.hidden = hidden
+      }
+    })
+  }
+
+  /**
+   * 显示事件
+   */
+  const onShowEvent = () => {
+    appStore.showComposite = true
+    appStore.compositeTabAcitve = 'event'
+  }
 
   return {
     onAlign,
     onMatchSize,
-    onLevel
+    onLevel,
+    onDeletePage,
+    onDelete,
+    onDeleteById,
+    onCopy,
+    onCopyFrom,
+    onLock,
+    onPaste,
+    onCut,
+    onHidden,
+    onShowEvent
   }
 })

+ 0 - 19
src/renderer/src/store/modules/project.ts

@@ -208,23 +208,6 @@ export const useProjectStore = defineStore('project', () => {
     ElMessage.success(t('saveSuccess'))
   }
 
-  // 删除页面
-  const deletePage = (pageId: string) => {
-    project.value?.screens.forEach((screen) => {
-      screen.pages = screen.pages.filter((page) => page.id !== pageId)
-    })
-  }
-
-  // 删除控件
-  const deleteWidget = (widgetId: string) => {
-    project.value?.screens.forEach((screen) => {
-      screen.pages.forEach((page) => {
-        // TODO: 遍历删除
-        page.children = page.children.filter((widget) => widget.id !== widgetId)
-      })
-    })
-  }
-
   // 打开本地项目
   const openLocalProject = async () => {
     const paths = await window.electron.ipcRenderer.invoke('get-file', {
@@ -278,8 +261,6 @@ export const useProjectStore = defineStore('project', () => {
     activePageId,
     activePage,
     activeWidget,
-    deletePage,
-    deleteWidget,
     projectPath,
     imageCompressFormat,
     loadProject,

+ 19 - 0
src/renderer/src/utils/index.ts

@@ -64,3 +64,22 @@ export function getAddWidgetIndex(page: Page, type: string) {
 
   return count + 1
 }
+
+/**
+ * 移动数组位置
+ * @param arr 数组
+ * @param fromIndex 原索引
+ * @param toIndex 目标索引
+ */
+export function moveToPosition(arr, fromIndex, toIndex) {
+  if (fromIndex === toIndex) return arr
+  // 处理负索引(从末尾计数)
+  const normalizeIndex = (i) => (i < 0 ? Math.max(0, arr.length + i) : Math.min(i, arr.length - 1))
+
+  const normalizedFrom = normalizeIndex(fromIndex)
+  const normalizedTo = normalizeIndex(toIndex)
+
+  const [item] = arr.splice(normalizedFrom, 1)
+  arr.splice(normalizedTo, 0, item)
+  return arr
+}

+ 6 - 10
src/renderer/src/views/designer/sidebar/components/PageTreeItem.vue

@@ -12,13 +12,7 @@
     </div>
     <div class="flex items-center gap-4px pr-12px invisible group-hover/item:visible">
       <el-tooltip v-if="data.type !== 'page'" content="删除">
-        <span>
-          <el-popconfirm class="box-item" title="确认删除?" @confirm="deleteWidget(data)">
-            <template #reference>
-              <span><LuTrash2 size="14px" /></span>
-            </template>
-          </el-popconfirm>
-        </span>
+        <span @click="deleteWidget(data)"><LuTrash2 size="14px" /></span>
       </el-tooltip>
       <el-tooltip content="隐藏/显示">
         <span @click.capture.stop="data.hidden = !data.hidden">
@@ -26,9 +20,9 @@
           <LuEyeOff size="14px" v-else />
         </span>
       </el-tooltip>
-      <el-tooltip content="锁定/解锁">
+      <el-tooltip :content="data.locked ? '解锁' : '锁定'">
         <span @click.capture.stop="data.locked = !data.locked">
-          <LuLock size="14px" v-if="!data.locked" />
+          <LuLock size="14px" v-if="data.locked" />
           <LuUnlock size="14px" v-else />
         </span>
       </el-tooltip>
@@ -50,6 +44,7 @@ import {
   LuBox
 } from 'vue-icons-plus/lu'
 import { useProjectStore } from '@/store/modules/project'
+import { useActionStore } from '@/store/modules/action'
 
 const props = defineProps<{
   node: RenderContentContext['node']
@@ -57,10 +52,11 @@ const props = defineProps<{
 }>()
 
 const projectStore = useProjectStore()
+const actionStore = useActionStore()
 
 // 删除控件
 const deleteWidget = (data: BaseWidget) => {
-  projectStore.deleteWidget(data.id)
+  actionStore.onDeleteById(data.id)
 }
 </script>
 

+ 3 - 1
src/renderer/src/views/designer/sidebar/components/ScreenTreeItem.vue

@@ -73,6 +73,7 @@ import {
 import { useProjectStore } from '@/store/modules/project'
 import { createPage } from '@/model'
 import { Page } from '@/types/page'
+import { useActionStore } from '@/store/modules/action'
 
 defineProps<{
   node: RenderContentContext['node']
@@ -81,6 +82,7 @@ defineProps<{
 
 const edit = ref(false)
 const projectStore = useProjectStore()
+const actionStore = useActionStore()
 
 // 创建页面
 const addPage = (screen: Screen) => {
@@ -95,7 +97,7 @@ const addPage = (screen: Screen) => {
 
 // 删除页面
 const deletePage = (page: Page) => {
-  projectStore.deletePage(page.id)
+  actionStore.onDeletePage(page.id)
 }
 </script>
 

+ 13 - 4
src/renderer/src/views/designer/tools/Operate.vue

@@ -37,6 +37,7 @@ const actionStore = useActionStore()
 const projectMenu = computed((): MenuItemType[] => {
   const disabledAlign = projectStore.activeWidgets.length < 2
   const disabledAvg = projectStore.activeWidgets.length < 3
+  const disabledLevel = !projectStore.activeWidgets.length
   return [
     {
       key: 'undo',
@@ -145,22 +146,30 @@ const projectMenu = computed((): MenuItemType[] => {
     {
       key: 'up',
       label: '上移一层',
-      img: LuArrowUp
+      img: LuArrowUp,
+      disabled: disabledLevel,
+      onClick: () => actionStore.onLevel('up')
     },
     {
       key: 'down',
       label: '下移一层',
-      img: LuLayoutGrid
+      img: LuLayoutGrid,
+      disabled: disabledLevel,
+      onClick: () => actionStore.onLevel('down')
     },
     {
       key: 'top',
       label: '置于顶层',
-      img: LuArrowUpToLine
+      img: LuArrowUpToLine,
+      disabled: disabledLevel,
+      onClick: () => actionStore.onLevel('top')
     },
     {
       key: 'bottom',
       label: '置于底层',
-      img: LuArrowDownToLine
+      img: LuArrowDownToLine,
+      disabled: disabledLevel,
+      onClick: () => actionStore.onLevel('bottom')
     }
   ]
 })

+ 119 - 0
src/renderer/src/views/designer/workspace/stage/ContentMenu.vue

@@ -0,0 +1,119 @@
+<template>
+  <el-dropdown
+    ref="dropdownRef"
+    :virtual-ref="virtualRef"
+    :show-arrow="false"
+    :popper-options="{
+      modifiers: [{ name: 'offset', options: { offset: [0, 0] } }]
+    }"
+    virtual-triggering
+    trigger="contextmenu"
+    placement="bottom-start"
+    size="small"
+    popper-class="w-180px"
+  >
+    <template #dropdown>
+      <el-dropdown-menu>
+        <el-dropdown-item
+          v-for="item in widgetMenus"
+          :icon="item.icon"
+          :divided="item.divider"
+          @click="item.onclick"
+        >
+          {{ item.label }}
+        </el-dropdown-item>
+      </el-dropdown-menu>
+    </template>
+  </el-dropdown>
+</template>
+
+<script setup lang="ts">
+import type { DropdownInstance } from 'element-plus'
+
+import { ref } from 'vue'
+import {
+  LuCopy,
+  LuCopyPlus,
+  LuScissors,
+  LuClipboard,
+  LuTrash2,
+  LuEyeOff,
+  LuLock,
+  LuUnlock,
+  LuArrowUpToLine,
+  LuArrowDownToLine,
+  LuArrowUp,
+  LuArrowDown,
+  LuZap
+} from 'vue-icons-plus/lu'
+
+import { useActionStore } from '@/store/modules/action'
+
+defineProps<{
+  virtualRef: any
+}>()
+
+const dropdownRef = ref<DropdownInstance>()
+
+const actionStore = useActionStore()
+
+const widgetMenus = [
+  { label: '复制', value: 'copy', icon: LuCopy, onclick: () => actionStore.onCopy() },
+  { label: '复用', value: 'duplicate', icon: LuCopyPlus, onclick: () => actionStore.onCopyFrom() },
+  { label: '剪切', value: 'cut', icon: LuScissors, onclick: () => actionStore.onCut() },
+  { label: '粘贴', value: 'paste', icon: LuClipboard, onclick: () => actionStore.onPaste() },
+  {
+    label: '删除',
+    value: 'delete',
+    icon: LuTrash2,
+    divider: true,
+    onclick: () => actionStore.onDelete()
+  },
+  { label: '隐藏', value: 'hidden', icon: LuEyeOff, onclick: () => actionStore.onHidden(true) },
+  { label: '锁定', value: 'locked', icon: LuLock, onclick: () => actionStore.onLock(true) },
+  {
+    label: '解锁',
+    value: 'unlock',
+    icon: LuUnlock,
+    onclick: () => actionStore.onLock(false)
+  },
+  {
+    label: '上移一层',
+    value: 'up',
+    icon: LuArrowUp,
+    divider: true,
+    onclick: () => actionStore.onLevel('up')
+  },
+  {
+    label: '下移一层',
+    value: 'down',
+    icon: LuArrowDown,
+    onclick: () => actionStore.onLevel('down')
+  },
+  { label: '置顶', value: 'top', icon: LuArrowUpToLine, onclick: () => actionStore.onLevel('top') },
+  {
+    label: '置底',
+    value: 'bottom',
+    icon: LuArrowDownToLine,
+    onclick: () => actionStore.onLevel('bottom')
+  },
+  {
+    label: '添加事件',
+    value: 'event',
+    icon: LuZap,
+    divider: true,
+    onclick: () => actionStore.onShowEvent()
+  }
+]
+
+defineExpose({
+  handleClose: () => {
+    dropdownRef.value?.handleClose()
+  },
+  handleOpen: () => {
+    dropdownRef.value?.handleOpen()
+  }
+})
+</script>
+
+<style scoped></style>

+ 55 - 4
src/renderer/src/views/designer/workspace/stage/Node.vue

@@ -5,6 +5,7 @@
     :class="schema.type === 'page' ? '' : 'ignore-click widget-node'"
     :widget-id="schema.id"
     @click.stop="handleSelect"
+    @contextmenu.stop="handleContextmenu"
     v-if="!schema.hidden"
   >
     <!-- 控件 -->
@@ -12,8 +13,9 @@
     <!-- 子节点 -->
     <NodeItem
       v-if="schema.children"
-      v-for="child in schema.children"
+      v-for="(child, index) in schema.children"
       :key="child.id"
+      :zIndex="schema.children.length - index"
       :schema="child"
       :rootContainer="nodeRef!"
     />
@@ -29,6 +31,8 @@
       :snappable="true"
       :useMutationObserver="true"
       :useResizeObserver="true"
+      :throttleDrag="1"
+      :throttleResize="1"
       :snapDirections="{
         top: true,
         left: true,
@@ -55,14 +59,17 @@
       @resize="onResize"
     />
   </div>
+  <!-- 右键菜单 -->
+  <ContentMenu ref="contentMenuRef" :virtualRef="triggerRef" />
 </template>
 
 <script setup lang="ts">
 import type { BaseWidget } from '@/types/baseWidget'
 import type { Page } from '@/types/page'
 import type { StageState } from './type'
+import type { CSSProperties } from 'vue'
 
-import { computed, type CSSProperties, ref, inject } from 'vue'
+import { computed, ref, inject } from 'vue'
 import { useDrop } from 'vue-hooks-plus'
 import { createWidget } from '@/model'
 import LvglWidgets from '@/lvgl-widgets'
@@ -70,6 +77,7 @@ import { useProjectStore } from '@/store/modules/project'
 import { useMutationObserver } from '@vueuse/core'
 
 import Moveable from 'vue3-moveable'
+import ContentMenu from './ContentMenu.vue'
 import { getAddWidgetIndex } from '@/utils'
 
 defineOptions({
@@ -87,6 +95,8 @@ const props = defineProps<{
   schema: BaseWidget | Page
   // 传入样式 如页面样式
   style?: CSSProperties
+  // 层级
+  zIndex?: number
 }>()
 
 const projectStore = useProjectStore()
@@ -101,7 +111,7 @@ const selected = computed(() =>
 
 // 组件样式
 const getStyle = computed((): CSSProperties => {
-  const { style = {}, schema } = props
+  const { style = {}, schema, zIndex } = props
 
   return {
     position: 'absolute',
@@ -110,6 +120,7 @@ const getStyle = computed((): CSSProperties => {
     transform: `translate(${schema.props.x}px, ${schema.props.y}px)`,
     width: schema.props.width + 'px',
     height: schema.props.height + 'px',
+    zIndex: zIndex,
     ...style
   }
 })
@@ -168,7 +179,8 @@ useDrop(nodeRef, {
     const newWidget = createWidget(content, index)
     newWidget.props.x = offsetX
     newWidget.props.y = offsetY
-    props.schema.children?.push(newWidget)
+    // 添加到前面
+    props.schema.children?.unshift(newWidget)
     projectStore.setSelectWidgets([newWidget])
   }
 })
@@ -183,6 +195,8 @@ const handleSelect = (e) => {
       projectStore.setSelectWidgets([props.schema as BaseWidget])
     }
   }
+  // 关闭右键菜单
+  contentMenuRef.value?.handleClose()
 }
 
 // 渲染节点拖拽
@@ -209,4 +223,41 @@ const onResize = (e) => {
 const onRender = (e) => {
   e.target.style.cssText += e.cssText
 }
+
+/******************************右键菜单*********************************/
+const position = ref({
+  top: 0,
+  left: 0,
+  bottom: 0,
+  right: 0
+} as DOMRect)
+
+const contentMenuRef = ref<InstanceType<typeof ContentMenu>>()
+
+const triggerRef = ref({
+  getBoundingClientRect: () => position.value
+})
+
+const handleContextmenu = (event: MouseEvent) => {
+  const { clientX, clientY } = event
+  position.value = DOMRect.fromRect({
+    x: clientX,
+    y: clientY
+  })
+  event.preventDefault()
+  contentMenuRef.value?.handleOpen()
+  // todo 关闭其他菜单
+
+  // 没选中当前节点时 右键选中节点
+  if (!projectStore.activeWidgets.map((item) => item.id).includes(props.schema.id)) {
+    if (props.schema.type !== 'page') {
+      // 判断当前是否按住ctrl
+      if (event.ctrlKey) {
+        projectStore.activeWidgets.push(props.schema as BaseWidget)
+      } else {
+        projectStore.setSelectWidgets([props.schema as BaseWidget])
+      }
+    }
+  }
+}
 </script>

src/renderer/src/views/designer/workspace/stage/Scaleplate.vue → src/renderer/src/views/designer/workspace/stage/Ruler.vue


+ 2 - 2
src/renderer/src/views/designer/workspace/stage/index.vue

@@ -15,7 +15,7 @@
         <!-- 画布 -->
         <DesignerCanvas :state="state" :page="page" ref="canvasRef" @changeState="handleSetState" />
         <!-- 标尺 -->
-        <Scaleplate :state="state" :page="page" v-show="state.showRuler" />
+        <Ruler :state="state" :page="page" v-show="state.showRuler" />
       </div>
       <!-- 底部工具栏 -->
       <div
@@ -84,7 +84,7 @@ import type { Screen } from '@/types/screen'
 import type { Page } from '@/types/page'
 
 import { ref, reactive, watch, nextTick, provide } from 'vue'
-import Scaleplate from './Scaleplate.vue'
+import Ruler from './Ruler.vue'
 import DesignerCanvas from './DesignerCanvas.vue'
 import { throttle } from 'lodash-es'
 import {