feat: 添加放映演讲者视图

This commit is contained in:
pipipi-pikachu 2022-03-27 16:39:42 +08:00
parent 7ea0fd4286
commit fe5c3aa234
11 changed files with 1049 additions and 507 deletions

View File

@ -287,6 +287,7 @@ export default defineComponent({
<style lang="scss" scoped>
.writing-board {
z-index: 8;
cursor: none;
@include absolute-0();
}

View File

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

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

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

View File

@ -76,6 +76,7 @@ export default defineComponent({
top: 0;
left: 0;
transform-origin: 0 0;
overflow: hidden;
}
.background {
width: 100%;

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

View File

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

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

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

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

View File

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