index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  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">{{ lang.ssCourseLoading }}</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. import { lang } from '@/main'
  156. // 定义组件props
  157. interface Props {
  158. courseid?: string | null
  159. }
  160. const props = withDefaults(defineProps<Props>(), {
  161. courseid: null,
  162. })
  163. const emit = defineEmits<{
  164. (e: 'courseLoaded', data: any): void
  165. }>()
  166. const mainStore = useMainStore()
  167. const {
  168. activeElementIdList,
  169. activeGroupElementId,
  170. handleElementId,
  171. hiddenElementIdList,
  172. editorAreaFocus,
  173. gridLineSize,
  174. showRuler,
  175. creatingElement,
  176. creatingCustomShape,
  177. canvasScale,
  178. textFormatPainter,
  179. } = storeToRefs(mainStore)
  180. const { currentSlide } = storeToRefs(useSlidesStore())
  181. const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
  182. const viewportRef = useTemplateRef<HTMLElement>('viewportRef')
  183. const alignmentLines = ref<AlignmentLineProps[]>([])
  184. const linkDialogVisible = ref(false)
  185. const openLinkDialog = () => linkDialogVisible.value = true
  186. const webpageLinkEditDialogVisible = ref(false)
  187. const webpageLinkEditElementId = ref('')
  188. const webpageLinkEditCurrentUrl = ref('')
  189. const openWebpageLinkEditDialog = (elementId: string, currentUrl: string) => {
  190. webpageLinkEditElementId.value = elementId
  191. webpageLinkEditCurrentUrl.value = currentUrl
  192. webpageLinkEditDialogVisible.value = true
  193. }
  194. // 课程加载loading状态
  195. const isCourseLoading = ref(false)
  196. watch(handleElementId, () => {
  197. mainStore.setActiveGroupElementId('')
  198. })
  199. const elementList = ref<PPTElement[]>([])
  200. const setLocalElementList = () => {
  201. elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
  202. }
  203. watchEffect(setLocalElementList)
  204. const canvasRef = useTemplateRef<HTMLElement>('canvasRef')
  205. const { dragViewport, viewportStyles } = useViewportSize(canvasRef)
  206. useDropImageOrText(canvasRef)
  207. const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = useMouseSelection(elementList, viewportRef)
  208. const { dragElement } = useDragElement(elementList, alignmentLines, canvasScale)
  209. const { dragLineElement } = useDragLineElement(elementList)
  210. const { selectElement } = useSelectAndMoveElement(elementList, dragElement)
  211. const { scaleElement, scaleMultiElement } = useScaleElement(elementList, alignmentLines, canvasScale)
  212. const { rotateElement } = useRotateElement(elementList, viewportRef, canvasScale)
  213. const { moveShapeKeypoint } = useMoveShapeKeypoint(elementList, canvasScale)
  214. const { selectAllElements } = useSelectElement()
  215. const { deleteAllElements } = useDeleteElement()
  216. const { pasteElement } = useCopyAndPasteElement()
  217. const { enterScreeningFromStart } = useScreening()
  218. const { updateSlideIndex } = useSlideHandler()
  219. const { createTextElement, createShapeElement } = useCreateElement()
  220. const { readJSON, getFile } = useImport()
  221. // 组件渲染时,如果存在元素焦点,需要清除
  222. // 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
  223. onMounted(() => {
  224. if (activeElementIdList.value.length) {
  225. nextTick(() => mainStore.setActiveElementIdList([]))
  226. }
  227. // 优化:避免重复代码,统一设置 window.pptLoading
  228. if (typeof window !== 'undefined') {
  229. (window as any).pptLoading = 1
  230. }
  231. if (props.courseid) {
  232. getCourseDetail()
  233. }
  234. else {
  235. (window as any).pptLoading = 2
  236. }
  237. })
  238. const getCourseDetail = async () => {
  239. // 显示全屏loading
  240. isCourseLoading.value = true
  241. try {
  242. const res = await api.getCourseDetail(props.courseid as string)
  243. console.log(res)
  244. const courseDetail = res[0][0]
  245. emit('courseLoaded', courseDetail)
  246. const pptJSONUrl = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : ''
  247. console.log(pptJSONUrl)
  248. if (pptJSONUrl) {
  249. const pptdata = await getFile(pptJSONUrl)
  250. // pptdata.data 是 ArrayBuffer,需要先转成字符串再解析为 JSON
  251. let jsonStr = ''
  252. if (pptdata && pptdata.data) {
  253. // 先将 ArrayBuffer 转为字符串
  254. const uint8Array = new Uint8Array(pptdata.data)
  255. jsonStr = new TextDecoder('utf-8').decode(uint8Array)
  256. try {
  257. const jsonObj = JSON.parse(jsonStr)
  258. console.log('jsonObj:', jsonObj)
  259. const slides = jsonObj.slides || []
  260. for (const slide of slides) {
  261. slide.elements = slide.elements || []
  262. for (const element of slide.elements) {
  263. if (element.type === 'frame' && element.url?.includes('/workPage') && !element.url.includes('/setWorkPage')) {
  264. element.url = element.url.replace(/\/(workPage(?:New)?)\b(?![^\/?#]*\.html)/g, '/setWorkPage')
  265. }
  266. }
  267. }
  268. readJSON(jsonObj, true)
  269. if (typeof window !== 'undefined') {
  270. const win = window as any
  271. win.pptLoading = 2
  272. }
  273. }
  274. catch (e) {
  275. console.error('解析pptdata.data失败:', e)
  276. message.error(lang.ssParsePptFail)
  277. if (typeof window !== 'undefined') {
  278. const win = window as any
  279. win.pptLoading = 2
  280. }
  281. }
  282. }
  283. else {
  284. if (typeof window !== 'undefined') {
  285. const win = window as any
  286. win.pptLoading = 2
  287. }
  288. }
  289. }
  290. }
  291. catch (error) {
  292. console.error('获取课程详情失败:', error)
  293. message.error(lang.ssFetchCourseFail)
  294. isCourseLoading.value = false
  295. if (typeof window !== 'undefined') {
  296. const win = window as any
  297. win.pptLoading = 2
  298. }
  299. }
  300. finally {
  301. // 隐藏loading
  302. isCourseLoading.value = false
  303. if (typeof window !== 'undefined') {
  304. const win = window as any
  305. win.pptLoading = 2
  306. }
  307. }
  308. }
  309. // 点击画布的空白区域:清空焦点元素、设置画布焦点、清除文字选区、清空格式刷状态
  310. const handleClickBlankArea = (e: MouseEvent) => {
  311. if (activeElementIdList.value.length) mainStore.setActiveElementIdList([])
  312. if (!spaceKeyState.value) updateMouseSelection(e)
  313. else dragViewport(e)
  314. if (!editorAreaFocus.value) mainStore.setEditorareaFocus(true)
  315. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  316. removeAllRanges()
  317. }
  318. // 双击空白处插入文本
  319. const handleDblClick = (e: MouseEvent) => {
  320. if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
  321. if (!viewportRef.value) return
  322. const viewportRect = viewportRef.value.getBoundingClientRect()
  323. const left = (e.pageX - viewportRect.x) / canvasScale.value
  324. const top = (e.pageY - viewportRect.y) / canvasScale.value
  325. createTextElement({
  326. left,
  327. top,
  328. width: 200 / canvasScale.value, // 除以 canvasScale 是为了与点击选区创建的形式保持相同的宽度
  329. height: 0,
  330. })
  331. }
  332. // 画布注销时清空格式刷状态
  333. onUnmounted(() => {
  334. if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
  335. })
  336. // 移除画布编辑区域焦点
  337. const removeEditorAreaFocus = () => {
  338. if (editorAreaFocus.value) mainStore.setEditorareaFocus(false)
  339. }
  340. // 滚动鼠标
  341. const { scaleCanvas } = useScaleCanvas()
  342. const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing: false })
  343. const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
  344. const handleMousewheelCanvas = (e: WheelEvent) => {
  345. // type 77(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
  346. // 报告的 .report-scroll、对话的 .chat-area),让内部 scroll 接管,canvas 不介入。
  347. // 如果不这样做,下面的 preventDefault 会把浏览器默认滚动行为一并抑制,内部永远滚不动。
  348. const targetEl = e.target instanceof Element ? e.target : null
  349. if (targetEl?.closest('.topic-discussion-preview')) return
  350. e.preventDefault()
  351. // 按住Ctrl键时:缩放画布
  352. if (ctrlKeyState.value) {
  353. if (e.deltaY > 0) throttleScaleCanvas('-')
  354. else if (e.deltaY < 0) throttleScaleCanvas('+')
  355. }
  356. // 上下翻页
  357. // else {
  358. // if (e.deltaY > 0) throttleUpdateSlideIndex(KEYS.DOWN)
  359. // else if (e.deltaY < 0) throttleUpdateSlideIndex(KEYS.UP)
  360. // }
  361. }
  362. // 开关标尺
  363. const toggleRuler = () => {
  364. mainStore.setRulerState(!showRuler.value)
  365. }
  366. // 在鼠标绘制的范围插入元素
  367. const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFromCreateSelection(viewportRef)
  368. // 插入自定义任意多边形
  369. const insertCustomShape = (data: CreateCustomShapeData) => {
  370. const {
  371. start,
  372. end,
  373. path,
  374. viewBox,
  375. } = data
  376. const position = formatCreateSelection({ start, end })
  377. if (position) {
  378. const supplement: Partial<PPTShapeElement> = {}
  379. if (data.fill) supplement.fill = data.fill
  380. if (data.outline) supplement.outline = data.outline
  381. createShapeElement(position, { path, viewBox }, supplement)
  382. }
  383. mainStore.setCreatingCustomShapeState(false)
  384. }
  385. const contextmenus = (): ContextmenuItem[] => {
  386. return [
  387. {
  388. text: lang.ssPaste,
  389. subText: 'Ctrl + V',
  390. handler: pasteElement,
  391. },
  392. {
  393. text: lang.ssSelectAll,
  394. subText: 'Ctrl + A',
  395. handler: selectAllElements,
  396. },
  397. {
  398. text: lang.ssRuler,
  399. subText: showRuler.value ? '√' : '',
  400. handler: toggleRuler,
  401. },
  402. {
  403. text: lang.ssGridLine,
  404. handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
  405. children: [
  406. {
  407. text: lang.ssNone,
  408. subText: gridLineSize.value === 0 ? '√' : '',
  409. handler: () => mainStore.setGridLineSize(0),
  410. },
  411. {
  412. text: lang.ssSmall,
  413. subText: gridLineSize.value === 25 ? '√' : '',
  414. handler: () => mainStore.setGridLineSize(25),
  415. },
  416. {
  417. text: lang.ssMedium,
  418. subText: gridLineSize.value === 50 ? '√' : '',
  419. handler: () => mainStore.setGridLineSize(50),
  420. },
  421. {
  422. text: lang.ssLarge,
  423. subText: gridLineSize.value === 100 ? '√' : '',
  424. handler: () => mainStore.setGridLineSize(100),
  425. },
  426. ],
  427. },
  428. {
  429. text: lang.ssResetPage,
  430. handler: deleteAllElements,
  431. },
  432. { divider: true },
  433. {
  434. text: lang.ssStage,
  435. subText: 'F5',
  436. handler: enterScreeningFromStart,
  437. },
  438. ]
  439. }
  440. provide(injectKeySlideScale, canvasScale)
  441. </script>
  442. <style lang="scss" scoped>
  443. .canvas {
  444. height: 100%;
  445. user-select: none;
  446. overflow: hidden;
  447. background-color: $lightGray;
  448. position: relative;
  449. }
  450. /* 全屏Loading样式 */
  451. .fullscreen-loading-overlay {
  452. position: fixed;
  453. top: 0;
  454. left: 0;
  455. right: 0;
  456. bottom: 0;
  457. background: rgba(255, 255, 255, 0.95);
  458. display: flex;
  459. align-items: center;
  460. justify-content: center;
  461. z-index: 9999;
  462. }
  463. .loading-content {
  464. text-align: center;
  465. color: #666;
  466. }
  467. .loading-spinner {
  468. width: 50px;
  469. height: 50px;
  470. border: 5px solid #f3f3f3;
  471. border-top: 5px solid #1890ff;
  472. border-radius: 50%;
  473. animation: spin 1s linear infinite;
  474. margin: 0 auto 20px;
  475. }
  476. .loading-text {
  477. font-size: 16px;
  478. color: #666;
  479. font-weight: 500;
  480. }
  481. @keyframes spin {
  482. 0% { transform: rotate(0deg); }
  483. 100% { transform: rotate(360deg); }
  484. }
  485. .drag-mask {
  486. cursor: grab;
  487. @include absolute-0();
  488. }
  489. .viewport-wrapper {
  490. position: absolute;
  491. box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
  492. }
  493. .viewport {
  494. position: absolute;
  495. top: 0;
  496. left: 0;
  497. transform-origin: 0 0;
  498. }
  499. </style>