CatPtain's picture
Upload 339 files
89ce340 verified
<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>