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
|
||||
outlined?: boolean
|
||||
pptxShapeType?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
interface ShapeListItem {
|
||||
@ -280,6 +281,11 @@ export const SHAPE_LIST: ShapeListItem[] = [
|
||||
pathFormula: ShapePathFormulasKeys.ROUND_RECT_DIAGONAL,
|
||||
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
|
||||
showRuler: boolean
|
||||
creatingElement: CreatingElement | null
|
||||
creatingCustomShape: boolean
|
||||
availableFonts: typeof SYS_FONTS
|
||||
toolbarState: ToolbarStates
|
||||
clipingImageElementId: string
|
||||
@ -54,6 +55,7 @@ export const useMainStore = defineStore('main', {
|
||||
gridLineSize: 0, // 网格线尺寸(0表示不显示网格线)
|
||||
showRuler: false, // 显示标尺
|
||||
creatingElement: null, // 正在插入的元素信息,需要通过绘制插入的元素(文字、形状、线条)
|
||||
creatingCustomShape: false, // 正在绘制任意多边形
|
||||
availableFonts: SYS_FONTS, // 当前环境可用字体
|
||||
toolbarState: ToolbarStates.SLIDE_DESIGN, // 右侧工具栏状态
|
||||
clipingImageElementId: '', // 当前正在裁剪的图片ID
|
||||
@ -139,6 +141,10 @@ export const useMainStore = defineStore('main', {
|
||||
this.creatingElement = element
|
||||
},
|
||||
|
||||
setCreatingCustomShapeState(state: boolean) {
|
||||
this.creatingCustomShape = state
|
||||
},
|
||||
|
||||
setAvailableFonts() {
|
||||
this.availableFonts = SYS_FONTS.filter(font => isSupportFont(font.value))
|
||||
},
|
||||
|
@ -78,6 +78,13 @@ export interface CreateElementSelectionData {
|
||||
end: [number, number]
|
||||
}
|
||||
|
||||
export interface CreateCustomShapeData {
|
||||
start: [number, number]
|
||||
end: [number, number]
|
||||
path: string
|
||||
viewBox: [number, number]
|
||||
}
|
||||
|
||||
export interface CreatingTextElement {
|
||||
type: 'text'
|
||||
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 {
|
||||
formatCreateSelection,
|
||||
insertElementFromCreateSelection,
|
||||
}
|
||||
}
|
@ -12,6 +12,10 @@
|
||||
v-if="creatingElement"
|
||||
@created="data => insertElementFromCreateSelection(data)"
|
||||
/>
|
||||
<ShapeCreateCanvas
|
||||
v-if="creatingCustomShape"
|
||||
@created="data => insertCustomShape(data)"
|
||||
/>
|
||||
<div
|
||||
class="viewport-wrapper"
|
||||
:style="{
|
||||
@ -103,7 +107,7 @@ import { storeToRefs } from 'pinia'
|
||||
import { useMainStore, useSlidesStore, useKeyboardStore } from '@/store'
|
||||
import type { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
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 { removeAllRanges } from '@/utils/selection'
|
||||
import { KEYS } from '@/configs/hotkey'
|
||||
@ -133,6 +137,7 @@ 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'
|
||||
@ -149,6 +154,7 @@ const {
|
||||
showRuler,
|
||||
showSelectPanel,
|
||||
creatingElement,
|
||||
creatingCustomShape,
|
||||
canvasScale,
|
||||
textFormatPainter,
|
||||
} = storeToRefs(mainStore)
|
||||
@ -190,7 +196,7 @@ const { deleteAllElements } = useDeleteElement()
|
||||
const { pasteElement } = useCopyAndPasteElement()
|
||||
const { enterScreeningFromStart } = useScreening()
|
||||
const { updateSlideIndex } = useSlideHandler()
|
||||
const { createTextElement } = useCreateElement()
|
||||
const { createTextElement, createShapeElement } = useCreateElement()
|
||||
|
||||
// 组件渲染时,如果存在元素焦点,需要清除
|
||||
// 这种情况存在于:有焦点元素的情况下进入了放映模式,再退出时,需要清除原先的焦点(因为可能已经切换了页面)
|
||||
@ -212,9 +218,9 @@ const handleClickBlankArea = (e: MouseEvent) => {
|
||||
removeAllRanges()
|
||||
}
|
||||
|
||||
// 双击插入文本
|
||||
// 双击空白处插入文本
|
||||
const handleDblClick = (e: MouseEvent) => {
|
||||
if (activeElementIdList.value.length) return
|
||||
if (activeElementIdList.value.length || creatingElement.value || creatingCustomShape.value) return
|
||||
if (!viewportRef.value) return
|
||||
|
||||
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[] => {
|
||||
return [
|
||||
|
@ -33,7 +33,7 @@
|
||||
<ShapePool @select="shape => drawShape(shape)" />
|
||||
</template>
|
||||
<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>
|
||||
</Popover>
|
||||
<Popover trigger="click" v-model:open="linePoolVisible">
|
||||
@ -142,7 +142,7 @@ import {
|
||||
} from 'ant-design-vue'
|
||||
|
||||
const mainStore = useMainStore()
|
||||
const { creatingElement } = storeToRefs(mainStore)
|
||||
const { creatingElement, creatingCustomShape } = storeToRefs(mainStore)
|
||||
const { canUndo, canRedo } = storeToRefs(useSnapshotStore())
|
||||
|
||||
const { redo, undo } = useHistorySnapshot()
|
||||
@ -193,12 +193,17 @@ const drawText = (vertical = false) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 绘制形状范围
|
||||
// 绘制形状范围(或绘制自定义任意多边形)
|
||||
const drawShape = (shape: ShapePoolItem) => {
|
||||
if (shape.title === '任意多边形') {
|
||||
mainStore.setCreatingCustomShapeState(true)
|
||||
}
|
||||
else {
|
||||
mainStore.setCreatingElement({
|
||||
type: 'shape',
|
||||
data: shape,
|
||||
})
|
||||
}
|
||||
shapePoolVisible.value = false
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user