| <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" | |
| > | |
| <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" | |
| 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> | |
| </div> | |
| </template> | |
| <script lang="ts" setup> | |
| import { nextTick, onMounted, onUnmounted, provide, ref, watch, watchEffect } 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 Modal from '@/components/Modal.vue' | |
| 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 = ref<HTMLElement>() | |
| const alignmentLines = ref<AlignmentLineProps[]>([]) | |
| const linkDialogVisible = ref(false) | |
| const openLinkDialog = () => linkDialogVisible.value = true | |
| 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 = ref<HTMLElement>() | |
| 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() | |
| // 组件渲染时,如果存在元素焦点,需要清除 | |
| // 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面) | |
| onMounted(() => { | |
| if (activeElementIdList.value.length) { | |
| nextTick(() => mainStore.setActiveElementIdList([])) | |
| } | |
| }) | |
| // 点击画布的空白区域:清空焦点元素、设置画布焦点、清除文字选区、清空格式刷状态 | |
| 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) => { | |
| 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: '粘贴', | |
| subText: 'Ctrl + V', | |
| handler: pasteElement, | |
| }, | |
| { | |
| text: '全选', | |
| subText: 'Ctrl + A', | |
| handler: selectAllElements, | |
| }, | |
| { | |
| text: '标尺', | |
| subText: showRuler.value ? '√' : '', | |
| handler: toggleRuler, | |
| }, | |
| { | |
| text: '网格线', | |
| handler: () => mainStore.setGridLineSize(gridLineSize.value ? 0 : 50), | |
| children: [ | |
| { | |
| text: '无', | |
| subText: gridLineSize.value === 0 ? '√' : '', | |
| handler: () => mainStore.setGridLineSize(0), | |
| }, | |
| { | |
| text: '小', | |
| subText: gridLineSize.value === 25 ? '√' : '', | |
| handler: () => mainStore.setGridLineSize(25), | |
| }, | |
| { | |
| text: '中', | |
| subText: gridLineSize.value === 50 ? '√' : '', | |
| handler: () => mainStore.setGridLineSize(50), | |
| }, | |
| { | |
| text: '大', | |
| subText: gridLineSize.value === 100 ? '√' : '', | |
| handler: () => mainStore.setGridLineSize(100), | |
| }, | |
| ], | |
| }, | |
| { | |
| text: '重置当前页', | |
| handler: deleteAllElements, | |
| }, | |
| { divider: true }, | |
| { | |
| text: '幻灯片放映', | |
| 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; | |
| } | |
| .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> |