| <template> | |
| <div class="slide-design-panel"> | |
| <div class="title">背景填充</div> | |
| <div class="row"> | |
| <Select | |
| style="flex: 1;" | |
| :value="background.type" | |
| @update:value="value => updateBackgroundType(value as 'gradient' | 'image' | 'solid')" | |
| :options="[ | |
| { label: '纯色填充', value: 'solid' }, | |
| { label: '图片填充', value: 'image' }, | |
| { label: '渐变填充', value: 'gradient' }, | |
| ]" | |
| /> | |
| <div style="width: 10px;"></div> | |
| <Popover trigger="click" v-if="background.type === 'solid'" style="flex: 1;"> | |
| <template #content> | |
| <ColorPicker | |
| :modelValue="background.color" | |
| @update:modelValue="color => updateBackground({ color })" | |
| /> | |
| </template> | |
| <ColorButton :color="background.color || '#fff'" /> | |
| </Popover> | |
| <Select | |
| style="flex: 1;" | |
| :value="background.image?.size || 'cover'" | |
| @update:value="value => updateImageBackground({ size: value as SlideBackgroundImageSize })" | |
| v-else-if="background.type === 'image'" | |
| :options="[ | |
| { label: '缩放', value: 'contain' }, | |
| { label: '拼贴', value: 'repeat' }, | |
| { label: '缩放铺满', value: 'cover' }, | |
| ]" | |
| /> | |
| <Select | |
| style="flex: 1;" | |
| :value="background.gradient?.type || ''" | |
| @update:value="value => updateGradientBackground({ type: value as GradientType })" | |
| v-else | |
| :options="[ | |
| { label: '线性渐变', value: 'linear' }, | |
| { label: '径向渐变', value: 'radial' }, | |
| ]" | |
| /> | |
| </div> | |
| <div class="background-image-wrapper" v-if="background.type === 'image'"> | |
| <FileInput @change="files => uploadBackgroundImage(files)"> | |
| <div class="background-image"> | |
| <div class="content" :style="{ backgroundImage: `url(${background.image?.src})` }"> | |
| <IconPlus /> | |
| </div> | |
| </div> | |
| </FileInput> | |
| </div> | |
| <div class="background-gradient-wrapper" v-if="background.type === 'gradient'"> | |
| <div class="row"> | |
| <GradientBar | |
| :value="background.gradient?.colors || []" | |
| :index="currentGradientIndex" | |
| @update:value="value => updateGradientBackground({ 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="background.gradient!.colors[currentGradientIndex].color" | |
| @update:modelValue="value => updateGradientBackgroundColors(value)" | |
| /> | |
| </template> | |
| <ColorButton :color="background.gradient!.colors[currentGradientIndex].color" /> | |
| </Popover> | |
| </div> | |
| <div class="row" v-if="background.gradient?.type === 'linear'"> | |
| <div style="width: 40%;">渐变角度:</div> | |
| <Slider | |
| :min="0" | |
| :max="360" | |
| :step="15" | |
| :value="background.gradient.rotate || 0" | |
| @update:value="value => updateGradientBackground({ rotate: value as number })" | |
| style="width: 60%;" | |
| /> | |
| </div> | |
| </div> | |
| <div class="row"> | |
| <Button style="flex: 1;" @click="applyBackgroundAllSlide()">应用背景到全部</Button> | |
| </div> | |
| <Divider /> | |
| <div class="row"> | |
| <Select | |
| style="width: 100%;" | |
| :value="viewportRatio" | |
| @update:value="value => updateViewportRatio(value as number)" | |
| :options="[ | |
| { label: '宽屏 16 : 9', value: 0.5625 }, | |
| { label: '宽屏 16 : 10', value: 0.625 }, | |
| { label: '标准 4 : 3', value: 0.75 }, | |
| { label: '纸张 A3 / A4', value: 0.70710678 }, | |
| { label: '竖向 A3 / A4', value: 1.41421356 }, | |
| ]" | |
| /> | |
| </div> | |
| <div class="row"> | |
| <div class="canvas-size">画布尺寸:{{ viewportSize }} × {{ toFixed(viewportSize * viewportRatio) }}</div> | |
| </div> | |
| <Divider /> | |
| <div class="title"> | |
| <span>全局主题</span> | |
| <span class="more" @click="moreThemeConfigsVisible = !moreThemeConfigsVisible"> | |
| <span class="text">更多</span> | |
| <IconDown v-if="moreThemeConfigsVisible" /> | |
| <IconRight v-else /> | |
| </span> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">字体:</div> | |
| <Select | |
| style="width: 60%;" | |
| :value="theme.fontName" | |
| search | |
| searchLabel="搜索字体" | |
| @update:value="value => updateTheme({ fontName: value as string })" | |
| :options="FONTS" | |
| /> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">字体颜色:</div> | |
| <Popover trigger="click" style="width: 60%;"> | |
| <template #content> | |
| <ColorPicker | |
| :modelValue="theme.fontColor" | |
| @update:modelValue="value => updateTheme({ fontColor: value })" | |
| /> | |
| </template> | |
| <ColorButton :color="theme.fontColor" /> | |
| </Popover> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">背景颜色:</div> | |
| <Popover trigger="click" style="width: 60%;"> | |
| <template #content> | |
| <ColorPicker | |
| :modelValue="theme.backgroundColor" | |
| @update:modelValue="value => updateTheme({ backgroundColor: value })" | |
| /> | |
| </template> | |
| <ColorButton :color="theme.backgroundColor" /> | |
| </Popover> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">主题色:</div> | |
| <ColorListButton style="width: 60%;" :colors="theme.themeColors" @click="themeColorsSettingVisible = true" /> | |
| </div> | |
| <template v-if="moreThemeConfigsVisible"> | |
| <div class="row"> | |
| <div style="width: 40%;">边框样式:</div> | |
| <SelectCustom style="width: 60%;"> | |
| <template #options> | |
| <div class="option" v-for="item in lineStyleOptions" :key="item" @click="updateTheme({ outline: { ...theme.outline, style: item } })"> | |
| <SVGLine :type="item" /> | |
| </div> | |
| </template> | |
| <template #label> | |
| <SVGLine :type="theme.outline.style" /> | |
| </template> | |
| </SelectCustom> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">边框颜色:</div> | |
| <Popover trigger="click" style="width: 60%;"> | |
| <template #content> | |
| <ColorPicker | |
| :modelValue="theme.outline.color" | |
| @update:modelValue="value => updateTheme({ outline: { ...theme.outline, color: value } })" | |
| /> | |
| </template> | |
| <ColorButton :color="theme.outline.color || '#000'" /> | |
| </Popover> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">边框粗细:</div> | |
| <NumberInput | |
| :value="theme.outline.width || 0" | |
| @update:value="value => updateTheme({ outline: { ...theme.outline, width: value } })" | |
| style="width: 60%;" | |
| /> | |
| </div> | |
| <div class="row" style="height: 30px;"> | |
| <div style="width: 40%;">水平阴影:</div> | |
| <Slider | |
| style="width: 60%;" | |
| :min="-10" | |
| :max="10" | |
| :step="1" | |
| :value="theme.shadow.h" | |
| @update:value="value => updateTheme({ shadow: { ...theme.shadow, h: value as number } })" | |
| /> | |
| </div> | |
| <div class="row" style="height: 30px;"> | |
| <div style="width: 40%;">垂直阴影:</div> | |
| <Slider | |
| style="width: 60%;" | |
| :min="-10" | |
| :max="10" | |
| :step="1" | |
| :value="theme.shadow.v" | |
| @update:value="value => updateTheme({ shadow: { ...theme.shadow, v: value as number } })" | |
| /> | |
| </div> | |
| <div class="row" style="height: 30px;"> | |
| <div style="width: 40%;">模糊距离:</div> | |
| <Slider | |
| style="width: 60%;" | |
| :min="1" | |
| :max="20" | |
| :step="1" | |
| :value="theme.shadow.blur" | |
| @update:value="value => updateTheme({ shadow: { ...theme.shadow, blur: value as number } })" | |
| /> | |
| </div> | |
| <div class="row"> | |
| <div style="width: 40%;">阴影颜色:</div> | |
| <Popover trigger="click" style="width: 60%;"> | |
| <template #content> | |
| <ColorPicker | |
| :modelValue="theme.shadow.color" | |
| @update:modelValue="value => updateTheme({ shadow: { ...theme.shadow, color: value } })" | |
| /> | |
| </template> | |
| <ColorButton :color="theme.shadow.color" /> | |
| </Popover> | |
| </div> | |
| </template> | |
| <div class="row"> | |
| <Button style="flex: 1;" @click="applyThemeToAllSlides(moreThemeConfigsVisible)">应用主题到全部</Button> | |
| </div> | |
| <div class="row"> | |
| <Button style="flex: 1;" @click="themeStylesExtractVisible = true">从幻灯片提取主题</Button> | |
| </div> | |
| <Divider /> | |
| <div class="title">预置主题</div> | |
| <div class="theme-list"> | |
| <div | |
| class="theme-item" | |
| v-for="(item, index) in PRESET_THEMES" | |
| :key="index" | |
| :style="{ | |
| backgroundColor: item.background, | |
| fontFamily: item.fontname, | |
| }" | |
| > | |
| <div class="theme-item-content"> | |
| <div class="text" :style="{ color: item.fontColor }">文字 Aa</div> | |
| <div class="colors"> | |
| <div class="color-block" v-for="(color, index) in item.colors" :key="index" :style="{ backgroundColor: color}"></div> | |
| </div> | |
| <div class="btns"> | |
| <Button type="primary" size="small" @click="applyPresetTheme(item)">设置</Button> | |
| <Button type="primary" size="small" style="margin-top: 3px;" @click="applyPresetTheme(item, true)">设置并应用</Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <Modal | |
| v-model:visible="themeStylesExtractVisible" | |
| :width="320" | |
| @closed="themeStylesExtractVisible = false" | |
| > | |
| <ThemeStylesExtract @close="themeStylesExtractVisible = false" /> | |
| </Modal> | |
| <Modal | |
| v-model:visible="themeColorsSettingVisible" | |
| :width="310" | |
| @closed="themeColorsSettingVisible = false" | |
| > | |
| <ThemeColorsSetting @close="themeColorsSettingVisible = false" /> | |
| </Modal> | |
| </template> | |
| <script lang="ts" setup> | |
| import { computed, ref, watch } from 'vue' | |
| import { storeToRefs } from 'pinia' | |
| import { useSlidesStore } from '@/store' | |
| import type { | |
| Gradient, | |
| GradientType, | |
| SlideBackground, | |
| SlideBackgroundType, | |
| SlideTheme, | |
| SlideBackgroundImage, | |
| SlideBackgroundImageSize, | |
| LineStyleType, | |
| } from '@/types/slides' | |
| import { PRESET_THEMES } from '@/configs/theme' | |
| import { FONTS } from '@/configs/font' | |
| import useHistorySnapshot from '@/hooks/useHistorySnapshot' | |
| import useSlideTheme from '@/hooks/useSlideTheme' | |
| import { getImageDataURL } from '@/utils/image' | |
| import ThemeStylesExtract from './ThemeStylesExtract.vue' | |
| import ThemeColorsSetting from './ThemeColorsSetting.vue' | |
| import SVGLine from '../common/SVGLine.vue' | |
| import ColorButton from '@/components/ColorButton.vue' | |
| import ColorListButton from '@/components/ColorListButton.vue' | |
| import FileInput from '@/components/FileInput.vue' | |
| import ColorPicker from '@/components/ColorPicker/index.vue' | |
| import Divider from '@/components/Divider.vue' | |
| import Slider from '@/components/Slider.vue' | |
| import Button from '@/components/Button.vue' | |
| import Select from '@/components/Select.vue' | |
| import Popover from '@/components/Popover.vue' | |
| import SelectCustom from '@/components/SelectCustom.vue' | |
| import NumberInput from '@/components/NumberInput.vue' | |
| import Modal from '@/components/Modal.vue' | |
| import GradientBar from '@/components/GradientBar.vue' | |
| const slidesStore = useSlidesStore() | |
| const { slides, currentSlide, slideIndex, viewportRatio, viewportSize, theme } = storeToRefs(slidesStore) | |
| const moreThemeConfigsVisible = ref(false) | |
| const themeStylesExtractVisible = ref(false) | |
| const themeColorsSettingVisible = ref(false) | |
| const currentGradientIndex = ref(0) | |
| const lineStyleOptions = ref<LineStyleType[]>(['solid', 'dashed', 'dotted']) | |
| const background = computed(() => { | |
| if (!currentSlide.value.background) { | |
| return { | |
| type: 'solid', | |
| value: '#fff', | |
| } as SlideBackground | |
| } | |
| return currentSlide.value.background | |
| }) | |
| const { addHistorySnapshot } = useHistorySnapshot() | |
| const { | |
| applyPresetTheme, | |
| applyThemeToAllSlides, | |
| } = useSlideTheme() | |
| watch(slideIndex, () => { | |
| currentGradientIndex.value = 0 | |
| }) | |
| // 设置背景模式:纯色、图片、渐变色 | |
| const updateBackgroundType = (type: SlideBackgroundType) => { | |
| if (type === 'solid') { | |
| const newBackground: SlideBackground = { | |
| ...background.value, | |
| type: 'solid', | |
| color: background.value.color || '#fff', | |
| } | |
| slidesStore.updateSlide({ background: newBackground }) | |
| } | |
| else if (type === 'image') { | |
| const newBackground: SlideBackground = { | |
| ...background.value, | |
| type: 'image', | |
| image: background.value.image || { | |
| src: '', | |
| size: 'cover', | |
| }, | |
| } | |
| slidesStore.updateSlide({ background: newBackground }) | |
| } | |
| else { | |
| const newBackground: SlideBackground = { | |
| ...background.value, | |
| type: 'gradient', | |
| gradient: background.value.gradient || { | |
| type: 'linear', | |
| colors: [ | |
| { pos: 0, color: '#fff' }, | |
| { pos: 100, color: '#fff' }, | |
| ], | |
| rotate: 0, | |
| }, | |
| } | |
| currentGradientIndex.value = 0 | |
| slidesStore.updateSlide({ background: newBackground }) | |
| } | |
| addHistorySnapshot() | |
| } | |
| // 设置背景 | |
| const updateBackground = (props: Partial<SlideBackground>) => { | |
| slidesStore.updateSlide({ background: { ...background.value, ...props } }) | |
| addHistorySnapshot() | |
| } | |
| // 设置渐变背景 | |
| const updateGradientBackground = (props: Partial<Gradient>) => { | |
| updateBackground({ gradient: { ...background.value.gradient!, ...props } }) | |
| } | |
| const updateGradientBackgroundColors = (color: string) => { | |
| const colors = background.value.gradient!.colors.map((item, index) => { | |
| if (index === currentGradientIndex.value) return { ...item, color } | |
| return item | |
| }) | |
| updateGradientBackground({ colors }) | |
| } | |
| // 设置图片背景 | |
| const updateImageBackground = (props: Partial<SlideBackgroundImage>) => { | |
| updateBackground({ image: { ...background.value.image!, ...props } }) | |
| } | |
| // 上传背景图片 | |
| const uploadBackgroundImage = (files: FileList) => { | |
| const imageFile = files[0] | |
| if (!imageFile) return | |
| getImageDataURL(imageFile).then(dataURL => updateImageBackground({ src: dataURL })) | |
| } | |
| // 应用当前页背景到全部页面 | |
| const applyBackgroundAllSlide = () => { | |
| const newSlides = slides.value.map(slide => { | |
| return { | |
| ...slide, | |
| background: currentSlide.value.background, | |
| } | |
| }) | |
| slidesStore.setSlides(newSlides) | |
| addHistorySnapshot() | |
| } | |
| // 设置主题 | |
| const updateTheme = (themeProps: Partial<SlideTheme>) => { | |
| slidesStore.setTheme(themeProps) | |
| } | |
| // 设置画布尺寸(宽高比例) | |
| const updateViewportRatio = (value: number) => { | |
| slidesStore.setViewportRatio(value) | |
| } | |
| const toFixed = (num: number) => { | |
| if (num % 1 !== 0) { | |
| return parseFloat(num.toFixed(1)) | |
| } | |
| return Math.floor(num) | |
| } | |
| </script> | |
| <style lang="scss" scoped> | |
| .slide-design-panel { | |
| user-select: none; | |
| } | |
| .row { | |
| width: 100%; | |
| display: flex; | |
| align-items: center; | |
| margin-bottom: 10px; | |
| } | |
| .title { | |
| display: flex; | |
| justify-content: space-between; | |
| margin-bottom: 10px; | |
| .more { | |
| cursor: pointer; | |
| .text { | |
| font-size: 12px; | |
| margin-right: 3px; | |
| } | |
| } | |
| } | |
| .background-image-wrapper { | |
| margin-bottom: 10px; | |
| } | |
| .background-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; | |
| } | |
| } | |
| .canvas-size { | |
| width: 100%; | |
| color: #888; | |
| font-size: 12px; | |
| text-align: center; | |
| } | |
| .theme-list { | |
| @include flex-grid-layout(); | |
| } | |
| .theme-item { | |
| @include flex-grid-layout-children(2, 48%); | |
| padding-bottom: 27%; | |
| border-radius: $borderRadius; | |
| position: relative; | |
| cursor: pointer; | |
| .theme-item-content { | |
| @include absolute-0(); | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: center; | |
| padding: 8px; | |
| border: 1px solid $borderColor; | |
| border-radius: $borderRadius; | |
| } | |
| .text { | |
| font-size: 15px; | |
| } | |
| .colors { | |
| display: flex; | |
| margin-top: 6px; | |
| } | |
| .color-block { | |
| width: 12px; | |
| height: 12px; | |
| margin-right: 2px; | |
| } | |
| &:hover .btns { | |
| opacity: 1; | |
| } | |
| .btns { | |
| @include absolute-0(); | |
| flex-direction: column; | |
| justify-content: center; | |
| align-items: center; | |
| display: flex; | |
| background-color: rgba($color: #000, $alpha: .25); | |
| opacity: 0; | |
| transition: opacity $transitionDelay; | |
| } | |
| } | |
| .option { | |
| height: 32px; | |
| padding: 0 5px; | |
| border-radius: $borderRadius; | |
| &:not(.selected):hover { | |
| background-color: rgba($color: $themeColor, $alpha: .05); | |
| cursor: pointer; | |
| } | |
| &.selected { | |
| color: $themeColor; | |
| font-weight: 700; | |
| } | |
| } | |
| </style> |