index.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  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. <!-- 全屏Loading状态 -->
  12. <div v-if="isCourseLoading" class="fullscreen-loading-overlay">
  13. <div class="loading-content">
  14. <div class="loading-spinner"></div>
  15. <div class="loading-text">正在加载课程内容...</div>
  16. </div>
  17. </div>
  18. <ElementCreateSelection
  19. v-if="creatingElement"
  20. @created="data => insertElementFromCreateSelection(data)"
  21. />
  22. <ShapeCreateCanvas
  23. v-if="creatingCustomShape"
  24. @created="data => insertCustomShape(data)"
  25. />
  26. <div
  27. class="viewport-wrapper"
  28. :style="{
  29. width: viewportStyles.width * canvasScale + 'px',
  30. height: viewportStyles.height * canvasScale + 'px',
  31. left: viewportStyles.left + 'px',
  32. top: viewportStyles.top + 'px',
  33. }"
  34. >
  35. <div class="operates">
  36. <AlignmentLine
  37. v-for="(line, index) in alignmentLines"
  38. :key="index"
  39. :type="line.type"
  40. :axis="line.axis"
  41. :length="line.length"
  42. :canvasScale="canvasScale"
  43. />
  44. <MultiSelectOperate
  45. v-if="activeElementIdList.length > 1"
  46. :elementList="elementList"
  47. :scaleMultiElement="scaleMultiElement"
  48. />
  49. <Operate
  50. v-for="element in elementList"
  51. :key="element.id"
  52. :elementInfo="element"
  53. :isSelected="activeElementIdList.includes(element.id)"
  54. :isActive="handleElementId === element.id"
  55. :isActiveGroupElement="activeGroupElementId === element.id"
  56. :isMultiSelect="activeElementIdList.length > 1"
  57. :rotateElement="rotateElement"
  58. :scaleElement="scaleElement"
  59. :openLinkDialog="openLinkDialog"
  60. :dragLineElement="dragLineElement"
  61. :moveShapeKeypoint="moveShapeKeypoint"
  62. v-show="!hiddenElementIdList.includes(element.id)"
  63. />
  64. <ViewportBackground />
  65. </div>
  66. <div
  67. class="viewport"
  68. ref="viewportRef"
  69. :style="{ transform: `scale(${canvasScale})` }"
  70. >
  71. <MouseSelection
  72. v-if="mouseSelectionVisible"
  73. :top="mouseSelection.top"
  74. :left="mouseSelection.left"
  75. :width="mouseSelection.width"
  76. :height="mouseSelection.height"
  77. :quadrant="mouseSelectionQuadrant"
  78. />
  79. <EditableElement
  80. v-for="(element, index) in elementList"
  81. :key="element.id"
  82. :elementInfo="element"
  83. :elementIndex="index + 1"
  84. :isMultiSelect="activeElementIdList.length > 1"
  85. :selectElement="selectElement"
  86. :openLinkDialog="openLinkDialog"
  87. :openWebpageLinkEditDialog="openWebpageLinkEditDialog"
  88. v-show="!hiddenElementIdList.includes(element.id)"
  89. />
  90. </div>
  91. </div>
  92. <div class="drag-mask" v-if="spaceKeyState"></div>
  93. <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
  94. <Modal
  95. v-model:visible="linkDialogVisible"
  96. :width="540"
  97. >
  98. <LinkDialog @close="linkDialogVisible = false" />
  99. </Modal>
  100. <Modal
  101. v-model:visible="webpageLinkEditDialogVisible"
  102. :width="540"
  103. >
  104. <WebpageLinkEditDialog
  105. :elementId="webpageLinkEditElementId"
  106. :currentUrl="webpageLinkEditCurrentUrl"
  107. @close="webpageLinkEditDialogVisible = false"
  108. />
  109. </Modal>
  110. </div>
  111. </template>
  112. <script lang="ts" setup>
  113. import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect, useTemplateRef } from 'vue'
  114. import { throttle } from 'lodash'
  115. import { storeToRefs } from 'pinia'
  116. import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
  117. import type { ContextmenuItem } from '@/components/Contextmenu/types'
  118. import type { PPTElement, PPTShapeElement } from '@/types/slides'
  119. import type { AlignmentLineProps, CreateCustomShapeData } from '@/types/edit'
  120. import { injectKeySlideScale } from '@/types/injectKey'
  121. import { removeAllRanges } from '@/utils/selection'
  122. import { KEYS } from '@/configs/hotkey'
  123. import useViewportSize from './hooks/useViewportSize'
  124. import useMouseSelection from './hooks/useMouseSelection'
  125. import useDropImageOrText from './hooks/useDropImageOrText'
  126. import useRotateElement from './hooks/useRotateElement'
  127. import useScaleElement from './hooks/useScaleElement'
  128. import useSelectAndMoveElement from './hooks/useSelectElement'
  129. import useDragElement from './hooks/useDragElement'
  130. import useDragLineElement from './hooks/useDragLineElement'
  131. import useMoveShapeKeypoint from './hooks/useMoveShapeKeypoint'
  132. import useInsertFromCreateSelection from './hooks/useInsertFromCreateSelection'
  133. import useDeleteElement from '@/hooks/useDeleteElement'
  134. import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
  135. import useSelectElement from '@/hooks/useSelectElement'
  136. import useScaleCanvas from '@/hooks/useScaleCanvas'
  137. import useScreening from '@/hooks/useScreening'
  138. import useSlideHandler from '@/hooks/useSlideHandler'
  139. import useCreateElement from '@/hooks/useCreateElement'
  140. import EditableElement from './EditableElement.vue'
  141. import MouseSelection from './MouseSelection.vue'
  142. import ViewportBackground from './ViewportBackground.vue'
  143. import AlignmentLine from './AlignmentLine.vue'
  144. import Ruler from './Ruler.vue'
  145. import ElementCreateSelection from './ElementCreateSelection.vue'
  146. import ShapeCreateCanvas from './ShapeCreateCanvas.vue'
  147. import MultiSelectOperate from './Operate/MultiSelectOperate.vue'
  148. import Operate from './Operate/index.vue'
  149. import LinkDialog from './LinkDialog.vue'
  150. import WebpageLinkEditDialog from './WebpageLinkEditDialog.vue'
  151. import Modal from '@/components/Modal.vue'
  152. import api from '@/services/course'
  153. import useImport from '@/hooks/useImport'
  154. import message from '@/utils/message'
  155. // 定义组件props
  156. interface Props {
  157. courseid?: string | null
  158. }
  159. const props = withDefaults(defineProps<Props>(), {
  160. courseid: null,
  161. })
  162. const emit = defineEmits<{
  163. (e: 'courseLoaded', data: any): void
  164. }>()
  165. const mainStore = useMainStore()
  166. const {
  167. activeElementIdList,
  168. activeGroupElementId,
  169. handleElementId,
  170. hiddenElementIdList,
  171. editorAreaFocus,
  172. gridLineSize,
  173. showRuler,
  174. creatingElement,
  175. creatingCustomShape,
  176. canvasScale,
  177. textFormatPainter,
  178. } = storeToRefs(mainStore)
  179. const { currentSlide } = storeToRefs(useSlidesStore())
  180. const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
  181. const viewportRef = useTemplateRef<HTMLElement>('viewportRef')
  182. const alignmentLines = ref<AlignmentLineProps[]>([])
  183. const linkDialogVisible = ref(false)
  184. const openLinkDialog = () => linkDialogVisible.value = true
  185. const webpageLinkEditDialogVisible = ref(false)
  186. const webpageLinkEditElementId = ref('')
  187. const webpageLinkEditCurrentUrl = ref('')
  188. const openWebpageLinkEditDialog = (elementId: string, currentUrl: string) => {
  189. webpageLinkEditElementId.value = elementId
  190. webpageLinkEditCurrentUrl.value = currentUrl
  191. webpageLinkEditDialogVisible.value = true
  192. }
  193. // 课程加载loading状态
  194. const isCourseLoading = ref(false)
  195. watch(handleElementId, () => {
  196. mainStore.setActiveGroupElementId('')
  197. })
  198. const elementList = ref<PPTElement[]>([])
  199. const setLocalElementList = () => {
  200. elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
  201. }
  202. watchEffect(setLocalElementList)
  203. const canvasRef = useTemplateRef<HTMLElement>('canvasRef')
  204. const { dragViewport, viewportStyles } = useViewportSize(canvasRef)
  205. useDropImageOrText(canvasRef)
  206. const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = useMouseSelection(elementList, viewportRef)
  207. const { dragElement } = useDragElement(elementList, alignmentLines, canvasScale)
  208. const { dragLineElement } = useDragLineElement(elementList)
  209. const { selectElement } = useSelectAndMoveElement(elementList, dragElement)
  210. const { scaleElement, scaleMultiElement } = useScaleElement(elementList, alignmentLines, canvasScale)
  211. const { rotateElement } = useRotateElement(elementList, viewportRef, canvasScale)
  212. const { moveShapeKeypoint } = useMoveShapeKeypoint(elementList, canvasScale)
  213. const { selectAllElements } = useSelectElement()
  214. const { deleteAllElements } = useDeleteElement()
  215. const { pasteElement } = useCopyAndPasteElement()
  216. const { enterScreeningFromStart } = useScreening()
  217. const { updateSlideIndex } = useSlideHandler()
  218. const { createTextElement, createShapeElement } = useCreateElement()
  219. const { readJSON, getFile } = useImport()
  220. // 组件渲染时,如果存在元素焦点,需要清除
  221. // 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
  222. onMounted(() => {
  223. if (activeElementIdList.value.length) {
  224. nextTick(() => mainStore.setActiveElementIdList([]))
  225. }
  226. // 优化:避免重复代码,统一设置 window.pptLoading
  227. if (typeof window !== 'undefined') {
  228. (window as any).pptLoading = 1
  229. }
  230. if (props.courseid) {
  231. getCourseDetail()
  232. }
  233. else {
  234. (window as any).pptLoading = 2
  235. }
  236. })
  237. const getCourseDetail = async () => {
  238. // 显示全屏loading
  239. isCourseLoading.value = true
  240. try {
  241. const res = await api.getCourseDetail(props.courseid as string)
  242. console.log(res)
  243. const courseDetail = res[0][0]
  244. emit('courseLoaded', courseDetail)
  245. const pptJSONUrl = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : ''
  246. console.log(pptJSONUrl)
  247. if (pptJSONUrl) {
  248. const pptdata = await getFile(pptJSONUrl)
  249. // pptdata.data 是 ArrayBuffer,需要先转成字符串再解析为 JSON
  250. let jsonStr = ''
  251. if (pptdata && pptdata.data) {
  252. // 先将 ArrayBuffer 转为字符串
  253. const uint8Array = new Uint8Array(pptdata.data)
  254. jsonStr = new TextDecoder('utf-8').decode(uint8Array)
  255. try {
  256. const jsonObj = JSON.parse(jsonStr)
  257. readJSON(jsonObj, true)
  258. if (typeof window !== 'undefined') {
  259. const win = window as any
  260. win.pptLoading = 2
  261. }
  262. }
  263. catch (e) {
  264. console.error('解析pptdata.data失败:', e)
  265. message.error('解析PPT数据失败')
  266. if (typeof window !== 'undefined') {
  267. const win = window as any
  268. win.pptLoading = 2
  269. }
  270. }
  271. }
  272. else {
  273. if (typeof window !== 'undefined') {
  274. const win = window as any
  275. win.pptLoading = 2
  276. }
  277. }
  278. }
  279. }
  280. catch (error) {
  281. console.error('获取课程详情失败:', error)
  282. message.error('获取课程详情失败')
  283. isCourseLoading.value = false
  284. if (typeof window !== 'undefined') {
  285. const win = window as any
  286. win.pptLoading = 2
  287. }
  288. }
  289. finally {
  290. // 隐藏loading
  291. isCourseLoading.value = false
  292. if (typeof window !== 'undefined') {
  293. const win = window as any
  294. win.pptLoading = 2
  295. }
  296. }
  297. }
  298. // 点击画布的空白区域:清空焦点元素、设置画布焦点、清除文字选区、清空格式刷状态
  299. const handleClickBlankArea = (e: MouseEvent) => {
  300. if (activeElementIdList.value.length) mainStore.setActiveElementIdList([])
  301. if (!spaceKeyState.value) updateMouseSelection(e)
  302. else dragViewport(e)
  303. if (!editorAreaFocus.value) mainStore.setEditorareaFocus(true)
  304. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  305. removeAllRanges()
  306. }
  307. // 双击空白处插入文本
  308. const handleDblClick = (e: MouseEvent) => {
  309. if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
  310. if (!viewportRef.value) return
  311. const viewportRect = viewportRef.value.getBoundingClientRect()
  312. const left = (e.pageX - viewportRect.x) / canvasScale.value
  313. const top = (e.pageY - viewportRect.y) / canvasScale.value
  314. createTextElement({
  315. left,
  316. top,
  317. width: 200 / canvasScale.value, // 除以 canvasScale 是为了与点击选区创建的形式保持相同的宽度
  318. height: 0,
  319. })
  320. }
  321. // 画布注销时清空格式刷状态
  322. onUnmounted(() => {
  323. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  324. })
  325. // 移除画布编辑区域焦点
  326. const removeEditorAreaFocus = () => {
  327. if (editorAreaFocus.value) mainStore.setEditorareaFocus(false)
  328. }
  329. // 滚动鼠标
  330. const { scaleCanvas } = useScaleCanvas()
  331. const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing: false })
  332. const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
  333. const handleMousewheelCanvas = (e: WheelEvent) => {
  334. e.preventDefault()
  335. // 按住Ctrl键时:缩放画布
  336. if (ctrlKeyState.value) {
  337. if (e.deltaY > 0) throttleScaleCanvas('-')
  338. else if (e.deltaY < 0) throttleScaleCanvas('+')
  339. }
  340. // 上下翻页
  341. // else {
  342. // if (e.deltaY > 0) throttleUpdateSlideIndex(KEYS.DOWN)
  343. // else if (e.deltaY < 0) throttleUpdateSlideIndex(KEYS.UP)
  344. // }
  345. }
  346. // 开关标尺
  347. const toggleRuler = () => {
  348. mainStore.setRulerState(!showRuler.value)
  349. }
  350. // 在鼠标绘制的范围插入元素
  351. const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFromCreateSelection(viewportRef)
  352. // 插入自定义任意多边形
  353. const insertCustomShape = (data: CreateCustomShapeData) => {
  354. const {
  355. start,
  356. end,
  357. path,
  358. viewBox,
  359. } = data
  360. const position = formatCreateSelection({ start, end })
  361. if (position) {
  362. const supplement: Partial<PPTShapeElement> = {}
  363. if (data.fill) supplement.fill = data.fill
  364. if (data.outline) supplement.outline = data.outline
  365. createShapeElement(position, { path, viewBox }, supplement)
  366. }
  367. mainStore.setCreatingCustomShapeState(false)
  368. }
  369. const contextmenus = (): ContextmenuItem[] => {
  370. return [
  371. {
  372. text: '粘贴',
  373. subText: 'Ctrl + V',
  374. handler: pasteElement,
  375. },
  376. {
  377. text: '全选',
  378. subText: 'Ctrl + A',
  379. handler: selectAllElements,
  380. },
  381. {
  382. text: '标尺',
  383. subText: showRuler.value ? '√' : '',
  384. handler: toggleRuler,
  385. },
  386. {
  387. text: '网格线',
  388. handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
  389. children: [
  390. {
  391. text: '无',
  392. subText: gridLineSize.value === 0 ? '√' : '',
  393. handler: () => mainStore.setGridLineSize(0),
  394. },
  395. {
  396. text: '小',
  397. subText: gridLineSize.value === 25 ? '√' : '',
  398. handler: () => mainStore.setGridLineSize(25),
  399. },
  400. {
  401. text: '中',
  402. subText: gridLineSize.value === 50 ? '√' : '',
  403. handler: () => mainStore.setGridLineSize(50),
  404. },
  405. {
  406. text: '大',
  407. subText: gridLineSize.value === 100 ? '√' : '',
  408. handler: () => mainStore.setGridLineSize(100),
  409. },
  410. ],
  411. },
  412. {
  413. text: '重置当前页',
  414. handler: deleteAllElements,
  415. },
  416. { divider: true },
  417. {
  418. text: '幻灯片放映',
  419. subText: 'F5',
  420. handler: enterScreeningFromStart,
  421. },
  422. ]
  423. }
  424. provide(injectKeySlideScale, canvasScale)
  425. </script>
  426. <style lang="scss" scoped>
  427. .canvas {
  428. height: 100%;
  429. user-select: none;
  430. overflow: hidden;
  431. background-color: $lightGray;
  432. position: relative;
  433. }
  434. /* 全屏Loading样式 */
  435. .fullscreen-loading-overlay {
  436. position: fixed;
  437. top: 0;
  438. left: 0;
  439. right: 0;
  440. bottom: 0;
  441. background: rgba(255, 255, 255, 0.95);
  442. display: flex;
  443. align-items: center;
  444. justify-content: center;
  445. z-index: 9999;
  446. }
  447. .loading-content {
  448. text-align: center;
  449. color: #666;
  450. }
  451. .loading-spinner {
  452. width: 50px;
  453. height: 50px;
  454. border: 5px solid #f3f3f3;
  455. border-top: 5px solid #1890ff;
  456. border-radius: 50%;
  457. animation: spin 1s linear infinite;
  458. margin: 0 auto 20px;
  459. }
  460. .loading-text {
  461. font-size: 16px;
  462. color: #666;
  463. font-weight: 500;
  464. }
  465. @keyframes spin {
  466. 0% { transform: rotate(0deg); }
  467. 100% { transform: rotate(360deg); }
  468. }
  469. .drag-mask {
  470. cursor: grab;
  471. @include absolute-0();
  472. }
  473. .viewport-wrapper {
  474. position: absolute;
  475. box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
  476. }
  477. .viewport {
  478. position: absolute;
  479. top: 0;
  480. left: 0;
  481. transform-origin: 0 0;
  482. }
  483. </style>