Merge branch 'chaoXxxxx-master'

This commit is contained in:
pipipi-pikachu 2022-05-13 15:13:26 +08:00
commit c65a765fb3
11 changed files with 542 additions and 213 deletions

View File

@ -1,4 +1,8 @@
export const ANIMATIONS = [ export const ANIMATION_DEFAULT_DURATION = 1000
export const ANIMATION_DEFAULT_TRIGGER = 'click'
export const ANIMATION_CLASS_PREFIX = 'animate__'
export const ENTER_ANIMATIONS = [
{ {
type: 'bounce', type: 'bounce',
name: '弹跳', name: '弹跳',
@ -34,10 +38,10 @@ export const ANIMATIONS = [
name: '旋转', name: '旋转',
children: [ children: [
{ name: '旋转进入', value: 'rotateIn' }, { name: '旋转进入', value: 'rotateIn' },
{ name: '绕左下旋转进入', value: 'rotateInDownLeft' }, { name: '绕左下进入', value: 'rotateInDownLeft' },
{ name: '绕右下旋转进入', value: 'rotateInDownRight' }, { name: '绕右下进入', value: 'rotateInDownRight' },
{ name: '绕左上旋转进入', value: 'rotateInUpLeft' }, { name: '绕左上进入', value: 'rotateInUpLeft' },
{ name: '绕右上旋转进入', value: 'rotateInUpRight' }, { name: '绕右上进入', value: 'rotateInUpRight' },
], ],
}, },
{ {
@ -88,3 +92,94 @@ export const ANIMATIONS = [
], ],
}, },
] ]
export const EXIT_ANIMATIONS = [
{
type: 'bounce',
name: '弹跳',
children: [
{ name: '弹出', value: 'bounceOut' },
{ name: '向左弹出', value: 'bounceOutLeft' },
{ name: '向右弹出', value: 'bounceOutRight' },
{ name: '向上弹出', value: 'bounceOutUp' },
{ name: '向下弹出', value: 'bounceOutDown' },
],
},
{
type: 'fade',
name: '浮现',
children: [
{ name: '浮出', value: 'fadeOut' },
{ name: '向下浮出', value: 'fadeOutDown' },
{ name: '向下长距浮出', value: 'fadeOutDownBig' },
{ name: '向左浮出', value: 'fadeOutLeft' },
{ name: '向左长距浮出', value: 'fadeOutLeftBig' },
{ name: '向右浮出', value: 'fadeOutRight' },
{ name: '向右长距浮出', value: 'fadeOutRightBig' },
{ name: '向上浮出', value: 'fadeOutUp' },
{ name: '向上长距浮出', value: 'fadeOutUpBig' },
{ name: '从左上浮出', value: 'fadeOutTopLeft' },
{ name: '从右上浮出', value: 'fadeOutTopRight' },
{ name: '从左下浮出', value: 'fadeOutBottomLeft' },
{ name: '从右下浮出', value: 'fadeOutBottomRight' },
],
},
{
type: 'rotate',
name: '旋转',
children: [
{ name: '旋转退出', value: 'rotateOut' },
{ name: '绕左下退出', value: 'rotateOutDownLeft' },
{ name: '绕右下退出', value: 'rotateOutDownRight' },
{ name: '绕左上退出', value: 'rotateOutUpLeft' },
{ name: '绕右上退出', value: 'rotateOutUpRight' },
],
},
{
type: 'zoom',
name: '缩放',
children: [
{ name: '缩小退出', value: 'zoomOut' },
{ name: '向下缩小退出', value: 'zoomOutDown' },
{ name: '从左缩小退出', value: 'zoomOutLeft' },
{ name: '从右缩小退出', value: 'zoomOutRight' },
{ name: '向上缩小退出', value: 'zoomOutUp' },
],
},
{
type: 'slide',
name: '滑出',
children: [
{ name: '向下滑出', value: 'slideOutDown' },
{ name: '从左滑出', value: 'slideOutLeft' },
{ name: '从右滑出', value: 'slideOutRight' },
{ name: '向上滑出', value: 'slideOutUp' },
],
},
{
type: 'flip',
name: '翻转',
children: [
{ name: 'X轴翻转退出', value: 'flipOutX' },
{ name: 'Y轴翻转退出', value: 'flipOutY' },
],
},
{
type: 'back',
name: '缩小滑出',
children: [
{ name: '向下缩小滑出', value: 'backOutDown' },
{ name: '从左缩小滑出', value: 'backOutLeft' },
{ name: '从右缩小滑出', value: 'backOutRight' },
{ name: '向上缩小滑出', value: 'backOutUp' },
],
},
{
type: 'lightSpeed',
name: '飞出',
children: [
{ name: '从右飞出', value: 'lightSpeedOutRight' },
{ name: '从左飞出', value: 'lightSpeedOutLeft' },
],
},
]

View File

