| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556 |
- <template>
- <div
- class="canvas"
- ref="canvasRef"
- @wheel="$event => handleMousewheelCanvas($event)"
- @mousedown="$event => handleClickBlankArea($event)"
- @dblclick="$event => handleDblClick($event)"
- v-contextmenu="contextmenus"
- v-click-outside="removeEditorAreaFocus"
- >
- <!-- 全屏Loading状态 -->
- <div v-if="isCourseLoading" class="fullscreen-loading-overlay">
- <div class="loading-content">
- <div class="loading-spinner"></div>
- <div class="loading-text">{{ lang.ssCourseLoading }}</div>
- </div>
- </div>
- <ElementCreateSelection
- v-if="creatingElement"
- @created="data => insertElementFromCreateSelection(data)"
- />
- <ShapeCreateCanvas
- v-if="creatingCustomShape"
- @created="data => insertCustomShape(data)"
- />
- <div
- class="viewport-wrapper"
- :style="{
- width: viewportStyles.width * canvasScale + 'px',
- height: viewportStyles.height * canvasScale + 'px',
- left: viewportStyles.left + 'px',
- top: viewportStyles.top + 'px',
- }"
- >
- <div class="operates">
- <AlignmentLine
- v-for="(line, index) in alignmentLines"
- :key="index"
- :type="line.type"
- :axis="line.axis"
- :length="line.length"
- :canvasScale="canvasScale"
- />
- <MultiSelectOperate
- v-if="activeElementIdList.length > 1"
- :elementList="elementList"
- :scaleMultiElement="scaleMultiElement"
- />
- <Operate
- v-for="element in elementList"
- :key="element.id"
- :elementInfo="element"
- :isSelected="activeElementIdList.includes(element.id)"
- :isActive="handleElementId === element.id"
- :isActiveGroupElement="activeGroupElementId === element.id"
- :isMultiSelect="activeElementIdList.length > 1"
- :rotateElement="rotateElement"
- :scaleElement="scaleElement"
- :openLinkDialog="openLinkDialog"
- :dragLineElement="dragLineElement"
- :moveShapeKeypoint="moveShapeKeypoint"
- v-show="!hiddenElementIdList.includes(element.id)"
- />
- <ViewportBackground />
- </div>
- <div
- class="viewport"
- ref="viewportRef"
- :style="{ transform: `scale(${canvasScale})` }"
- >
- <MouseSelection
- v-if="mouseSelectionVisible"
- :top="mouseSelection.top"
- :left="mouseSelection.left"
- :width="mouseSelection.width"
- :height="mouseSelection.height"
- :quadrant="mouseSelectionQuadrant"
- />
- <EditableElement
- v-for="(element, index) in elementList"
- :key="element.id"
- :elementInfo="element"
- :elementIndex="index + 1"
- :isMultiSelect="activeElementIdList.length > 1"
- :selectElement="selectElement"
- :openLinkDialog="openLinkDialog"
- :openWebpageLinkEditDialog="openWebpageLinkEditDialog"
- v-show="!hiddenElementIdList.includes(element.id)"
- />
- </div>
- </div>
- <div class="drag-mask" v-if="spaceKeyState"></div>
- <Ruler :viewportStyles="viewportStyles" :elementList="elementList" v-if="showRuler" />
- <Modal
- v-model:visible="linkDialogVisible"
- :width="540"
- >
- <LinkDialog @close="linkDialogVisible = false" />
- </Modal>
- <Modal
- v-model:visible="webpageLinkEditDialogVisible"
- :width="540"
- >
- <WebpageLinkEditDialog
- :elementId="webpageLinkEditElementId"
- :currentUrl="webpageLinkEditCurrentUrl"
- @close="webpageLinkEditDialogVisible = false"
- />
- </Modal>
- </div>
- </template>
- <script lang="ts" setup>
- import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect, useTemplateRef } from 'vue'
- import { throttle } from 'lodash'
- import { storeToRefs } from 'pinia'
- import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
- import type { ContextmenuItem } from '@/components/Contextmenu/types'
- import type { PPTElement, PPTShapeElement } from '@/types/slides'
- import type { AlignmentLineProps, CreateCustomShapeData } from '@/types/edit'
- import { injectKeySlideScale } from '@/types/injectKey'
- import { removeAllRanges } from '@/utils/selection'
- import { KEYS } from '@/configs/hotkey'
- import useViewportSize from './hooks/useViewportSize'
- import useMouseSelection from './hooks/useMouseSelection'
- import useDropImageOrText from './hooks/useDropImageOrText'
- import useRotateElement from './hooks/useRotateElement'
- import useScaleElement from './hooks/useScaleElement'
- import useSelectAndMoveElement from './hooks/useSelectElement'
- import useDragElement from './hooks/useDragElement'
- import useDragLineElement from './hooks/useDragLineElement'
- import useMoveShapeKeypoint from './hooks/useMoveShapeKeypoint'
- import useInsertFromCreateSelection from './hooks/useInsertFromCreateSelection'
- import useDeleteElement from '@/hooks/useDeleteElement'
- import useCopyAndPasteElement from '@/hooks/useCopyAndPasteElement'
- import useSelectElement from '@/hooks/useSelectElement'
- import useScaleCanvas from '@/hooks/useScaleCanvas'
- import useScreening from '@/hooks/useScreening'
- import useSlideHandler from '@/hooks/useSlideHandler'
- import useCreateElement from '@/hooks/useCreateElement'
- import EditableElement from './EditableElement.vue'
- import MouseSelection from './MouseSelection.vue'
- import ViewportBackground from './ViewportBackground.vue'
- import AlignmentLine from './AlignmentLine.vue'
- import Ruler from './Ruler.vue'
- import ElementCreateSelection from './ElementCreateSelection.vue'
- import ShapeCreateCanvas from './ShapeCreateCanvas.vue'
- import MultiSelectOperate from './Operate/MultiSelectOperate.vue'
- import Operate from './Operate/index.vue'
- import LinkDialog from './LinkDialog.vue'
- import WebpageLinkEditDialog from './WebpageLinkEditDialog.vue'
- import Modal from '@/components/Modal.vue'
- import api from '@/services/course'
- import useImport from '@/hooks/useImport'
- import message from '@/utils/message'
- import { lang } from '@/main'
- // 定义组件props
- interface Props {
- courseid?: string | null
- }
- const props = withDefaults(defineProps<Props>(), {
- courseid: null,
- })
- const emit = defineEmits<{
- (e: 'courseLoaded', data: any): void
- }>()
- const mainStore = useMainStore()
- const {
- activeElementIdList,
- activeGroupElementId,
- handleElementId,
- hiddenElementIdList,
- editorAreaFocus,
- gridLineSize,
- showRuler,
- creatingElement,
- creatingCustomShape,
- canvasScale,
- textFormatPainter,
- } = storeToRefs(mainStore)
- const { currentSlide } = storeToRefs(useSlidesStore())
- const { ctrlKeyState, spaceKeyState } = storeToRefs(useKeyboardStore())
- const viewportRef = useTemplateRef<HTMLElement>('viewportRef')
- const alignmentLines = ref<AlignmentLineProps[]>([])
- const linkDialogVisible = ref(false)
- const openLinkDialog = () => linkDialogVisible.value = true
- const webpageLinkEditDialogVisible = ref(false)
- const webpageLinkEditElementId = ref('')
- const webpageLinkEditCurrentUrl = ref('')
- const openWebpageLinkEditDialog = (elementId: string, currentUrl: string) => {
- webpageLinkEditElementId.value = elementId
- webpageLinkEditCurrentUrl.value = currentUrl
- webpageLinkEditDialogVisible.value = true
- }
- // 课程加载loading状态
- const isCourseLoading = ref(false)
- watch(handleElementId, () => {
- mainStore.setActiveGroupElementId('')
- })
- const elementList = ref<PPTElement[]>([])
- const setLocalElementList = () => {
- elementList.value = currentSlide.value ? JSON.parse(JSON.stringify(currentSlide.value.elements)) : []
- }
- watchEffect(setLocalElementList)
- const canvasRef = useTemplateRef<HTMLElement>('canvasRef')
- const { dragViewport, viewportStyles } = useViewportSize(canvasRef)
- useDropImageOrText(canvasRef)
- const { mouseSelection, mouseSelectionVisible, mouseSelectionQuadrant, updateMouseSelection } = useMouseSelection(elementList, viewportRef)
- const { dragElement } = useDragElement(elementList, alignmentLines, canvasScale)
- const { dragLineElement } = useDragLineElement(elementList)
- const { selectElement } = useSelectAndMoveElement(elementList, dragElement)
- const { scaleElement, scaleMultiElement } = useScaleElement(elementList, alignmentLines, canvasScale)
- const { rotateElement } = useRotateElement(elementList, viewportRef, canvasScale)
- const { moveShapeKeypoint } = useMoveShapeKeypoint(elementList, canvasScale)
- const { selectAllElements } = useSelectElement()
- const { deleteAllElements } = useDeleteElement()
- const { pasteElement } = useCopyAndPasteElement()
- const { enterScreeningFromStart } = useScreening()
- const { updateSlideIndex } = useSlideHandler()
- const { createTextElement, createShapeElement } = useCreateElement()
- const { readJSON, getFile } = useImport()
- // 组件渲染时,如果存在元素焦点,需要清除
- // 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
- onMounted(() => {
- if (activeElementIdList.value.length) {
- nextTick(() => mainStore.setActiveElementIdList([]))
- }
- // 优化:避免重复代码,统一设置 window.pptLoading
- if (typeof window !== 'undefined') {
- (window as any).pptLoading = 1
- }
- if (props.courseid) {
- getCourseDetail()
- }
- else {
- (window as any).pptLoading = 2
- }
- })
- const getCourseDetail = async () => {
- // 显示全屏loading
- isCourseLoading.value = true
-
- try {
- const res = await api.getCourseDetail(props.courseid as string)
- console.log(res)
- const courseDetail = res[0][0]
- emit('courseLoaded', courseDetail)
-
- const pptJSONUrl = JSON.parse(courseDetail.chapters).pptData ? JSON.parse(courseDetail.chapters).pptData : ''
- console.log(pptJSONUrl)
-
- if (pptJSONUrl) {
- const pptdata = await getFile(pptJSONUrl)
- // pptdata.data 是 ArrayBuffer,需要先转成字符串再解析为 JSON
- let jsonStr = ''
- if (pptdata && pptdata.data) {
- // 先将 ArrayBuffer 转为字符串
- const uint8Array = new Uint8Array(pptdata.data)
- jsonStr = new TextDecoder('utf-8').decode(uint8Array)
- try {
- const jsonObj = JSON.parse(jsonStr)
- console.log('jsonObj:', jsonObj)
- const slides = jsonObj.slides || []
- for (const slide of slides) {
- slide.elements = slide.elements || []
- for (const element of slide.elements) {
- if (element.type === 'frame' && element.url?.includes('/workPage') && !element.url.includes('/setWorkPage')) {
- element.url = element.url.replace(/\/(workPage(?:New)?)\b(?![^\/?#]*\.html)/g, '/setWorkPage')
- }
- }
- }
- readJSON(jsonObj, true)
- if (typeof window !== 'undefined') {
- const win = window as any
- win.pptLoading = 2
- }
- }
- catch (e) {
- console.error('解析pptdata.data失败:', e)
- message.error(lang.ssParsePptFail)
- if (typeof window !== 'undefined') {
- const win = window as any
- win.pptLoading = 2
- }
- }
- }
- else {
- if (typeof window !== 'undefined') {
- const win = window as any
- win.pptLoading = 2
- }
- }
- }
- }
- catch (error) {
- console.error('获取课程详情失败:', error)
- message.error(lang.ssFetchCourseFail)
- isCourseLoading.value = false
- if (typeof window !== 'undefined') {
- const win = window as any
- win.pptLoading = 2
- }
- }
- finally {
- // 隐藏loading
- isCourseLoading.value = false
- if (typeof window !== 'undefined') {
- const win = window as any
- win.pptLoading = 2
- }
- }
- }
- // 点击画布的空白区域:清空焦点元素、设置画布焦点、清除文字选区、清空格式刷状态
- const handleClickBlankArea = (e: MouseEvent) => {
- if (activeElementIdList.value.length) mainStore.setActiveElementIdList([])
- if (!spaceKeyState.value) updateMouseSelection(e)
- else dragViewport(e)
- if (!editorAreaFocus.value) mainStore.setEditorareaFocus(true)
- if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
- removeAllRanges()
- }
- // 双击空白处插入文本
- const handleDblClick = (e: MouseEvent) => {
- if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
- if (!viewportRef.value) return
- const viewportRect = viewportRef.value.getBoundingClientRect()
- const left = (e.pageX - viewportRect.x) / canvasScale.value
- const top = (e.pageY - viewportRect.y) / canvasScale.value
- createTextElement({
- left,
- top,
- width: 200 / canvasScale.value, // 除以 canvasScale 是为了与点击选区创建的形式保持相同的宽度
- height: 0,
- })
- }
- // 画布注销时清空格式刷状态
- onUnmounted(() => {
- if (textFormatPainter.value) mainStore.setTextFormatPainter(null)
- })
- // 移除画布编辑区域焦点
- const removeEditorAreaFocus = () => {
- if (editorAreaFocus.value) mainStore.setEditorareaFocus(false)
- }
- // 滚动鼠标
- const { scaleCanvas } = useScaleCanvas()
- const throttleScaleCanvas = throttle(scaleCanvas, 100, { leading: true, trailing: false })
- const throttleUpdateSlideIndex = throttle(updateSlideIndex, 300, { leading: true, trailing: false })
- const handleMousewheelCanvas = (e: WheelEvent) => {
- // type 77(英语口语预览)这类可交互 widget:如果 wheel 来自它内部的某个 scroll 容器(比如
- // 报告的 .report-scroll、对话的 .chat-area),让内部 scroll 接管,canvas 不介入。
- // 如果不这样做,下面的 preventDefault 会把浏览器默认滚动行为一并抑制,内部永远滚不动。
- const targetEl = e.target instanceof Element ? e.target : null
- if (targetEl?.closest('.topic-discussion-preview')) return
- e.preventDefault()
- // 按住Ctrl键时:缩放画布
- if (ctrlKeyState.value) {
- if (e.deltaY > 0) throttleScaleCanvas('-')
- else if (e.deltaY < 0) throttleScaleCanvas('+')
- }
- // 上下翻页
- // else {
- // if (e.deltaY > 0) throttleUpdateSlideIndex(KEYS.DOWN)
- // else if (e.deltaY < 0) throttleUpdateSlideIndex(KEYS.UP)
- // }
- }
- // 开关标尺
- const toggleRuler = () => {
- mainStore.setRulerState(!showRuler.value)
- }
- // 在鼠标绘制的范围插入元素
- const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFromCreateSelection(viewportRef)
- // 插入自定义任意多边形
- const insertCustomShape = (data: CreateCustomShapeData) => {
- const {
- start,
- end,
- path,
- viewBox,
- } = data
- const position = formatCreateSelection({ start, end })
- if (position) {
- const supplement: Partial<PPTShapeElement> = {}
- if (data.fill) supplement.fill = data.fill
- if (data.outline) supplement.outline = data.outline
- createShapeElement(position, { path, viewBox }, supplement)
- }
- mainStore.setCreatingCustomShapeState(false)
- }
- const contextmenus = (): ContextmenuItem[] => {
- return [
- {
- text: lang.ssPaste,
- subText: 'Ctrl + V',
- handler: pasteElement,
- },
- {
- text: lang.ssSelectAll,
- subText: 'Ctrl + A',
- handler: selectAllElements,
- },
- {
- text: lang.ssRuler,
- subText: showRuler.value ? '√' : '',
- handler: toggleRuler,
- },
- {
- text: lang.ssGridLine,
- handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50),
- children: [
- {
- text: lang.ssNone,
- subText: gridLineSize.value === 0 ? '√' : '',
- handler: () => mainStore.setGridLineSize(0),
- },
- {
- text: lang.ssSmall,
- subText: gridLineSize.value === 25 ? '√' : '',
- handler: () => mainStore.setGridLineSize(25),
- },
- {
- text: lang.ssMedium,
- subText: gridLineSize.value === 50 ? '√' : '',
- handler: () => mainStore.setGridLineSize(50),
- },
- {
- text: lang.ssLarge,
- subText: gridLineSize.value === 100 ? '√' : '',
- handler: () => mainStore.setGridLineSize(100),
- },
- ],
- },
- {
- text: lang.ssResetPage,
- handler: deleteAllElements,
- },
- { divider: true },
- {
- text: lang.ssStage,
- subText: 'F5',
- handler: enterScreeningFromStart,
- },
- ]
- }
- provide(injectKeySlideScale, canvasScale)
- </script>
- <style lang="scss" scoped>
- .canvas {
- height: 100%;
- user-select: none;
- overflow: hidden;
- background-color: $lightGray;
- position: relative;
- }
- /* 全屏Loading样式 */
- .fullscreen-loading-overlay {
- position: fixed;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- background: rgba(255, 255, 255, 0.95);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 9999;
- }
- .loading-content {
- text-align: center;
- color: #666;
- }
- .loading-spinner {
- width: 50px;
- height: 50px;
- border: 5px solid #f3f3f3;
- border-top: 5px solid #1890ff;
- border-radius: 50%;
- animation: spin 1s linear infinite;
- margin: 0 auto 20px;
- }
- .loading-text {
- font-size: 16px;
- color: #666;
- font-weight: 500;
- }
- @keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
- }
- .drag-mask {
- cursor: grab;
- @include absolute-0();
- }
- .viewport-wrapper {
- position: absolute;
- box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.01), 0 0 12px 0 rgba(0, 0, 0, 0.1);
- }
- .viewport {
- position: absolute;
- top: 0;
- left: 0;
- transform-origin: 0 0;
- }
- </style>
|