返回 slidev
presenter.vue
根目录 / packages / client / pages / presenter.vue
1 <script setup lang="ts">
2 import { useHead } from '@unhead/vue'
3 import { useEventListener, useLocalStorage, useMediaQuery, useWindowFocus } from '@vueuse/core'
4 import { computed, onMounted, ref, shallowRef, watch, watchEffect } from 'vue'
5 import { createClicksContextBase } from '../composables/useClicks'
6 import { useDrawings } from '../composables/useDrawings'
7 import { useMousePosInSlide } from '../composables/useMousePosInSlide'
8 import { useNav } from '../composables/useNav'
9 import { useSwipeControls } from '../composables/useSwipeControls'
10 import { useWakeLock } from '../composables/useWakeLock'
11 import { slidesTitle } from '../env'
12 import ClicksSlider from '../internals/ClicksSlider.vue'
13 import ContextMenu from '../internals/ContextMenu.vue'
14 import CurrentProgressBar from '../internals/CurrentProgressBar.vue'
15 import DrawingControls from '../internals/DrawingControls.vue'
16 import Goto from '../internals/Goto.vue'
17 import IconButton from '../internals/IconButton.vue'
18 import LaserPointer from '../internals/LaserPointer.vue'
19 import NavControls from '../internals/NavControls.vue'
20 import NoteEditable from '../internals/NoteEditable.vue'
21 import NoteStatic from '../internals/NoteStatic.vue'
22 import QuickOverview from '../internals/QuickOverview.vue'
23 import ScreenCaptureMirror from '../internals/ScreenCaptureMirror.vue'
24 import SegmentControl from '../internals/SegmentControl.vue'
25 import SlideContainer from '../internals/SlideContainer.vue'
26 import SlidesShow from '../internals/SlidesShow.vue'
27 import SlideWrapper from '../internals/SlideWrapper.vue'
28 import TimerBar from '../internals/TimerBar.vue'
29 import TimerInlined from '../internals/TimerInlined.vue'
30 import { onContextMenu } from '../logic/contextMenu'
31 import { registerShortcuts } from '../logic/shortcuts'
32 import { cursorStyle, decreasePresenterFontSize, increasePresenterFontSize, presenterLayout, presenterNotesFontSize, showEditor, showPresenterCursor } from '../state'
33 import { sharedState } from '../state/shared'
34
35 const inFocus = useWindowFocus()
36 const main = ref<HTMLDivElement>()
37 const gridContainer = ref<HTMLDivElement>()
38 const noteSection = ref<HTMLDivElement>()
39 const bottomSection = ref<HTMLDivElement>()
40
41 registerShortcuts()
42 useSwipeControls(main)
43 if (__SLIDEV_FEATURE_WAKE_LOCK__)
44 useWakeLock()
45
46 const {
47 clicksContext,
48 currentSlideNo,
49 currentSlideRoute,
50 hasNext,
51 nextRoute,
52 slides,
53 getPrimaryClicks,
54 } = useNav()
55 const { isDrawing } = useDrawings()
56
57 useHead({ title: `Presenter - ${slidesTitle}` })
58
59 const notesEditing = ref(false)
60
61 const clicksCtxMap = computed(() => slides.value.map((route) => {
62 const clicks = ref(0)
63 return {
64 context: createClicksContextBase(clicks, route?.meta.slide?.frontmatter.clicksStart ?? 0, route?.meta.clicks),
65 clicks,
66 }
67 }))
68 const nextFrame = computed(() => {
69 if (clicksContext.value.current < clicksContext.value.total)
70 return [currentSlideRoute.value!, clicksContext.value.current + 1] as const
71 else if (hasNext.value)
72 return [nextRoute.value, 0] as const
73 else
74 return null
75 })
76
77 const nextFrameClicksCtx = computed(() => {
78 return nextFrame.value && clicksCtxMap.value[nextFrame.value[0].no - 1]
79 })
80
81 watch(
82 nextFrame,
83 () => {
84 if (nextFrameClicksCtx.value && nextFrame.value)
85 nextFrameClicksCtx.value.clicks.value = nextFrame.value[1]
86 },
87 { immediate: true },
88 )
89
90 const mainSlideMode = useLocalStorage<'slides' | 'mirror'>('slidev-presenter-main-slide-mode', 'slides')
91
92 // Resize state (persisted)
93 const notesWidth = useLocalStorage('slidev-presenter-notes-width', 360)
94 const notesRowSize = useLocalStorage('slidev-presenter-notes-row-size', 280)
95 const bottomSectionHeight = ref(0)
96 const isResizingNotes = ref(false)
97 const isResizingNotesRow = ref(false)
98 const resizeStartX = ref(0)
99 const resizeStartWidth = ref(360)
100 const resizeStartY = ref(0)
101 const resizeStartRowSize = ref(280)
102
103 const RESIZER_LIMITS = {
104 minNotesWidth: 240,
105 maxNotesWidth: 720,
106 minNotesRowSize: 160,
107 maxNotesWidthRatio: 0.7,
108 maxNotesRowHeightRatio: 0.75,
109 }
110
111 const isLayout1Wide = useMediaQuery('(min-aspect-ratio: 1/1)')
112 const isLayout1Stacked = useMediaQuery('(max-aspect-ratio: 3/5)')
113 const isNotesOnRight = computed(() => presenterLayout.value === 1 && isLayout1Wide.value)
114 const isNotesResizable = computed(() => !(presenterLayout.value === 1 && isLayout1Stacked.value))
115 const isNotesRowResizable = computed(() =>
116 (presenterLayout.value === 1 && !isLayout1Stacked.value) || presenterLayout.value === 2 || presenterLayout.value === 3,
117 )
118 const isNotesOnBottom = computed(() => presenterLayout.value === 1 && !isLayout1Stacked.value)
119
120 function clampNotesWidth(width: number) {
121 if (!Number.isFinite(width))
122 return RESIZER_LIMITS.minNotesWidth
123 return Math.max(
124 RESIZER_LIMITS.minNotesWidth,
125 Math.min(RESIZER_LIMITS.maxNotesWidth, Math.round(width)),
126 )
127 }
128
129 function updateNotesWidthFromPointer(clientX: number) {
130 const container = gridContainer.value
131 if (!container)
132 return
133
134 const rect = container.getBoundingClientRect()
135 const deltaX = clientX - resizeStartX.value
136 const proposedWidth = isNotesOnRight.value
137 ? resizeStartWidth.value - deltaX
138 : resizeStartWidth.value + deltaX
139 const nextWidth = clampNotesWidth(proposedWidth)
140 const maxByViewport = Math.round(rect.width * RESIZER_LIMITS.maxNotesWidthRatio)
141 notesWidth.value = Math.min(nextWidth, Math.max(RESIZER_LIMITS.minNotesWidth, maxByViewport))
142 }
143
144 function onNotesResizeStart(e: PointerEvent) {
145 if (!isNotesResizable.value)
146 return
147 if (e.button !== 0)
148 return
149 e.preventDefault()
150 resizeStartX.value = e.clientX
151 resizeStartWidth.value = notesWidth.value
152 isResizingNotes.value = true
153 }
154
155 function clampNotesRowSize(size: number) {
156 if (!Number.isFinite(size))
157 return RESIZER_LIMITS.minNotesRowSize
158 return Math.max(RESIZER_LIMITS.minNotesRowSize, Math.round(size))
159 }
160
161 function updateNotesRowSizeFromPointer(clientY: number) {
162 const container = gridContainer.value
163 if (!container)
164 return
165
166 const rect = container.getBoundingClientRect()
167 const deltaY = clientY - resizeStartY.value
168 const proposed = isNotesOnBottom.value
169 ? resizeStartRowSize.value - deltaY
170 : resizeStartRowSize.value + deltaY
171 const maxByViewport = Math.round(rect.height * RESIZER_LIMITS.maxNotesRowHeightRatio)
172 notesRowSize.value = Math.min(clampNotesRowSize(proposed), Math.max(RESIZER_LIMITS.minNotesRowSize, maxByViewport))
173 }
174
175 function onNotesRowResizeStart(e: PointerEvent) {
176 if (!isNotesRowResizable.value)
177 return
178 if (e.button !== 0)
179 return
180 e.preventDefault()
181
182 // In layout 2, notesRowSize controls the top section (main slide) height
183 // In other layouts, it represents the notes area height
184 const currentHeight = presenterLayout.value === 2
185 ? main.value?.getBoundingClientRect().height
186 : noteSection.value?.getBoundingClientRect().height
187 resizeStartY.value = e.clientY
188 resizeStartRowSize.value = clampNotesRowSize(currentHeight ?? notesRowSize.value)
189 isResizingNotesRow.value = true
190 }
191
192 function updateBottomSectionHeight() {
193 const element = bottomSection.value
194 if (!element)
195 return
196 bottomSectionHeight.value = Math.round(element.getBoundingClientRect().height)
197 }
198
199 function stopResizing() {
200 isResizingNotes.value = false
201 isResizingNotesRow.value = false
202 }
203
204 function syncResizerLayoutState() {
205 updateBottomSectionHeight()
206 normalizeResizerState()
207 }
208
209 useEventListener(window, 'pointermove', (e) => {
210 if (isResizingNotes.value)
211 updateNotesWidthFromPointer(e.clientX)
212 if (isResizingNotesRow.value)
213 updateNotesRowSizeFromPointer(e.clientY)
214 })
215
216 useEventListener(window, 'pointerup', stopResizing)
217 useEventListener(window, 'pointercancel', stopResizing)
218
219 onMounted(() => {
220 syncResizerLayoutState()
221 })
222
223 useEventListener(window, 'resize', () => {
224 syncResizerLayoutState()
225 })
226
227 function normalizeResizerState() {
228 notesWidth.value = clampNotesWidth(notesWidth.value)
229 notesRowSize.value = clampNotesRowSize(notesRowSize.value)
230
231 const container = gridContainer.value
232 if (!container)
233 return
234
235 const rect = container.getBoundingClientRect()
236 const maxWidth = Math.round(rect.width * RESIZER_LIMITS.maxNotesWidthRatio)
237 const maxRowSize = Math.round(rect.height * RESIZER_LIMITS.maxNotesRowHeightRatio)
238
239 notesWidth.value = Math.min(notesWidth.value, Math.max(RESIZER_LIMITS.minNotesWidth, maxWidth))
240 notesRowSize.value = Math.min(notesRowSize.value, Math.max(RESIZER_LIMITS.minNotesRowSize, maxRowSize))
241 }
242
243 const SideEditor = shallowRef<any>()
244 if (__DEV__ && __SLIDEV_FEATURE_EDITOR__)
245 import('../internals/SideEditor.vue').then(v => SideEditor.value = v.default)
246
247 // sync presenter cursor
248 onMounted(() => {
249 const mouse = useMousePosInSlide()
250 const focus = useWindowFocus()
251
252 watchEffect(() => {
253 if (!mouse.value || !focus.value || isDrawing.value || !showPresenterCursor.value) {
254 sharedState.cursor = undefined
255 }
256 else {
257 sharedState.cursor = {
258 ...mouse.value,
259 style: cursorStyle.value,
260 }
261 }
262 })
263 })
264 </script>
265
266 <template>
267 <div class="bg-main h-full slidev-presenter grid grid-rows-[max-content_1fr] of-hidden">
268 <div>
269 <CurrentProgressBar />
270 <TimerBar />
271 </div>
272 <div
273 ref="gridContainer"
274 class="grid-container"
275 :class="`layout${presenterLayout}`"
276 :style="{
277 '--slidev-presenter-notes-width': `${notesWidth}px`,
278 '--slidev-presenter-notes-row-size': `${notesRowSize}px`,
279 '--slidev-presenter-bottom-height': `${bottomSectionHeight}px`,
280 }"
281 >
282 <!-- Unified vertical resizer for wide layout -->
283 <div
284 v-if="isNotesResizable && isNotesOnRight"
285 class="notes-vertical-resizer"
286 role="separator"
287 aria-orientation="vertical"
288 title="Resize notes panel"
289 @pointerdown="onNotesResizeStart"
290 />
291 <!-- Unified vertical resizer for layout 3 -->
292 <div
293 v-if="isNotesResizable && presenterLayout === 3"
294 class="notes-vertical-resizer-left"
295 role="separator"
296 aria-orientation="vertical"
297 title="Resize notes panel"
298 @pointerdown="onNotesResizeStart"
299 />
300 <div ref="main" class="relative grid-section main flex flex-col">
301 <div flex="~ gap-4 items-center" border="b main" p1>
302 <span op50 px2>Current</span>
303 <div flex-auto />
304 <SegmentControl
305 v-model="mainSlideMode"
306 :options="[
307 { label: 'Slides', value: 'slides' },
308 { label: 'Screen Mirror', value: 'mirror' },
309 ]"
310 />
311 </div>
312 <template v-if="mainSlideMode === 'mirror'">
313 <ScreenCaptureMirror />
314 </template>
315
316 <!-- We use v-show here to still infer the clicks context -->
317 <SlideContainer
318 v-show="mainSlideMode === 'slides'"
319 key="main"
320 class="p-2 lg:p-4 flex-auto"
321 is-main
322 @contextmenu="onContextMenu"
323 >
324 <SlidesShow render-context="presenter" />
325 <LaserPointer />
326 </SlideContainer>
327
328 <ClicksSlider
329 :key="currentSlideRoute?.no"
330 :clicks-context="getPrimaryClicks(currentSlideRoute)"
331 class="w-full pb2 px4 flex-none"
332 />
333 </div>
334 <div class="relative grid-section next flex flex-col p-2 lg:p-4">
335 <div
336 v-if="isNotesRowResizable && presenterLayout === 2"
337 class="notes-row-resizer top-[-6px]"
338 role="separator"
339 aria-orientation="horizontal"
340 title="Resize notes panel height"
341 @pointerdown="onNotesRowResizeStart"
342 />
343 <SlideContainer v-if="nextFrame && nextFrameClicksCtx" key="next">
344 <SlideWrapper
345 :key="nextFrame[0].no"
346 :clicks-context="nextFrameClicksCtx.context"
347 :route="nextFrame[0]"
348 render-context="previewNext"
349 />
350 </SlideContainer>
351 <div v-else class="h-full flex justify-center items-center">
352 <div class="text-gray-500">
353 End of the presentation
354 </div>
355 </div>
356 <div class="absolute left-0 top-0 bg-main border-b border-r border-main px2 py1 op50 text-sm">
357 Next
358 </div>
359 </div>
360 <div ref="noteSection" class="relative grid-section note overflow-hidden">
361 <div
362 v-if="isNotesResizable && !isNotesOnRight && presenterLayout !== 3"
363 class="notes-resizer right-[-6px]"
364 role="separator"
365 aria-orientation="vertical"
366 title="Resize notes panel"
367 @pointerdown="onNotesResizeStart"
368 />
369 <div
370 v-if="isNotesRowResizable && presenterLayout !== 2"
371 class="notes-row-resizer"
372 :class="isNotesOnBottom ? 'top-[-6px]' : 'bottom-[-6px]'"
373 role="separator"
374 aria-orientation="horizontal"
375 title="Resize notes panel height"
376 @pointerdown="onNotesRowResizeStart"
377 />
378
379 <SideEditor v-if="SideEditor && showEditor" class="h-full" />
380
381 <div v-else class="h-full grid grid-rows-[1fr_min-content]">
382 <NoteEditable
383 v-if="__DEV__"
384 :key="`edit-${currentSlideNo}`"
385 v-model:editing="notesEditing"
386 :no="currentSlideNo"
387 class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
388 :clicks-context="clicksContext"
389 :style="{ fontSize: `${presenterNotesFontSize}em` }"
390 />
391 <NoteStatic
392 v-else
393 :key="`static-${currentSlideNo}`"
394 :no="currentSlideNo"
395 class="w-full max-w-full h-full overflow-auto p-2 lg:p-4"
396 :style="{ fontSize: `${presenterNotesFontSize}em` }"
397 :clicks-context="clicksContext"
398 />
399 <div border-t border-main />
400 <div class="py-1 px-2 text-sm transition" :class="inFocus ? '' : 'op25'">
401 <IconButton title="Increase font size" @click="increasePresenterFontSize">
402 <div class="i-carbon:zoom-in" />
403 </IconButton>
404 <IconButton title="Decrease font size" @click="decreasePresenterFontSize">
405 <div class="i-carbon:zoom-out" />
406 </IconButton>
407 <IconButton
408 v-if="__DEV__"
409 title="Edit Notes"
410 @click="notesEditing = !notesEditing"
411 >
412 <div class="i-carbon:edit" />
413 </IconButton>
414 </div>
415 </div>
416 </div>
417 <div ref="bottomSection" class="grid-section bottom flex">
418 <NavControls :persist="true" class="transition" :class="inFocus ? '' : 'op25'" />
419 <div flex-auto />
420 <TimerInlined />
421 </div>
422 <DrawingControls v-if="__SLIDEV_FEATURE_DRAWINGS__" />
423 </div>
424 </div>
425 <Goto />
426 <QuickOverview />
427 <ContextMenu />
428 </template>
429
430 <style scoped>
431 .slidev-presenter {
432 --slidev-controls-foreground: current;
433 }
434
435 .grid-container {
436 --slidev-presenter-notes-width: 360px;
437 --slidev-presenter-notes-row-size: 280px;
438 --uno: bg-gray/20 flex-1 of-hidden;
439 position: relative;
440 display: grid;
441 gap: 1px 1px;
442 }
443
444 .grid-container.layout1 {
445 grid-template-columns: var(--slidev-presenter-notes-width) minmax(0, 1fr);
446 grid-template-rows: minmax(0, 2fr) minmax(0, var(--slidev-presenter-notes-row-size)) min-content;
447 grid-template-areas:
448 'main main'
449 'note next'
450 'bottom bottom';
451 }
452
453 .grid-container.layout2 {
454 grid-template-columns: var(--slidev-presenter-notes-width) minmax(0, 1fr);
455 grid-template-rows: minmax(0, var(--slidev-presenter-notes-row-size)) minmax(0, 1fr) min-content;
456 grid-template-areas:
457 'note main'
458 'note next'
459 'bottom bottom';
460 }
461
462 @media (max-aspect-ratio: 3/5) {
463 .grid-container.layout1 {
464 grid-template-columns: 1fr;
465 grid-template-rows: 2fr 1fr 1fr min-content;
466 grid-template-areas:
467 'main'
468 'note'
469 'next'
470 'bottom';
471 }
472 }
473
474 @media (min-aspect-ratio: 1/1) {
475 .grid-container.layout1 {
476 grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr) var(--slidev-presenter-notes-width);
477 grid-template-rows: minmax(0, 1fr) minmax(0, var(--slidev-presenter-notes-row-size)) min-content;
478 grid-template-areas:
479 'main main next'
480 'main main note'
481 'bottom bottom bottom';
482 }
483 }
484
485 .grid-container.layout3 {
486 grid-template-columns: var(--slidev-presenter-notes-width) minmax(0, 1fr);
487 grid-template-rows: minmax(0, var(--slidev-presenter-notes-row-size)) minmax(0, 1fr) min-content;
488 grid-template-areas:
489 'note next'
490 'main next'
491 'bottom bottom';
492 }
493
494 .grid-section {
495 --uno: bg-main;
496 }
497 .grid-section.top {
498 grid-area: top;
499 }
500 .grid-section.main {
501 grid-area: main;
502 }
503 .grid-section.next {
504 grid-area: next;
505 }
506 .grid-section.note {
507 grid-area: note;
508 }
509 .grid-section.bottom {
510 grid-area: bottom;
511 }
512
513 .notes-resizer {
514 position: absolute;
515 top: 0;
516 width: 12px;
517 height: 100%;
518 cursor: col-resize;
519 z-index: 10;
520 touch-action: none;
521 }
522
523 .notes-resizer::before {
524 content: '';
525 position: absolute;
526 left: 50%;
527 top: 0;
528 width: 1px;
529 height: 100%;
530 background-color: currentColor;
531 opacity: 0.2;
532 transform: translateX(-50%);
533 }
534
535 .notes-row-resizer {
536 position: absolute;
537 left: 0;
538 width: 100%;
539 height: 12px;
540 cursor: row-resize;
541 z-index: 10;
542 touch-action: none;
543 }
544
545 .notes-row-resizer::before {
546 content: '';
547 position: absolute;
548 left: 0;
549 top: 50%;
550 width: 100%;
551 height: 1px;
552 background-color: currentColor;
553 opacity: 0.2;
554 transform: translateY(-50%);
555 }
556
557 .notes-vertical-resizer {
558 position: absolute;
559 right: var(--slidev-presenter-notes-width);
560 top: 0;
561 bottom: var(--slidev-presenter-bottom-height, 0px);
562 width: 12px;
563 cursor: col-resize;
564 z-index: 10;
565 touch-action: none;
566 transform: translateX(50%);
567 }
568
569 .notes-vertical-resizer::before {
570 content: '';
571 position: absolute;
572 left: 50%;
573 top: 0;
574 width: 1px;
575 height: 100%;
576 transform: translateX(-50%);
577 }
578
579 .notes-vertical-resizer-left {
580 position: absolute;
581 left: var(--slidev-presenter-notes-width);
582 top: 0;
583 bottom: var(--slidev-presenter-bottom-height, 0px);
584 width: 12px;
585 cursor: col-resize;
586 z-index: 10;
587 touch-action: none;
588 transform: translateX(-50%);
589 }
590
591 .notes-vertical-resizer-left::before {
592 content: '';
593 position: absolute;
594 left: 50%;
595 top: 0;
596 width: 1px;
597 height: 100%;
598 transform: translateX(-50%);
599 }
600 </style>
601
601 lines Plain Text