feat: 绘制任意多边形

This commit is contained in:
pipipi-pikachu 2023-08-13 16:02:17 +08:00
parent 75f97b4767
commit bced3b889c
8 changed files with 213 additions and 13 deletions

View File

@ -107,6 +107,7 @@ npm run serve
- 重置图片
- 设置为背景图
#### 形状
- 绘制任意多边形
- 替换形状
- 填充色
- 边框
@ -155,7 +156,7 @@ npm run serve
- 基础编辑
- 页面添加、删除、复制、备注、撤销重做
- 插入文字、图片、矩形、圆形
- 元素通用操作:移动、缩放、复制、删除、层级调整、对齐
- 元素通用操作:移动、缩放、旋转、复制、删除、层级调整、对齐
- 元素样式:文字(加粗、斜体、下划线、删除线、字号、颜色、对齐方向)、填充色
- 基础预览
- 播放预览

View File

@ -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: '任意多边形',
},
]
},

View File

@ -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))
},

View File

@ -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

View 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>

View File

@ -89,6 +89,7 @@ export default (viewportRef: Ref<HTMLElement | undefined>) => {
}
return {
formatCreateSelection,
insertElementFromCreateSelection,
}
}

View File

@ -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 [

View File

@ -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) => {
mainStore.setCreatingElement({
type: 'shape',
data: shape,
})
if (shape.title === '任意多边形') {
mainStore.setCreatingCustomShapeState(true)
}
else {
mainStore.setCreatingElement({
type: 'shape',
data: shape,
})
}
shapePoolVisible.value = false
}