index.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355
  1. <template>
  2. <div
  3. class="canvas"
  4. ref="canvasRef"
  5. @wheel="$event => handleMousewheelCanvas($event)"
  6. @mousedown="$event => handleClickBlankArea($event)"
  7. @dblclick="$event => handleDblClick($event)"
  8. v-contextmenu="contextmenus"
  9. v-click-outside="removeEditorAreaFocus"
  10. >
  11. <ElementCreateSelection v-if="creatingElement" @created="data => insertElementFromCreateSelection(data)" />
  12. <ShapeCreateCanvas v-if="creatingCustomShape" @created="data => insertCustomShape(data)" />
  13. <div
  14. class="viewport-wrapper"
  15. :style="{
  16. width: viewportStyles.width * canvasScale + 'px',
  17. height: viewportStyles.height * canvasScale + 'px',
  18. left: viewportStyles.left + 'px',
  19. top: viewportStyles.top + 'px'
  20. }"
  21. >
  22. <div class="operates">
  23. <AlignmentLine
  24. v-for="(line, index) in alignmentLines"
  25. :key="index"
  26. :type="line.type"
  27. :axis="line.axis"
  28. :length="line.length"
  29. :canvasScale="canvasScale"
  30. />
  31. <MultiSelectOperate v-if="activeElementIdList.length > 1" :elementList="elementList" :scaleMultiElement="scaleMultiElement" />
  32. <Operate
  33. v-for="element in elementList"
  34. :key="element.id"
  35. :elementInfo="element"
  36. :isSelected="activeElementIdList.includes(element.id)"
  37. :isActive="handleElementId === element.id"
  38. :isActiveGroupElement="activeGroupElementId === element.id"
  39. :isMultiSelect="activeElementIdList.length > 1"
  40. :rotateElement="rotateElement"
  41. :scaleElement="scaleElement"
  42. :openLinkDialog="openLinkDialog"
  43. :dragLineElement="dragLineElement"
  44. :moveShapeKeypoint="moveShapeKeypoint"
  45. v-show="!hiddenElementIdList.includes(element.id)"
  46. />
  47. <ViewportBackground />
  48. </div>
  49. <div class="viewport" ref="viewportRef" :style="{ transform: `scale(${canvasScale})` }">
  50. <MouseSelection
  51. v-if="mouseSelectionVisible"
  52. :top="mouseSelection.top"
  53. :left="mouseSelection.left"
  54. :width="mouseSelection.width"
  55. :height="mouseSelection.height"
  56. :quadrant="mouseSelectionQuadrant"
  57. />
  58. <EditableElement
  59. v-for="(element, index) in elementList"
  60. :key="element.id"
  61. :elementInfo="element"
  62. :elementIndex="index + 1"
  63. :isMultiSelect="activeElementIdList.length > 1"
  64. :selectElement="selectElement"
  65. :openLinkDialog="openLinkDialog"
  66. v-show="!hiddenElementIdList.includes(element.id)"
  67. />
  68. </div>
  69. </div>
  70. <div class="drag-mask" v-if="spaceKeyState"></div>
  71. <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
  72. <Modal v-model:visible="linkDialogVisible" :width="540">
  73. <LinkDialog @close="linkDialogVisible = false" />
  74. </Modal>
  75. </div>
  76. </template>
  77. <script lang="ts" setup>
  78. import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect } from "vue"
  79. import { throttle } from "lodash"
  80. import { storeToRefs } from "pinia"
  81. import { useMainStore, useSlidesStore, useKeyboardStore } from "@/store"
  82. import type { ContextmenuItem } from "@/components/Contextmenu/types"
  83. import type { PPTElement, PPTShapeElement } from "@/types/slides"
  84. import type { AlignmentLineProps, CreateCustomShapeData } from "@/types/edit"
  85. import { injectKeySlideScale } from "@/types/injectKey"
  86. import { removeAllRanges } from "@/utils/selection"
  87. import { KEYS } from "@/configs/hotkey"
  88. import useViewportSize from "./hooks/useViewportSize"
  89. import useMouseSelection from "./hooks/useMouseSelection"
  90. import useDropImageOrText from "./hooks/useDropImageOrText"
  91. import useRotateElement from "./hooks/useRotateElement"
  92. import useScaleElement from "./hooks/useScaleElement"
  93. import useSelectAndMoveElement from "./hooks/useSelectElement"
  94. import useDragElement from "./hooks/useDragElement"
  95. import useDragLineElement from "./hooks/useDragLineElement"
  96. import useMoveShapeKeypoint from "./hooks/useMoveShapeKeypoint"
  97. import useInsertFromCreateSelection from "./hooks/useInsertFromCreateSelection"
  98. import useDeleteElement from "@/hooks/useDeleteElement"
  99. import useCopyAndPasteElement from "@/hooks/useCopyAndPasteElement"
  100. import useSelectElement from "@/hooks/useSelectElement"
  101. import useScaleCanvas from "@/hooks/useScaleCanvas"
  102. import useScreening from "@/hooks/useScreening"
  103. import useSlideHandler from "@/hooks/useSlideHandler"
  104. import useCreateElement from "@/hooks/useCreateElement"
  105. import EditableElement from "./EditableElement.vue"
  106. import MouseSelection from "./MouseSelection.vue"
  107. import ViewportBackground from "./ViewportBackground.vue"
  108. import AlignmentLine from "./AlignmentLine.vue"
  109. import Ruler from "./Ruler.vue"
  110. import ElementCreateSelection from "./ElementCreateSelection.vue"
  111. import ShapeCreateCanvas from "./ShapeCreateCanvas.vue"
  112. import MultiSelectOperate from "./Operate/MultiSelectOperate.vue"
  113. import Operate from "./Operate/index.vue"
  114. import LinkDialog from "./LinkDialog.vue"
  115. import Modal from "@/components/Modal.vue"
  116. const mainStore = useMainStore()
  117. const {
  118. activeElementIdList,
  119. activeGroupElementId,
  120. handleElementId,
  121. hiddenElementIdList,
  122. editorAreaFocus,
  123. gridLineSize,
  124. showRuler,
  125. creatingElement,
  126. creatingCustomShape,
  127. canvasScale,
  128. textFormatPainter,
  129. isPPTWheelPage
  130. } = storeToRefs(mainStore)
  131. const { currentSlide } = storeToRefs(useSlidesStore())
  132. const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
  133. const viewportRef = ref<HTMLElement>()
  134. const alignmentLines = ref<AlignmentLineProps[]>([])
  135. const linkDialogVisible = ref(false)
  136. const openLinkDialog = () => (linkDialogVisible.value = true)
  137. watch(handleElementId, () => {
  138. mainStore.setActiveGroupElementId("")
  139. })
  140. const elementList = ref<PPTElement[]>([])
  141. const setLocalElementList = () => {
  142. elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
  143. }
  144. watchEffect(setLocalElementList)
  145. const canvasRef = ref<HTMLElement>()
  146. const { dragViewport, viewportStyles } = useViewportSize(canvasRef)
  147. /* 这个功能和 enjoyPlayer 冲突,所以禁用掉 */
  148. //useDropImageOrText(canvasRef)
  149. const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = useMouseSelection(elementList, viewportRef)
  150. const { dragElement } = useDragElement(elementList, alignmentLines, canvasScale)
  151. const { dragLineElement } = useDragLineElement(elementList)
  152. const { selectElement } = useSelectAndMoveElement(elementList, dragElement)
  153. const { scaleElement, scaleMultiElement } = useScaleElement(elementList, alignmentLines, canvasScale)
  154. const { rotateElement } = useRotateElement(elementList, viewportRef, canvasScale)
  155. const { moveShapeKeypoint } = useMoveShapeKeypoint(elementList, canvasScale)
  156. const { selectAllElements } = useSelectElement()
  157. const { deleteAllElements } = useDeleteElement()
  158. const { pasteElement } = useCopyAndPasteElement()
  159. const { enterScreeningFromStart } = useScreening()
  160. const { updateSlideIndex } = useSlideHandler()
  161. const { createTextElement, createShapeElement } = useCreateElement()
  162. // 组件渲染时,如果存在元素焦点,需要清除
  163. // 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
  164. onMounted(() => {
  165. if (activeElementIdList.value.length) {
  166. nextTick(() => mainStore.setActiveElementIdList([]))
  167. }
  168. })
  169. // 点击画布的空白区域:清空焦点元素、设置画布焦点、清除文字选区、清空格式刷状态
  170. const handleClickBlankArea = (e: MouseEvent) => {
  171. if (activeElementIdList.value.length) mainStore.setActiveElementIdList([])
  172. if (!spaceKeyState.value) updateMouseSelection(e)
  173. else dragViewport(e)
  174. if (!editorAreaFocus.value) mainStore.setEditorareaFocus(true)
  175. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  176. removeAllRanges()
  177. }
  178. // 双击空白处插入文本
  179. const handleDblClick = (e: MouseEvent) => {
  180. if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
  181. if (!viewportRef.value) return
  182. const viewportRect = viewportRef.value.getBoundingClientRect()
  183. const left = (e.pageX - viewportRect.x) / canvasScale.value
  184. const top = (e.pageY - viewportRect.y) / canvasScale.value
  185. createTextElement({
  186. left,
  187. top,
  188. width: 200 / canvasScale.value, // 除以 canvasScale 是为了与点击选区创建的形式保持相同的宽度
  189. height: 0
  190. })
  191. }
  192. // 画布注销时清空格式刷状态
  193. onUnmounted(() => {
  194. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  195. })
  196. // 移除画布编辑区域焦点
  197. const removeEditorAreaFocus = () => {
  198. if (editorAreaFocus.value) mainStore.setEditorareaFocus(false)
  199. }
  200. // 滚动鼠标
  201. const { scaleCanvas } = useScaleCanvas()
  202. const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing: false })
  203. const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
  204. const handleMousewheelCanvas = (e: WheelEvent) => {
  205. // 按住Ctrl键时:缩放画布
  206. if (ctrlKeyState.value) {
  207. e.preventDefault()
  208. if (e.deltaY > 0) throttleScaleCanvas("-")
  209. else if (e.deltaY < 0) throttleScaleCanvas("+")
  210. }
  211. // 上下翻页
  212. else {
  213. // 控制能不能翻页
  214. if (!isPPTWheelPage.value) return
  215. e.preventDefault()
  216. if (e.deltaY > 0) throttleUpdateSlideIndex(KEYS.DOWN)
  217. else if (e.deltaY < 0) throttleUpdateSlideIndex(KEYS.UP)
  218. }
  219. }
  220. // 开关标尺
  221. const toggleRuler = () => {
  222. mainStore.setRulerState(!showRuler.value)
  223. }
  224. // 在鼠标绘制的范围插入元素
  225. const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFromCreateSelection(viewportRef)
  226. // 插入自定义任意多边形
  227. const insertCustomShape = (data: CreateCustomShapeData) => {
  228. const { start, end, path, viewBox } = data
  229. const position = formatCreateSelection({ start, end })
  230. if (position) {
  231. const supplement: Partial<PPTShapeElement> = {}
  232. if (data.fill) supplement.fill = data.fill
  233. if (data.outline) supplement.outline = data.outline
  234. createShapeElement(position, { path, viewBox }, supplement)
  235. }
  236. mainStore.setCreatingCustomShapeState(false)
  237. }
  238. const contextmenus = (): ContextmenuItem[] => {
  239. return [
  240. {
  241. text: "粘贴",
  242. subText: "Ctrl + V",
  243. handler: pasteElement
  244. },
  245. {
  246. text: "全选",
  247. subText: "Ctrl + A",
  248. handler: selectAllElements
  249. },
  250. {
  251. text: "标尺",
  252. subText: showRuler.value ? "√" : "",
  253. handler: toggleRuler
  254. },
  255. {
  256. text: "网格线",
  257. handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
  258. children: [
  259. {
  260. text: "无",
  261. subText: gridLineSize.value === 0 ? "√" : "",
  262. handler: () => mainStore.setGridLineSize(0)
  263. },
  264. {
  265. text: "小",
  266. subText: gridLineSize.value === 25 ? "√" : "",
  267. handler: () => mainStore.setGridLineSize(25)
  268. },
  269. {
  270. text: "中",
  271. subText: gridLineSize.value === 50 ? "√" : "",
  272. handler: () => mainStore.setGridLineSize(50)
  273. },
  274. {
  275. text: "大",
  276. subText: gridLineSize.value === 100 ? "√" : "",
  277. handler: () => mainStore.setGridLineSize(100)
  278. }
  279. ]
  280. },
  281. {
  282. text: "重置当前页",
  283. handler: deleteAllElements
  284. },
  285. { divider: true },
  286. {
  287. text: "幻灯片放映",
  288. subText: "F5",
  289. handler: enterScreeningFromStart
  290. }
  291. ]
  292. }
  293. provide(injectKeySlideScale, canvasScale)
  294. </script>
  295. <style lang="scss" scoped>
  296. .canvas {
  297. height: 100%;
  298. user-select: none;
  299. overflow: hidden;
  300. background-color: $lightGray;
  301. position: relative;
  302. }
  303. .drag-mask {
  304. cursor: grab;
  305. @include absolute-0();
  306. }
  307. .viewport-wrapper {
  308. position: absolute;
  309. box-shadow:
  310. 0 0 0 1px rgba(0, 0, 0, 0.01),
  311. 0 0 12px 0 rgba(0, 0, 0, 0.1);
  312. }
  313. .viewport {
  314. position: absolute;
  315. top: 0;
  316. left: 0;
  317. transform-origin: 0 0;
  318. }
  319. </style>