PresenterView.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341
  1. <template>
  2. <div class="presenter-view">
  3. <div class="toolbar">
  4. <div class="tool-btn" @click="changeViewMode('base')"><IconListView class="tool-icon" /><span>普通视图</span></div>
  5. <div class="tool-btn" :class="{ 'active': writingBoardToolVisible }" @click="writingBoardToolVisible = !writingBoardToolVisible"><IconWrite class="tool-icon" /><span>画笔</span></div>
  6. <div class="tool-btn" :class="{ 'active': laserPen }" @click="laserPen = !laserPen"><IconMagic class="tool-icon" /><span>激光笔</span></div>
  7. <div class="tool-btn" :class="{ 'active': timerlVisible }" @click="timerlVisible = !timerlVisible"><IconStopwatchStart class="tool-icon" /><span>计时器</span></div>
  8. <div class="tool-btn" @click="() => fullscreenState ? manualExitFullscreen() : enterFullscreen()">
  9. <IconOffScreenOne class="tool-icon" v-if="fullscreenState" />
  10. <IconFullScreenOne class="tool-icon" v-else />
  11. <span>{{ fullscreenState ? '退出全屏' : '全屏' }}</span>
  12. </div>
  13. <Divider class="divider" />
  14. <div class="tool-btn" @click="exitScreening()"><IconPower class="tool-icon" /><span>结束放映</span></div>
  15. </div>
  16. <div class="content">
  17. <div
  18. class="slide-list-wrap"
  19. :class="{ 'laser-pen': laserPen }"
  20. ref="slideListWrapRef"
  21. >
  22. <ScreenSlideList
  23. :slideWidth="slideWidth"
  24. :slideHeight="slideHeight"
  25. :animationIndex="animationIndex"
  26. :turnSlideToId="turnSlideToId"
  27. :manualExitFullscreen="manualExitFullscreen"
  28. @wheel="$event => mousewheelListener($event)"
  29. @touchstart="$event => touchStartListener($event)"
  30. @touchend="$event => touchEndListener($event)"
  31. v-contextmenu="contextmenus"
  32. :slideIndex="slideIndex"
  33. />
  34. <WritingBoardTool
  35. :slideWidth="slideWidth"
  36. :slideHeight="slideHeight"
  37. :left="-365"
  38. :top="-155"
  39. v-if="writingBoardToolVisible"
  40. @close="writingBoardToolVisible = false"
  41. />
  42. <CountdownTimer
  43. v-if="timerlVisible"
  44. :left="75"
  45. @close="timerlVisible = false"
  46. />
  47. </div>
  48. <div class="thumbnails"
  49. ref="thumbnailsRef"
  50. @wheel.prevent="$event => handleMousewheelThumbnails($event)"
  51. >
  52. <div
  53. class="thumbnail"
  54. :class="{ 'active': index === slideIndex }"
  55. v-for="(slide, index) in slides"
  56. :key="slide.id"
  57. @click="turnSlideToIndex(index)"
  58. >
  59. <ThumbnailSlide :slide="slide" :size="120 / viewportRatio" :visible="index < slidesLoadLimit" />
  60. </div>
  61. </div>
  62. </div>
  63. <div class="remark">
  64. <div class="header">
  65. <span>演讲者备注</span>
  66. <span>P {{slideIndex + 1}} / {{slides.length}}</span>
  67. </div>
  68. <div class="remark-content ProseMirror-static" :class="{ 'empty': !currentSlideRemark }" :style="{ fontSize: remarkFontSize + 'px' }" v-html="currentSlideRemark || '无备注'"></div>
  69. <div class="remark-scale">
  70. <div :class="['scale-btn', { 'disable': remarkFontSize === 12 }]" @click="setRemarkFontSize(remarkFontSize - 2)"><IconMinus /></div>
  71. <div :class="['scale-btn', { 'disable': remarkFontSize === 40 }]" @click="setRemarkFontSize(remarkFontSize + 2)"><IconPlus /></div>
  72. </div>
  73. </div>
  74. </div>
  75. </template>
  76. <script lang="ts" setup>
  77. import { computed, nextTick, ref, watch, useTemplateRef } from 'vue'
  78. import { storeToRefs } from 'pinia'
  79. import { useSlidesStore } from '@/store'
  80. import type { ContextmenuItem } from '@/components/Contextmenu/types'
  81. import { enterFullscreen } from '@/utils/fullscreen'
  82. import { parseText2Paragraphs } from '@/utils/textParser'
  83. import useScreening from '@/hooks/useScreening'
  84. import useLoadSlides from '@/hooks/useLoadSlides'
  85. import useExecPlay from './hooks/useExecPlay'
  86. import useSlideSize from './hooks/useSlideSize'
  87. import useFullscreen from './hooks/useFullscreen'
  88. import ThumbnailSlide from '@/views/components/ThumbnailSlide/index.vue'
  89. import ScreenSlideList from './ScreenSlideList.vue'
  90. import WritingBoardTool from './WritingBoardTool.vue'
  91. import CountdownTimer from './CountdownTimer.vue'
  92. import Divider from '@/components/Divider.vue'
  93. const props = defineProps<{
  94. changeViewMode: (mode: 'base' | 'presenter') => void
  95. }>()
  96. const { slides, slideIndex, viewportRatio, currentSlide } = storeToRefs(useSlidesStore())
  97. const slideListWrapRef = useTemplateRef<HTMLElement>('slideListWrapRef')
  98. const thumbnailsRef = useTemplateRef<HTMLElement>('thumbnailsRef')
  99. const writingBoardToolVisible = ref(false)
  100. const timerlVisible = ref(false)
  101. const laserPen = ref(false)
  102. const {
  103. mousewheelListener,
  104. touchStartListener,
  105. touchEndListener,
  106. turnPrevSlide,
  107. turnNextSlide,
  108. turnSlideToIndex,
  109. turnSlideToId,
  110. animationIndex,
  111. } = useExecPlay()
  112. const { slideWidth, slideHeight } = useSlideSize(slideListWrapRef)
  113. const { exitScreening } = useScreening()
  114. const { slidesLoadLimit } = useLoadSlides()
  115. const { fullscreenState, manualExitFullscreen } = useFullscreen()
  116. const remarkFontSize = ref(16)
  117. const currentSlideRemark = computed(() => {
  118. if (!currentSlide.value.remark) return ''
  119. return parseText2Paragraphs(currentSlide.value.remark)
  120. })
  121. const handleMousewheelThumbnails = (e: WheelEvent) => {
  122. if (!thumbnailsRef.value) return
  123. thumbnailsRef.value.scrollBy(e.deltaY, 0)
  124. }
  125. const setRemarkFontSize = (fontSize: number) => {
  126. if (fontSize < 12 || fontSize > 40) return
  127. remarkFontSize.value = fontSize
  128. }
  129. watch(slideIndex, () => {
  130. nextTick(() => {
  131. if (!thumbnailsRef.value) return
  132. const activeThumbnailRef: HTMLElement | null = thumbnailsRef.value.querySelector('.thumbnail.active')
  133. if (!activeThumbnailRef) return
  134. const width = thumbnailsRef.value.offsetWidth
  135. const offsetLeft = activeThumbnailRef.offsetLeft + activeThumbnailRef.clientWidth / 2
  136. thumbnailsRef.value.scrollTo({ left: offsetLeft - width / 2, behavior: 'smooth' })
  137. })
  138. })
  139. const contextmenus = (): ContextmenuItem[] => {
  140. return [
  141. {
  142. text: '上一页',
  143. subText: '↑ ←',
  144. disable: slideIndex.value <= 0,
  145. handler: () => turnPrevSlide(),
  146. },
  147. {
  148. text: '下一页',
  149. subText: '↓ →',
  150. disable: slideIndex.value >= slides.value.length - 1,
  151. handler: () => turnNextSlide(),
  152. },
  153. {
  154. text: '第一页',
  155. disable: slideIndex.value === 0,
  156. handler: () => turnSlideToIndex(0),
  157. },
  158. {
  159. text: '最后一页',
  160. disable: slideIndex.value === slides.value.length - 1,
  161. handler: () => turnSlideToIndex(slides.value.length - 1),
  162. },
  163. { divider: true },
  164. {
  165. text: '画笔工具',
  166. handler: () => writingBoardToolVisible.value = true,
  167. },
  168. {
  169. text: '普通视图',
  170. handler: () => props.changeViewMode('base'),
  171. },
  172. { divider: true },
  173. {
  174. text: '结束放映',
  175. subText: 'ESC',
  176. handler: exitScreening,
  177. },
  178. ]
  179. }
  180. </script>
  181. <style lang="scss" scoped>
  182. .presenter-view {
  183. width: 100%;
  184. height: 100%;
  185. display: flex;
  186. }
  187. .toolbar {
  188. width: 70px;
  189. height: 100%;
  190. background-color: #fff;
  191. border-right: solid 1px #eee;
  192. font-size: 12px;
  193. margin: 20px 0;
  194. .tool-btn {
  195. display: flex;
  196. flex-direction: column;
  197. justify-content: center;
  198. align-items: center;
  199. cursor: pointer;
  200. & + .tool-btn {
  201. margin-top: 22px;
  202. }
  203. &:hover, &.active {
  204. color: $themeColor;
  205. }
  206. }
  207. .divider {
  208. width: 70%;
  209. margin: 24px 15% !important;
  210. }
  211. .tool-icon {
  212. margin-bottom: 8px;
  213. font-size: 22px;
  214. }
  215. }
  216. .content {
  217. width: calc(100% - 430px);
  218. height: 100%;
  219. background-color: #1d1d1d;
  220. }
  221. .slide-list-wrap {
  222. height: calc(100% - 190px);
  223. margin: 20px;
  224. overflow: hidden;
  225. position: relative;
  226. &.laser-pen {
  227. cursor: url(data:image/png;base64,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) 20 20, default !important;
  228. }
  229. }
  230. .thumbnails {
  231. height: 150px;
  232. padding: 15px;
  233. white-space: nowrap;
  234. overflow-x: auto;
  235. overflow-y: hidden;
  236. border-top: solid 1px #3a3a3a;
  237. position: relative;
  238. }
  239. .thumbnail {
  240. display: inline-block;
  241. outline: 2px solid #aaa;
  242. & + .thumbnail {
  243. margin-left: 10px;
  244. }
  245. &:hover {
  246. outline-color: $themeColor;
  247. }
  248. &.active {
  249. outline-width: 3px;
  250. outline-color: $themeColor;
  251. }
  252. }
  253. .remark {
  254. width: 360px;
  255. height: 100%;
  256. position: relative;
  257. background-color: #2a2a2a;
  258. border-left: solid 1px #3a3a3a;
  259. color: #fff;
  260. .header {
  261. height: 60px;
  262. padding: 0 20px;
  263. display: flex;
  264. justify-content: space-between;
  265. align-items: center;
  266. font-size: 18px;
  267. border-bottom: 1px solid #3a3a3a;
  268. }
  269. .remark-content {
  270. height: calc(100% - 60px);
  271. padding: 20px;
  272. line-height: 1.5;
  273. @include overflow-overlay();
  274. &.empty {
  275. color: #999;
  276. font-style: italic;
  277. }
  278. }
  279. .remark-scale {
  280. position: absolute;
  281. right: 5px;
  282. bottom: 5px;
  283. font-size: 22px;
  284. display: flex;
  285. }
  286. .scale-btn {
  287. width: 40px;
  288. height: 40px;
  289. display: flex;
  290. justify-content: center;
  291. align-items: center;
  292. user-select: none;
  293. cursor: pointer;
  294. &.disable {
  295. color: #666;
  296. cursor: no-drop;
  297. }
  298. &:not(.disable):hover {
  299. background-color: #333;
  300. }
  301. }
  302. }
  303. ::-webkit-scrollbar {
  304. width: 0;
  305. height: 0;
  306. }
  307. </style>