mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 绘制任意多边形
This commit is contained in:
parent
75f97b4767
commit
bced3b889c
@ -107,6 +107,7 @@ npm run serve
|
|||||||
- 重置图片
|
- 重置图片
|
||||||
- 设置为背景图
|
- 设置为背景图
|
||||||
#### 形状
|
#### 形状
|
||||||
|
- 绘制任意多边形
|
||||||
- 替换形状
|
- 替换形状
|
||||||
- 填充色
|
- 填充色
|
||||||
- 边框
|
- 边框
|
||||||
@ -155,7 +156,7 @@ npm run serve
|
|||||||
- 基础编辑
|
- 基础编辑
|
||||||
- 页面添加、删除、复制、备注、撤销重做
|
- 页面添加、删除、复制、备注、撤销重做
|
||||||
- 插入文字、图片、矩形、圆形
|
- 插入文字、图片、矩形、圆形
|
||||||
- 元素通用操作:移动、缩放、复制、删除、层级调整、对齐
|
- 元素通用操作:移动、缩放、旋转、复制、删除、层级调整、对齐
|
||||||
- 元素样式:文字(加粗、斜体、下划线、删除线、字号、颜色、对齐方向)、填充色
|
- 元素样式:文字(加粗、斜体、下划线、删除线、字号、颜色、对齐方向)、填充色
|
||||||
- 基础预览
|
- 基础预览
|
||||||
- 播放预览
|
- 播放预览
|
||||||
|
@ -11,6 +11,7 @@ export interface ShapePoolItem {
|
|||||||
pathFormula?: ShapePathFormulasKeys
|
pathFormula?: ShapePathFormulasKeys
|
||||||
outlined?: boolean
|
outlined?: boolean
|
||||||
pptxShapeType?: string
|
pptxShapeType?: string
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ShapeListItem {
|
interface ShapeListItem {
|
||||||
@ -280,6 +281,11 @@ export const SHAPE_LIST: ShapeListItem[] = [
|
|||||||
pathFormula: ShapePathFormulasKeys.ROUND_RECT_DIAGONAL,
|
pathFormula: ShapePathFormulasKeys.ROUND_RECT_DIAGONAL,
|
||||||
pptxShapeType: 'round2DiagRect',
|
pptxShapeType: 'round2DiagRect',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
viewBox: [200, 200],
|
||||||
|
path: 'M 0 80 L 60 0 L 100 40 L 180 20 L 200 120 L 160 200 L 0 200 L 60 140 Z',
|
||||||
|
title: '任意多边形',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -23,6 +23,7 @@ export interface MainState {
|
|||||||
gridLineSize: number
|
gridLineSize: number
|
||||||
showRuler: boolean
|
showRuler: boolean
|
||||||
creatingElement: CreatingElement | null
|
creatingElement: CreatingElement | null
|
||||||
|
creatingCustomShape: boolean
|
||||||
availableFonts: typeof SYS_FONTS
|
availableFonts: typeof SYS_FONTS
|
||||||
toolbarState: ToolbarStates
|
toolbarState: ToolbarStates
|
||||||
clipingImageElementId: string
|
clipingImageElementId: string
|
||||||
@ -54,6 +55,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
gridLineSize: 0, // 网格线尺寸(0表示不显示网格线)
|
gridLineSize: 0, // 网格线尺寸(0表示不显示网格线)
|
||||||
showRuler: false, // 显示标尺
|
showRuler: false, // 显示标尺
|
||||||
creatingElement: null, // 正在插入的元素信息,需要通过绘制插入的元素(文字、形状、线条)
|
creatingElement: null, // 正在插入的元素信息,需要通过绘制插入的元素(文字、形状、线条)
|
||||||
|
creatingCustomShape: false, // 正在绘制任意多边形
|
||||||
availableFonts: SYS_FONTS, // 当前环境可用字体
|
availableFonts: SYS_FONTS, // 当前环境可用字体
|
||||||
toolbarState: ToolbarStates.SLIDE_DESIGN, // 右侧工具栏状态
|
toolbarState: ToolbarStates.SLIDE_DESIGN, // 右侧工具栏状态
|
||||||
clipingImageElementId: '', // 当前正在裁剪的图片ID
|
clipingImageElementId: '', // 当前正在裁剪的图片ID
|
||||||
@ -139,6 +141,10 @@ export const useMainStore = defineStore('main', {
|
|||||||
this.creatingElement = element
|
this.creatingElement = element
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setCreatingCustomShapeState(state: boolean) {
|
||||||
|
this.creatingCustomShape = state
|
||||||
|
},
|
||||||
|
|
||||||
setAvailableFonts() {
|
setAvailableFonts() {
|
||||||
this.availableFonts = SYS_FONTS.filter(font => isSupportFont(font.value))
|
this.availableFonts = SYS_FONTS.filter(font => isSupportFont(font.value))
|
||||||
},
|
},
|
||||||
|
@ -78,6 +78,13 @@ export interface CreateElementSelectionData {
|
|||||||
end: [number, number]
|
end: [number, number]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CreateCustomShapeData {
|
||||||
|
start: [number, number]
|
||||||
|
end: [number, number]
|
||||||
|
path: string
|
||||||
|
viewBox: [number, number]
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatingTextElement {
|
export interface CreatingTextElement {
|
||||||
type: 'text'
|
type: 'text'
|
||||||
vertical?: boolean
|
vertical?: boolean
|
||||||
|
154
src/views/Editor/Canvas/ShapeCreateCanvas.vue
Normal file
154
src/views/Editor/Canvas/ShapeCreateCanvas.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="shape-create-canvas"
|
||||||
|
ref="shapeCanvasRef"
|
||||||
|
@mousedown.stop="$event => addPoint($event)"
|
||||||
|
@mousemove="$event => updateMousePosition($event)"
|
||||||
|
@contextmenu.stop.prevent="close()"
|
||||||
|
>
|
||||||
|
<svg overflow="visible">
|
||||||
|
<path
|
||||||
|
:d="path"
|
||||||
|
stroke="#d14424"
|
||||||
|
:fill="closed ? 'rgba(226, 83, 77, 0.15)' : 'none'"
|
||||||
|
stroke-width="2"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { storeToRefs } from 'pinia'
|
||||||
|
import { useKeyboardStore, useMainStore } from '@/store'
|
||||||
|
import type { CreateCustomShapeData } from '@/types/edit'
|
||||||
|
import { KEYS } from '@/configs/hotkey'
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'created', payload: CreateCustomShapeData): void
|
||||||
|
}>()
|
||||||
|
const mainStore = useMainStore()
|
||||||
|
const { ctrlOrShiftKeyActive } = storeToRefs(useKeyboardStore())
|
||||||
|
|
||||||
|
const shapeCanvasRef = ref<HTMLElement>()
|
||||||
|
const offset = ref({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
if (!shapeCanvasRef.value) return
|
||||||
|
const { x, y } = shapeCanvasRef.value.getBoundingClientRect()
|
||||||
|
offset.value = { x, y }
|
||||||
|
})
|
||||||
|
|
||||||
|
const mousePosition = ref<[number, number]>()
|
||||||
|
const points = ref<[number, number][]>([])
|
||||||
|
const closed = ref(false)
|
||||||
|
|
||||||
|
const getPoint = (e: MouseEvent) => {
|
||||||
|
let pageX = e.pageX - offset.value.x
|
||||||
|
let pageY = e.pageY - offset.value.y
|
||||||
|
|
||||||
|
if (ctrlOrShiftKeyActive.value && points.value.length) {
|
||||||
|
const [lastPointX, lastPointY] = points.value[points.value.length - 1]
|
||||||
|
if (Math.abs(lastPointX - pageX) - Math.abs(lastPointY - pageY) > 0) {
|
||||||
|
pageY = lastPointY
|
||||||
|
}
|
||||||
|
else pageX = lastPointX
|
||||||
|
}
|
||||||
|
return { pageX, pageY }
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateMousePosition = (e: MouseEvent) => {
|
||||||
|
const { pageX, pageY } = getPoint(e)
|
||||||
|
mousePosition.value = [pageX, pageY]
|
||||||
|
|
||||||
|
if (points.value.length >= 2) {
|
||||||
|
const [firstPointX, firstPointY] = points.value[0]
|
||||||
|
if (Math.abs(firstPointX - pageX) < 5 && Math.abs(firstPointY - pageY) < 5) {
|
||||||
|
closed.value = true
|
||||||
|
}
|
||||||
|
else closed.value = false
|
||||||
|
}
|
||||||
|
else closed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = computed(() => {
|
||||||
|
let d = ''
|
||||||
|
for (let i = 0; i < points.value.length; i++) {
|
||||||
|
const point = points.value[i]
|
||||||
|
if (i === 0) d += `M ${point[0]} ${point[1]} `
|
||||||
|
else d += `L ${point[0]} ${point[1]} `
|
||||||
|
}
|
||||||
|
if (points.value.length && mousePosition.value) {
|
||||||
|
d += `L ${mousePosition.value[0]} ${mousePosition.value[1]}`
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
})
|
||||||
|
|
||||||
|
const addPoint = (e: MouseEvent) => {
|
||||||
|
const { pageX, pageY } = getPoint(e)
|
||||||
|
|
||||||
|
if (closed.value) {
|
||||||
|
const xList = points.value.map(item => item[0])
|
||||||
|
const yList = points.value.map(item => item[1])
|
||||||
|
const minX = Math.min(...xList)
|
||||||
|
const minY = Math.min(...yList)
|
||||||
|
const maxX = Math.max(...xList)
|
||||||
|
const maxY = Math.max(...yList)
|
||||||
|
|
||||||
|
const formatedPoints = points.value.map(point => {
|
||||||
|
return [point[0] - minX, point[1] - minY]
|
||||||
|
})
|
||||||
|
let path = ''
|
||||||
|
for (let i = 0; i < formatedPoints.length; i++) {
|
||||||
|
const point = formatedPoints[i]
|
||||||
|
if (i === 0) path += `M ${point[0]} ${point[1]} `
|
||||||
|
else path += `L ${point[0]} ${point[1]} `
|
||||||
|
}
|
||||||
|
path += 'Z'
|
||||||
|
|
||||||
|
emit('created', {
|
||||||
|
start: [minX + offset.value.x, minY + offset.value.y],
|
||||||
|
end: [maxX + offset.value.x, maxY + offset.value.y],
|
||||||
|
path,
|
||||||
|
viewBox: [maxX - minX, maxY - minY],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
else points.value.push([pageX, pageY])
|
||||||
|
}
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
mainStore.setCreatingCustomShapeState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const keydownListener = (e: KeyboardEvent) => {
|
||||||
|
const key = e.key.toUpperCase()
|
||||||
|
if (key === KEYS.ESC) close()
|
||||||
|
}
|
||||||
|
onMounted(() => {
|
||||||
|
message.success('点击开始绘制任意多边形,首尾闭合完成绘制,按 ESC 键或鼠标右键关闭')
|
||||||
|
document.addEventListener('keydown', keydownListener)
|
||||||
|
})
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', keydownListener))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.shape-create-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 2;
|
||||||
|
cursor: crosshair;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -89,6 +89,7 @@ export default (viewportRef: Ref<HTMLElement | undefined>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
formatCreateSelection,
|
||||||
insertElementFromCreateSelection,
|
insertElementFromCreateSelection,
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -12,6 +12,10 @@
|
|||||||
v-if="creatingElement"
|
v-if="creatingElement"
|
||||||
@created="data => insertElementFromCreateSelection(data)"
|
@created="data => insertElementFromCreateSelection(data)"
|
||||||
/>
|
/>
|
||||||
|
<ShapeCreateCanvas
|
||||||
|
v-if="creatingCustomShape"
|
||||||
|
@created="data => insertCustomShape(data)"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="viewport-wrapper"
|
class="viewport-wrapper"
|
||||||
:style="{
|
:style="{
|
||||||
@ -103,7 +107,7 @@ import { storeToRefs } from 'pinia'
|
|||||||
import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
|
import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
|
||||||
import type { ContextmenuItem } from '@/components/Contextmenu/types'
|
import type { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||||
import type { PPTElement } from '@/types/slides'
|
import type { PPTElement } from '@/types/slides'
|
||||||
import type { AlignmentLineProps } from '@/types/edit'
|
import type { AlignmentLineProps, CreateCustomShapeData } from '@/types/edit'
|
||||||
import { injectKeySlideScale } from '@/types/injectKey'
|
import { injectKeySlideScale } from '@/types/injectKey'
|
||||||
import { removeAllRanges } from '@/utils/selection'
|
import { removeAllRanges } from '@/utils/selection'
|
||||||
import { KEYS } from '@/configs/hotkey'
|
import { KEYS } from '@/configs/hotkey'
|
||||||
@ -133,6 +137,7 @@ import ViewportBackground from './ViewportBackground.vue'
|
|||||||
import AlignmentLine from './AlignmentLine.vue'
|
import AlignmentLine from './AlignmentLine.vue'
|
||||||
import Ruler from './Ruler.vue'
|
import Ruler from './Ruler.vue'
|
||||||
import ElementCreateSelection from './ElementCreateSelection.vue'
|
import ElementCreateSelection from './ElementCreateSelection.vue'
|
||||||
|
import ShapeCreateCanvas from './ShapeCreateCanvas.vue'
|
||||||
import MultiSelectOperate from './Operate/MultiSelectOperate.vue'
|
import MultiSelectOperate from './Operate/MultiSelectOperate.vue'
|
||||||
import Operate from './Operate/index.vue'
|
import Operate from './Operate/index.vue'
|
||||||
import LinkDialog from './LinkDialog.vue'
|
import LinkDialog from './LinkDialog.vue'
|
||||||
@ -149,6 +154,7 @@ const {
|
|||||||
showRuler,
|
showRuler,
|
||||||
showSelectPanel,
|
showSelectPanel,
|
||||||
creatingElement,
|
creatingElement,
|
||||||
|
creatingCustomShape,
|
||||||
canvasScale,
|
canvasScale,
|
||||||
textFormatPainter,
|
textFormatPainter,
|
||||||
} = storeToRefs(mainStore)
|
} = storeToRefs(mainStore)
|
||||||
@ -190,7 +196,7 @@ const { deleteAllElements } = useDeleteElement()
|
|||||||
const { pasteElement } = useCopyAndPasteElement()
|
const { pasteElement } = useCopyAndPasteElement()
|
||||||
const { enterScreeningFromStart } = useScreening()
|
const { enterScreeningFromStart } = useScreening()
|
||||||
const { updateSlideIndex } = useSlideHandler()
|
const { updateSlideIndex } = useSlideHandler()
|
||||||
const { createTextElement } = useCreateElement()
|
const { createTextElement, createShapeElement } = useCreateElement()
|
||||||
|
|
||||||
// 组件渲染时,如果存在元素焦点,需要清除
|
// 组件渲染时,如果存在元素焦点,需要清除
|
||||||
// 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
|
// 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
|
||||||
@ -212,9 +218,9 @@ const handleClickBlankArea = (e: MouseEvent) => {
|
|||||||
removeAllRanges()
|
removeAllRanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 双击插入文本
|
// 双击空白处插入文本
|
||||||
const handleDblClick = (e: MouseEvent) => {
|
const handleDblClick = (e: MouseEvent) => {
|
||||||
if (activeElementIdList.value.length) return
|
if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
|
||||||
if (!viewportRef.value) return
|
if (!viewportRef.value) return
|
||||||
|
|
||||||
const viewportRect = viewportRef.value.getBoundingClientRect()
|
const viewportRect = viewportRef.value.getBoundingClientRect()
|
||||||
@ -265,7 +271,21 @@ const toggleRuler = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 在鼠标绘制的范围插入元素
|
// 在鼠标绘制的范围插入元素
|
||||||
const { insertElementFromCreateSelection } = useInsertFromCreateSelection(viewportRef)
|
const { insertElementFromCreateSelection, formatCreateSelection } = useInsertFromCreateSelection(viewportRef)
|
||||||
|
|
||||||
|
// 插入自定义任意多边形
|
||||||
|
const insertCustomShape = (data: CreateCustomShapeData) => {
|
||||||
|
const {
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
path,
|
||||||
|
viewBox,
|
||||||
|
} = data
|
||||||
|
const position = formatCreateSelection({ start, end })
|
||||||
|
position && createShapeElement(position, { path, viewBox })
|
||||||
|
|
||||||
|
mainStore.setCreatingCustomShapeState(false)
|
||||||
|
}
|
||||||
|
|
||||||
const contextmenus = (): ContextmenuItem[] => {
|
const contextmenus = (): ContextmenuItem[] => {
|
||||||
return [
|
return [
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
<ShapePool @select="shape => drawShape(shape)" />
|
<ShapePool @select="shape => drawShape(shape)" />
|
||||||
</template>
|
</template>
|
||||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="插入形状" :align="{ offset: [0, 0] }">
|
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="插入形状" :align="{ offset: [0, 0] }">
|
||||||
<IconGraphicDesign class="handler-item" :class="{ 'active': creatingElement?.type === 'shape' }" />
|
<IconGraphicDesign class="handler-item" :class="{ 'active': creatingCustomShape || creatingElement?.type === 'shape' }" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover trigger="click" v-model:open="linePoolVisible">
|
<Popover trigger="click" v-model:open="linePoolVisible">
|
||||||
@ -142,7 +142,7 @@ import {
|
|||||||
} from 'ant-design-vue'
|
} from 'ant-design-vue'
|
||||||
|
|
||||||
const mainStore = useMainStore()
|
const mainStore = useMainStore()
|
||||||
const { creatingElement } = storeToRefs(mainStore)
|
const { creatingElement, creatingCustomShape } = storeToRefs(mainStore)
|
||||||
const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
|
const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
|
||||||
|
|
||||||
const { redo, undo } = useHistorySnapshot()
|
const { redo, undo } = useHistorySnapshot()
|
||||||
@ -193,12 +193,17 @@ const drawText = (vertical = false) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 绘制形状范围
|
// 绘制形状范围(或绘制自定义任意多边形)
|
||||||
const drawShape = (shape: ShapePoolItem) => {
|
const drawShape = (shape: ShapePoolItem) => {
|
||||||
mainStore.setCreatingElement({
|
if (shape.title === '任意多边形') {
|
||||||
type: 'shape',
|
mainStore.setCreatingCustomShapeState(true)
|
||||||
data: shape,
|
}
|
||||||
})
|
else {
|
||||||
|
mainStore.setCreatingElement({
|
||||||
|
type: 'shape',
|
||||||
|
data: shape,
|
||||||
|
})
|
||||||
|
}
|
||||||
shapePoolVisible.value = false
|
shapePoolVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user