feat: 形状支持图片填充(#291)

This commit is contained in:
zxc 2025-03-16 16:31:59 +08:00
parent ebf9b2b15f
commit dedaf8b88b
9 changed files with 131 additions and 14 deletions

View File

@ -107,7 +107,7 @@ Browser access: http://127.0.0.1:5173/
- Draw any polygon
- Draw any line (unclosed shape simulation)
- Replace shape
- Fill color
- Fill (solid color, gradient, image)
- Border
- Shadow
- Transparency

View File

@ -102,7 +102,7 @@ npm run dev
- 绘制任意多边形
- 绘制任意线条(未封闭形状模拟)
- 替换形状
- 填充色
- 填充(纯、渐变、图片)
- 边框
- 阴影
- 透明度

View File

@ -317,6 +317,8 @@ export default () => {
rotate: el.fill.value.rot,
} : undefined
const pattern: string | undefined = el.fill?.type === 'image' ? el.fill.value.picBase64 : undefined
const fill = el.fill?.type === 'color' ? el.fill.value : ''
const element: PPTShapeElement = {
@ -330,6 +332,7 @@ export default () => {
path: 'M 0 0 L 200 0 L 200 200 L 0 200 Z',
fill,
gradient,
pattern,
fixedRatio: false,
rotate: el.rotate,
outline: {

View File

@ -327,6 +327,8 @@ export interface ShapeText {
*
* gradient?: 渐变
*
* pattern?: 图案
*
* outline?: 边框
*
* opacity?: 不透明度
@ -354,6 +356,7 @@ export interface PPTShapeElement extends PPTBaseElement {
fixedRatio: boolean
fill: string
gradient?: Gradient
pattern?: string
outline?: PPTElementOutline
opacity?: number
flipH?: boolean

View File

@ -22,13 +22,14 @@
<Select
style="flex: 1;"
:value="fillType"
@update:value="value => updateFillType(value as 'fill' | 'gradient')"
@update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')"
:options="[
{ label: '纯色填充', value: 'fill' },
{ label: '渐变填充', value: 'gradient' },
{ label: '图片填充', value: 'pattern' },
]"
/>
<div style="width: 10px;"></div>
<div style="width: 10px;" v-if="fillType !== 'pattern'"></div>
<Popover trigger="click" v-if="fillType === 'fill'" style="flex: 1;">
<template #content>
<ColorPicker
@ -42,7 +43,7 @@
style="flex: 1;"
:value="gradient.type"
@update:value="value => updateGradient({ type: value as GradientType })"
v-else
v-else-if="fillType === 'gradient'"
:options="[
{ label: '线性渐变', value: 'linear' },
{ label: '径向渐变', value: 'radial' },
@ -83,6 +84,18 @@
/>
</div>
</template>
<template v-if="fillType === 'pattern'">
<div class="pattern-image-wrapper">
<FileInput @change="files => uploadPattern(files)">
<div class="pattern-image">
<div class="content" :style="{ backgroundImage: `url(${pattern})` }">
<IconPlus />
</div>
</div>
</FileInput>
</div>
</template>
<ElementFlip />
@ -131,6 +144,7 @@ import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store'
import type { GradientType, PPTShapeElement, Gradient, ShapeText } from '@/types/slides'
import { type ShapePoolItem, SHAPE_LIST, SHAPE_PATH_FORMULAS } from '@/configs/shapes'
import { getImageDataURL } from '@/utils/image'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import useShapeFormatPainter from '@/hooks/useShapeFormatPainter'
@ -150,6 +164,7 @@ import RadioGroup from '@/components/RadioGroup.vue'
import Select from '@/components/Select.vue'
import Popover from '@/components/Popover.vue'
import GradientBar from '@/components/GradientBar.vue'
import FileInput from '@/components/FileInput.vue'
const mainStore = useMainStore()
const slidesStore = useSlidesStore()
@ -158,6 +173,7 @@ const { handleElement, handleElementId, shapeFormatPainter } = storeToRefs(mainS
const handleShapeElement = handleElement as Ref<PPTShapeElement>
const fill = ref<string>('#000')
const pattern = ref<string>('')
const gradient = ref<Gradient>({
type: 'linear',
rotate: 0,
@ -179,7 +195,8 @@ watch(handleElement, () => {
{ pos: 100, color: '#fff' },
]
gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, colors: defaultGradientColor }
fillType.value = handleElement.value.gradient ? 'gradient' : 'fill'
pattern.value = handleElement.value.pattern || ''
fillType.value = (handleElement.value.pattern !== undefined) ? 'pattern' : (handleElement.value.gradient ? 'gradient' : 'fill')
textAlign.value = handleElement.value?.text?.align || 'middle'
}, { deep: true, immediate: true })
@ -196,15 +213,20 @@ const updateElement = (props: Partial<PPTShapeElement>) => {
}
//
const updateFillType = (type: 'gradient' | 'fill') => {
const updateFillType = (type: 'gradient' | 'fill' | 'pattern') => {
if (type === 'fill') {
slidesStore.removeElementProps({ id: handleElementId.value, propName: 'gradient' })
slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] })
addHistorySnapshot()
}
else {
else if (type === 'gradient') {
currentGradientIndex.value = 0
slidesStore.removeElementProps({ id: handleElementId.value, propName: 'pattern' })
updateElement({ gradient: gradient.value })
}
else if (type === 'pattern') {
slidesStore.removeElementProps({ id: handleElementId.value, propName: 'gradient' })
updateElement({ pattern: '' })
}
}
//
@ -221,6 +243,16 @@ const updateGradientColors = (color: string) => {
updateGradient({ colors })
}
//
const uploadPattern = (files: FileList) => {
const imageFile = files[0]
if (!imageFile) return
getImageDataURL(imageFile).then(dataURL => {
pattern.value = dataURL
updateElement({ pattern: dataURL })
})
}
//
const updateFill = (value: string) => {
updateElement({ fill: value })
@ -304,4 +336,33 @@ const updateTextAlign = (align: 'top' | 'middle' | 'bottom') => {
padding-bottom: 14%;
flex-shrink: 0;
}
.pattern-image-wrapper {
margin-bottom: 10px;
}
.pattern-image {
height: 0;
padding-bottom: 56.25%;
border: 1px dashed $borderColor;
border-radius: $borderRadius;
position: relative;
transition: all $transitionDelay;
&:hover {
border-color: $themeColor;
color: $themeColor;
}
.content {
@include absolute-0();
display: flex;
justify-content: center;
align-items: center;
background-position: center;
background-size: contain;
background-repeat: no-repeat;
cursor: pointer;
}
}
</style>

View File

@ -27,8 +27,14 @@
:width="elementInfo.width"
:height="elementInfo.height"
>
<defs v-if="elementInfo.gradient">
<defs>
<PatternDefs
v-if="elementInfo.pattern"
:id="`base-pattern-${elementInfo.id}`"
:src="elementInfo.pattern"
/>
<GradientDefs
v-else-if="elementInfo.gradient"
:id="`base-gradient-${elementInfo.id}`"
:type="elementInfo.gradient.type"
:colors="elementInfo.gradient.colors"
@ -43,7 +49,7 @@
stroke-linecap="butt"
stroke-miterlimit="8"
:d="elementInfo.path"
:fill="elementInfo.gradient ? `url(#base-gradient-${elementInfo.id})` : (elementInfo.fill || 'none')"
:fill="fill"
:stroke="outlineColor"
:stroke-width="outlineWidth"
:stroke-dasharray="strokeDashArray"
@ -65,13 +71,18 @@ import type { PPTShapeElement, ShapeText } from '@/types/slides'
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
import useElementFill from '@/views/components/element/hooks/useElementFill'
import GradientDefs from './GradientDefs.vue'
import PatternDefs from './PatternDefs.vue'
const props = defineProps<{
elementInfo: PPTShapeElement
}>()
const element = computed(() => props.elementInfo)
const { fill } = useElementFill(element, 'base')
const outline = computed(() => props.elementInfo.outline)
const { outlineWidth, outlineColor, strokeDashArray } = useElementOutline(outline)

View File

@ -0,0 +1,12 @@
<template>
<pattern :id="id" patternContentUnits="objectBoundingBox" patternUnits="objectBoundingBox" width="1" height="1">
<image :href="src" width="1" height="1" preserveAspectRatio="xMidYMid slice" />
</pattern>
</template>
<script lang="ts" setup>
defineProps<{
id: string
src: string
}>()
</script>

View File

@ -36,9 +36,15 @@
:width="elementInfo.width"
:height="elementInfo.height"
>
<defs v-if="elementInfo.gradient">
<defs>
<PatternDefs
v-if="elementInfo.pattern"
:id="`editable-pattern-${elementInfo.id}`"
:src="elementInfo.pattern"
/>
<GradientDefs
:id="`editabel-gradient-${elementInfo.id}`"
v-else-if="elementInfo.gradient"
:id="`editable-gradient-${elementInfo.id}`"
:type="elementInfo.gradient.type"
:colors="elementInfo.gradient.colors"
:rotate="elementInfo.gradient.rotate"
@ -53,7 +59,7 @@
stroke-linecap="butt"
stroke-miterlimit="8"
:d="elementInfo.path"
:fill="elementInfo.gradient ? `url(#editabel-gradient-${elementInfo.id})` : (elementInfo.fill || 'none')"
:fill="fill"
:stroke="outlineColor"
:stroke-width="outlineWidth"
:stroke-dasharray="strokeDashArray"
@ -89,9 +95,11 @@ import type { ContextmenuItem } from '@/components/Contextmenu/types'
import useElementOutline from '@/views/components/element/hooks/useElementOutline'
import useElementShadow from '@/views/components/element/hooks/useElementShadow'
import useElementFlip from '@/views/components/element/hooks/useElementFlip'
import useElementFill from '@/views/components/element/hooks/useElementFill'
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import GradientDefs from './GradientDefs.vue'
import PatternDefs from './PatternDefs.vue'
import ProsemirrorEditor from '@/views/components/element/ProsemirrorEditor.vue'
const props = defineProps<{
@ -126,6 +134,9 @@ const execFormatPainter = () => {
if (!keep) mainStore.setShapeFormatPainter(null)
}
const element = computed(() => props.elementInfo)
const { fill } = useElementFill(element, 'editable')
const outline = computed(() => props.elementInfo.outline)
const { outlineWidth, outlineColor, strokeDashArray } = useElementOutline(outline)
@ -187,6 +198,7 @@ const startEdit = () => {
.editable-element-shape {
position: absolute;
pointer-events: none;
background-size: contain;
&.lock .element-content {
cursor: default;

View File

@ -0,0 +1,15 @@
import type { PPTShapeElement } from '@/types/slides'
import { computed, type Ref } from 'vue'
// 计算元素的填充样式
export default (element: Ref<PPTShapeElement>, source: string) => {
const fill = computed(() => {
if (element.value.pattern) return `url(#${source}-pattern-${element.value.id})`
if (element.value.gradient) return `url(#${source}-gradient-${element.value.id})`
return element.value.fill || 'none'
})
return {
fill,
}
}