@ -58,8 +58,10 @@ export default () => {
element.id = elIdMap[element.id] element.id = elIdMap[element.id]
if (element.groupId) element.groupId = groupIdMap[element.groupId] if (element.groupId) element.groupId = groupIdMap[element.groupId]
} }
// 动画id替换
if (slide.animations) { if (slide.animations) {
for (const animation of slide.animations) { for (const animation of slide.animations) {
animation.id = nanoid(10)
animation.elId = elIdMap[animation.elId] animation.elId = elIdMap[animation.elId]
} }
} }

View File

@ -1,7 +1,7 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import tinycolor from 'tinycolor2' import tinycolor from 'tinycolor2'
import { omit } from 'lodash' import { omit } from 'lodash'
import { Slide, SlideTheme, PPTElement } from '@/types/slides' import { Slide, SlideTheme, PPTElement, PPTAnimation } from '@/types/slides'
import { slides } from '@/mocks/slides' import { slides } from '@/mocks/slides'
import { theme } from '@/mocks/theme' import { theme } from '@/mocks/theme'
import { layouts } from '@/mocks/layout' import { layouts } from '@/mocks/layout'
@ -16,6 +16,11 @@ interface UpdateElementData {
props: Partial<PPTElement>; props: Partial<PPTElement>;
} }
interface FormatedAnimation {
animations: PPTAnimation[];
autoNext: boolean;
}
export interface SlidesState { export interface SlidesState {
theme: SlideTheme; theme: SlideTheme;
slides: Slide[]; slides: Slide[];
@ -38,13 +43,43 @@ export const useSlidesStore = defineStore('slides', {
currentSlideAnimations(state) { currentSlideAnimations(state) {
const currentSlide = state.slides[state.slideIndex] const currentSlide = state.slides[state.slideIndex]
if (!currentSlide) return null if (!currentSlide?.animations) return []
const animations = currentSlide.animations
if (!animations) return null
const els = currentSlide.elements const els = currentSlide.elements
const elIds = els.map(el => el.id) const elIds = els.map(el => el.id)
return animations.filter(animation => elIds.includes(animation.elId)) return currentSlide.animations.filter(animation => elIds.includes(animation.elId))
},
// 格式化的当前页动画
// 将触发条件为“与上一动画同时”的项目向上合并到序列中的同一位置
// 为触发条件为“上一动画之后”项目的上一项添加自动向下执行标记
formatedAnimations(state) {
const currentSlide = state.slides[state.slideIndex]
if (!currentSlide?.animations) return []
const els = currentSlide.elements
const elIds = els.map(el => el.id)
const animations = currentSlide.animations.filter(animation => elIds.includes(animation.elId))
const formatedAnimations: FormatedAnimation[] = []
for (const animation of animations) {
if (animation.trigger === 'click') {
formatedAnimations.push({ animations: [animation], autoNext: false })
}
if (animation.trigger === 'meantime') {
const last = formatedAnimations[formatedAnimations.length - 1] || { animations: [], autoNext: false }
last.animations = last.animations.filter(item => item.elId !== animation.elId)
last.animations.push(animation)
formatedAnimations[formatedAnimations.length - 1] = last
}
if (animation.trigger === 'auto') {
const last = formatedAnimations[formatedAnimations.length - 1] || { animations: [], autoNext: false }
last.autoNext = true
formatedAnimations[formatedAnimations.length - 1] = last
formatedAnimations.push({ animations: [animation], autoNext: false })
}
}
return formatedAnimations
}, },
layouts(state) { layouts(state) {

View File

@ -568,16 +568,25 @@ export type PPTElement = PPTTextElement | PPTImageElement | PPTShapeElement | PP
/** /**
* *
* *
* id: 动画id
*
* elId: 元素ID * elId: 元素ID
* *
* type: * effect: 动画效果
*
* type: 退
* *
* duration: 动画持续时间 * duration: 动画持续时间
*
* trigger: 动画触发方式(click - meantime - auto - )
*/ */
export interface PPTAnimation { export interface PPTAnimation {
id: string;
elId: string; elId: string;
type: string; effect: string;
type: 'in' | 'out';
duration: number; duration: number;
trigger: 'click' | 'meantime' | 'auto';
} }
/** /**

View File

@ -21,9 +21,9 @@
<div <div
class="animation-index" class="animation-index"
v-if="toolbarState === 'elAnimation' && elementIndexInAnimation !== -1" v-if="toolbarState === 'elAnimation' && elementIndexListInAnimation.length"
> >
{{elementIndexInAnimation + 1}} <div class="index-item" v-for="index in elementIndexListInAnimation" :key="index">{{index + 1}}</div>
</div> </div>
<LinkHandler <LinkHandler
@ -96,7 +96,7 @@ export default defineComponent({
}, },
setup(props) { setup(props) {
const { canvasScale, toolbarState } = storeToRefs(useMainStore()) const { canvasScale, toolbarState } = storeToRefs(useMainStore())
const { currentSlide } = storeToRefs(useSlidesStore()) const { formatedAnimations } = storeToRefs(useSlidesStore())
const currentOperateComponent = computed(() => { const currentOperateComponent = computed(() => {
const elementTypeMap = { const elementTypeMap = {
@ -113,9 +113,13 @@ export default defineComponent({
return elementTypeMap[props.elementInfo.type] || null return elementTypeMap[props.elementInfo.type] || null
}) })
const elementIndexInAnimation = computed(() => { const elementIndexListInAnimation = computed(() => {
const animations = currentSlide.value.animations || [] const indexList = []
return animations.findIndex(animation => animation.elId === props.elementInfo.id) for (let i = 0; i < formatedAnimations.value.length; i++) {
const elIds = formatedAnimations.value[i].animations.map(item => item.elId)
if (elIds.includes(props.elementInfo.id)) indexList.push(i)
}
return indexList
}) })
const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0) const rotate = computed(() => 'rotate' in props.elementInfo ? props.elementInfo.rotate : 0)
@ -125,7 +129,7 @@ export default defineComponent({
currentOperateComponent, currentOperateComponent,
canvasScale, canvasScale,
toolbarState, toolbarState,
elementIndexInAnimation, elementIndexListInAnimation,
rotate, rotate,
height, height,
} }
@ -148,13 +152,20 @@ export default defineComponent({
top: 0; top: 0;
left: -24px; left: -24px;
font-size: 12px; font-size: 12px;
width: 18px;
height: 18px; .index-item {
background-color: #fff; width: 18px;
color: $themeColor; height: 18px;
border: 1px solid $themeColor; background-color: #fff;
display: flex; color: $themeColor;
justify-content: center; border: 1px solid $themeColor;
align-items: center; display: flex;
justify-content: center;
align-items: center;
& + .index-item {
margin-top: 5px;
}
}
} }
</style> </style>

View File

@ -4,44 +4,48 @@
<Popover <Popover
trigger="click" trigger="click"
v-model:visible="animationPoolVisible" v-model:visible="animationPoolVisible"
v-if="!['chart', 'video'].includes(handleElement.type)"
@visibleChange="visible => handlePopoverVisibleChange(visible)" @visibleChange="visible => handlePopoverVisibleChange(visible)"
> >
<template #content> <template #content>
<div class="animation-pool"> <div class="tabs">
<div class="pool-type" v-for="type in animations" :key="type.name"> <div
<div class="type-title">{{type.name}}</div> :class="['tab', tab.key, { 'active': activeTab === tab.key }]"
<div class="pool-item-wrapper"> v-for="tab in tabs"
<div :key="tab.key"
class="pool-item" @click="activeTab = tab.key"
v-for="item in type.children" :key="item.name" >{{tab.label}}</div>
@mouseenter="hoverPreviewAnimation = item.value" </div>
@mouseleave="hoverPreviewAnimation = ''" <template v-for="key in Object.keys(animations)">
@click="addAnimation(item.value)" <div :class="['animation-pool', key]" :key="key" v-if="activeTab === key">
> <div class="pool-type" :key="effect.name" v-for="effect in animations[key]">
<div class="type-title">{{effect.name}}</div>
<div class="pool-item-wrapper">
<div <div
class="animation-box" class="pool-item"
:class="[ v-for="item in effect.children" :key="item.name"
'animate__animated', @mouseenter="hoverPreviewAnimation = item.value"
'animate__faster', @mouseleave="hoverPreviewAnimation = ''"
hoverPreviewAnimation === item.value && `animate__${item.value}`, @click="addAnimation(key, item.value)"
]"
> >
{{item.name}} <div
class="animation-box"
:class="[
`${prefix}animated`,
`${prefix}faster`,
hoverPreviewAnimation === item.value && `${prefix}${item.value}`,
]"
>{{item.name}}</div>
</div> </div>
</div> </div>
</div> </div>
<div class="mask" v-if="!popoverMaskHide"></div>
</div> </div>
<div class="mask" v-if="!popoverMaskHide"></div> </template>
</div>
</template> </template>
<Button class="element-animation-btn"> <Button class="element-animation-btn" @click="handleAnimationId = ''">
<IconEffects style="margin-right: 5px;" /> {{handleElementAnimationName || '点击选择动画'}} <IconEffects style="margin-right: 5px;" /> 添加动画
</Button> </Button>
</Popover> </Popover>
<Button class="element-animation-btn" v-else disabled>
<IconEffects style="margin-right: 5px;" /> 该元素暂不支持动画
</Button>
</div> </div>
<div class="tip" v-else><IconClick style="margin-right: 5px;" /> 选中画布中的元素添加动画</div> <div class="tip" v-else><IconClick style="margin-right: 5px;" /> 选中画布中的元素添加动画</div>
@ -54,62 +58,96 @@
:animation="300" :animation="300"
:scroll="true" :scroll="true"
:scrollSensitivity="50" :scrollSensitivity="50"
handle=".sequence-content"
@end="handleDragEnd" @end="handleDragEnd"
itemKey="id" itemKey="id"
> >
<template #item="{ element, index }"> <template #item="{ element }">
<div class="sequence-item" :class="{ 'active': handleElement?.id === element.elId }"> <div class="sequence-item" :class="[element.type, { 'active': handleElement?.id === element.elId }]">
<div class="index">{{index + 1}}</div> <div class="sequence-content">
<div class="text">{{element.elType}}{{element.animationType}}</div> <div class="index">{{element.index}}</div>
<div class="handler"> <div class="text">{{element.elType}}{{element.animationEffect}}</div>
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="预览"> <div class="handler">
<IconPlayOne class="handler-btn" @click="runAnimation(element.elId, element.type, element.duration)" /> <Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="预览">
</Tooltip> <IconPlayOne class="handler-btn" @click="runAnimation(element.elId, element.effect, element.duration)" />
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="删除"> </Tooltip>
<IconCloseSmall class="handler-btn" @click="deleteAnimation(element.elId)" /> <Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.5" title="删除">
</Tooltip> <IconCloseSmall class="handler-btn" @click="deleteAnimation(element.id)" />
</Tooltip>
</div>
</div>
<div class="configs" v-if="handleElementAnimation[0]?.elId === element.elId">
<Divider style="margin: 16px 0;" />
<div class="config-item">
<div style="flex: 3;">持续时长</div>
<InputNumber
:min="500"
:max="3000"
:step="500"
:value="element.duration"
@change="value => updateElementAnimationDuration(element.id, value)"
style="flex: 5;"
/>
</div>
<div class="config-item">
<div style="flex: 3;">触发方式</div>
<Select
:value="element.trigger"
@change="value => updateElementAnimationTrigger(element.id, value)"
style="flex: 5;"
>
<SelectOption value="click">主动触发</SelectOption>
<SelectOption value="meantime">与上一动画同时</SelectOption>
<SelectOption value="auto">上一动画之后</SelectOption>
</Select>
</div>
<div class="config-item">
<Button style="flex: 1;" @click="openAnimationPool(element.id)">更换动画</Button>
</div>
</div> </div>
</div> </div>
</template> </template>
</Draggable> </Draggable>
<div class="configs" v-if="handleElementAnimation">
<Divider />
<div class="duration">
<div style="flex: 4;">持续时间毫秒</div>
<InputNumber
:min="100"
:max="5000"
:step="100"
:value="handleElementAnimation.duration"
@change="value => updateElementAnimationDuration(value)"
style="flex: 3;"
/>
</div>
</div>
</div> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref } from 'vue' import { computed, defineComponent, ref, watch } from 'vue'
import { nanoid } from 'nanoid'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useMainStore, useSlidesStore } from '@/store' import { useMainStore, useSlidesStore } from '@/store'
import { PPTAnimation } from '@/types/slides' import { PPTAnimation } from '@/types/slides'
import { ANIMATIONS } from '@/configs/animation' import {
ENTER_ANIMATIONS,
EXIT_ANIMATIONS,
ANIMATION_DEFAULT_DURATION,
ANIMATION_DEFAULT_TRIGGER,
ANIMATION_CLASS_PREFIX,
} from '@/configs/animation'
import { ELEMENT_TYPE_ZH } from '@/configs/element' import { ELEMENT_TYPE_ZH } from '@/configs/element'
import useHistorySnapshot from '@/hooks/useHistorySnapshot' import useHistorySnapshot from '@/hooks/useHistorySnapshot'
import Draggable from 'vuedraggable' import Draggable from 'vuedraggable'
const defaultDuration = 1000 const animationEffects: { [key: string]: string } = {}
for (const effect of ENTER_ANIMATIONS) {
const animationTypes: { [key: string]: string } = {} for (const animation of effect.children) {
for (const type of ANIMATIONS) { animationEffects[animation.value] = animation.name
for (const animation of type.children) {
animationTypes[animation.value] = animation.name
} }
} }
for (const effect of EXIT_ANIMATIONS) {
for (const animation of effect.children) {
animationEffects[animation.value] = animation.name
}
}
type AnimationType = 'in' | 'out'
interface TabItem {
key: AnimationType;
label: string;
}
export default defineComponent({ export default defineComponent({
name: 'element-animation-panel', name: 'element-animation-panel',
@ -119,55 +157,61 @@ export default defineComponent({
setup() { setup() {
const slidesStore = useSlidesStore() const slidesStore = useSlidesStore()
const { handleElement, handleElementId } = storeToRefs(useMainStore()) const { handleElement, handleElementId } = storeToRefs(useMainStore())
const { currentSlide, currentSlideAnimations } = storeToRefs(slidesStore) const { currentSlide, formatedAnimations, currentSlideAnimations } = storeToRefs(slidesStore)
const tabs: TabItem[] = [
{ key: 'in', label: '入场' },
{ key: 'out', label: '退场' },
]
const activeTab = ref('in')
watch(() => handleElementId.value, () => {
animationPoolVisible.value = false
})
const hoverPreviewAnimation = ref('') const hoverPreviewAnimation = ref('')
const animationPoolVisible = ref(false) const animationPoolVisible = ref(false)
const { addHistorySnapshot } = useHistorySnapshot() const { addHistorySnapshot } = useHistorySnapshot()
const animations = ANIMATIONS
// //
const animationSequence = computed(() => { const animationSequence = computed(() => {
if (!currentSlideAnimations.value) return []
const animationSequence = [] const animationSequence = []
for (const animation of currentSlideAnimations.value) { for (let i = 0; i < formatedAnimations.value.length; i++) {
const el = currentSlide.value.elements.find(el => el.id === animation.elId) const item = formatedAnimations.value[i]
if (!el) continue for (let j = 0; j < item.animations.length; j++) {
const elType = ELEMENT_TYPE_ZH[el.type] const animation = item.animations[j]
const animationType = animationTypes[animation.type] const el = currentSlide.value.elements.find(el => el.id === animation.elId)
if (!el) continue
animationSequence.push({ const elType = ELEMENT_TYPE_ZH[el.type]
...animation, const animationEffect = animationEffects[animation.effect]
elType, animationSequence.push({
animationType, ...animation,
}) index: j === 0 ? i + 1 : '',
elType,
animationEffect,
})
}
} }
return animationSequence return animationSequence
}) })
// //
const handleElementAnimation = computed(() => { const handleElementAnimation = computed(() => {
const animations = currentSlideAnimations.value || [] const animations = currentSlideAnimations.value
const animation = animations.find(item => item.elId === handleElementId.value) const animation = animations.filter(item => item.elId === handleElementId.value)
return animation || null return animation || []
}) })
// //
const handleElementAnimationName = computed(() => { const deleteAnimation = (id: string) => {
if (!handleElementAnimation.value) return null const animations = currentSlideAnimations.value.filter(item => item.id !== id)
return animationTypes[handleElementAnimation.value.type]
})
//
const deleteAnimation = (elId: string) => {
const animations = (currentSlideAnimations.value as PPTAnimation[]).filter(item => item.elId !== elId)
slidesStore.updateSlide({ animations }) slidesStore.updateSlide({ animations })
addHistorySnapshot() addHistorySnapshot()
} }
// //
const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => { const handleDragEnd = (eventData: { newIndex: number; oldIndex: number }) => {
const { newIndex, oldIndex } = eventData const { newIndex, oldIndex } = eventData
if (oldIndex === newIndex) return if (oldIndex === newIndex) return
@ -181,29 +225,48 @@ export default defineComponent({
addHistorySnapshot() addHistorySnapshot()
} }
// //
const runAnimation = (elId: string, animationType: string, duration: number) => { const runAnimation = (elId: string, effect: string, duration: number) => {
const prefix = 'animate__'
const elRef = document.querySelector(`#editable-element-${elId} [class^=editable-element-]`) const elRef = document.querySelector(`#editable-element-${elId} [class^=editable-element-]`)
if (elRef) { if (elRef) {
const animationName = `${prefix}${animationType}` const animationName = `${ANIMATION_CLASS_PREFIX}${effect}`
document.documentElement.style.setProperty('--animate-duration', `${duration}ms`) document.documentElement.style.setProperty('--animate-duration', `${duration}ms`)
elRef.classList.add(`${prefix}animated`, animationName) elRef.classList.add(`${ANIMATION_CLASS_PREFIX}animated`, animationName)
const handleAnimationEnd = () => { const handleAnimationEnd = () => {
document.documentElement.style.removeProperty('--animate-duration') document.documentElement.style.removeProperty('--animate-duration')
elRef.classList.remove(`${prefix}animated`, animationName) elRef.classList.remove(`${ANIMATION_CLASS_PREFIX}animated`, animationName)
} }
elRef.addEventListener('animationend', handleAnimationEnd, { once: true }) elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
} }
} }
// //
const updateElementAnimation = (type: string) => { const updateElementAnimationDuration = (id: string, duration: number) => {
if (!currentSlideAnimations.value) return if (duration < 100 || duration > 5000) return
const animations = currentSlideAnimations.value.map(item => { const animations = currentSlideAnimations.value.map(item => {
if (item.elId === handleElementId.value) return { ...item, type } if (item.id === id) return { ...item, duration }
return item
})
slidesStore.updateSlide({ animations })
addHistorySnapshot()
}
//
const updateElementAnimationTrigger = (id: string, trigger: 'click' | 'meantime' | 'auto') => {
const animations = currentSlideAnimations.value.map(item => {
if (item.id === id) return { ...item, trigger }
return item
})
slidesStore.updateSlide({ animations })
addHistorySnapshot()
}
//
const updateElementAnimation = (type: AnimationType, effect: string) => {
const animations = currentSlideAnimations.value.map(item => {
if (item.id === handleAnimationId.value) return { ...item, type, effect }
return item return item
}) })
slidesStore.updateSlide({ animations }) slidesStore.updateSlide({ animations })
@ -211,41 +274,33 @@ export default defineComponent({
addHistorySnapshot() addHistorySnapshot()
const animationItem = currentSlideAnimations.value.find(item => item.elId === handleElementId.value) const animationItem = currentSlideAnimations.value.find(item => item.elId === handleElementId.value)
const duration = animationItem?.duration || defaultDuration const duration = animationItem?.duration || ANIMATION_DEFAULT_DURATION
runAnimation(handleElementId.value, type, duration) runAnimation(handleElementId.value, effect, duration)
} }
// const handleAnimationId = ref('')
const updateElementAnimationDuration = (duration: number) => { //
if (!currentSlideAnimations.value) return const addAnimation = (type: AnimationType, effect: string) => {
if (duration < 100 || duration > 5000) return if (handleAnimationId.value) {
updateElementAnimation(type, effect)
const animations = currentSlideAnimations.value.map(item => {
if (item.elId === handleElementId.value) return { ...item, duration }
return item
})
slidesStore.updateSlide({ animations })
addHistorySnapshot()
}
//
const addAnimation = (type: string) => {
if (handleElementAnimationName.value) {
updateElementAnimation(type)
return return
} }
const animations: PPTAnimation[] = currentSlideAnimations.value ? JSON.parse(JSON.stringify(currentSlideAnimations.value)) : []
const animations: PPTAnimation[] = JSON.parse(JSON.stringify(currentSlideAnimations.value))
animations.push({ animations.push({
id: nanoid(10),
elId: handleElementId.value, elId: handleElementId.value,
type, type,
duration: defaultDuration, effect,
duration: ANIMATION_DEFAULT_DURATION,
trigger: ANIMATION_DEFAULT_TRIGGER,
}) })
slidesStore.updateSlide({ animations }) slidesStore.updateSlide({ animations })
animationPoolVisible.value = false animationPoolVisible.value = false
addHistorySnapshot() addHistorySnapshot()
runAnimation(handleElementId.value, type, defaultDuration) runAnimation(handleElementId.value, effect, ANIMATION_DEFAULT_DURATION)
} }
// 500ms // 500ms
@ -257,32 +312,73 @@ export default defineComponent({
else popoverMaskHide.value = false else popoverMaskHide.value = false
} }
const openAnimationPool = (elementId: string) => {
animationPoolVisible.value = true
handleAnimationId.value = elementId
handlePopoverVisibleChange(true)
}
return { return {
tabs,
activeTab,
handleAnimationId,
handleElement, handleElement,
animationPoolVisible, animationPoolVisible,
animations,
animationSequence, animationSequence,
hoverPreviewAnimation, hoverPreviewAnimation,
handleElementAnimation, handleElementAnimation,
handleElementAnimationName,
popoverMaskHide, popoverMaskHide,
animations: {
in: ENTER_ANIMATIONS,
out: EXIT_ANIMATIONS,
},
prefix: ANIMATION_CLASS_PREFIX,
addAnimation, addAnimation,
deleteAnimation, deleteAnimation,
handleDragEnd, handleDragEnd,
runAnimation, runAnimation,
updateElementAnimationDuration, updateElementAnimationDuration,
updateElementAnimationTrigger,
handlePopoverVisibleChange, handlePopoverVisibleChange,
openAnimationPool,
} }
}, },
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
$inColor: #68a490;
$outColor: #d86344;
.element-animation-panel { .element-animation-panel {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.tabs {
display: flex;
justify-content: flex-start;
align-items: center;
border-bottom: 1px solid $borderColor;
margin-bottom: 20px;
}
.tab {
width: 50%;
padding-bottom: 8px;
border-bottom: 2px solid transparent;
text-align: center;
cursor: pointer;
&.active {
border-bottom: 2px solid $themeColor;
}
&.in.active {
border-bottom-color: $inColor;
}
&.out.active {
border-bottom-color: $outColor;
}
}
.element-animation { .element-animation {
height: 32px; height: 32px;
display: flex; display: flex;
@ -291,10 +387,13 @@ export default defineComponent({
.element-animation-btn { .element-animation-btn {
width: 100%; width: 100%;
} }
.duration { .config-item {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
& + .config-item {
margin-top: 5px;
}
} }
.tip { .tip {
height: 32px; height: 32px;
@ -310,15 +409,19 @@ export default defineComponent({
overflow-x: hidden; overflow-x: hidden;
font-size: 12px; font-size: 12px;
margin-right: -12px; margin-right: -12px;
padding-right: 12px;
position: relative; position: relative;
.mask { .mask {
width: 400px; @include absolute-0();
height: 500px; }
position: absolute;
top: 0; &.in .type-title {
left: 0; border-left-color: $inColor;
background-color: rgba($color: $inColor, $alpha: .15);
}
&.out .type-title {
border-left-color: $outColor;
background-color: rgba($color: $outColor, $alpha: .15);
} }
} }
.type-title { .type-title {
@ -353,37 +456,46 @@ export default defineComponent({
@include overflow-overlay(); @include overflow-overlay();
} }
.sequence-item { .sequence-item {
height: 36px;
display: flex;
align-items: center;
border: 1px solid $borderColor; border: 1px solid $borderColor;
padding: 6px; padding: 10px 6px;
border-radius: $borderRadius; border-radius: $borderRadius;
margin-bottom: 8px; margin-bottom: 8px;
cursor: grab; transition: all .5s;
&:active { &.in.active {
cursor: grabbing; border-color: $inColor;
}
&.out.active {
border-color: $outColor;
} }
&.active { &.active {
border-color: $themeColor; height: auto;
} }
.index { .sequence-content {
flex: 1; display: flex;
} align-items: center;
.text { cursor: grab;
flex: 6;
} &:active {
.handler { cursor: grabbing;
flex: 2; }
font-size: 15px;
text-align: right; .index {
} flex: 1;
.handler-btn { }
margin-left: 8px; .text {
cursor: pointer; flex: 6;
}
.handler {
flex: 2;
font-size: 15px;
text-align: right;
}
.handler-btn {
margin-left: 8px;
cursor: pointer;
}
} }
} }
</style> </style>

View File

@ -48,7 +48,7 @@ export default defineComponent({
}, },
animationIndex: { animationIndex: {
type: Number, type: Number,
default: -1, required: true,
}, },
turnSlideToId: { turnSlideToId: {
type: Function as PropType<(id: string) => void>, type: Function as PropType<(id: string) => void>,
@ -75,13 +75,27 @@ export default defineComponent({
return elementTypeMap[props.elementInfo.type] || null return elementTypeMap[props.elementInfo.type] || null
}) })
const { currentSlide, theme } = storeToRefs(useSlidesStore()) const { formatedAnimations, theme } = storeToRefs(useSlidesStore())
// //
const needWaitAnimation = computed(() => { const needWaitAnimation = computed(() => {
const animations = currentSlide.value.animations || [] //
const elementIndexInAnimation = animations.findIndex(animation => animation.elId === props.elementInfo.id) const elementIndexInAnimation = formatedAnimations.value.findIndex(item => {
if (elementIndexInAnimation !== -1 && elementIndexInAnimation >= props.animationIndex) return true const elIds = item.animations.map(item => item.elId)
return elIds.includes(props.elementInfo.id)
})
//
if (elementIndexInAnimation === -1) return false
//
// 退退
if (elementIndexInAnimation < props.animationIndex) return false
//
//
const firstAnimation = formatedAnimations.value[elementIndexInAnimation].animations.find(item => item.elId === props.elementInfo.id)
if (firstAnimation?.type === 'in') return true
return false return false
}) })

View File

@ -47,7 +47,7 @@ export default defineComponent({
}, },
animationIndex: { animationIndex: {
type: Number, type: Number,
default: -1, required: true,
}, },
turnSlideToId: { turnSlideToId: {
type: Function as PropType<(id: string) => void>, type: Function as PropType<(id: string) => void>,

View File

@ -20,7 +20,7 @@
width: slideWidth + 'px', width: slideWidth + 'px',
height: slideHeight + 'px', height: slideHeight + 'px',
}" }"
v-if="Math.abs(slideIndex - index) < 2" v-if="Math.abs(slideIndex - index) < 2 || slide.animations?.length"
> >
<ScreenSlide <ScreenSlide
:slide="slide" :slide="slide"
@ -59,7 +59,7 @@ export default defineComponent({
}, },
animationIndex: { animationIndex: {
type: Number, type: Number,
default: -1, required: true,
}, },
turnSlideToId: { turnSlideToId: {
type: Function as PropType<(id: string) => void>, type: Function as PropType<(id: string) => void>,

View File

@ -1,39 +1,87 @@
import { computed, onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { throttle } from 'lodash' import { throttle } from 'lodash'
import { storeToRefs } from 'pinia' import { storeToRefs } from 'pinia'
import { useSlidesStore } from '@/store' import { useSlidesStore } from '@/store'
import { KEYS } from '@/configs/hotkey' import { KEYS } from '@/configs/hotkey'
import { ANIMATION_CLASS_PREFIX } from '@/configs/animation'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
export default () => { export default () => {
const slidesStore = useSlidesStore() const slidesStore = useSlidesStore()
const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore) const { slides, slideIndex, formatedAnimations } = storeToRefs(slidesStore)
// 当前页的元素动画列表和当前执行到的位置 // 当前页的元素动画执行到的位置
const animations = computed(() => currentSlide.value.animations || [])
const animationIndex = ref(0) const animationIndex = ref(0)
const inAnimation = ref(false)
// 执行元素的入场动画 // 执行元素动画
const runAnimation = () => { const runAnimation = () => {
const prefix = 'animate__' // 正在执行动画时,禁止其他新的动画开始
const animation = animations.value[animationIndex.value] if (inAnimation.value) return
const { animations, autoNext } = formatedAnimations.value[animationIndex.value]
animationIndex.value += 1 animationIndex.value += 1
const elRef = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`) // 标记开始执行动画
if (elRef) { inAnimation.value = true
const animationName = `${prefix}${animation.type}`
document.documentElement.style.setProperty('--animate-duration', `${animation.duration}ms`)
elRef.classList.add(`${prefix}animated`, animationName)
let endAnimationCount = 0
// 依次执行该位置中的全部动画
for (const animation of animations) {
const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
if (!elRef) {
endAnimationCount += 1
continue
}
const animationName = `${ANIMATION_CLASS_PREFIX}${animation.effect}`
// 执行动画前先清除原有的动画状态(如果有)
elRef.style.removeProperty('--animate-duration')
for (const classname of elRef.classList) {
if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
}
// 执行动画
elRef.style.setProperty('--animate-duration', `${animation.duration}ms`)
elRef.classList.add(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
// 执行动画结束,将“退场”以外的动画状态清除
const handleAnimationEnd = () => { const handleAnimationEnd = () => {
document.documentElement.style.removeProperty('--animate-duration') if (animation.type !== 'out') {
elRef.classList.remove(`${prefix}animated`, animationName) elRef.style.removeProperty('--animate-duration')
elRef.classList.remove(animationName, `${ANIMATION_CLASS_PREFIX}animated`)
}
// 判断该位置上的全部动画都已经结束后,标记动画执行完成,并尝试继续向下执行(如果有需要)
endAnimationCount += 1
if (endAnimationCount === animations.length) {
inAnimation.value = false
if (autoNext) runAnimation()
}
} }
elRef.addEventListener('animationend', handleAnimationEnd, { once: true }) elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
} }
} }
// 撤销元素动画,除了将索引前移外,还需要清除动画状态
const revokeAnimation = () => {
animationIndex.value -= 1
const { animations } = formatedAnimations.value[animationIndex.value]
for (const animation of animations) {
const elRef: HTMLElement | null = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
if (!elRef) continue
elRef.style.removeProperty('--animate-duration')
for (const classname of elRef.classList) {
if (classname.indexOf(ANIMATION_CLASS_PREFIX) !== -1) elRef.classList.remove(classname, `${ANIMATION_CLASS_PREFIX}animated`)
}
}
}
// 关闭自动播放 // 关闭自动播放
const autoPlayTimer = ref(0) const autoPlayTimer = ref(0)
const closeAutoPlay = () => { const closeAutoPlay = () => {
@ -52,29 +100,32 @@ export default () => {
// 遇到元素动画时,优先执行动画播放,无动画则执行翻页 // 遇到元素动画时,优先执行动画播放,无动画则执行翻页
// 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画 // 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画
const execPrev = () => { const execPrev = () => {
if (animations.value.length && animationIndex.value > 0) { if (formatedAnimations.value.length && animationIndex.value > 0) {
animationIndex.value -= 1 revokeAnimation()
} }
else if (slideIndex.value > 0) { else if (slideIndex.value > 0) {
slidesStore.updateSlideIndex(slideIndex.value - 1) slidesStore.updateSlideIndex(slideIndex.value - 1)
const lastIndex = animations.value ? animations.value.length : 0 animationIndex.value = formatedAnimations.value.length
animationIndex.value = lastIndex inAnimation.value = false
} }
else { else {
throttleMassage('已经是第一页了') throttleMassage('已经是第一页了')
inAnimation.value = false
} }
} }
const execNext = () => { const execNext = () => {
if (animations.value.length && animationIndex.value < animations.value.length) { if (formatedAnimations.value.length && animationIndex.value < formatedAnimations.value.length) {
runAnimation() runAnimation()
} }
else if (slideIndex.value < slides.value.length - 1) { else if (slideIndex.value < slides.value.length - 1) {
slidesStore.updateSlideIndex(slideIndex.value + 1) slidesStore.updateSlideIndex(slideIndex.value + 1)
animationIndex.value = 0 animationIndex.value = 0
inAnimation.value = false
} }
else { else {
throttleMassage('已经是最后一页了') throttleMassage('已经是最后一页了')
closeAutoPlay() closeAutoPlay()
inAnimation.value = false
} }
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
class="editable-element-shape" class="base-element-line"
:style="{ :style="{
top: elementInfo.top + 'px', top: elementInfo.top + 'px',
left: elementInfo.left + 'px', left: elementInfo.left + 'px',
@ -59,7 +59,7 @@ import useElementShadow from '@/views/components/element/hooks/useElementShadow'
import LinePointMarker from './LinePointMarker.vue' import LinePointMarker from './LinePointMarker.vue'
export default defineComponent({ export default defineComponent({
name: 'editable-element-shape', name: 'base-element-line',
components: { components: {
LinePointMarker, LinePointMarker,
}, },
@ -100,7 +100,7 @@ export default defineComponent({
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.editable-element-shape { .base-element-line {
position: absolute; position: absolute;
} }