pipipi-pikachu 5 лет назад
Родитель
Сommit
ce9069d941

+ 7 - 7
src/configs/element.ts

@@ -1,12 +1,12 @@
 const DEFAULT_COLOR = '#41464b'
 
-export enum ELEMENTS {
-  text = '文本',
-  image = '图片',
-  shape = '形状',
-  line = '线条',
-  chart = '图表',
-  table = '表格',
+export enum ElementTypes {
+  TEXT = '文本',
+  IMAGE = '图片',
+  SHAPE = '形状',
+  LINE = '线条',
+  CHART = '图表',
+  TABLE = '表格',
 }
 
 export const DEFAULT_TEXT = {

+ 1 - 1
src/mocks/index.ts

@@ -3,7 +3,7 @@ import { Slide } from '@/types/slides'
 export const slides: Slide[] = [
   {
     id: 'xxx1',
-    background: ['solid', '#323f4f'],
+    background: ['solid', '#fff'],
     elements: [
       {
         elId: 'xxx1',

+ 4 - 0
src/store/constants.ts

@@ -24,4 +24,8 @@ export enum MutationTypes {
   UNDO = 'undo',
   REDO = 'redo',
   SET_HISTORY_RECORD_LENGTH = 'setHistoryRecordLength',
+
+  // keyboard
+  SET_CTRL_KEY_STATE = 'setCtrlKeyState',
+  SET_SHIFT_KEY_STATE = 'setShiftKeyState',
 }

+ 5 - 0
src/store/getters.ts

@@ -8,6 +8,7 @@ export type Getters = {
   handleElement(state: State): PPTElement | null;
   canUndo(state: State): boolean;
   canRedo(state: State): boolean;
+  ctrlOrShiftKeyActive(state: State): boolean;
 }
 
 export const getters: Getters = {
@@ -45,4 +46,8 @@ export const getters: Getters = {
   canRedo(state) {
     return state.cursor < state.historyRecordLength - 1
   },
+
+  ctrlOrShiftKeyActive(state) {
+    return state.ctrlKeyState || state.shiftKeyState
+  },
 }

+ 12 - 0
src/store/mutations.ts

@@ -36,6 +36,9 @@ export type Mutations = {
   [MutationTypes.UNDO](state: State): void;
   [MutationTypes.REDO](state: State): void;
   [MutationTypes.SET_HISTORY_RECORD_LENGTH](state: State, length: number): void;
+
+  [MutationTypes.SET_CTRL_KEY_STATE](state: State, isActive: boolean): void;
+  [MutationTypes.SET_SHIFT_KEY_STATE](state: State, isActive: boolean): void;
 }
 
 export const mutations: Mutations = {
@@ -143,4 +146,13 @@ export const mutations: Mutations = {
   [MutationTypes.SET_HISTORY_RECORD_LENGTH](state, length) {
     state.historyRecordLength = length
   },
+
+  // keyBoard
+
+  [MutationTypes.SET_CTRL_KEY_STATE](state, isActive) {
+    state.ctrlKeyState = isActive
+  },
+  [MutationTypes.SET_SHIFT_KEY_STATE](state, isActive) {
+    state.shiftKeyState = isActive
+  },
 }

+ 4 - 0
src/store/state.ts

@@ -15,6 +15,8 @@ export type State = {
   slideIndex: number;
   cursor: number;
   historyRecordLength: number;
+  ctrlKeyState: boolean;
+  shiftKeyState: boolean;
 }
 
 export const state: State = {
@@ -30,4 +32,6 @@ export const state: State = {
   slideIndex: 0,
   cursor: -1,
   historyRecordLength: 0,
+  ctrlKeyState: false,
+  shiftKeyState: false,
 }

+ 11 - 2
src/types/slides.ts

@@ -1,5 +1,14 @@
 export type ElementType = 'text' | 'image' | 'shape' | 'line' | 'chart' | 'table'
 
+export enum ElementTypes {
+  TEXT = 'text',
+  IMAGE = 'image',
+  SHAPE = 'shape',
+  LINE = 'line',
+  CHART = 'chart',
+  TABLE = 'table',
+}
+
 export interface PPTElementBaseProps {
   elId: string;
   isLock: boolean;
@@ -41,7 +50,7 @@ export interface PPTImageElement extends PPTElementBaseProps, PPTElementSizeProp
     range: [[number, number], [number, number]];
     shape: 'rect' | 'roundRect' | 'ellipse' | 'triangle' | 'pentagon' | 'rhombus' | 'star';
   };
-  flip?: { x?: number, y?: number };
+  flip?: { x?: number; y?: number };
   shadow?: string;
 }
 
@@ -70,7 +79,7 @@ export interface PPTChartElement extends PPTElementBaseProps, PPTElementSizeProp
   type: 'chart';
   chartType: string;
   theme: string;
-  data: Object;
+  data: string;
 }
 
 export interface TableElementCell {

+ 132 - 0
src/views/Editor/Canvas/MultiSelectOperate.vue

@@ -0,0 +1,132 @@
+<template>
+  <div 
+    class="multi-select-operate"
+    :style="{
+      left: minX + 'px',
+      top: minY + 'px',
+      transform: `scale(${1 / canvasScale})`,
+    }"
+  >
+    <BorderLine v-for="line in borderLines" :key="line.type" :type="line.type" :style="line.style" />
+
+    <template v-if="!disableResizablePoint">
+      <ResizablePoint
+        v-for="point in resizablePoints"
+        :key="point.type"
+        :type="point.type"
+        :style="point.style"
+        @mousedown.stop="scaleMultiElement($event, { minX, maxX, minY, maxY }, point.direction)"
+      />
+    </template>
+  </div>
+</template>
+
+<script lang="ts">
+import { computed, defineComponent, reactive, PropType, watch, toRefs, onMounted } from 'vue'
+import { OPERATE_KEYS } from '@/configs/element'
+import { PPTElement, ElementTypes } from '@/types/slides'
+import { getElementListRange } from './utils/elementRange'
+import { ElementScaleHandler, OperateResizablePointTypes, OperateBorderLineTypes } from '@/types/edit'
+
+import ResizablePoint from '@/views/_common/_operate/ResizablePoint.vue'
+import BorderLine from '@/views/_common/_operate/BorderLine.vue'
+
+interface Range {
+  minX: number;
+  maxX: number;
+  minY: number;
+  maxY: number;
+}
+
+export default defineComponent({
+  name: 'multi-select-operate',
+  components: {
+    ResizablePoint,
+    BorderLine,
+  },
+  props: {
+    canvasScale: {
+      type: Number,
+      required: true,
+    },
+    activeElementList: {
+      type: Array as PropType<PPTElement[]>,
+      required: true,
+    },
+    scaleMultiElement: {
+      type: Function as PropType<(e: MouseEvent, range: Range, command: ElementScaleHandler) => void>,
+      required: true,
+    },
+  },
+  setup(props) {
+    const range = reactive({
+      minX: 0,
+      maxX: 0,
+      minY: 0,
+      maxY: 0,
+    })
+
+    const width = computed(() => (range.maxX - range.minX) * props.canvasScale)
+    const height = computed(() => (range.maxY - range.minY) * props.canvasScale)
+
+    const resizablePoints = computed(() => {
+      return [
+        { type: OperateResizablePointTypes.TL, direction: OPERATE_KEYS.LEFT_TOP, style: {} },
+        { type: OperateResizablePointTypes.TC, direction: OPERATE_KEYS.TOP, style: {left: width.value / 2 + 'px'} },
+        { type: OperateResizablePointTypes.TR, direction: OPERATE_KEYS.RIGHT_TOP, style: {left: width.value + 'px'} },
+        { type: OperateResizablePointTypes.ML, direction: OPERATE_KEYS.LEFT, style: {top: height.value / 2 + 'px'} },
+        { type: OperateResizablePointTypes.MR, direction: OPERATE_KEYS.RIGHT, style: {left: width.value + 'px', top: height.value / 2 + 'px'} },
+        { type: OperateResizablePointTypes.BL, direction: OPERATE_KEYS.LEFT_BOTTOM, style: {top: height.value + 'px'} },
+        { type: OperateResizablePointTypes.BC, direction: OPERATE_KEYS.BOTTOM, style: {left: width.value / 2 + 'px', top: height.value + 'px'} },
+        { type: OperateResizablePointTypes.BR, direction: OPERATE_KEYS.RIGHT_BOTTOM, style: {left: width.value + 'px', top: height.value + 'px'} },
+      ]
+    })
+
+    const borderLines = computed(() => {
+      return [
+        { type: OperateBorderLineTypes.T, style: {width: width.value + 'px'} },
+        { type: OperateBorderLineTypes.B, style: {top: height.value + 'px', width: width.value + 'px'} },
+        { type: OperateBorderLineTypes.L, style: {height: height.value + 'px'} },
+        { type: OperateBorderLineTypes.R, style: {left: width.value + 'px', height: height.value + 'px'} },
+      ]
+    })
+
+    const disableResizablePoint = computed(() => {
+      return props.activeElementList.some(item => {
+        if(
+          (item.type === ElementTypes.IMAGE || item.type === ElementTypes.SHAPE) && 
+          !item.rotate
+        ) return false
+        return true
+      })
+    })
+
+    const setRange = () => {
+      const { minX, maxX, minY, maxY } = getElementListRange(props.activeElementList)
+      range.minX = minX
+      range.maxX = maxX
+      range.minY = minY
+      range.maxY = maxY
+    }
+
+    onMounted(setRange)
+    watch(props.activeElementList, setRange)
+
+    return {
+      ...toRefs(range),
+      borderLines,
+      disableResizablePoint,
+      resizablePoints,
+    }
+  },
+})
+</script>
+
+<style lang="scss" scoped>
+.multi-select-operate {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 100;
+}
+</style>

+ 162 - 9
src/views/Editor/Canvas/index.vue

@@ -36,6 +36,13 @@
         :type="line.type" :axis="line.axis" :length="line.length"
       />
 
+      <MultiSelectOperate 
+        v-if="activeElementIdList.length > 1"
+        :activeElementList="activeElementList"
+        :canvasScale="canvasScale"
+        :scaleMultiElement="scaleMultiElement"
+      />
+
       <EditableElement 
         v-for="(element, index) in elementList" 
         :key="element.elId"
@@ -63,13 +70,17 @@
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, reactive, ref, watch } from 'vue'
+import { computed, defineComponent, onMounted, reactive, ref, watch } from 'vue'
 import { useStore } from 'vuex'
+import uniq from 'lodash/uniq'
 import { State } from '@/store/state'
 import { MutationTypes } from '@/store/constants'
 import { ContextmenuItem } from '@/components/Contextmenu/types'
 import { VIEWPORT_SIZE, VIEWPORT_ASPECT_RATIO } from '@/configs/canvas'
 import { getImageDataURL } from '@/utils/image'
+import { getElementRange } from './utils/elementRange'
+
+import { PPTElement } from '@/types/slides'
 
 import useDropImage from '@/hooks/useDropImage'
 import useSetViewportSize from './hooks/useSetViewportSize'
@@ -77,6 +88,7 @@ import useSetViewportSize from './hooks/useSetViewportSize'
 import EditableElement from '@/views/_common/_element/EditableElement.vue'
 import MouseSelection from './MouseSelection.vue'
 import SlideBackground from './SlideBackground.vue'
+import MultiSelectOperate from './MultiSelectOperate.vue'
 import AlignmentLine, { AlignmentLineProps } from './AlignmentLine.vue'
 
 export default defineComponent({
@@ -85,22 +97,29 @@ export default defineComponent({
     EditableElement,
     MouseSelection,
     SlideBackground,
+    MultiSelectOperate,
     AlignmentLine,
   },
   setup() {
     const store = useStore<State>()
-    const elementList = computed(() => {
-      const currentSlide = store.getters.currentSlide
-      return currentSlide ? JSON.parse(JSON.stringify(currentSlide.elements)) : []
-    })
+
     const activeElementIdList = computed(() => store.state.activeElementIdList)
+    const activeElementList = computed(() => store.getters.activeElementList)
     const handleElementId = computed(() => store.state.handleElementId)
-    const activeGroupElementId = ref('')
+    const ctrlOrShiftKeyActive = computed(() => store.getters.ctrlOrShiftKeyActive)
 
+    const activeGroupElementId = ref('')
     const viewportRef = ref<HTMLElement | null>(null)
     const isShowGridLines = ref(false)
     const alignmentLines = ref<AlignmentLineProps[]>([])
+
     const currentSlide = computed(() => store.getters.currentSlide)
+    const elementList = ref<PPTElement[]>([])
+    const setLocalElementList = () => {
+      elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
+    }
+    onMounted(setLocalElementList)
+    watch(currentSlide, setLocalElementList)
 
     const dropImageFile = useDropImage(viewportRef)
     watch(dropImageFile, () => {
@@ -180,6 +199,67 @@ export default defineComponent({
         document.onmouseup = null
         isMouseDown = false
 
+        // 计算当前页面中的每一个元素是否处在鼠标选择范围中(必须完全包裹)
+        // 将选择范围中的元素添加为激活元素
+        let inRangeElementList: PPTElement[] = []
+        for(let i = 0; i < elementList.value.length; i++) {
+          const element = elementList.value[i]
+          const mouseSelectionLeft = mouseSelectionState.left
+          const mouseSelectionTop = mouseSelectionState.top
+          const mouseSelectionWidth = mouseSelectionState.width
+          const mouseSelectionHeight = mouseSelectionState.height
+
+          const quadrant = mouseSelectionState.quadrant
+
+          const { minX, maxX, minY, maxY } = getElementRange(element)
+
+          let isInclude = false
+          if(quadrant === 4) {
+            isInclude = minX > mouseSelectionLeft && 
+                        maxX < mouseSelectionLeft + mouseSelectionWidth && 
+                        minY > mouseSelectionTop && 
+                        maxY < mouseSelectionTop + mouseSelectionHeight
+          }
+          else if(quadrant === 1) {
+            isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) && 
+                        maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth && 
+                        minY > (mouseSelectionTop - mouseSelectionHeight) && 
+                        maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight
+          }
+          else if(quadrant === 2) {
+            isInclude = minX > mouseSelectionLeft && 
+                        maxX < mouseSelectionLeft + mouseSelectionWidth && 
+                        minY > (mouseSelectionTop - mouseSelectionHeight) && 
+                        maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight
+          }
+          else if(quadrant === 3) {
+            isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) && 
+                        maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth && 
+                        minY > mouseSelectionTop && 
+                        maxY < mouseSelectionTop + mouseSelectionHeight
+          }
+
+          // 被锁定的元素除外
+          if(isInclude && !element.isLock) inRangeElementList.push(element)
+        }
+
+        // 对于组合元素成员,必须所有成员都在选择范围中才算被选中
+        inRangeElementList = inRangeElementList.filter(inRangeElement => {
+          if(inRangeElement.groupId) {
+            const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.elId)
+            const groupElementList = elementList.value.filter(element => element.groupId === inRangeElement.groupId)
+            return groupElementList.every(groupElement => inRangeElementIdList.includes(groupElement.elId))
+          }
+          return true
+        })
+        const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.elId)
+
+        // 原本就存在激活元素(可能需要清空),或者本次选择了至少一个元素(可能需要选择),才会具体更新激活元素状态
+        // 否则不做多余的激活元素状态更新(原本就没有激活元素,本次也没有选择任何元素,只是点击了一下空白区域,状态为:空 -> 空)
+        if(activeElementIdList.value.length > 0 || inRangeElementIdList.length) {
+          store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, inRangeElementIdList)
+        }
+
         mouseSelectionState.isShow = false
       }
     }
@@ -187,7 +267,7 @@ export default defineComponent({
     const editorAreaFocus = computed(() => store.state.editorAreaFocus)
 
     const handleClickBlankArea = (e: MouseEvent) => {
-      updateMouseSelection(e)
+      if(!ctrlOrShiftKeyActive.value) updateMouseSelection(e)
       if(!editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, true)
     }
 
@@ -195,8 +275,76 @@ export default defineComponent({
       if(editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, false)
     }
 
-    const selectElement = () => {
-      console.log('selectElement')
+    const moveElement = (e: MouseEvent, element: PPTElement) => {
+      console.log(e, element)
+    }
+    const selectElement = (e: MouseEvent, element: PPTElement, canMove = true) => {
+      if(!editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, true)
+
+      // 如果被点击的元素处于未激活状态,则将他设置为激活元素(单选),或者加入到激活元素中(多选)
+      if(!activeElementIdList.value.includes(element.elId)) {
+        let newActiveIdList: string[] = []
+
+        if(ctrlOrShiftKeyActive.value) {
+          newActiveIdList = [...activeElementIdList.value, element.elId]
+        }
+        else newActiveIdList = [element.elId]
+        
+        // 同时如果该元素是分组成员,需要将和他同组的元素一起激活
+        if(element.groupId) {
+          const groupMembersId: string[] = []
+          elementList.value.forEach((el: PPTElement) => {
+            if(el.groupId === element.groupId) groupMembersId.push(el.elId)
+          })
+          newActiveIdList = [...newActiveIdList, ...groupMembersId]
+        }
+
+        store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, uniq(newActiveIdList))
+        store.commit(MutationTypes.SET_HANDLE_ELEMENT_ID, element.elId)
+      }
+
+      // 如果被点击的元素已激活,且按下了多选按钮,则取消其激活状态(除非该元素或分组是最后的一个激活元素)
+      else if(ctrlOrShiftKeyActive.value) {
+        let newActiveIdList: string[] = []
+
+        // 同时如果该元素是分组成员,需要将和他同组的元素一起取消
+        if(element.groupId) {
+          const groupMembersId: string[] = []
+          elementList.value.forEach((el: PPTElement) => {
+            if(el.groupId === element.groupId) groupMembersId.push(el.elId)
+          })
+          newActiveIdList = activeElementIdList.value.filter(elId => !groupMembersId.includes(elId))
+        }
+        else {
+          newActiveIdList = activeElementIdList.value.filter(elId => elId !== element.elId)
+        }
+
+        if(newActiveIdList.length > 0) {
+          store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, newActiveIdList)
+        }
+      }
+
+      // 如果被点击的元素已激活,且没有按下多选按钮,且该元素不是当前操作元素,则将其设置为当前操作元素
+      else if(handleElementId.value !== element.elId) {
+        store.commit(MutationTypes.SET_HANDLE_ELEMENT_ID, element.elId)
+      }
+
+      else if(activeGroupElementId.value !== element.elId && element.groupId) {
+        const startPageX = e.pageX
+        const startPageY = e.pageY
+
+        ;(e.target as HTMLElement).onmouseup = (e: MouseEvent) => {
+          const currentPageX = e.pageX
+          const currentPageY = e.pageY
+
+          if(startPageX === currentPageX && startPageY === currentPageY) {
+            activeGroupElementId.value = element.elId
+            ;(e.target as HTMLElement).onmouseup = null
+          }
+        }
+      }
+
+      if(canMove) moveElement(e, element)
     }
     const rotateElement = () => {
       console.log('rotateElement')
@@ -204,6 +352,9 @@ export default defineComponent({
     const scaleElement = () => {
       console.log('scaleElement')
     }
+    const scaleMultiElement = () => {
+      console.log('scaleMultiElement')
+    }
     const orderElement = () => {
       console.log('orderElement')
     }
@@ -248,6 +399,7 @@ export default defineComponent({
     return {
       elementList,
       activeElementIdList,
+      activeElementList,
       handleElementId,
       activeGroupElementId,
       canvasRef,
@@ -263,6 +415,7 @@ export default defineComponent({
       selectElement,
       rotateElement,
       scaleElement,
+      scaleMultiElement,
       orderElement,
       combineElements,
       uncombineElements,

+ 10 - 8
src/views/Editor/index.vue

@@ -13,7 +13,7 @@
 </template>
 
 <script lang="ts">
-import { computed, defineComponent, onMounted, onUnmounted, ref } from 'vue'
+import { computed, defineComponent, onMounted, onUnmounted } from 'vue'
 import { useStore } from 'vuex'
 import { State } from '@/store/state'
 import { KEYCODE } from '@/configs/keyCode'
@@ -27,6 +27,7 @@ import Canvas from './Canvas/index.vue'
 import CanvasTool from './CanvasTool/index.vue'
 import Thumbnails from './Thumbnails/index.vue'
 import Toolbar from './Toolbar/index.vue'
+import { MutationTypes } from '@/store/constants'
 
 export default defineComponent({
   name: 'editor',
@@ -38,10 +39,11 @@ export default defineComponent({
     Toolbar,
   },
   setup() {
-    const ctrlKeyDown = ref(false)
-    const shiftKeyDown = ref(false)
-
     const store = useStore<State>()
+
+    const ctrlKeyActive = computed(() => store.state.ctrlKeyState)
+    const shiftKeyActive = computed(() => store.state.shiftKeyState)
+
     const editorAreaFocus = computed(() => store.state.editorAreaFocus)
     const thumbnailsFocus = computed(() => store.state.thumbnailsFocus)
     const disableHotkeys = computed(() => store.state.disableHotkeys)
@@ -83,8 +85,8 @@ export default defineComponent({
     const keydownListener = (e: KeyboardEvent) => {
       const { keyCode, ctrlKey, shiftKey } = e
 
-      if(ctrlKey && !ctrlKeyDown.value) ctrlKeyDown.value = true
-      if(shiftKey && !shiftKeyDown.value) shiftKeyDown.value = true
+      if(ctrlKey && !ctrlKeyActive.value) store.commit(MutationTypes.SET_CTRL_KEY_STATE, true)
+      if(shiftKey && !shiftKeyActive.value) store.commit(MutationTypes.SET_SHIFT_KEY_STATE, true)
       
       if(!editorAreaFocus.value && !thumbnailsFocus.value) return      
 
@@ -147,8 +149,8 @@ export default defineComponent({
     }
     
     const keyupListener = () => {
-      if(ctrlKeyDown.value) ctrlKeyDown.value = false
-      if(shiftKeyDown.value) shiftKeyDown.value = false
+      if(ctrlKeyActive.value) store.commit(MutationTypes.SET_CTRL_KEY_STATE, false)
+      if(shiftKeyActive.value) store.commit(MutationTypes.SET_SHIFT_KEY_STATE, false)
     }
 
     const pasteImageFile = (imageFile: File) => {

+ 1 - 1
src/views/_common/_element/TextElement/index.vue

@@ -36,7 +36,7 @@
     <div 
       class="operate" 
       :class="{
-        'show': isActive,
+        'active': isActive,
         'multi-select': isMultiSelect && isActive,
         'selected': isHandleEl
       }"