mirror of
https://github.com/pipipi-pikachu/PPTist.git
synced 2025-04-15 02:20:00 +08:00
feat: 提取已有幻灯片主题风格
This commit is contained in:
parent
5eb46e3e96
commit
796328f115
@ -55,6 +55,7 @@ npm run dev
|
||||
- Rulers
|
||||
- Canvas zoom and move
|
||||
- Theme settings
|
||||
- Extract slides style
|
||||
- Speaker notes (rich text)
|
||||
- Slide templates
|
||||
- Transition animations
|
||||
|
@ -41,6 +41,7 @@ npm run dev
|
||||
- 标尺
|
||||
- 画布缩放、移动
|
||||
- 主题设置
|
||||
- 提取已有幻灯片风格
|
||||
- 演讲者备注(富文本)
|
||||
- 幻灯片模板
|
||||
- 翻页动画
|
||||
|
@ -5,12 +5,195 @@ import type { Slide } from '@/types/slides'
|
||||
import type { PresetTheme } from '@/configs/theme'
|
||||
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
|
||||
interface ThemeValueWithArea {
|
||||
area: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export default () => {
|
||||
const slidesStore = useSlidesStore()
|
||||
const { slides, currentSlide, theme } = storeToRefs(slidesStore)
|
||||
|
||||
const { addHistorySnapshot } = useHistorySnapshot()
|
||||
|
||||
// 获取指定幻灯片内的主要主题样式,并以在当中的占比进行排序
|
||||
const getSlidesThemeStyles = (slide: Slide | Slide[]) => {
|
||||
const slides = Array.isArray(slide) ? slide : [slide]
|
||||
|
||||
const backgroundColorValues: ThemeValueWithArea[] = []
|
||||
const themeColorValues: ThemeValueWithArea[] = []
|
||||
const fontColorValues: ThemeValueWithArea[] = []
|
||||
const fontNameValues: ThemeValueWithArea[] = []
|
||||
|
||||
for (const slide of slides) {
|
||||
if (slide.background) {
|
||||
if (slide.background.type === 'solid' && slide.background.color) {
|
||||
backgroundColorValues.push({ area: 1, value: slide.background.color })
|
||||
}
|
||||
else if (slide.background.type === 'gradient' && slide.background.gradientColor) {
|
||||
backgroundColorValues.push(...slide.background.gradientColor.map(item => ({
|
||||
area: 1,
|
||||
value: item,
|
||||
})))
|
||||
}
|
||||
else backgroundColorValues.push({ area: 1, value: theme.value.backgroundColor })
|
||||
}
|
||||
for (const el of slide.elements) {
|
||||
const elWidth = el.width
|
||||
let elHeight = 0
|
||||
if (el.type === 'line') {
|
||||
const [startX, startY] = el.start
|
||||
const [endX, endY] = el.end
|
||||
elHeight = Math.sqrt(Math.pow(Math.abs(startX - endX), 2) + Math.pow(Math.abs(startY - endY), 2))
|
||||
}
|
||||
else elHeight = el.height
|
||||
|
||||
const area = elWidth * elHeight
|
||||
|
||||
if (el.type === 'shape' || el.type === 'text') {
|
||||
if (el.fill) {
|
||||
themeColorValues.push({ area, value: el.fill })
|
||||
}
|
||||
|
||||
const text = (el.type === 'shape' ? el.text?.content : el.content) || ''
|
||||
if (!text) continue
|
||||
|
||||
const plainText = text.replace(/<[^>]+>/g, '').replace(/\s*/g, '')
|
||||
const matchForColor = text.match(/<[^>]+color: .+?<\/.+?>/g)
|
||||
const matchForFont = text.match(/<[^>]+font-family: .+?<\/.+?>/g)
|
||||
|
||||
let defaultColorPercent = 1
|
||||
let defaultFontPercent = 1
|
||||
|
||||
if (matchForColor) {
|
||||
for (const item of matchForColor) {
|
||||
const ret = item.match(/color: (.+?);/)
|
||||
if (!ret) continue
|
||||
const text = item.replace(/<[^>]+>/g, '').replace(/\s*/g, '')
|
||||
const color = ret[1]
|
||||
const percentage = text.length / plainText.length
|
||||
defaultColorPercent = defaultColorPercent - percentage
|
||||
|
||||
fontColorValues.push({
|
||||
area: area * percentage,
|
||||
value: color,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (matchForFont) {
|
||||
for (const item of matchForFont) {
|
||||
const ret = item.match(/font-family: (.+?);/)
|
||||
if (!ret) continue
|
||||
const text = item.replace(/<[^>]+>/g, '').replace(/\s*/g, '')
|
||||
const font = ret[1]
|
||||
const percentage = text.length / plainText.length
|
||||
defaultFontPercent = defaultFontPercent - percentage
|
||||
|
||||
fontNameValues.push({
|
||||
area: area * percentage,
|
||||
value: font,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (defaultColorPercent) {
|
||||
const _defaultColor = el.type === 'shape' ? el.text?.defaultColor : el.defaultColor
|
||||
const defaultColor = _defaultColor || theme.value.fontColor
|
||||
fontColorValues.push({
|
||||
area: area * defaultColorPercent,
|
||||
value: defaultColor,
|
||||
})
|
||||
}
|
||||
if (defaultFontPercent) {
|
||||
const _defaultFont = el.type === 'shape' ? el.text?.defaultFontName : el.defaultFontName
|
||||
const defaultFont = _defaultFont || theme.value.fontName
|
||||
fontNameValues.push({
|
||||
area: area * defaultFontPercent,
|
||||
value: defaultFont,
|
||||
})
|
||||
}
|
||||
}
|
||||
else if (el.type === 'table') {
|
||||
const cellCount = el.data.length * el.data[0].length
|
||||
let cellWithFillCount = 0
|
||||
for (const row of el.data) {
|
||||
for (const cell of row) {
|
||||
if (cell.style?.backcolor) {
|
||||
cellWithFillCount += 1
|
||||
themeColorValues.push({ area: area / cellCount, value: cell.style?.backcolor })
|
||||
}
|
||||
if (cell.text) {
|
||||
const percent = (cell.text.length >= 10) ? 1 : (cell.text.length / 10)
|
||||
if (cell.style?.color) {
|
||||
fontColorValues.push({ area: area / cellCount * percent, value: cell.style?.color })
|
||||
}
|
||||
if (cell.style?.fontname) {
|
||||
fontColorValues.push({ area: area / cellCount * percent, value: cell.style?.fontname })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (el.theme) {
|
||||
const percent = 1 - cellWithFillCount / cellCount
|
||||
themeColorValues.push({ area: area * percent, value: el.theme.color })
|
||||
}
|
||||
}
|
||||
else if (el.type === 'chart') {
|
||||
if (el.fill) {
|
||||
themeColorValues.push({ area: area * 0.5, value: el.fill })
|
||||
}
|
||||
themeColorValues.push({ area: area * 0.5, value: el.themeColor[0] })
|
||||
}
|
||||
else if (el.type === 'line') {
|
||||
themeColorValues.push({ area, value: el.color })
|
||||
}
|
||||
else if (el.type === 'audio') {
|
||||
themeColorValues.push({ area, value: el.color })
|
||||
}
|
||||
else if (el.type === 'latex') {
|
||||
fontColorValues.push({ area, value: el.color })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const backgroundColors: { [key: string]: number } = {}
|
||||
for (const item of backgroundColorValues) {
|
||||
const color = tinycolor(item.value).toRgbString()
|
||||
if (color === 'rgba(0, 0, 0, 0)') continue
|
||||
if (!backgroundColors[color]) backgroundColors[color] = 1
|
||||
else backgroundColors[color] += 1
|
||||
}
|
||||
|
||||
const themeColors: { [key: string]: number } = {}
|
||||
for (const item of themeColorValues) {
|
||||
const color = tinycolor(item.value).toRgbString()
|
||||
if (color === 'rgba(0, 0, 0, 0)') continue
|
||||
if (!themeColors[color]) themeColors[color] = item.area
|
||||
else themeColors[color] += item.area
|
||||
}
|
||||
|
||||
const fontColors: { [key: string]: number } = {}
|
||||
for (const item of fontColorValues) {
|
||||
const color = tinycolor(item.value).toRgbString()
|
||||
if (color === 'rgba(0, 0, 0, 0)') continue
|
||||
if (!fontColors[color]) fontColors[color] = item.area
|
||||
else fontColors[color] += item.area
|
||||
}
|
||||
|
||||
const fontNames: { [key: string]: number } = {}
|
||||
for (const item of fontNameValues) {
|
||||
if (!fontNames[item.value]) fontNames[item.value] = item.area
|
||||
else fontNames[item.value] += item.area
|
||||
}
|
||||
|
||||
return {
|
||||
backgroundColors: Object.keys(backgroundColors).sort((a, b) => backgroundColors[b] - backgroundColors[a]),
|
||||
themeColors: Object.keys(themeColors).sort((a, b) => themeColors[b] - themeColors[a]),
|
||||
fontColors: Object.keys(fontColors).sort((a, b) => fontColors[b] - fontColors[a]),
|
||||
fontNames: Object.keys(fontNames).sort((a, b) => fontNames[b] - fontNames[a]),
|
||||
}
|
||||
}
|
||||
|
||||
// 获取指定幻灯片内所有颜色(主要的)
|
||||
const getSlideAllColors = (slide: Slide) => {
|
||||
const colors: string[] = []
|
||||
@ -178,6 +361,7 @@ export default () => {
|
||||
}
|
||||
|
||||
return {
|
||||
getSlidesThemeStyles,
|
||||
applyPresetThemeToSingleSlide,
|
||||
applyPresetThemeToAllSlides,
|
||||
applyThemeToAllSlides,
|
||||
|
@ -257,6 +257,10 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="row">
|
||||
<Button style="flex: 1;" @click="themeStylesExtractVisible = true">从幻灯片提取</Button>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<Button style="flex: 1;" @click="applyThemeToAllSlides(moreThemeConfigsVisible)">应用主题到全部</Button>
|
||||
</div>
|
||||
@ -288,6 +292,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
v-model:visible="themeStylesExtractVisible"
|
||||
:width="320"
|
||||
@closed="themeStylesExtractVisible = false"
|
||||
>
|
||||
<ThemeStylesExtract @close="themeStylesExtractVisible = false" />
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@ -301,6 +313,7 @@ import useHistorySnapshot from '@/hooks/useHistorySnapshot'
|
||||
import useSlideTheme from '@/hooks/useSlideTheme'
|
||||
import { getImageDataURL } from '@/utils/image'
|
||||
|
||||
import ThemeStylesExtract from './ThemeStylesExtract.vue'
|
||||
import ColorButton from './common/ColorButton.vue'
|
||||
import FileInput from '@/components/FileInput.vue'
|
||||
import ColorPicker from '@/components/ColorPicker/index.vue'
|
||||
@ -310,12 +323,14 @@ import Button from '@/components/Button.vue'
|
||||
import Select from '@/components/Select.vue'
|
||||
import Popover from '@/components/Popover.vue'
|
||||
import NumberInput from '@/components/NumberInput.vue'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
|
||||
const slidesStore = useSlidesStore()
|
||||
const { availableFonts } = storeToRefs(useMainStore())
|
||||
const { slides, currentSlide, viewportRatio, theme } = storeToRefs(slidesStore)
|
||||
|
||||
const moreThemeConfigsVisible = ref(false)
|
||||
const themeStylesExtractVisible = ref(false)
|
||||
|
||||
const background = computed(() => {
|
||||
if (!currentSlide.value.background) {
|
||||
|
220
src/views/Editor/Toolbar/ThemeStylesExtract.vue
Normal file
220
src/views/Editor/Toolbar/ThemeStylesExtract.vue
Normal file
@ -0,0 +1,220 @@
|
||||
<template>
|
||||
<div class="theme-styles-extract">
|
||||
<Tabs
|
||||
:tabs="tabs"
|
||||
v-model:value="activeTab"
|
||||
:tabsStyle="{ marginBottom: '12px' }"
|
||||
:tabStyle="{ padding: '8px 12px' }"
|
||||
/>
|
||||
<div class="content">
|
||||
<div class="config-item">
|
||||
<div class="label">字体:</div>
|
||||
<div class="values">
|
||||
<div class="value-wrap" v-for="(item, index) in themeStyles.fontNames" :key="item">
|
||||
<div class="value" :style="{ fontFamily: item }">{{ fontMap[item] || item }}</div>
|
||||
<div class="handler">
|
||||
<div class="state" :class="{ 'active': selectedIndex.fontName === index }">√</div>
|
||||
<div class="config-btn" @click="selectedIndex.fontName = index">选择</div>
|
||||
<div class="config-btn" @click="updateTheme({ fontName: item }); selectedIndex.fontName = index">配置到主题</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="label">文字颜色:</div>
|
||||
<div class="values">
|
||||
<div class="value-wrap" v-for="(item, index) in themeStyles.fontColors" :key="item">
|
||||
<div class="value" :style="{ backgroundColor: item }"></div>
|
||||
<div class="handler">
|
||||
<div class="state" :class="{ 'active': selectedIndex.fontColor === index }">√</div>
|
||||
<div class="config-btn" @click="selectedIndex.fontColor = index">选择</div>
|
||||
<div class="config-btn" @click="updateTheme({ fontColor: item }); selectedIndex.fontColor = index">配置到主题</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="label">背景颜色:</div>
|
||||
<div class="values">
|
||||
<div class="value-wrap" v-for="(item, index) in themeStyles.backgroundColors" :key="item">
|
||||
<div class="value" :style="{ backgroundColor: item }"></div>
|
||||
<div class="handler">
|
||||
<div class="state" :class="{ 'active': selectedIndex.backgroundColor === index }">√</div>
|
||||
<div class="config-btn" @click="selectedIndex.backgroundColor = index">选择</div>
|
||||
<div class="config-btn" @click="updateTheme({ backgroundColor: item }); selectedIndex.backgroundColor = index">配置到主题</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<div class="label">主题色:</div>
|
||||
<div class="values">
|
||||
<div class="value-wrap" v-for="(item, index) in themeStyles.themeColors" :key="item">
|
||||
<div class="value" :style="{ backgroundColor: item }"></div>
|
||||
<div class="handler">
|
||||
<div class="state" :class="{ 'active': selectedIndex.themeColor === index }">√</div>
|
||||
<div class="config-btn" @click="selectedIndex.themeColor = index">选择</div>
|
||||
<div class="config-btn" @click="updateTheme({ themeColor: item }); selectedIndex.themeColor = index">配置到主题</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btns">
|
||||
<Button class="btn" type="primary" @click="updateAllThemes()">配置到主题</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { SYS_FONTS, WEB_FONTS } from '@/configs/font'
|
||||
import useSlideTheme from '@/hooks/useSlideTheme'
|
||||
import Tabs from '@/components/Tabs.vue'
|
||||
import Button from '@/components/Button.vue'
|
||||
import type { SlideTheme } from '@/types/slides'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'close'): void
|
||||
}>()
|
||||
|
||||
const slidesStore = useSlidesStore()
|
||||
const { slides, currentSlide } = storeToRefs(slidesStore)
|
||||
const { getSlidesThemeStyles } = useSlideTheme()
|
||||
|
||||
interface TabItem {
|
||||
key: 'single' | 'all'
|
||||
label: string
|
||||
}
|
||||
|
||||
const tabs: TabItem[] = [
|
||||
{ key: 'single', label: '从当前页中提取' },
|
||||
{ key: 'all', label: '从全部幻灯片提取' },
|
||||
]
|
||||
const activeTab = ref<'single' | 'all'>('single')
|
||||
|
||||
const fontMap = ref<{ [key: string]: string }>({})
|
||||
onMounted(() => {
|
||||
const map: { [key: string]: string } = {}
|
||||
for (const item of SYS_FONTS) {
|
||||
map[item.value] = item.label
|
||||
}
|
||||
for (const item of WEB_FONTS) {
|
||||
map[item.value] = item.label
|
||||
}
|
||||
fontMap.value = map
|
||||
})
|
||||
|
||||
const themeStyles = ref<ReturnType<typeof getSlidesThemeStyles>>({
|
||||
backgroundColors: [],
|
||||
themeColors: [],
|
||||
fontColors: [],
|
||||
fontNames: [],
|
||||
})
|
||||
const selectedIndex = ref({
|
||||
backgroundColor: 0,
|
||||
themeColor: 0,
|
||||
fontColor: 0,
|
||||
fontName: 0,
|
||||
})
|
||||
|
||||
watch(activeTab, () => {
|
||||
if (activeTab.value === 'single') themeStyles.value = getSlidesThemeStyles(currentSlide.value)
|
||||
else themeStyles.value = getSlidesThemeStyles(slides.value)
|
||||
})
|
||||
onMounted(() => {
|
||||
themeStyles.value = getSlidesThemeStyles(currentSlide.value)
|
||||
})
|
||||
|
||||
const updateTheme = (themeProps: Partial<SlideTheme>) => {
|
||||
slidesStore.setTheme(themeProps)
|
||||
}
|
||||
|
||||
const updateAllThemes = () => {
|
||||
slidesStore.setTheme({
|
||||
backgroundColor: themeStyles.value.backgroundColors[selectedIndex.value.backgroundColor],
|
||||
themeColor: themeStyles.value.themeColors[selectedIndex.value.themeColor],
|
||||
fontColor: themeStyles.value.fontColors[selectedIndex.value.fontColor],
|
||||
fontName: themeStyles.value.fontNames[selectedIndex.value.fontName],
|
||||
})
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.theme-styles-extract {
|
||||
height: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding-right: 20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
.config-item {
|
||||
padding: 12px 0 10px;
|
||||
border-bottom: 1px dashed #f5f5f5;
|
||||
font-size: 13px;
|
||||
}
|
||||
.label {
|
||||
margin-bottom: 5px
|
||||
}
|
||||
.values {
|
||||
.value-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
& + .value-wrap {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
.handler {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
|
||||
.state {
|
||||
opacity: 0;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.config-btn {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $themeColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
.value {
|
||||
width: 150px;
|
||||
height: 24px;
|
||||
border: 1px solid $borderColor;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
padding: 5px;
|
||||
border-radius: $borderRadius;
|
||||
}
|
||||
}
|
||||
.btns {
|
||||
margin-top: 12px;
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user