feat: 提取已有幻灯片主题风格

This commit is contained in:
zxc 2024-05-04 12:21:10 +08:00
parent 5eb46e3e96
commit 796328f115
5 changed files with 421 additions and 0 deletions

View File

@ -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

View File

@ -41,6 +41,7 @@ npm run dev
- 标尺
- 画布缩放、移动
- 主题设置
- 提取已有幻灯片风格
- 演讲者备注(富文本)
- 幻灯片模板
- 翻页动画

View File

@ -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,

View File

@ -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) {

View 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>