index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <template>
  2. <div
  3. class="canvas"
  4. ref="canvasRef"
  5. @mousedown="$event => handleClickBlankArea($event)"
  6. v-contextmenu="contextmenus"
  7. v-click-outside="removeEditorAreaFocus"
  8. >
  9. <div
  10. class="viewport"
  11. ref="viewportRef"
  12. :style="{
  13. width: viewportStyles.width + 'px',
  14. height: viewportStyles.height + 'px',
  15. left: viewportStyles.left + 'px',
  16. top: viewportStyles.top + 'px',
  17. transform: `scale(${canvasScale})`,
  18. }"
  19. >
  20. <MouseSelection
  21. v-if="mouseSelectionState.isShow"
  22. :top="mouseSelectionState.top"
  23. :left="mouseSelectionState.left"
  24. :width="mouseSelectionState.width"
  25. :height="mouseSelectionState.height"
  26. :quadrant="mouseSelectionState.quadrant"
  27. />
  28. <SlideBackground
  29. :background="currentSlide?.background"
  30. :isShowGridLines="isShowGridLines"
  31. />
  32. <AlignmentLine
  33. v-for="(line, index) in alignmentLines" :key="index"
  34. :type="line.type" :axis="line.axis" :length="line.length"
  35. />
  36. <MultiSelectOperate
  37. v-if="activeElementIdList.length > 1"
  38. :activeElementList="activeElementList"
  39. :canvasScale="canvasScale"
  40. :scaleMultiElement="scaleMultiElement"
  41. />
  42. <EditableElement
  43. v-for="(element, index) in elementList"
  44. :key="element.elId"
  45. :elementInfo="element"
  46. :elementIndex="index + 1"
  47. :isActive="activeElementIdList.includes(element.elId)"
  48. :isHandleEl="element.elId === handleElementId"
  49. :isActiveGroupElement="activeGroupElementId === element.elId"
  50. :isMultiSelect="activeElementIdList.length > 1"
  51. :canvasScale="canvasScale"
  52. :selectElement="selectElement"
  53. :rotateElement="rotateElement"
  54. :scaleElement="scaleElement"
  55. :orderElement="orderElement"
  56. :combineElements="combineElements"
  57. :uncombineElements="uncombineElements"
  58. :alignElement="alignElement"
  59. :deleteElement="deleteElement"
  60. :lockElement="lockElement"
  61. :copyElement="copyElement"
  62. :cutElement="cutElement"
  63. />
  64. </div>
  65. </div>
  66. </template>
  67. <script lang="ts">
  68. import { computed, defineComponent, onMounted, reactive, ref, watch } from 'vue'
  69. import { useStore } from 'vuex'
  70. import uniq from 'lodash/uniq'
  71. import { State } from '@/store/state'
  72. import { MutationTypes } from '@/store/constants'
  73. import { ContextmenuItem } from '@/components/Contextmenu/types'
  74. import { VIEWPORT_SIZE, VIEWPORT_ASPECT_RATIO } from '@/configs/canvas'
  75. import { getImageDataURL } from '@/utils/image'
  76. import { getElementRange } from './utils/elementRange'
  77. import { PPTElement } from '@/types/slides'
  78. import useDropImage from '@/hooks/useDropImage'
  79. import useSetViewportSize from './hooks/useSetViewportSize'
  80. import EditableElement from '@/views/_common/_element/EditableElement.vue'
  81. import MouseSelection from './MouseSelection.vue'
  82. import SlideBackground from './SlideBackground.vue'
  83. import MultiSelectOperate from './MultiSelectOperate.vue'
  84. import AlignmentLine, { AlignmentLineProps } from './AlignmentLine.vue'
  85. export default defineComponent({
  86. name: 'v-canvas',
  87. components: {
  88. EditableElement,
  89. MouseSelection,
  90. SlideBackground,
  91. MultiSelectOperate,
  92. AlignmentLine,
  93. },
  94. setup() {
  95. const store = useStore<State>()
  96. const activeElementIdList = computed(() => store.state.activeElementIdList)
  97. const activeElementList = computed(() => store.getters.activeElementList)
  98. const handleElementId = computed(() => store.state.handleElementId)
  99. const ctrlOrShiftKeyActive = computed(() => store.getters.ctrlOrShiftKeyActive)
  100. const activeGroupElementId = ref('')
  101. const viewportRef = ref<HTMLElement | null>(null)
  102. const isShowGridLines = ref(false)
  103. const alignmentLines = ref<AlignmentLineProps[]>([])
  104. const currentSlide = computed(() => store.getters.currentSlide)
  105. const elementList = ref<PPTElement[]>([])
  106. const setLocalElementList = () => {
  107. elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
  108. }
  109. onMounted(setLocalElementList)
  110. watch(currentSlide, setLocalElementList)
  111. const dropImageFile = useDropImage(viewportRef)
  112. watch(dropImageFile, () => {
  113. if(dropImageFile.value) {
  114. getImageDataURL(dropImageFile.value).then(dataURL => {
  115. console.log(dataURL)
  116. })
  117. }
  118. })
  119. const canvasRef = ref<HTMLElement | null>(null)
  120. const { canvasScale, viewportLeft, viewportTop } = useSetViewportSize(canvasRef)
  121. const viewportStyles = computed(() => ({
  122. width: VIEWPORT_SIZE,
  123. height: VIEWPORT_SIZE * VIEWPORT_ASPECT_RATIO,
  124. left: viewportLeft.value,
  125. top: viewportTop.value,
  126. }))
  127. const mouseSelectionState = reactive({
  128. isShow: false,
  129. top: 0,
  130. left: 0,
  131. width: 0,
  132. height: 0,
  133. quadrant: 1,
  134. })
  135. const updateMouseSelection = (e: MouseEvent) => {
  136. if(!viewportRef.value) return
  137. let isMouseDown = true
  138. const viewportRect = viewportRef.value.getBoundingClientRect()
  139. const minSelectionRange = 5
  140. const startPageX = e.pageX
  141. const startPageY = e.pageY
  142. const left = (startPageX - viewportRect.x) / canvasScale.value
  143. const top = (startPageY - viewportRect.y) / canvasScale.value
  144. mouseSelectionState.isShow = false
  145. mouseSelectionState.quadrant = 4
  146. mouseSelectionState.top = top
  147. mouseSelectionState.left = left
  148. mouseSelectionState.width = 0
  149. mouseSelectionState.height = 0
  150. document.onmousemove = e => {
  151. if(!isMouseDown) return
  152. const currentPageX = e.pageX
  153. const currentPageY = e.pageY
  154. const offsetWidth = (currentPageX - startPageX) / canvasScale.value
  155. const offsetHeight = (currentPageY - startPageY) / canvasScale.value
  156. const width = Math.abs(offsetWidth)
  157. const height = Math.abs(offsetHeight)
  158. if( width < minSelectionRange || height < minSelectionRange ) return
  159. let quadrant = 0
  160. if( offsetWidth > 0 && offsetHeight > 0 ) quadrant = 4
  161. else if( offsetWidth < 0 && offsetHeight < 0 ) quadrant = 1
  162. else if( offsetWidth > 0 && offsetHeight < 0 ) quadrant = 2
  163. else if( offsetWidth < 0 && offsetHeight > 0 ) quadrant = 3
  164. mouseSelectionState.isShow = true
  165. mouseSelectionState.quadrant = quadrant
  166. mouseSelectionState.width = width
  167. mouseSelectionState.height = height
  168. }
  169. document.onmouseup = () => {
  170. document.onmousemove = null
  171. document.onmouseup = null
  172. isMouseDown = false
  173. // 计算当前页面中的每一个元素是否处在鼠标选择范围中(必须完全包裹)
  174. // 将选择范围中的元素添加为激活元素
  175. let inRangeElementList: PPTElement[] = []
  176. for(let i = 0; i < elementList.value.length; i++) {
  177. const element = elementList.value[i]
  178. const mouseSelectionLeft = mouseSelectionState.left
  179. const mouseSelectionTop = mouseSelectionState.top
  180. const mouseSelectionWidth = mouseSelectionState.width
  181. const mouseSelectionHeight = mouseSelectionState.height
  182. const quadrant = mouseSelectionState.quadrant
  183. const { minX, maxX, minY, maxY } = getElementRange(element)
  184. let isInclude = false
  185. if(quadrant === 4) {
  186. isInclude = minX > mouseSelectionLeft &&
  187. maxX < mouseSelectionLeft + mouseSelectionWidth &&
  188. minY > mouseSelectionTop &&
  189. maxY < mouseSelectionTop + mouseSelectionHeight
  190. }
  191. else if(quadrant === 1) {
  192. isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) &&
  193. maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth &&
  194. minY > (mouseSelectionTop - mouseSelectionHeight) &&
  195. maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight
  196. }
  197. else if(quadrant === 2) {
  198. isInclude = minX > mouseSelectionLeft &&
  199. maxX < mouseSelectionLeft + mouseSelectionWidth &&
  200. minY > (mouseSelectionTop - mouseSelectionHeight) &&
  201. maxY < (mouseSelectionTop - mouseSelectionHeight) + mouseSelectionHeight
  202. }
  203. else if(quadrant === 3) {
  204. isInclude = minX > (mouseSelectionLeft - mouseSelectionWidth) &&
  205. maxX < (mouseSelectionLeft - mouseSelectionWidth) + mouseSelectionWidth &&
  206. minY > mouseSelectionTop &&
  207. maxY < mouseSelectionTop + mouseSelectionHeight
  208. }
  209. // 被锁定的元素除外
  210. if(isInclude && !element.isLock) inRangeElementList.push(element)
  211. }
  212. // 对于组合元素成员,必须所有成员都在选择范围中才算被选中
  213. inRangeElementList = inRangeElementList.filter(inRangeElement => {
  214. if(inRangeElement.groupId) {
  215. const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.elId)
  216. const groupElementList = elementList.value.filter(element => element.groupId === inRangeElement.groupId)
  217. return groupElementList.every(groupElement => inRangeElementIdList.includes(groupElement.elId))
  218. }
  219. return true
  220. })
  221. const inRangeElementIdList = inRangeElementList.map(inRangeElement => inRangeElement.elId)
  222. // 原本就存在激活元素(可能需要清空),或者本次选择了至少一个元素(可能需要选择),才会具体更新激活元素状态
  223. // 否则不做多余的激活元素状态更新(原本就没有激活元素,本次也没有选择任何元素,只是点击了一下空白区域,状态为:空 -> 空)
  224. if(activeElementIdList.value.length > 0 || inRangeElementIdList.length) {
  225. store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, inRangeElementIdList)
  226. }
  227. mouseSelectionState.isShow = false
  228. }
  229. }
  230. const editorAreaFocus = computed(() => store.state.editorAreaFocus)
  231. const handleClickBlankArea = (e: MouseEvent) => {
  232. if(!ctrlOrShiftKeyActive.value) updateMouseSelection(e)
  233. if(!editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, true)
  234. }
  235. const removeEditorAreaFocus = () => {
  236. if(editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, false)
  237. }
  238. const moveElement = (e: MouseEvent, element: PPTElement) => {
  239. console.log(e, element)
  240. }
  241. const selectElement = (e: MouseEvent, element: PPTElement, canMove = true) => {
  242. if(!editorAreaFocus.value) store.commit(MutationTypes.SET_EDITORAREA_FOCUS, true)
  243. // 如果被点击的元素处于未激活状态,则将他设置为激活元素(单选),或者加入到激活元素中(多选)
  244. if(!activeElementIdList.value.includes(element.elId)) {
  245. let newActiveIdList: string[] = []
  246. if(ctrlOrShiftKeyActive.value) {
  247. newActiveIdList = [...activeElementIdList.value, element.elId]
  248. }
  249. else newActiveIdList = [element.elId]
  250. // 同时如果该元素是分组成员,需要将和他同组的元素一起激活
  251. if(element.groupId) {
  252. const groupMembersId: string[] = []
  253. elementList.value.forEach((el: PPTElement) => {
  254. if(el.groupId === element.groupId) groupMembersId.push(el.elId)
  255. })
  256. newActiveIdList = [...newActiveIdList, ...groupMembersId]
  257. }
  258. store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, uniq(newActiveIdList))
  259. store.commit(MutationTypes.SET_HANDLE_ELEMENT_ID, element.elId)
  260. }
  261. // 如果被点击的元素已激活,且按下了多选按钮,则取消其激活状态(除非该元素或分组是最后的一个激活元素)
  262. else if(ctrlOrShiftKeyActive.value) {
  263. let newActiveIdList: string[] = []
  264. // 同时如果该元素是分组成员,需要将和他同组的元素一起取消
  265. if(element.groupId) {
  266. const groupMembersId: string[] = []
  267. elementList.value.forEach((el: PPTElement) => {
  268. if(el.groupId === element.groupId) groupMembersId.push(el.elId)
  269. })
  270. newActiveIdList = activeElementIdList.value.filter(elId => !groupMembersId.includes(elId))
  271. }
  272. else {
  273. newActiveIdList = activeElementIdList.value.filter(elId => elId !== element.elId)
  274. }
  275. if(newActiveIdList.length > 0) {
  276. store.commit(MutationTypes.SET_ACTIVE_ELEMENT_ID_LIST, newActiveIdList)
  277. }
  278. }
  279. // 如果被点击的元素已激活,且没有按下多选按钮,且该元素不是当前操作元素,则将其设置为当前操作元素
  280. else if(handleElementId.value !== element.elId) {
  281. store.commit(MutationTypes.SET_HANDLE_ELEMENT_ID, element.elId)
  282. }
  283. else if(activeGroupElementId.value !== element.elId && element.groupId) {
  284. const startPageX = e.pageX
  285. const startPageY = e.pageY
  286. ;(e.target as HTMLElement).onmouseup = (e: MouseEvent) => {
  287. const currentPageX = e.pageX
  288. const currentPageY = e.pageY
  289. if(startPageX === currentPageX && startPageY === currentPageY) {
  290. activeGroupElementId.value = element.elId
  291. ;(e.target as HTMLElement).onmouseup = null
  292. }
  293. }
  294. }
  295. if(canMove) moveElement(e, element)
  296. }
  297. const rotateElement = () => {
  298. console.log('rotateElement')
  299. }
  300. const scaleElement = () => {
  301. console.log('scaleElement')
  302. }
  303. const scaleMultiElement = () => {
  304. console.log('scaleMultiElement')
  305. }
  306. const orderElement = () => {
  307. console.log('orderElement')
  308. }
  309. const combineElements = () => {
  310. console.log('combineElements')
  311. }
  312. const uncombineElements = () => {
  313. console.log('uncombineElements')
  314. }
  315. const alignElement = () => {
  316. console.log('alignElement')
  317. }
  318. const deleteElement = () => {
  319. console.log('deleteElement')
  320. }
  321. const lockElement = () => {
  322. console.log('lockElement')
  323. }
  324. const copyElement = () => {
  325. console.log('copyElement')
  326. }
  327. const cutElement = () => {
  328. console.log('cutElement')
  329. }
  330. const contextmenus = (): ContextmenuItem[] => {
  331. return [
  332. {
  333. text: '全选',
  334. subText: 'Ctrl + A',
  335. },
  336. {
  337. text: '粘贴',
  338. subText: 'Ctrl + V',
  339. },
  340. {
  341. text: '清空页面',
  342. },
  343. ]
  344. }
  345. return {
  346. elementList,
  347. activeElementIdList,
  348. activeElementList,
  349. handleElementId,
  350. activeGroupElementId,
  351. canvasRef,
  352. viewportRef,
  353. viewportStyles,
  354. canvasScale,
  355. mouseSelectionState,
  356. handleClickBlankArea,
  357. removeEditorAreaFocus,
  358. currentSlide,
  359. isShowGridLines,
  360. alignmentLines,
  361. selectElement,
  362. rotateElement,
  363. scaleElement,
  364. scaleMultiElement,
  365. orderElement,
  366. combineElements,
  367. uncombineElements,
  368. alignElement,
  369. deleteElement,
  370. lockElement,
  371. copyElement,
  372. cutElement,
  373. contextmenus,
  374. }
  375. },
  376. })
  377. </script>
  378. <style lang="scss" scoped>
  379. .canvas {
  380. height: 100%;
  381. user-select: none;
  382. overflow: hidden;
  383. background-color: #f9f9f9;
  384. position: relative;
  385. }
  386. .viewport {
  387. position: absolute;
  388. transform-origin: 0 0;
  389. background-color: #fff;
  390. box-shadow: 0 0 20px 0 rgba(0, 0, 0, .1);
  391. }
  392. </style>