|
<template> |
|
<div class="shape-style-panel"> |
|
<div class="title"> |
|
<span>点击替换形状</span> |
|
<IconDown /> |
|
</div> |
|
<div class="shape-pool"> |
|
<div class="category" v-for="item in SHAPE_LIST" :key="item.type"> |
|
<div class="shape-list"> |
|
<ShapeItemThumbnail |
|
class="shape-item" |
|
v-for="(shape, index) in item.children" |
|
:key="index" |
|
:shape="shape" |
|
@click="changeShape(shape)" |
|
/> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="row"> |
|
<Select |
|
style="flex: 1;" |
|
:value="fillType" |
|
@update:value="value => updateFillType(value as 'fill' | 'gradient' | 'pattern')" |
|
:options="[ |
|
{ label: '纯色填充', value: 'fill' }, |
|
{ label: '渐变填充', value: 'gradient' }, |
|
{ label: '图片填充', value: 'pattern' }, |
|
]" |
|
/> |
|
<div style="width: 10px;" v-if="fillType !== 'pattern'"></div> |
|
<Popover trigger="click" v-if="fillType === 'fill'" style="flex: 1;"> |
|
<template #content> |
|
<ColorPicker |
|
:modelValue="fill" |
|
@update:modelValue="value => updateFill(value)" |
|
/> |
|
</template> |
|
<ColorButton :color="fill" /> |
|
</Popover> |
|
<Select |
|
style="flex: 1;" |
|
:value="gradient.type" |
|
@update:value="value => updateGradient({ type: value as GradientType })" |
|
v-else-if="fillType === 'gradient'" |
|
:options="[ |
|
{ label: '线性渐变', value: 'linear' }, |
|
{ label: '径向渐变', value: 'radial' }, |
|
]" |
|
/> |
|
</div> |
|
|
|
<template v-if="fillType === 'gradient'"> |
|
<div class="row"> |
|
<GradientBar |
|
:value="gradient.colors" |
|
:index="currentGradientIndex" |
|
@update:value="value => updateGradient({ colors: value })" |
|
@update:index="index => currentGradientIndex = index" |
|
/> |
|
</div> |
|
<div class="row"> |
|
<div style="width: 40%;">当前色块:</div> |
|
<Popover trigger="click" style="width: 60%;"> |
|
<template #content> |
|
<ColorPicker |
|
:modelValue="gradient.colors[currentGradientIndex].color" |
|
@update:modelValue="value => updateGradientColors(value)" |
|
/> |
|
</template> |
|
<ColorButton :color="gradient.colors[currentGradientIndex].color" /> |
|
</Popover> |
|
</div> |
|
<div class="row" v-if="gradient.type === 'linear'"> |
|
<div style="width: 40%;">渐变角度:</div> |
|
<Slider |
|
style="width: 60%;" |
|
:min="0" |
|
:max="360" |
|
:step="15" |
|
:value="gradient.rotate" |
|
@update:value="value => updateGradient({ rotate: value as number })" |
|
/> |
|
</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 /> |
|
|
|
<Divider /> |
|
|
|
<template v-if="handleShapeElement.text?.content"> |
|
<RichTextBase /> |
|
<Divider /> |
|
|
|
<RadioGroup |
|
class="row" |
|
button-style="solid" |
|
:value="textAlign" |
|
@update:value="value => updateTextAlign(value as 'top' | 'middle' | 'bottom')" |
|
> |
|
<RadioButton value="top" v-tooltip="'顶对齐'" style="flex: 1;"><IconAlignTextTopOne /></RadioButton> |
|
<RadioButton value="middle" v-tooltip="'居中'" style="flex: 1;"><IconAlignTextMiddleOne /></RadioButton> |
|
<RadioButton value="bottom" v-tooltip="'底对齐'" style="flex: 1;"><IconAlignTextBottomOne /></RadioButton> |
|
</RadioGroup> |
|
|
|
<Divider /> |
|
</template> |
|
|
|
<ElementOutline /> |
|
<Divider /> |
|
<ElementShadow /> |
|
<Divider /> |
|
<ElementOpacity /> |
|
<Divider /> |
|
|
|
<div class="row"> |
|
<CheckboxButton |
|
v-tooltip="'双击连续使用'" |
|
style="flex: 1;" |
|
:checked="!!shapeFormatPainter" |
|
@click="toggleShapeFormatPainter()" |
|
@dblclick="toggleShapeFormatPainter(true)" |
|
><IconFormatBrush /> 形状格式刷</CheckboxButton> |
|
</div> |
|
</div> |
|
</template> |
|
|
|
<script lang="ts" setup> |
|
import { type Ref, ref, watch } from 'vue' |
|
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 emitter, { EmitterEvents } from '@/utils/emitter' |
|
import useHistorySnapshot from '@/hooks/useHistorySnapshot' |
|
import useShapeFormatPainter from '@/hooks/useShapeFormatPainter' |
|
|
|
import ElementOpacity from '../common/ElementOpacity.vue' |
|
import ElementOutline from '../common/ElementOutline.vue' |
|
import ElementShadow from '../common/ElementShadow.vue' |
|
import ElementFlip from '../common/ElementFlip.vue' |
|
import RichTextBase from '../common/RichTextBase.vue' |
|
import ShapeItemThumbnail from '@/views/Editor/CanvasTool/ShapeItemThumbnail.vue' |
|
import ColorButton from '@/components/ColorButton.vue' |
|
import CheckboxButton from '@/components/CheckboxButton.vue' |
|
import ColorPicker from '@/components/ColorPicker/index.vue' |
|
import Divider from '@/components/Divider.vue' |
|
import Slider from '@/components/Slider.vue' |
|
import RadioButton from '@/components/RadioButton.vue' |
|
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() |
|
const { handleElement, handleElementId, shapeFormatPainter } = storeToRefs(mainStore) |
|
|
|
const handleShapeElement = handleElement as Ref<PPTShapeElement> |
|
|
|
const fill = ref<string>('#000') |
|
const pattern = ref<string>('') |
|
const gradient = ref<Gradient>({ |
|
type: 'linear', |
|
rotate: 0, |
|
colors: [ |
|
{ pos: 0, color: '#fff' }, |
|
{ pos: 100, color: '#fff' }, |
|
], |
|
}) |
|
const fillType = ref('fill') |
|
const textAlign = ref('middle') |
|
const currentGradientIndex = ref(0) |
|
|
|
watch(handleElement, () => { |
|
if (!handleElement.value || handleElement.value.type !== 'shape') return |
|
|
|
fill.value = handleElement.value.fill || '#fff' |
|
const defaultGradientColor = [ |
|
{ pos: 0, color: fill.value }, |
|
{ pos: 100, color: '#fff' }, |
|
] |
|
gradient.value = handleElement.value.gradient || { type: 'linear', rotate: 0, colors: defaultGradientColor } |
|
pattern.value = handleElement.value.pattern || '' |
|
fillType.value = (handleElement.value.pattern !== undefined) ? 'pattern' : (handleElement.value.gradient ? 'gradient' : 'fill') |
|
textAlign.value = handleElement.value?.text?.align || 'middle' |
|
|
|
if (handleElement.value.text?.content) { |
|
emitter.emit(EmitterEvents.SYNC_RICH_TEXT_ATTRS_TO_STORE) |
|
} |
|
}, { deep: true, immediate: true }) |
|
|
|
watch(handleElementId, () => { |
|
currentGradientIndex.value = 0 |
|
}) |
|
|
|
const { addHistorySnapshot } = useHistorySnapshot() |
|
const { toggleShapeFormatPainter } = useShapeFormatPainter() |
|
|
|
const updateElement = (props: Partial<PPTShapeElement>) => { |
|
slidesStore.updateElement({ id: handleElementId.value, props }) |
|
addHistorySnapshot() |
|
} |
|
|
|
// 设置填充类型:渐变、纯色 |
|
const updateFillType = (type: 'gradient' | 'fill' | 'pattern') => { |
|
if (type === 'fill') { |
|
slidesStore.removeElementProps({ id: handleElementId.value, propName: ['gradient', 'pattern'] }) |
|
addHistorySnapshot() |
|
} |
|
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: '' }) |
|
} |
|
} |
|
|
|
// 设置渐变填充 |
|
const updateGradient = (gradientProps: Partial<Gradient>) => { |
|
if (!gradient.value) return |
|
const _gradient = { ...gradient.value, ...gradientProps } |
|
updateElement({ gradient: _gradient }) |
|
} |
|
const updateGradientColors = (color: string) => { |
|
const colors = gradient.value.colors.map((item, index) => { |
|
if (index === currentGradientIndex.value) return { ...item, color } |
|
return item |
|
}) |
|
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 }) |
|
} |
|
|
|
// 修改形状 |
|
const changeShape = (shape: ShapePoolItem) => { |
|
const { width, height } = handleElement.value as PPTShapeElement |
|
const props: Partial<PPTShapeElement> = { |
|
viewBox: shape.viewBox, |
|
path: shape.path, |
|
special: shape.special, |
|
} |
|
if (shape.pathFormula) { |
|
props.pathFormula = shape.pathFormula |
|
props.viewBox = [width, height] |
|
|
|
const pathFormula = SHAPE_PATH_FORMULAS[shape.pathFormula] |
|
if ('editable' in pathFormula) { |
|
props.path = pathFormula.formula(width, height, pathFormula.defaultValue) |
|
props.keypoints = pathFormula.defaultValue |
|
} |
|
else props.path = pathFormula.formula(width, height) |
|
} |
|
else { |
|
props.pathFormula = undefined |
|
props.keypoints = undefined |
|
} |
|
updateElement(props) |
|
} |
|
|
|
const updateTextAlign = (align: 'top' | 'middle' | 'bottom') => { |
|
const _handleElement = handleElement.value as PPTShapeElement |
|
|
|
const defaultText: ShapeText = { |
|
content: '', |
|
defaultFontName: '', |
|
defaultColor: '#000', |
|
align: 'middle', |
|
} |
|
const _text = _handleElement.text || defaultText |
|
updateElement({ text: { ..._text, align } }) |
|
} |
|
</script> |
|
|
|
<style lang="scss" scoped> |
|
.shape-style-panel { |
|
user-select: none; |
|
} |
|
.row { |
|
width: 100%; |
|
display: flex; |
|
align-items: center; |
|
margin-bottom: 10px; |
|
} |
|
.font-size-btn { |
|
padding: 0; |
|
} |
|
.title { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 10px; |
|
} |
|
.shape-pool { |
|
width: 235px; |
|
height: 150px; |
|
overflow: auto; |
|
padding: 5px; |
|
padding-right: 10px; |
|
border: 1px solid $borderColor; |
|
margin-bottom: 20px; |
|
} |
|
.shape-list { |
|
@include flex-grid-layout(); |
|
} |
|
.shape-item { |
|
@include flex-grid-layout-children(6, 14%); |
|
|
|
height: 0; |
|
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> |