mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
422 lines
12 KiB
Vue
422 lines
12 KiB
Vue
<template>
|
||
<div class="image-style-panel">
|
||
<div
|
||
class="origin-image"
|
||
:style="{ backgroundImage: `url(${handleElement.src})` }"
|
||
></div>
|
||
|
||
<ButtonGroup class="row">
|
||
<Button style="flex: 5;" @click="clipImage()"><IconTailoring class="btn-icon" /> 裁剪图片</Button>
|
||
<Popover trigger="click" v-model:visible="clipPanelVisible">
|
||
<template #content>
|
||
<div class="clip">
|
||
<div class="title">按形状裁剪:</div>
|
||
<div class="shape-clip">
|
||
<div
|
||
class="shape-clip-item"
|
||
v-for="(item, index) in shapeClipPathOptions"
|
||
:key="index"
|
||
@click="presetImageClip(index)"
|
||
>
|
||
<div class="shape" :style="{ clipPath: item.style }"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-for="type in ratioClipOptions" :key="type.label">
|
||
<div class="title" v-if="type.label">{{type.label}}:</div>
|
||
<ButtonGroup class="row">
|
||
<Button
|
||
style="flex: 1;"
|
||
v-for="item in type.children"
|
||
:key="item.key"
|
||
@click="presetImageClip('rect', item.ratio)"
|
||
>{{item.key}}</Button>
|
||
</ButtonGroup>
|
||
</template>
|
||
</div>
|
||
</template>
|
||
<Button class="no-padding" style="flex: 1;"><IconDown /></Button>
|
||
</Popover>
|
||
</ButtonGroup>
|
||
|
||
<Popover trigger="click">
|
||
<template #content>
|
||
<div class="filter">
|
||
<div class="filter-item" v-for="filter in filterOptions" :key="filter.key">
|
||
<div class="name">{{filter.label}}</div>
|
||
<Slider
|
||
class="filter-slider"
|
||
:max="filter.max"
|
||
:min="filter.min"
|
||
:step="filter.step"
|
||
:value="filter.value"
|
||
@change="value => updateFilter(filter, value)"
|
||
/>
|
||
<div class="value">{{`${filter.value}${filter.unit}`}}</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<Button class="full-width-btn"><IconColorFilter class="btn-icon" /> 设置滤镜</Button>
|
||
</Popover>
|
||
|
||
<div class="row">
|
||
<div style="flex: 2;">水平翻转:</div>
|
||
<div class="switch-wrapper" style="flex: 3;">
|
||
<Switch
|
||
:checked="flip.x === 180"
|
||
@change="checked => updateImage({ flip: { x: checked ? 180 : 0, y: flip.y } })"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="row">
|
||
<div style="flex: 2;">垂直翻转:</div>
|
||
<div class="switch-wrapper" style="flex: 3;">
|
||
<Switch
|
||
:checked="flip.y === 180"
|
||
@change="checked => updateImage({ flip: { x: flip.x, y: checked ? 180 : 0 } })"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<Divider />
|
||
<ElementOutline />
|
||
<Divider />
|
||
<ElementShadow />
|
||
<Divider />
|
||
|
||
<FileInput @change="files => replaceImage(files)">
|
||
<Button class="full-width-btn"><IconTransform class="btn-icon" /> 替换图片</Button>
|
||
</FileInput>
|
||
<Button class="full-width-btn" @click="resetImage()"><IconUndo class="btn-icon" /> 重置样式</Button>
|
||
</div>
|
||
</template>
|
||
|
||
<script lang="ts">
|
||
import { computed, defineComponent, ref, watch } from 'vue'
|
||
import { MutationTypes, useStore } from '@/store'
|
||
import { PPTImageElement } from '@/types/slides'
|
||
import { CLIPPATHS } from '@/configs/imageClip'
|
||
import { getImageDataURL } from '@/utils/image'
|
||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||
|
||
import ElementOutline from '../common/ElementOutline.vue'
|
||
import ElementShadow from '../common/ElementShadow.vue'
|
||
|
||
interface FilterOption {
|
||
label: string;
|
||
key: string;
|
||
default: number;
|
||
value: number;
|
||
unit: string;
|
||
max: number;
|
||
step: number;
|
||
}
|
||
|
||
const defaultFilters: FilterOption[] = [
|
||
{ label: '模糊', key: 'blur', default: 0, value: 0, unit: 'px', max: 10, step: 1 },
|
||
{ label: '亮度', key: 'brightness', default: 100, value: 100, unit: '%', max: 200, step: 5 },
|
||
{ label: '对比度', key: 'contrast', default: 100, value: 100, unit: '%', max: 200, step: 5 },
|
||
{ label: '灰度', key: 'grayscale', default: 0, value: 0, unit: '%', max: 100, step: 5 },
|
||
{ label: '饱和度', key: 'saturate', default: 100, value: 100, unit: '%', max: 200, step: 5 },
|
||
{ label: '色相', key: 'hue-rotate', default: 0, value: 0, unit: 'deg', max: 360, step: 10 },
|
||
{ label: '不透明度', key: 'opacity', default: 100, value: 100, unit: '%', max: 100, step: 5 },
|
||
]
|
||
|
||
const shapeClipPathOptions = CLIPPATHS
|
||
const ratioClipOptions = [
|
||
{
|
||
label: '纵横比(方形)',
|
||
children: [
|
||
{ key: '1:1', ratio: 1 / 1 },
|
||
],
|
||
},
|
||
{
|
||
label: '纵横比(纵向)',
|
||
children: [
|
||
{ key: '2:3', ratio: 3 / 2 },
|
||
{ key: '3:4', ratio: 4 / 3 },
|
||
{ key: '3:5', ratio: 5 / 3 },
|
||
{ key: '4:5', ratio: 5 / 4 },
|
||
],
|
||
},
|
||
{
|
||
label: '纵横比(横向)',
|
||
children: [
|
||
{ key: '3:2', ratio: 2 / 3 },
|
||
{ key: '4:3', ratio: 3 / 4 },
|
||
{ key: '5:3', ratio: 3 / 5 },
|
||
{ key: '5:4', ratio: 4 / 5 },
|
||
],
|
||
},
|
||
{
|
||
children: [
|
||
{ key: '16:9', ratio: 9 / 16 },
|
||
{ key: '16:10', ratio: 10 / 16 },
|
||
],
|
||
},
|
||
]
|
||
|
||
export default defineComponent({
|
||
name: 'image-style-panel',
|
||
components: {
|
||
ElementOutline,
|
||
ElementShadow,
|
||
},
|
||
setup() {
|
||
const store = useStore()
|
||
const handleElement = computed<PPTImageElement>(() => store.getters.handleElement)
|
||
|
||
const clipPanelVisible = ref(false)
|
||
|
||
const flip = ref({
|
||
x: 0,
|
||
y: 0,
|
||
})
|
||
|
||
const filterOptions = ref<FilterOption[]>(JSON.parse(JSON.stringify(defaultFilters)))
|
||
|
||
watch(handleElement, () => {
|
||
if(!handleElement.value || handleElement.value.type !== 'image') return
|
||
|
||
if(handleElement.value.flip) {
|
||
flip.value = {
|
||
x: handleElement.value.flip.x || 0,
|
||
y: handleElement.value.flip.y || 0,
|
||
}
|
||
}
|
||
else flip.value = { x: 0, y: 0 }
|
||
|
||
const filters = handleElement.value.filters
|
||
if(filters) {
|
||
filterOptions.value = defaultFilters.map(item => {
|
||
if(filters[item.key] !== undefined) return { ...item, value: parseInt(filters[item.key]) }
|
||
return item
|
||
})
|
||
}
|
||
else filterOptions.value = JSON.parse(JSON.stringify(defaultFilters))
|
||
}, { deep: true, immediate: true })
|
||
|
||
const { addHistorySnapshot } = useHistorySnapshot()
|
||
|
||
const updateImage = (props: Partial<PPTImageElement>) => {
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
|
||
addHistorySnapshot()
|
||
}
|
||
|
||
const updateFilter = (filter: FilterOption, value: number) => {
|
||
const originFilters = handleElement.value.filters || {}
|
||
const filters = { ...originFilters, [filter.key]: `${value}${filter.unit}` }
|
||
const props = { filters }
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
|
||
addHistorySnapshot()
|
||
}
|
||
|
||
const clipImage = () => {
|
||
store.commit(MutationTypes.SET_CLIPING_IMAGE_ELEMENT_ID, handleElement.value.id)
|
||
clipPanelVisible.value = false
|
||
}
|
||
|
||
const getImageElementDataBeforeClip = () => {
|
||
// 图片当前宽高位置、裁剪范围
|
||
const imgWidth = handleElement.value.width
|
||
const imgHeight = handleElement.value.height
|
||
const imgLeft = handleElement.value.left
|
||
const imgTop = handleElement.value.top
|
||
const originClipRange = handleElement.value.clip ? handleElement.value.clip.range : [[0, 0], [100, 100]]
|
||
|
||
// 图片原本未裁剪过时的宽高位置
|
||
const originWidth = imgWidth / ((originClipRange[1][0] - originClipRange[0][0]) / 100)
|
||
const originHeight = imgHeight / ((originClipRange[1][1] - originClipRange[0][1]) / 100)
|
||
const originLeft = imgLeft - originWidth * (originClipRange[0][0] / 100)
|
||
const originTop = imgTop - originHeight * (originClipRange[0][1] / 100)
|
||
|
||
return {
|
||
originClipRange,
|
||
originWidth,
|
||
originHeight,
|
||
originLeft,
|
||
originTop,
|
||
}
|
||
}
|
||
|
||
const presetImageClip = (shape: string, ratio = 0) => {
|
||
const {
|
||
originClipRange,
|
||
originWidth,
|
||
originHeight,
|
||
originLeft,
|
||
originTop,
|
||
} = getImageElementDataBeforeClip()
|
||
|
||
// 设置形状和纵横比
|
||
if(ratio) {
|
||
const imageRatio = originHeight / originWidth
|
||
|
||
const min = 0
|
||
const max = 100
|
||
let range
|
||
|
||
if(imageRatio > ratio) {
|
||
const distance = ((1 - ratio / imageRatio) / 2) * 100
|
||
range = [[min, distance], [max, max - distance]]
|
||
}
|
||
else {
|
||
const distance = ((1 - imageRatio / ratio) / 2) * 100
|
||
range = [[distance, min], [max - distance, max]]
|
||
}
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||
id: handleElement.value.id,
|
||
props: {
|
||
clip: { ...handleElement.value.clip, shape, range },
|
||
left: originLeft + originWidth * (range[0][0] / 100),
|
||
top: originTop + originHeight * (range[0][1] / 100),
|
||
width: originWidth * (range[1][0] - range[0][0]) / 100,
|
||
height: originHeight * (range[1][1] - range[0][1]) / 100,
|
||
},
|
||
})
|
||
}
|
||
// 仅设置形状(维持目前的裁剪范围)
|
||
else {
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||
id: handleElement.value.id,
|
||
props: {
|
||
clip: { ...handleElement.value.clip, shape, range: originClipRange }
|
||
},
|
||
})
|
||
}
|
||
clipImage()
|
||
addHistorySnapshot()
|
||
}
|
||
|
||
const replaceImage = (files: File[]) => {
|
||
const imageFile = files[0]
|
||
if(!imageFile) return
|
||
getImageDataURL(imageFile).then(dataURL => {
|
||
const props = { src: dataURL }
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, { id: handleElement.value.id, props })
|
||
})
|
||
addHistorySnapshot()
|
||
}
|
||
|
||
const resetImage = () => {
|
||
if(handleElement.value.clip) {
|
||
const {
|
||
originWidth,
|
||
originHeight,
|
||
originLeft,
|
||
originTop,
|
||
} = getImageElementDataBeforeClip()
|
||
|
||
store.commit(MutationTypes.UPDATE_ELEMENT, {
|
||
id: handleElement.value.id,
|
||
props: {
|
||
left: originLeft,
|
||
top: originTop,
|
||
width: originWidth,
|
||
height: originHeight,
|
||
},
|
||
})
|
||
}
|
||
|
||
store.commit(MutationTypes.REMOVE_ELEMENT_PROPS, {
|
||
id: handleElement.value.id,
|
||
propName: ['clip', 'outline', 'flip', 'shadow', 'filters'],
|
||
})
|
||
addHistorySnapshot()
|
||
}
|
||
|
||
return {
|
||
clipPanelVisible,
|
||
shapeClipPathOptions,
|
||
ratioClipOptions,
|
||
filterOptions,
|
||
flip,
|
||
handleElement,
|
||
updateImage,
|
||
updateFilter,
|
||
clipImage,
|
||
presetImageClip,
|
||
replaceImage,
|
||
resetImage,
|
||
}
|
||
},
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.row {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 10px;
|
||
}
|
||
.switch-wrapper {
|
||
text-align: right;
|
||
}
|
||
.origin-image {
|
||
height: 100px;
|
||
background-size: contain;
|
||
background-repeat: no-repeat;
|
||
background-position: center;
|
||
background-color: $lightGray;
|
||
margin-bottom: 10px;
|
||
}
|
||
.full-width-btn {
|
||
width: 100%;
|
||
margin-bottom: 10px;
|
||
}
|
||
.btn-icon {
|
||
margin-right: 3px;
|
||
}
|
||
|
||
.filter {
|
||
width: 280px;
|
||
font-size: 12px;
|
||
}
|
||
.filter-item {
|
||
padding: 8px 5px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
|
||
.name {
|
||
width: 60px;
|
||
}
|
||
.filter-slider {
|
||
flex: 1;
|
||
margin: 0 6px;
|
||
}
|
||
.value {
|
||
width: 40px;
|
||
text-align: right;
|
||
}
|
||
}
|
||
|
||
.clip {
|
||
width: 280px;
|
||
font-size: 12px;
|
||
|
||
.title {
|
||
margin-bottom: 5px;
|
||
}
|
||
}
|
||
.shape-clip {
|
||
margin-bottom: 10px;
|
||
|
||
@include grid-layout-wrapper();
|
||
}
|
||
.shape-clip-item {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
cursor: pointer;
|
||
|
||
@include grid-layout-item(5, 19%);
|
||
|
||
.shape {
|
||
width: 40px;
|
||
height: 40px;
|
||
background-color: #e1e1e1;
|
||
}
|
||
}
|
||
</style> |