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
7ea0fd4286
commit
fe5c3aa234
@ -287,6 +287,7 @@ export default defineComponent({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.writing-board {
|
||||
z-index: 8;
|
||||
cursor: none;
|
||||
@include absolute-0();
|
||||
}
|
||||
|
@ -91,6 +91,7 @@ import {
|
||||
FullScreenOne,
|
||||
OffScreenOne,
|
||||
Power,
|
||||
ListView,
|
||||
} from '@icon-park/vue-next'
|
||||
|
||||
export default {
|
||||
@ -205,6 +206,7 @@ export default {
|
||||
app.component('IconFolderClose', FolderClose)
|
||||
app.component('IconElectronicPen', ElectronicPen)
|
||||
app.component('IconPower', Power)
|
||||
app.component('IconListView', ListView)
|
||||
|
||||
// 视频播放器
|
||||
app.component('IconPause', Pause)
|
||||
|
272
src/views/Screen/BaseView.vue
Normal file
272
src/views/Screen/BaseView.vue
Normal file
@ -0,0 +1,272 @@
|
||||
<template>
|
||||
<div class="base-view">
|
||||
<ScreenSlideList
|
||||
:slideWidth="slideWidth"
|
||||
:slideHeight="slideHeight"
|
||||
:animationIndex="animationIndex"
|
||||
:turnSlideToId="turnSlideToId"
|
||||
@mousewheel="$event => mousewheelListener($event)"
|
||||
@touchstart="$event => touchStartListener($event)"
|
||||
@touchend="$event => touchEndListener($event)"
|
||||
v-contextmenu="contextmenus"
|
||||
/>
|
||||
|
||||
<SlideThumbnails
|
||||
v-if="slideThumbnailModelVisible"
|
||||
:turnSlideToIndex="turnSlideToIndex"
|
||||
@close="slideThumbnailModelVisible = false"
|
||||
/>
|
||||
|
||||
<WritingBoardTool
|
||||
:slideWidth="slideWidth"
|
||||
:slideHeight="slideHeight"
|
||||
v-if="writingBoardToolVisible"
|
||||
@close="writingBoardToolVisible = false"
|
||||
/>
|
||||
|
||||
<div class="tools-left">
|
||||
<IconLeftTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="execPrev()" />
|
||||
<IconRightTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="execNext()" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tools-right" :class="{ 'visible': rightToolsVisible }"
|
||||
@mouseleave="rightToolsVisible = false"
|
||||
@mouseenter="rightToolsVisible = true"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="tool-btn page-number" @click="slideThumbnailModelVisible = true">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="画笔工具">
|
||||
<IconWrite class="tool-btn" @click="writingBoardToolVisible = true" />
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="演讲者视图">
|
||||
<IconListView class="tool-btn" @click="changeViewMode('presenter')" />
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" :title="fullscreenState ? '退出全屏' : '进入全屏'">
|
||||
<IconOffScreenOne class="tool-btn" v-if="fullscreenState" @click="exitFullscreen()" />
|
||||
<IconFullScreenOne class="tool-btn" v-else @click="enterFullscreen()" />
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="结束放映">
|
||||
<IconPower class="tool-btn" @click="exitScreening()" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, PropType, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
import { enterFullscreen, exitFullscreen } from '@/utils/fullscreen'
|
||||
import useScreening from '@/hooks/useScreening'
|
||||
import useExecPlay from './hooks/useExecPlay'
|
||||
import useSlideSize from './hooks/useSlideSize'
|
||||
import useFullscreenState from './hooks/useFullscreenState'
|
||||
|
||||
import ScreenSlideList from './ScreenSlideList.vue'
|
||||
import SlideThumbnails from './SlideThumbnails.vue'
|
||||
import WritingBoardTool from './WritingBoardTool.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'screen',
|
||||
components: {
|
||||
ScreenSlideList,
|
||||
SlideThumbnails,
|
||||
WritingBoardTool,
|
||||
},
|
||||
props: {
|
||||
changeViewMode: {
|
||||
type: Function as PropType<(mode: 'base' | 'presenter') => void>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { slides, slideIndex } = storeToRefs(useSlidesStore())
|
||||
|
||||
const {
|
||||
autoPlayTimer,
|
||||
autoPlay,
|
||||
closeAutoPlay,
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
turnPrevSlide,
|
||||
turnNextSlide,
|
||||
turnSlideToIndex,
|
||||
turnSlideToId,
|
||||
execPrev,
|
||||
execNext,
|
||||
animationIndex,
|
||||
} = useExecPlay()
|
||||
|
||||
const { slideWidth, slideHeight } = useSlideSize()
|
||||
const { exitScreening } = useScreening()
|
||||
const { fullscreenState } = useFullscreenState()
|
||||
|
||||
const rightToolsVisible = ref(false)
|
||||
const writingBoardToolVisible = ref(false)
|
||||
const slideThumbnailModelVisible = ref(false)
|
||||
|
||||
|
||||
const contextmenus = (): ContextmenuItem[] => {
|
||||
return [
|
||||
{
|
||||
text: '上一页',
|
||||
subText: '↑ ←',
|
||||
disable: slideIndex.value <= 0,
|
||||
handler: () => turnPrevSlide(),
|
||||
},
|
||||
{
|
||||
text: '下一页',
|
||||
subText: '↓ →',
|
||||
disable: slideIndex.value >= slides.value.length - 1,
|
||||
handler: () => turnNextSlide(),
|
||||
},
|
||||
{
|
||||
text: '第一页',
|
||||
disable: slideIndex.value === 0,
|
||||
handler: () => turnSlideToIndex(0),
|
||||
},
|
||||
{
|
||||
text: '最后一页',
|
||||
disable: slideIndex.value === slides.value.length - 1,
|
||||
handler: () => turnSlideToIndex(slides.value.length - 1),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '显示工具栏',
|
||||
handler: () => rightToolsVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '查看所有幻灯片',
|
||||
handler: () => slideThumbnailModelVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '画笔工具',
|
||||
handler: () => writingBoardToolVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '演讲者视图',
|
||||
handler: () => props.changeViewMode('presenter'),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: autoPlayTimer.value ? '取消自动放映' : '自动放映',
|
||||
handler: autoPlayTimer.value ? closeAutoPlay : autoPlay,
|
||||
},
|
||||
{
|
||||
text: '结束放映',
|
||||
subText: 'ESC',
|
||||
handler: exitScreening,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
slides,
|
||||
slideIndex,
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
animationIndex,
|
||||
contextmenus,
|
||||
execPrev,
|
||||
execNext,
|
||||
turnSlideToIndex,
|
||||
turnSlideToId,
|
||||
slideThumbnailModelVisible,
|
||||
writingBoardToolVisible,
|
||||
rightToolsVisible,
|
||||
fullscreenState,
|
||||
exitScreening,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.tools-left {
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
font-size: 25px;
|
||||
color: #666;
|
||||
z-index: 10;
|
||||
|
||||
.tool-btn {
|
||||
opacity: .35;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: .9;
|
||||
}
|
||||
& + .tool-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.tools-right {
|
||||
height: 66px;
|
||||
position: fixed;
|
||||
bottom: -66px;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
padding: 8px;
|
||||
transition: bottom $transitionDelay;
|
||||
|
||||
&.visible {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 66px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -66px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: $borderRadius;
|
||||
font-size: 25px;
|
||||
background-color: #fff;
|
||||
color: $textColor;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
|
||||
border: 1px solid #e2e6ed;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $themeColor;
|
||||
}
|
||||
|
||||
& + .tool-btn {
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
.page-number {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
354
src/views/Screen/PresenterView.vue
Normal file
354
src/views/Screen/PresenterView.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="presenter-view">
|
||||
<div class="toolbar">
|
||||
<div class="tool-btn" @click="changeViewMode('base')"><IconListView class="tool-icon" /><span>普通视图</span></div>
|
||||
<div class="tool-btn" @click="writingBoardToolVisible = !writingBoardToolVisible"><IconWrite class="tool-icon" /><span>画笔</span></div>
|
||||
<div class="tool-btn" @click="() => fullscreenState ? exitFullscreen() : enterFullscreen()">
|
||||
<IconOffScreenOne class="tool-icon" v-if="fullscreenState" />
|
||||
<IconOffScreenOne class="tool-icon" v-else />
|
||||
<span>{{ fullscreenState ? '退出全屏' : '全屏' }}</span>
|
||||
</div>
|
||||
<Divider class="divider" />
|
||||
<div class="tool-btn" @click="exitScreening()"><IconPower class="tool-icon" /><span>结束放映</span></div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="slide-list-wrap" ref="slideListWrapRef">
|
||||
<ScreenSlideList
|
||||
:slideWidth="slideWidth"
|
||||
:slideHeight="slideHeight"
|
||||
:animationIndex="animationIndex"
|
||||
:turnSlideToId="turnSlideToId"
|
||||
@mousewheel="$event => mousewheelListener($event)"
|
||||
@touchstart="$event => touchStartListener($event)"
|
||||
@touchend="$event => touchEndListener($event)"
|
||||
v-contextmenu="contextmenus"
|
||||
/>
|
||||
<WritingBoardTool
|
||||
:slideWidth="slideWidth"
|
||||
:slideHeight="slideHeight"
|
||||
:position="{
|
||||
left: '75px',
|
||||
top: '5px',
|
||||
}"
|
||||
v-if="writingBoardToolVisible"
|
||||
@close="writingBoardToolVisible = false"
|
||||
/>
|
||||
</div>
|
||||
<div class="thumbnails"
|
||||
ref="thumbnailsRef"
|
||||
@mousewheel.prevent="$event => handleMousewheelThumbnails($event)"
|
||||
>
|
||||
<div
|
||||
class="thumbnail"
|
||||
:class="{ 'active': index === slideIndex }"
|
||||
v-for="(slide, index) in slides"
|
||||
:key="slide.id"
|
||||
@click="turnSlideToIndex(index)"
|
||||
>
|
||||
<ThumbnailSlide :slide="slide" :size="120 / viewportRatio" :visible="index < slidesLoadLimit" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="remark">
|
||||
<div class="header">
|
||||
<span>演讲者备注</span>
|
||||
<span>P {{slideIndex + 1}} / {{slides.length}}</span>
|
||||
</div>
|
||||
<div class="remark-content" :style="{ fontSize: remarkFontSize + 'px' }" v-html="currentSlideRemark"></div>
|
||||
<div class="remark-scale">
|
||||
<div :class="['scale-btn', { 'disable': remarkFontSize === 12 }]" @click="setRemarkFontSize(remarkFontSize - 2)"><IconMinus /></div>
|
||||
<div :class="['scale-btn', { 'disable': remarkFontSize === 40 }]" @click="setRemarkFontSize(remarkFontSize + 2)"><IconPlus /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, nextTick, ref, watch, PropType } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
import { enterFullscreen, exitFullscreen } from '@/utils/fullscreen'
|
||||
import { parseText2Paragraphs } from '@/utils/textParser'
|
||||
import useScreening from '@/hooks/useScreening'
|
||||
import useLoadSlides from '@/hooks/useLoadSlides'
|
||||
import useExecPlay from './hooks/useExecPlay'
|
||||
import useSlideSize from './hooks/useSlideSize'
|
||||
import useFullscreenState from './hooks/useFullscreenState'
|
||||
|
||||
import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
|
||||
import ScreenSlideList from './ScreenSlideList.vue'
|
||||
import WritingBoardTool from './WritingBoardTool.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'presenter-view',
|
||||
components: {
|
||||
ScreenSlideList,
|
||||
ThumbnailSlide,
|
||||
WritingBoardTool,
|
||||
},
|
||||
props: {
|
||||
changeViewMode: {
|
||||
type: Function as PropType<(mode: 'base' | 'presenter') => void>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { slides, slideIndex, viewportRatio, currentSlide } = storeToRefs(useSlidesStore())
|
||||
|
||||
const slideListWrapRef = ref<HTMLElement>()
|
||||
const thumbnailsRef = ref<HTMLElement>()
|
||||
const writingBoardToolVisible = ref(false)
|
||||
|
||||
const {
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
turnPrevSlide,
|
||||
turnNextSlide,
|
||||
turnSlideToIndex,
|
||||
turnSlideToId,
|
||||
animationIndex,
|
||||
} = useExecPlay()
|
||||
|
||||
const { slideWidth, slideHeight } = useSlideSize(slideListWrapRef)
|
||||
const { exitScreening } = useScreening()
|
||||
const { slidesLoadLimit } = useLoadSlides()
|
||||
const { fullscreenState } = useFullscreenState()
|
||||
|
||||
const remarkFontSize = ref(16)
|
||||
const currentSlideRemark = computed(() => {
|
||||
return parseText2Paragraphs(currentSlide.value.remark || '无备注')
|
||||
})
|
||||
|
||||
const handleMousewheelThumbnails = (e: WheelEvent) => {
|
||||
if (!thumbnailsRef.value) return
|
||||
thumbnailsRef.value.scrollBy(e.deltaY, 0)
|
||||
}
|
||||
|
||||
const setRemarkFontSize = (fontSize: number) => {
|
||||
if (fontSize < 12 || fontSize > 40) return
|
||||
remarkFontSize.value = fontSize
|
||||
}
|
||||
|
||||
watch(slideIndex, () => {
|
||||
nextTick(() => {
|
||||
if (!thumbnailsRef.value) return
|
||||
|
||||
const activeThumbnailRef: HTMLElement | null = thumbnailsRef.value.querySelector('.thumbnail.active')
|
||||
if (!activeThumbnailRef) return
|
||||
|
||||
const width = thumbnailsRef.value.offsetWidth
|
||||
const offsetLeft = activeThumbnailRef.offsetLeft
|
||||
thumbnailsRef.value.scrollTo({ left: offsetLeft - width / 2, behavior: 'smooth' })
|
||||
})
|
||||
})
|
||||
|
||||
const contextmenus = (): ContextmenuItem[] => {
|
||||
return [
|
||||
{
|
||||
text: '上一页',
|
||||
subText: '↑ ←',
|
||||
disable: slideIndex.value <= 0,
|
||||
handler: () => turnPrevSlide(),
|
||||
},
|
||||
{
|
||||
text: '下一页',
|
||||
subText: '↓ →',
|
||||
disable: slideIndex.value >= slides.value.length - 1,
|
||||
handler: () => turnNextSlide(),
|
||||
},
|
||||
{
|
||||
text: '第一页',
|
||||
disable: slideIndex.value === 0,
|
||||
handler: () => turnSlideToIndex(0),
|
||||
},
|
||||
{
|
||||
text: '最后一页',
|
||||
disable: slideIndex.value === slides.value.length - 1,
|
||||
handler: () => turnSlideToIndex(slides.value.length - 1),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '画笔工具',
|
||||
handler: () => writingBoardToolVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '普通视图',
|
||||
handler: () => props.changeViewMode('base'),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '结束放映',
|
||||
subText: 'ESC',
|
||||
handler: exitScreening,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
slides,
|
||||
slideIndex,
|
||||
viewportRatio,
|
||||
remarkFontSize,
|
||||
currentSlideRemark,
|
||||
setRemarkFontSize,
|
||||
slideListWrapRef,
|
||||
thumbnailsRef,
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
animationIndex,
|
||||
turnSlideToId,
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
turnSlideToIndex,
|
||||
contextmenus,
|
||||
slidesLoadLimit,
|
||||
handleMousewheelThumbnails,
|
||||
exitScreening,
|
||||
fullscreenState,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
writingBoardToolVisible,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.presenter-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
.toolbar {
|
||||
width: 70px;
|
||||
height: 100%;
|
||||
background-color: #fff;
|
||||
border-right: solid 1px #eee;
|
||||
font-size: 12px;
|
||||
margin: 20px 0;
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
||||
& + .tool-btn {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $themeColor;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 70%;
|
||||
min-width: 70%;
|
||||
margin: 24px 15%;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
margin-bottom: 8px;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
.content {
|
||||
width: calc(100% - 430px);
|
||||
height: 100%;
|
||||
background-color: #1d1d1d;
|
||||
}
|
||||
.slide-list-wrap {
|
||||
height: calc(100% - 190px);
|
||||
margin: 20px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.thumbnails {
|
||||
height: 150px;
|
||||
padding: 15px;
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-top: solid 1px #3a3a3a;
|
||||
}
|
||||
.thumbnail {
|
||||
display: inline-block;
|
||||
outline: 2px solid #aaa;
|
||||
|
||||
& + .thumbnail {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
outline-color: $themeColor;
|
||||
}
|
||||
|
||||
&.active {
|
||||
outline-width: 3px;
|
||||
outline-color: $themeColor;
|
||||
}
|
||||
}
|
||||
.remark {
|
||||
width: 360px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: #2a2a2a;
|
||||
border-left: solid 1px #3a3a3a;
|
||||
color: #fff;
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
padding: 0 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.remark-content {
|
||||
height: calc(100% - 60px);
|
||||
padding: 20px;
|
||||
line-height: 1.5;
|
||||
@include overflow-overlay();
|
||||
}
|
||||
|
||||
.remark-scale {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
font-size: 22px;
|
||||
display: flex;
|
||||
}
|
||||
.scale-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&.disable {
|
||||
color: #666;
|
||||
cursor: no-drop;
|
||||
}
|
||||
|
||||
&:not(.disable):hover {
|
||||
background-color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
@ -76,6 +76,7 @@ export default defineComponent({
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.background {
|
||||
width: 100%;
|
||||
|
153
src/views/Screen/ScreenSlideList.vue
Normal file
153
src/views/Screen/ScreenSlideList.vue
Normal file
@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<div class="screen-slide-list">
|
||||
<div
|
||||
:class="[
|
||||
'slide-item',
|
||||
`turning-mode-${slide.turningMode || 'slideY'}`,
|
||||
{
|
||||
'current': index === slideIndex,
|
||||
'before': index < slideIndex,
|
||||
'after': index > slideIndex,
|
||||
'hide': (index === slideIndex - 1 || index === slideIndex + 1) && slide.turningMode !== currentSlide.turningMode,
|
||||
}
|
||||
]"
|
||||
v-for="(slide, index) in slides"
|
||||
:key="slide.id"
|
||||
>
|
||||
<div
|
||||
class="slide-content"
|
||||
:style="{
|
||||
width: slideWidth + 'px',
|
||||
height: slideHeight + 'px',
|
||||
}"
|
||||
v-if="Math.abs(slideIndex - index) < 2"
|
||||
>
|
||||
<ScreenSlide
|
||||
:slide="slide"
|
||||
:scale="scale"
|
||||
:animationIndex="animationIndex"
|
||||
:turnSlideToId="turnSlideToId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, PropType, provide } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { VIEWPORT_SIZE } from '@/configs/canvas'
|
||||
|
||||
import ScreenSlide from './ScreenSlide.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'screen-slide-list',
|
||||
components: {
|
||||
ScreenSlide,
|
||||
},
|
||||
props: {
|
||||
slideWidth: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
slideHeight: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
animationIndex: {
|
||||
type: Number,
|
||||
default: -1,
|
||||
},
|
||||
turnSlideToId: {
|
||||
type: Function as PropType<(id: string) => void>,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { slides, slideIndex, currentSlide } = storeToRefs(useSlidesStore())
|
||||
|
||||
const scale = computed(() => props.slideWidth / VIEWPORT_SIZE)
|
||||
provide('slideScale', scale)
|
||||
|
||||
return {
|
||||
slides,
|
||||
slideIndex,
|
||||
currentSlide,
|
||||
scale,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.screen-slide-list {
|
||||
background: #1d1d1d;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.slide-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.current {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.turning-mode-no {
|
||||
&.before {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
&.turning-mode-fade {
|
||||
transition: opacity .75s;
|
||||
&.before {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
&.after {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
&.turning-mode-slideX {
|
||||
transition: transform .35s;
|
||||
&.before {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
&.turning-mode-slideY {
|
||||
transition: transform .35s;
|
||||
&.before {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
.slide-content {
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
@ -14,7 +14,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="tools">
|
||||
<div class="tools" :style="position">
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="画笔">
|
||||
<div class="btn" :class="{ 'active': writingBoardModel === 'pen' }" @click="changePen()"><IconWrite class="icon" /></div>
|
||||
</Tooltip>
|
||||
@ -45,11 +45,18 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent, ref } from 'vue'
|
||||
import { defineComponent, PropType, ref } from 'vue'
|
||||
import WritingBoard from '@/components/WritingBoard.vue'
|
||||
|
||||
const writingBoardColors = ['#000000', '#ffffff', '#1e497b', '#4e81bb', '#e2534d', '#9aba60', '#8165a0', '#47acc5', '#f9974c']
|
||||
|
||||
interface Position {
|
||||
left?: number | string;
|
||||
right?: number | string;
|
||||
top?: number | string;
|
||||
bottom?: number | string;
|
||||
}
|
||||
|
||||
export default defineComponent({
|
||||
name: 'writing-board-tool',
|
||||
emits: ['close'],
|
||||
@ -65,6 +72,13 @@ export default defineComponent({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
position: {
|
||||
type: Object as PropType<Position>,
|
||||
default: () => ({
|
||||
right: '5px',
|
||||
bottom: '5px',
|
||||
})
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const writingBoardRef = ref()
|
||||
@ -130,8 +144,6 @@ export default defineComponent({
|
||||
.tools {
|
||||
height: 50px;
|
||||
position: fixed;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
z-index: 11;
|
||||
padding: 12px;
|
||||
background-color: #eee;
|
||||
|
171
src/views/Screen/hooks/useExecPlay.ts
Normal file
171
src/views/Screen/hooks/useExecPlay.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { throttle } from 'lodash'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { KEYS } from '@/configs/hotkey'
|
||||
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
export default () => {
|
||||
const slidesStore = useSlidesStore()
|
||||
const { slides, slideIndex, currentSlide } = storeToRefs(slidesStore)
|
||||
|
||||
// 当前页的元素动画列表和当前执行到的位置
|
||||
const animations = computed(() => currentSlide.value.animations || [])
|
||||
const animationIndex = ref(0)
|
||||
|
||||
// 执行元素的入场动画
|
||||
const runAnimation = () => {
|
||||
const prefix = 'animate__'
|
||||
const animation = animations.value[animationIndex.value]
|
||||
animationIndex.value += 1
|
||||
|
||||
const elRef = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
|
||||
if (elRef) {
|
||||
const animationName = `${prefix}${animation.type}`
|
||||
document.documentElement.style.setProperty('--animate-duration', `${animation.duration}ms`)
|
||||
elRef.classList.add(`${prefix}animated`, animationName)
|
||||
|
||||
const handleAnimationEnd = () => {
|
||||
document.documentElement.style.removeProperty('--animate-duration')
|
||||
elRef.classList.remove(`${prefix}animated`, animationName)
|
||||
}
|
||||
elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭自动播放
|
||||
const autoPlayTimer = ref(0)
|
||||
const closeAutoPlay = () => {
|
||||
if (autoPlayTimer.value) {
|
||||
clearInterval(autoPlayTimer.value)
|
||||
autoPlayTimer.value = 0
|
||||
}
|
||||
}
|
||||
onUnmounted(closeAutoPlay)
|
||||
|
||||
const throttleMassage = throttle(function(msg) {
|
||||
message.success(msg)
|
||||
}, 1000, { leading: true, trailing: false })
|
||||
|
||||
// 向上/向下播放
|
||||
// 遇到元素动画时,优先执行动画播放,无动画则执行翻页
|
||||
// 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画
|
||||
const execPrev = () => {
|
||||
if (animations.value.length && animationIndex.value > 0) {
|
||||
animationIndex.value -= 1
|
||||
}
|
||||
else if (slideIndex.value > 0) {
|
||||
slidesStore.updateSlideIndex(slideIndex.value - 1)
|
||||
const lastIndex = animations.value ? animations.value.length : 0
|
||||
animationIndex.value = lastIndex
|
||||
}
|
||||
else {
|
||||
throttleMassage('已经是第一页了')
|
||||
}
|
||||
}
|
||||
const execNext = () => {
|
||||
if (animations.value.length && animationIndex.value < animations.value.length) {
|
||||
runAnimation()
|
||||
}
|
||||
else if (slideIndex.value < slides.value.length - 1) {
|
||||
slidesStore.updateSlideIndex(slideIndex.value + 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
else {
|
||||
throttleMassage('已经是最后一页了')
|
||||
closeAutoPlay()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动播放
|
||||
const autoPlay = () => {
|
||||
closeAutoPlay()
|
||||
message.success('开始自动放映')
|
||||
autoPlayTimer.value = setInterval(execNext, 2500)
|
||||
}
|
||||
|
||||
// 鼠标滚动翻页
|
||||
const mousewheelListener = throttle(function(e: WheelEvent) {
|
||||
if (e.deltaY < 0) execPrev()
|
||||
else if (e.deltaY > 0) execNext()
|
||||
}, 500, { leading: true, trailing: false })
|
||||
|
||||
// 触摸屏上下滑动翻页
|
||||
const touchInfo = ref<{ x: number; y: number; } | null>(null)
|
||||
|
||||
const touchStartListener = (e: TouchEvent) => {
|
||||
touchInfo.value = {
|
||||
x: e.changedTouches[0].pageX,
|
||||
y: e.changedTouches[0].pageY,
|
||||
}
|
||||
}
|
||||
const touchEndListener = (e: TouchEvent) => {
|
||||
if (!touchInfo.value) return
|
||||
|
||||
const offsetX = Math.abs(touchInfo.value.x - e.changedTouches[0].pageX)
|
||||
const offsetY = e.changedTouches[0].pageY - touchInfo.value.y
|
||||
|
||||
if ( Math.abs(offsetY) > offsetX && Math.abs(offsetY) > 50 ) {
|
||||
touchInfo.value = null
|
||||
|
||||
if (offsetY > 0) execPrev()
|
||||
else execNext()
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷键翻页
|
||||
const keydownListener = (e: KeyboardEvent) => {
|
||||
const key = e.key.toUpperCase()
|
||||
|
||||
if (key === KEYS.UP || key === KEYS.LEFT) execPrev()
|
||||
else if (
|
||||
key === KEYS.DOWN ||
|
||||
key === KEYS.RIGHT ||
|
||||
key === KEYS.SPACE ||
|
||||
key === KEYS.ENTER
|
||||
) execNext()
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('keydown', keydownListener))
|
||||
onUnmounted(() => document.removeEventListener('keydown', keydownListener))
|
||||
|
||||
// 切换到上一张/上一张幻灯片(无视元素的入场动画)
|
||||
const turnPrevSlide = () => {
|
||||
slidesStore.updateSlideIndex(slideIndex.value - 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
const turnNextSlide = () => {
|
||||
slidesStore.updateSlideIndex(slideIndex.value + 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
|
||||
// 切换幻灯片到指定的页面
|
||||
const turnSlideToIndex = (index: number) => {
|
||||
slidesStore.updateSlideIndex(index)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
const turnSlideToId = (id: string) => {
|
||||
const index = slides.value.findIndex(slide => slide.id === id)
|
||||
if (index !== -1) {
|
||||
slidesStore.updateSlideIndex(index)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
autoPlayTimer,
|
||||
autoPlay,
|
||||
closeAutoPlay,
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
turnPrevSlide,
|
||||
turnNextSlide,
|
||||
turnSlideToIndex,
|
||||
turnSlideToId,
|
||||
execPrev,
|
||||
execNext,
|
||||
animationIndex,
|
||||
}
|
||||
}
|
15
src/views/Screen/hooks/useFullscreenState.ts
Normal file
15
src/views/Screen/hooks/useFullscreenState.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { isFullscreen } from '@/utils/fullscreen'
|
||||
|
||||
export default () => {
|
||||
const fullscreenState = ref(true)
|
||||
|
||||
const windowResizeListener = () => fullscreenState.value = isFullscreen()
|
||||
|
||||
onMounted(() => window.addEventListener('resize', windowResizeListener))
|
||||
onUnmounted(() => window.removeEventListener('resize', windowResizeListener))
|
||||
|
||||
return {
|
||||
fullscreenState,
|
||||
}
|
||||
}
|
47
src/views/Screen/hooks/useSlideSize.ts
Normal file
47
src/views/Screen/hooks/useSlideSize.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { onMounted, onUnmounted, Ref, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
|
||||
export default (wrapRef?: Ref<HTMLElement | undefined>) => {
|
||||
const slidesStore = useSlidesStore()
|
||||
const { viewportRatio } = storeToRefs(slidesStore)
|
||||
|
||||
const slideWidth = ref(0)
|
||||
const slideHeight = ref(0)
|
||||
|
||||
// 计算和更新幻灯片内容的尺寸(按比例自适应屏幕)
|
||||
const setSlideContentSize = () => {
|
||||
const slideWrapRef = wrapRef?.value || document.body
|
||||
const winWidth = slideWrapRef.clientWidth
|
||||
const winHeight = slideWrapRef.clientHeight
|
||||
let width, height
|
||||
|
||||
if (winHeight / winWidth === viewportRatio.value) {
|
||||
width = winWidth
|
||||
height = winHeight
|
||||
}
|
||||
else if (winHeight / winWidth > viewportRatio.value) {
|
||||
width = winWidth
|
||||
height = winWidth * viewportRatio.value
|
||||
}
|
||||
else {
|
||||
width = winHeight / viewportRatio.value
|
||||
height = winHeight
|
||||
}
|
||||
slideWidth.value = width
|
||||
slideHeight.value = height
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setSlideContentSize()
|
||||
window.addEventListener('resize', setSlideContentSize)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', setSlideContentSize)
|
||||
})
|
||||
|
||||
return {
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
}
|
||||
}
|
@ -1,385 +1,45 @@
|
||||
<template>
|
||||
<div class="pptist-screen">
|
||||
<div
|
||||
class="slide-list"
|
||||
@mousewheel="$event => mousewheelListener($event)"
|
||||
@touchstart="$event => touchStartListener($event)"
|
||||
@touchend="$event => touchEndListener($event)"
|
||||
v-contextmenu="contextmenus"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'slide-item',
|
||||
`turning-mode-${slide.turningMode || 'slideY'}`,
|
||||
{
|
||||
'current': index === slideIndex,
|
||||
'before': index < slideIndex,
|
||||
'after': index > slideIndex,
|
||||
'hide': (index === slideIndex - 1 || index === slideIndex + 1) && slide.turningMode !== currentSlide.turningMode,
|
||||
}
|
||||
]"
|
||||
v-for="(slide, index) in slides"
|
||||
:key="slide.id"
|
||||
>
|
||||
<div
|
||||
class="slide-content"
|
||||
:style="{
|
||||
width: slideWidth + 'px',
|
||||
height: slideHeight + 'px',
|
||||
}"
|
||||
v-if="Math.abs(slideIndex - index) < 2"
|
||||
>
|
||||
<ScreenSlide
|
||||
:slide="slide"
|
||||
:scale="scale"
|
||||
:animationIndex="animationIndex"
|
||||
:turnSlideToId="turnSlideToId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SlideThumbnails
|
||||
v-if="slideThumbnailModelVisible"
|
||||
:turnSlideToIndex="turnSlideToIndex"
|
||||
@close="slideThumbnailModelVisible = false"
|
||||
/>
|
||||
|
||||
<WritingBoardTool
|
||||
:slideWidth="slideWidth"
|
||||
:slideHeight="slideHeight"
|
||||
v-if="writingBoardToolVisible"
|
||||
@close="writingBoardToolVisible = false"
|
||||
/>
|
||||
|
||||
<div class="tools-left">
|
||||
<IconLeftTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="execPrev()" />
|
||||
<IconRightTwo class="tool-btn" theme="two-tone" :fill="['#111', '#fff']" @click="execNext()" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tools-right" :class="{ 'visible': rightToolsVisible }"
|
||||
@mouseleave="rightToolsVisible = false"
|
||||
@mouseenter="rightToolsVisible = true"
|
||||
>
|
||||
<div class="content">
|
||||
<div class="tool-btn page-number" @click="slideThumbnailModelVisible = true">幻灯片 {{slideIndex + 1}} / {{slides.length}}</div>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="画笔工具">
|
||||
<IconWrite class="tool-btn" @click="writingBoardToolVisible = true" />
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" :title="fullscreenState ? '退出全屏' : '进入全屏'">
|
||||
<IconOffScreenOne class="tool-btn" v-if="fullscreenState" @click="exitFullscreen()" />
|
||||
<IconFullScreenOne class="tool-btn" v-else @click="enterFullscreen()" />
|
||||
</Tooltip>
|
||||
<Tooltip :mouseLeaveDelay="0" :mouseEnterDelay="0.3" title="结束放映">
|
||||
<IconPower class="tool-btn" @click="exitScreening()" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<BaseView :changeViewMode="changeViewMode" v-if="viewMode === 'base'" />
|
||||
<PresenterView :changeViewMode="changeViewMode" v-else-if="viewMode === 'presenter'" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { computed, defineComponent, onMounted, onUnmounted, provide, ref } from 'vue'
|
||||
import { throttle } from 'lodash'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useSlidesStore } from '@/store'
|
||||
import { VIEWPORT_SIZE } from '@/configs/canvas'
|
||||
import { defineComponent, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { KEYS } from '@/configs/hotkey'
|
||||
import { ContextmenuItem } from '@/components/Contextmenu/types'
|
||||
import { isFullscreen, enterFullscreen, exitFullscreen } from '@/utils/fullscreen'
|
||||
import useScreening from '@/hooks/useScreening'
|
||||
|
||||
import { message } from 'ant-design-vue'
|
||||
|
||||
import ScreenSlide from './ScreenSlide.vue'
|
||||
import SlideThumbnails from './SlideThumbnails.vue'
|
||||
import WritingBoardTool from './WritingBoardTool.vue'
|
||||
import BaseView from './BaseView.vue'
|
||||
import PresenterView from './PresenterView.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'screen',
|
||||
name: 'pptist-screen',
|
||||
components: {
|
||||
ScreenSlide,
|
||||
SlideThumbnails,
|
||||
WritingBoardTool,
|
||||
BaseView,
|
||||
PresenterView,
|
||||
},
|
||||
setup() {
|
||||
const slidesStore = useSlidesStore()
|
||||
const { slides, slideIndex, currentSlide, viewportRatio } = storeToRefs(slidesStore)
|
||||
const viewMode = ref<'base' | 'presenter'>('base')
|
||||
|
||||
const slideWidth = ref(0)
|
||||
const slideHeight = ref(0)
|
||||
|
||||
const scale = computed(() => slideWidth.value / VIEWPORT_SIZE)
|
||||
|
||||
const rightToolsVisible = ref(false)
|
||||
const slideThumbnailModelVisible = ref(false)
|
||||
const writingBoardToolVisible = ref(false)
|
||||
const changeViewMode = (mode: 'base' | 'presenter') => {
|
||||
viewMode.value = mode
|
||||
}
|
||||
|
||||
const { exitScreening } = useScreening()
|
||||
|
||||
// 计算和更新幻灯片内容的尺寸(按比例自适应屏幕)
|
||||
const setSlideContentSize = () => {
|
||||
const winWidth = document.body.clientWidth
|
||||
const winHeight = document.body.clientHeight
|
||||
let width, height
|
||||
|
||||
if (winHeight / winWidth === viewportRatio.value) {
|
||||
width = winWidth
|
||||
height = winHeight
|
||||
}
|
||||
else if (winHeight / winWidth > viewportRatio.value) {
|
||||
width = winWidth
|
||||
height = winWidth * viewportRatio.value
|
||||
}
|
||||
else {
|
||||
width = winHeight / viewportRatio.value
|
||||
height = winHeight
|
||||
}
|
||||
slideWidth.value = width
|
||||
slideHeight.value = height
|
||||
}
|
||||
|
||||
// 窗口尺寸变化监听:窗口发生变化时更新幻灯片的大小
|
||||
const fullscreenState = ref(true)
|
||||
|
||||
const windowResizeListener = () => {
|
||||
setSlideContentSize()
|
||||
fullscreenState.value = isFullscreen()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setSlideContentSize()
|
||||
window.addEventListener('resize', windowResizeListener)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', windowResizeListener)
|
||||
})
|
||||
|
||||
// 当前页的元素动画列表和当前执行到的位置
|
||||
const animations = computed(() => currentSlide.value.animations || [])
|
||||
const animationIndex = ref(0)
|
||||
|
||||
// 执行元素的入场动画
|
||||
const runAnimation = () => {
|
||||
const prefix = 'animate__'
|
||||
const animation = animations.value[animationIndex.value]
|
||||
animationIndex.value += 1
|
||||
|
||||
const elRef = document.querySelector(`#screen-element-${animation.elId} [class^=base-element-]`)
|
||||
if (elRef) {
|
||||
const animationName = `${prefix}${animation.type}`
|
||||
document.documentElement.style.setProperty('--animate-duration', `${animation.duration}ms`)
|
||||
elRef.classList.add(`${prefix}animated`, animationName)
|
||||
|
||||
const handleAnimationEnd = () => {
|
||||
document.documentElement.style.removeProperty('--animate-duration')
|
||||
elRef.classList.remove(`${prefix}animated`, animationName)
|
||||
}
|
||||
elRef.addEventListener('animationend', handleAnimationEnd, { once: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭自动播放
|
||||
const autoPlayTimer = ref(0)
|
||||
const closeAutoPlay = () => {
|
||||
if (autoPlayTimer.value) {
|
||||
clearInterval(autoPlayTimer.value)
|
||||
autoPlayTimer.value = 0
|
||||
}
|
||||
}
|
||||
onUnmounted(closeAutoPlay)
|
||||
|
||||
const throttleMassage = throttle(function(msg) {
|
||||
message.success(msg)
|
||||
}, 1000, { leading: true, trailing: false })
|
||||
|
||||
// 向上/向下播放
|
||||
// 遇到元素动画时,优先执行动画播放,无动画则执行翻页
|
||||
// 向上播放遇到动画时,仅撤销到动画执行前的状态,不需要反向播放动画
|
||||
const execPrev = () => {
|
||||
if (animations.value.length && animationIndex.value > 0) {
|
||||
animationIndex.value -= 1
|
||||
}
|
||||
else if (slideIndex.value > 0) {
|
||||
slidesStore.updateSlideIndex(slideIndex.value - 1)
|
||||
const lastIndex = animations.value ? animations.value.length : 0
|
||||
animationIndex.value = lastIndex
|
||||
}
|
||||
else {
|
||||
throttleMassage('已经是第一页了')
|
||||
}
|
||||
}
|
||||
const execNext = () => {
|
||||
if (animations.value.length && animationIndex.value < animations.value.length) {
|
||||
runAnimation()
|
||||
}
|
||||
else if (slideIndex.value < slides.value.length - 1) {
|
||||
slidesStore.updateSlideIndex(slideIndex.value + 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
else {
|
||||
throttleMassage('已经是最后一页了')
|
||||
closeAutoPlay()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动播放
|
||||
const autoPlay = () => {
|
||||
closeAutoPlay()
|
||||
message.success('开始自动放映')
|
||||
autoPlayTimer.value = setInterval(execNext, 2500)
|
||||
}
|
||||
|
||||
// 鼠标滚动翻页
|
||||
const mousewheelListener = throttle(function(e: WheelEvent) {
|
||||
if (e.deltaY < 0) execPrev()
|
||||
else if (e.deltaY > 0) execNext()
|
||||
}, 500, { leading: true, trailing: false })
|
||||
|
||||
// 触摸屏上下滑动翻页
|
||||
const touchInfo = ref<{ x: number; y: number; } | null>(null)
|
||||
|
||||
const touchStartListener = (e: TouchEvent) => {
|
||||
touchInfo.value = {
|
||||
x: e.changedTouches[0].pageX,
|
||||
y: e.changedTouches[0].pageY,
|
||||
}
|
||||
}
|
||||
const touchEndListener = (e: TouchEvent) => {
|
||||
if (!touchInfo.value) return
|
||||
|
||||
const offsetX = Math.abs(touchInfo.value.x - e.changedTouches[0].pageX)
|
||||
const offsetY = e.changedTouches[0].pageY - touchInfo.value.y
|
||||
|
||||
if ( Math.abs(offsetY) > offsetX && Math.abs(offsetY) > 50 ) {
|
||||
touchInfo.value = null
|
||||
|
||||
if (offsetY > 0) execPrev()
|
||||
else execNext()
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷键翻页/退出
|
||||
// 快捷键退出放映
|
||||
const keydownListener = (e: KeyboardEvent) => {
|
||||
const key = e.key.toUpperCase()
|
||||
|
||||
if (key === KEYS.ESC) exitScreening()
|
||||
else if (key === KEYS.UP || key === KEYS.LEFT) execPrev()
|
||||
else if (
|
||||
key === KEYS.DOWN ||
|
||||
key === KEYS.RIGHT ||
|
||||
key === KEYS.SPACE ||
|
||||
key === KEYS.ENTER
|
||||
) execNext()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', keydownListener)
|
||||
})
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', keydownListener)
|
||||
})
|
||||
|
||||
// 切换到上一张/上一张幻灯片(无视元素的入场动画)
|
||||
const turnPrevSlide = () => {
|
||||
slidesStore.updateSlideIndex(slideIndex.value - 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
const turnNextSlide = () => {
|
||||
slidesStore.updateSlideIndex(slideIndex.value + 1)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
|
||||
// 切换幻灯片到指定的页面
|
||||
const turnSlideToIndex = (index: number) => {
|
||||
slideThumbnailModelVisible.value = false
|
||||
slidesStore.updateSlideIndex(index)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
const turnSlideToId = (id: string) => {
|
||||
const index = slides.value.findIndex(slide => slide.id === id)
|
||||
if (index !== -1) {
|
||||
slidesStore.updateSlideIndex(index)
|
||||
animationIndex.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const contextmenus = (): ContextmenuItem[] => {
|
||||
return [
|
||||
{
|
||||
text: '上一页',
|
||||
subText: '↑ ←',
|
||||
disable: slideIndex.value <= 0,
|
||||
handler: () => turnPrevSlide(),
|
||||
},
|
||||
{
|
||||
text: '下一页',
|
||||
subText: '↓ →',
|
||||
disable: slideIndex.value >= slides.value.length - 1,
|
||||
handler: () => turnNextSlide(),
|
||||
},
|
||||
{
|
||||
text: '第一页',
|
||||
disable: slideIndex.value === 0,
|
||||
handler: () => turnSlideToIndex(0),
|
||||
},
|
||||
{
|
||||
text: '最后一页',
|
||||
disable: slideIndex.value === slides.value.length - 1,
|
||||
handler: () => turnSlideToIndex(slides.value.length - 1),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: '显示工具栏',
|
||||
handler: () => rightToolsVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '查看所有幻灯片',
|
||||
handler: () => slideThumbnailModelVisible.value = true,
|
||||
},
|
||||
{
|
||||
text: '画笔工具',
|
||||
handler: () => writingBoardToolVisible.value = true,
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
text: autoPlayTimer.value ? '取消自动放映' : '自动放映',
|
||||
handler: autoPlayTimer.value ? closeAutoPlay : autoPlay,
|
||||
},
|
||||
{
|
||||
text: '结束放映',
|
||||
subText: 'ESC',
|
||||
handler: exitScreening,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
provide('slideScale', scale)
|
||||
onMounted(() => document.addEventListener('keydown', keydownListener))
|
||||
onUnmounted(() => document.removeEventListener('keydown', keydownListener))
|
||||
|
||||
return {
|
||||
slides,
|
||||
slideIndex,
|
||||
currentSlide,
|
||||
slideWidth,
|
||||
slideHeight,
|
||||
scale,
|
||||
mousewheelListener,
|
||||
touchStartListener,
|
||||
touchEndListener,
|
||||
animationIndex,
|
||||
contextmenus,
|
||||
execPrev,
|
||||
execNext,
|
||||
turnSlideToIndex,
|
||||
turnSlideToId,
|
||||
slideThumbnailModelVisible,
|
||||
writingBoardToolVisible,
|
||||
rightToolsVisible,
|
||||
fullscreenState,
|
||||
exitScreening,
|
||||
enterFullscreen,
|
||||
exitFullscreen,
|
||||
viewMode,
|
||||
changeViewMode,
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -389,151 +49,5 @@ export default defineComponent({
|
||||
.pptist-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: #111;
|
||||
}
|
||||
.slide-list {
|
||||
background: #1d1d1d;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.slide-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
&.current {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&.turning-mode-no {
|
||||
&.before {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
&.turning-mode-fade {
|
||||
transition: opacity .75s;
|
||||
&.before {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
&.after {
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
&.turning-mode-slideX {
|
||||
transition: transform .35s;
|
||||
&.before {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
&.turning-mode-slideY {
|
||||
transition: transform .35s;
|
||||
&.before {
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
&.after {
|
||||
transform: translateY(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
.slide-content {
|
||||
background-color: #fff;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tools-left {
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
font-size: 25px;
|
||||
color: #666;
|
||||
z-index: 10;
|
||||
|
||||
.tool-btn {
|
||||
opacity: .35;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
opacity: .9;
|
||||
}
|
||||
& + .tool-btn {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.tools-right {
|
||||
height: 66px;
|
||||
position: fixed;
|
||||
bottom: -66px;
|
||||
right: 0;
|
||||
z-index: 5;
|
||||
padding: 8px;
|
||||
transition: bottom $transitionDelay;
|
||||
|
||||
&.visible {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 100%;
|
||||
height: 66px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -66px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: $borderRadius;
|
||||
font-size: 25px;
|
||||
background-color: #fff;
|
||||
color: $textColor;
|
||||
padding: 8px 10px;
|
||||
box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%);
|
||||
border: 1px solid #e2e6ed;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: $themeColor;
|
||||
}
|
||||
|
||||
& + .tool-btn {
|
||||
margin-left: 15px;
|
||||
}
|
||||
}
|
||||
.page-number {
|
||||
font-size: 13px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user