@slidev/client 0.50.0 → 0.51.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -165,7 +165,7 @@ export function createFixedClicks(
165
165
  ): ClicksContext {
166
166
  const clicksStart = route?.meta.slide?.frontmatter.clicksStart ?? 0
167
167
  return createClicksContextBase(
168
- computed(() => Math.max(toValue(currentInit), clicksStart)),
168
+ ref(Math.max(toValue(currentInit), clicksStart)),
169
169
  clicksStart,
170
170
  route?.meta?.clicks,
171
171
  )
@@ -13,7 +13,7 @@ export function useTimer() {
13
13
 
14
14
  return {
15
15
  timer,
16
- isTimerAvctive: isActive,
16
+ isTimerActive: isActive,
17
17
  resetTimer: reset,
18
18
  toggleTimer: () => (isActive.value ? pause() : resume()),
19
19
  }
@@ -0,0 +1,48 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import {
4
+ getHashColorFromString,
5
+ getHsla,
6
+ } from '../logic/color'
7
+
8
+ const props = withDefaults(
9
+ defineProps<{
10
+ text?: string
11
+ color?: boolean | number
12
+ as?: string
13
+ size?: string
14
+ }>(),
15
+ {
16
+ color: true,
17
+ },
18
+ )
19
+
20
+ const style = computed(() => {
21
+ if (!props.text || props.color === false)
22
+ return {}
23
+ return {
24
+ color: typeof props.color === 'number'
25
+ ? getHsla(props.color)
26
+ : getHashColorFromString(props.text),
27
+ background: typeof props.color === 'number'
28
+ ? getHsla(props.color, 0.1)
29
+ : getHashColorFromString(props.text, 0.1),
30
+ }
31
+ })
32
+
33
+ const sizeClasses = computed(() => {
34
+ switch (props.size || 'sm') {
35
+ case 'sm':
36
+ return 'px-1.5 text-11px leading-1.6em'
37
+ }
38
+ return ''
39
+ })
40
+ </script>
41
+
42
+ <template>
43
+ <component :is="as || 'span'" ws-nowrap rounded :class="sizeClasses" :style>
44
+ <slot>
45
+ <span v-text="props.text" />
46
+ </slot>
47
+ </component>
48
+ </template>
@@ -43,13 +43,21 @@ ensureDevicesListPermissions()
43
43
  </script>
44
44
 
45
45
  <template>
46
- <div class="text-sm">
47
- <SelectList v-model="currentCamera" title="Camera" :items="camerasItems" />
48
- <SelectList v-model="currentMic" title="Microphone" :items="microphonesItems" />
46
+ <div text-sm flex="~ col gap-2">
47
+ <SelectList
48
+ v-model="currentCamera"
49
+ title="Camera"
50
+ :items="camerasItems"
51
+ />
52
+ <SelectList
53
+ v-model="currentMic"
54
+ title="Microphone"
55
+ :items="microphonesItems"
56
+ />
49
57
  <SelectList
50
58
  v-if="mimeTypeItems.length"
51
59
  v-model="mimeType"
52
- title="mimeType"
60
+ title="Video Format"
53
61
  :items="mimeTypeItems"
54
62
  />
55
63
  </div>
@@ -30,8 +30,8 @@ onClickOutside(el, () => {
30
30
  <KeepAlive>
31
31
  <div
32
32
  v-if="value"
33
- class="rounded-md bg-main text-main shadow-xl absolute bottom-10 left-0 z-menu"
34
- dark:border="~ main"
33
+ class="bg-main text-main shadow-xl absolute bottom-10 left-0 z-menu"
34
+ border="~ main rounded-md"
35
35
  >
36
36
  <slot name="menu" />
37
37
  </div>
@@ -10,6 +10,7 @@ import { downloadPDF } from '../utils'
10
10
  import IconButton from './IconButton.vue'
11
11
  import MenuButton from './MenuButton.vue'
12
12
  import Settings from './Settings.vue'
13
+ import SyncControls from './SyncControls.vue'
13
14
 
14
15
  import VerticalDivider from './VerticalDivider.vue'
15
16
 
@@ -48,7 +49,7 @@ function onMouseLeave() {
48
49
 
49
50
  const barStyle = computed(() => props.persist
50
51
  ? 'text-$slidev-controls-foreground bg-transparent'
51
- : 'rounded-md bg-main shadow-xl dark:border dark:border-main')
52
+ : 'rounded-md bg-main shadow-xl border border-main')
52
53
 
53
54
  const RecordingControls = shallowRef<any>()
54
55
  if (__SLIDEV_FEATURE_RECORD__)
@@ -130,19 +131,15 @@ if (__SLIDEV_FEATURE_RECORD__)
130
131
  >
131
132
  <div class="i-carbon:text-annotation-toggle" />
132
133
  </IconButton>
133
-
134
- <IconButton v-if="isPresenter" title="Toggle Presenter Layout" class="aspect-ratio-initial flex items-center" @click="togglePresenterLayout">
135
- <div class="i-carbon:template" />
136
- {{ presenterLayout }}
137
- </IconButton>
138
134
  </template>
135
+
139
136
  <template v-if="!__DEV__">
140
137
  <IconButton v-if="configs.download" title="Download as PDF" @click="downloadPDF">
141
138
  <div class="i-carbon:download" />
142
139
  </IconButton>
143
140
  </template>
144
141
 
145
- <template v-if="__SLIDEV_FEATURE_BROWSER_EXPORTER__">
142
+ <template v-if="__SLIDEV_FEATURE_BROWSER_EXPORTER__ && !isEmbedded && !isPresenter">
146
143
  <IconButton title="Browser Exporter" to="/export">
147
144
  <div class="i-carbon:document-pdf" />
148
145
  </IconButton>
@@ -156,7 +153,16 @@ if (__SLIDEV_FEATURE_RECORD__)
156
153
  <div class="i-carbon:information" />
157
154
  </IconButton>
158
155
 
159
- <template v-if="!isPresenter && !isEmbedded">
156
+ <template v-if="!isEmbedded">
157
+ <VerticalDivider />
158
+
159
+ <IconButton v-if="isPresenter" title="Toggle Presenter Layout" class="aspect-ratio-initial flex items-center" @click="togglePresenterLayout">
160
+ <div class="i-carbon:template" />
161
+ {{ presenterLayout }}
162
+ </IconButton>
163
+
164
+ <SyncControls v-if="__SLIDEV_FEATURE_PRESENTER__" />
165
+
160
166
  <MenuButton>
161
167
  <template #button>
162
168
  <IconButton title="More Options">
@@ -3,7 +3,7 @@ import { useLocalStorage } from '@vueuse/core'
3
3
  import { onMounted, watch } from 'vue'
4
4
  import { recorder } from '../logic/recording'
5
5
  import { currentCamera, showRecordingDialog } from '../state'
6
- import DevicesList from './DevicesList.vue'
6
+ import DevicesSelectors from './DevicesSelectors.vue'
7
7
  import IconButton from './IconButton.vue'
8
8
  import MenuButton from './MenuButton.vue'
9
9
 
@@ -59,7 +59,7 @@ onMounted(() => {
59
59
  </IconButton>
60
60
  </template>
61
61
  <template #menu>
62
- <DevicesList />
62
+ <DevicesSelectors />
63
63
  </template>
64
64
  </MenuButton>
65
65
  </template>
@@ -2,7 +2,7 @@
2
2
  import { useVModel } from '@vueuse/core'
3
3
  import { nextTick } from 'vue'
4
4
  import { getFilename, mimeType, recordCamera, recorder, recordingName } from '../logic/recording'
5
- import DevicesList from './DevicesList.vue'
5
+ import DevicesSelectors from './DevicesSelectors.vue'
6
6
  import Modal from './Modal.vue'
7
7
 
8
8
  const props = defineProps({
@@ -72,14 +72,14 @@ async function start() {
72
72
  </div>
73
73
  </div>
74
74
  </div>
75
- <DevicesList />
75
+ <DevicesSelectors />
76
76
  </div>
77
77
  <div class="flex my-1">
78
- <button class="cancel" @click="close">
78
+ <button class="slidev-form-button" @click="close">
79
79
  Cancel
80
80
  </button>
81
81
  <div class="flex-auto" />
82
- <button @click="start">
82
+ <button class="slidev-form-button primary" @click="start">
83
83
  Start
84
84
  </button>
85
85
  </div>
@@ -111,15 +111,5 @@ async function start() {
111
111
  input[type='text'] {
112
112
  @apply border border-main rounded px-2 py-1;
113
113
  }
114
-
115
- button {
116
- @apply bg-orange-400 text-white px-4 py-1 rounded border-b-2 border-orange-600;
117
- @apply hover:(bg-orange-500 border-orange-700);
118
- }
119
-
120
- button.cancel {
121
- @apply bg-gray-400 bg-opacity-50 text-white px-4 py-1 rounded border-b-2 border-main;
122
- @apply hover:(bg-opacity-75 border-opacity-75);
123
- }
124
114
  }
125
115
  </style>
@@ -0,0 +1,45 @@
1
+ <script setup lang="ts">
2
+ import { shallowRef, useTemplateRef } from 'vue'
3
+
4
+ const video = useTemplateRef('video')
5
+ const stream = shallowRef<MediaStream | null>(null)
6
+ const started = shallowRef(false)
7
+
8
+ async function startCapture() {
9
+ stream.value = await navigator.mediaDevices.getDisplayMedia({
10
+ video: {
11
+ // @ts-expect-error missing types
12
+ cursor: 'always',
13
+ },
14
+ audio: false,
15
+ selfBrowserSurface: 'include',
16
+ preferCurrentTab: false,
17
+ })
18
+ video.value!.srcObject = stream.value
19
+ video.value!.play()
20
+ started.value = true
21
+ stream.value.addEventListener('inactive', () => {
22
+ video.value!.srcObject = null
23
+ started.value = false
24
+ })
25
+ stream.value.addEventListener('ended', () => {
26
+ video.value!.srcObject = null
27
+ started.value = false
28
+ })
29
+ }
30
+ </script>
31
+
32
+ <template>
33
+ <div h-full w-full>
34
+ <video v-show="started" ref="video" class="w-full h-full object-contain" />
35
+ <div v-if="!started" w-full h-full flex="~ col gap-4 items-center justify-center">
36
+ <div op50>
37
+ Use screen capturing to mirror your main screen back to presenter view.<br>
38
+ Click the button below and <b>select your other monitor or window</b>.
39
+ </div>
40
+ <button class="slidev-form-button" @click="startCapture">
41
+ Start Screen Mirroring
42
+ </button>
43
+ </div>
44
+ </div>
45
+ </template>
@@ -0,0 +1,29 @@
1
+ <script setup lang="ts">
2
+ import Badge from './Badge.vue'
3
+
4
+ defineProps<{
5
+ options: { label: string, value: any }[]
6
+ modelValue: any
7
+ }>()
8
+
9
+ defineEmits<{
10
+ (event: 'update:modelValue', newValue: any): void
11
+ }>()
12
+ </script>
13
+
14
+ <template>
15
+ <div flex="~ gap-1 items-center" rounded bg-gray:2 p1>
16
+ <Badge
17
+ v-for="option in options"
18
+ :key="option.value"
19
+ class="px-2 py-1 text-xs font-mono"
20
+ :class="option.value === modelValue ? '' : 'op50'"
21
+ :color="option.value === modelValue"
22
+ :aria-pressed="option.value === modelValue"
23
+ size="none"
24
+ :text="option.label"
25
+ as="button"
26
+ @click="$emit('update:modelValue', option.value)"
27
+ />
28
+ </div>
29
+ </template>
@@ -44,10 +44,6 @@ const value = useVModel(props, 'modelValue', emit, { passive: true })
44
44
  </template>
45
45
 
46
46
  <style lang="postcss" scoped>
47
- .select-list {
48
- @apply my-2;
49
- }
50
-
51
47
  .item {
52
48
  @apply flex rounded whitespace-nowrap py-1 gap-1 px-2 cursor-default hover:bg-gray-400 hover:bg-opacity-10;
53
49
 
@@ -57,6 +53,6 @@ const value = useVModel(props, 'modelValue', emit, { passive: true })
57
53
  }
58
54
 
59
55
  .title {
60
- @apply text-xs uppercase opacity-50 tracking-widest px-7 py-1 select-none text-nowrap;
56
+ @apply text-sm op75 px4 pt2 pb1 select-none text-nowrap font-bold border-t border-main;
61
57
  }
62
58
  </style>
@@ -1,9 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import type { SelectionItem } from './types'
3
3
  import { useWakeLock } from '@vueuse/core'
4
+ import { useNav } from '../composables/useNav'
4
5
  import { slideScale, wakeLockEnabled } from '../state'
5
6
  import SelectList from './SelectList.vue'
6
7
 
8
+ const { isPresenter } = useNav()
9
+
7
10
  const scaleItems: SelectionItem<number>[] = [
8
11
  {
9
12
  display: 'Fit',
@@ -30,11 +33,18 @@ const wakeLockItems: SelectionItem<boolean>[] = [
30
33
  </script>
31
34
 
32
35
  <template>
33
- <div class="text-sm select-none mb-2">
34
- <SelectList v-model="slideScale" title="Scale" :items="scaleItems" />
36
+ <div text-sm select-none flex="~ col gap-2" min-w-30>
37
+ <SelectList
38
+ v-if="!isPresenter"
39
+ v-model="slideScale"
40
+ title="Scale"
41
+ :items="scaleItems"
42
+ />
35
43
  <SelectList
36
44
  v-if="__SLIDEV_FEATURE_WAKE_LOCK__ && isSupported"
37
- v-model="wakeLockEnabled" title="Wake lock" :items="wakeLockItems"
45
+ v-model="wakeLockEnabled"
46
+ title="Wake lock"
47
+ :items="wakeLockItems"
38
48
  />
39
49
  </div>
40
50
  </template>
@@ -0,0 +1,73 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useNav } from '../composables/useNav'
4
+ import { syncDirections } from '../state'
5
+ import IconButton from './IconButton.vue'
6
+ import MenuButton from './MenuButton.vue'
7
+ import SelectList from './SelectList.vue'
8
+
9
+ const { isPresenter } = useNav()
10
+
11
+ const shouldReceive = computed({
12
+ get: () => isPresenter.value
13
+ ? syncDirections.value.presenterReceive
14
+ : syncDirections.value.viewerReceive,
15
+ set(v) {
16
+ if (isPresenter.value) {
17
+ syncDirections.value.presenterReceive = v
18
+ }
19
+ else {
20
+ syncDirections.value.viewerReceive = v
21
+ }
22
+ },
23
+ })
24
+
25
+ const shouldSend = computed({
26
+ get: () => isPresenter.value
27
+ ? syncDirections.value.presenterSend
28
+ : syncDirections.value.viewerSend,
29
+ set(v) {
30
+ if (isPresenter.value) {
31
+ syncDirections.value.presenterSend = v
32
+ }
33
+ else {
34
+ syncDirections.value.viewerSend = v
35
+ }
36
+ },
37
+ })
38
+ </script>
39
+
40
+ <template>
41
+ <MenuButton>
42
+ <template #button>
43
+ <IconButton title="Change sync settings">
44
+ <div class="i-ph:arrow-up-bold mx--1.2 scale-x-80" :class="shouldSend ? 'text-green6 dark:text-green' : 'op30'" />
45
+ <div class="i-ph:arrow-down-bold mx--1.2 scale-x-80" :class="shouldReceive ? 'text-green6 dark:text-green' : 'op30'" />
46
+ </IconButton>
47
+ </template>
48
+ <template #menu>
49
+ <div text-sm flex="~ col gap-2">
50
+ <div px4 pt3 pb1 ws-nowrap>
51
+ <span op75>Slides navigation syncing for </span>
52
+ <span font-bold text-primary>{{ isPresenter ? 'presenter' : 'viewer' }}</span>
53
+ </div>
54
+ <SelectList
55
+ v-model="shouldSend"
56
+ title="Send Changes"
57
+ :items="[
58
+ { value: true, display: 'On' },
59
+ { value: false, display: 'Off' },
60
+ ]"
61
+ />
62
+ <SelectList
63
+ v-model="shouldReceive"
64
+ title="Receive Changes"
65
+ :items="[
66
+ { value: true, display: 'On' },
67
+ { value: false, display: 'Off' },
68
+ ]"
69
+ />
70
+ </div>
71
+ </template>
72
+ </MenuButton>
73
+ </template>
package/logic/color.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { isDark } from './dark'
2
+
3
+ /**
4
+ * Predefined color map for matching the branding
5
+ *
6
+ * Accpet a 6-digit hex color string or a hue number
7
+ * Hue numbers are preferred because they will adapt better contrast in light/dark mode
8
+ *
9
+ * Hue numbers reference:
10
+ * - 0: red
11
+ * - 30: orange
12
+ * - 60: yellow
13
+ * - 120: green
14
+ * - 180: cyan
15
+ * - 240: blue
16
+ * - 270: purple
17
+ */
18
+ const predefinedColorMap = {
19
+ error: 0,
20
+ client: 60,
21
+ } as Record<string, number>
22
+
23
+ export function getHashColorFromString(
24
+ name: string,
25
+ opacity: number | string = 1,
26
+ ) {
27
+ if (predefinedColorMap[name])
28
+ return getHsla(predefinedColorMap[name], opacity)
29
+
30
+ let hash = 0
31
+ for (let i = 0; i < name.length; i++)
32
+ hash = name.charCodeAt(i) + ((hash << 5) - hash)
33
+ const hue = hash % 360
34
+ return getHsla(hue, opacity)
35
+ }
36
+
37
+ export function getHsla(
38
+ hue: number,
39
+ opacity: number | string = 1,
40
+ ) {
41
+ const saturation = hue === -1
42
+ ? 0
43
+ : isDark.value ? 50 : 100
44
+ const lightness = isDark.value ? 60 : 20
45
+ return `hsla(${hue}, ${saturation}%, ${lightness}%, ${opacity})`
46
+ }
47
+
48
+ export function getPluginColor(name: string, opacity = 1): string {
49
+ if (predefinedColorMap[name]) {
50
+ const color = predefinedColorMap[name]
51
+ if (typeof color === 'number') {
52
+ return getHsla(color, opacity)
53
+ }
54
+ else {
55
+ if (opacity === 1)
56
+ return color
57
+ const opacityHex = Math.floor(opacity * 255).toString(16).padStart(2, '0')
58
+ return color + opacityHex
59
+ }
60
+ }
61
+ return getHashColorFromString(name, opacity)
62
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@slidev/client",
3
3
  "type": "module",
4
- "version": "0.50.0",
4
+ "version": "0.51.0-beta.1",
5
5
  "description": "Presentation slides for developers",
6
6
  "author": "antfu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -32,21 +32,21 @@
32
32
  "@iconify-json/carbon": "^1.2.5",
33
33
  "@iconify-json/ph": "^1.2.2",
34
34
  "@iconify-json/svg-spinners": "^1.2.2",
35
- "@shikijs/monaco": "^1.24.2",
36
- "@shikijs/vitepress-twoslash": "^1.24.2",
35
+ "@shikijs/monaco": "^1.24.4",
36
+ "@shikijs/vitepress-twoslash": "^1.24.4",
37
37
  "@slidev/rough-notation": "^0.1.0",
38
38
  "@typescript/ata": "^0.9.7",
39
39
  "@unhead/vue": "^1.11.14",
40
- "@unocss/reset": "^0.65.1",
41
- "@vueuse/core": "^12.0.0",
42
- "@vueuse/math": "^12.0.0",
40
+ "@unocss/reset": "^0.65.3",
41
+ "@vueuse/core": "^12.2.0",
42
+ "@vueuse/math": "^12.2.0",
43
43
  "@vueuse/motion": "^2.2.6",
44
44
  "drauu": "^0.4.2",
45
45
  "file-saver": "^2.0.5",
46
46
  "floating-vue": "^5.2.2",
47
47
  "fuse.js": "^7.0.0",
48
48
  "html-to-image": "^1.11.11",
49
- "katex": "^0.16.17",
49
+ "katex": "^0.16.18",
50
50
  "lz-string": "^1.5.0",
51
51
  "mermaid": "^11.4.1",
52
52
  "monaco-editor": "0.51.0",
@@ -54,17 +54,17 @@
54
54
  "pptxgenjs": "^3.12.0",
55
55
  "prettier": "^3.4.2",
56
56
  "recordrtc": "^5.6.2",
57
- "shiki": "^1.24.2",
57
+ "shiki": "^1.24.4",
58
58
  "shiki-magic-move": "^0.5.2",
59
59
  "typescript": "5.6.3",
60
- "unocss": "^0.65.1",
60
+ "unocss": "^0.65.3",
61
61
  "vue": "^3.5.13",
62
62
  "vue-router": "^4.5.0",
63
63
  "yaml": "^2.6.1",
64
- "@slidev/parser": "0.50.0",
65
- "@slidev/types": "0.50.0"
64
+ "@slidev/parser": "0.51.0-beta.1",
65
+ "@slidev/types": "0.51.0-beta.1"
66
66
  },
67
67
  "devDependencies": {
68
- "vite": "^6.0.3"
68
+ "vite": "^6.0.6"
69
69
  }
70
70
  }
package/pages/export.vue CHANGED
@@ -238,8 +238,8 @@ if (import.meta.hot) {
238
238
  <div class="min-w-fit" flex="~ col gap-3">
239
239
  <div border="~ main rounded-lg" p3 flex="~ col gap-2">
240
240
  <h2>Export as Vector File</h2>
241
- <div class="flex flex-col gap-2 items-start min-w-max">
242
- <button @click="pdf">
241
+ <div class="flex flex-col gap-2 min-w-max">
242
+ <button class="slidev-form-button" @click="pdf">
243
243
  PDF
244
244
  </button>
245
245
  </div>
@@ -253,22 +253,22 @@ if (import.meta.hot) {
253
253
  If you encounter issues, please use a modern Chromium-based browser,
254
254
  or export via the CLI.
255
255
  </div>
256
- <div class="flex flex-col gap-2 items-start min-w-max">
257
- <button @click="pptx">
256
+ <div class="flex flex-col gap-2 min-w-max">
257
+ <button class="slidev-form-button" @click="pptx">
258
258
  PPTX
259
259
  </button>
260
- <button @click="pngsGz">
260
+ <button class="slidev-form-button" @click="pngsGz">
261
261
  PNGs.gz
262
262
  </button>
263
263
  </div>
264
264
  <div w-full h-1px border="t main" my2 />
265
265
  <div class="relative flex flex-col gap-2 flex-nowrap">
266
- <div class="flex flex-col gap-2 items-start min-w-max">
267
- <button v-if="capturedImages" class="flex justify-center items-center gap-2" @click="capturedImages = null">
266
+ <div class="flex flex-col gap-2 min-w-max">
267
+ <button v-if="capturedImages" class="slidev-form-button flex justify-center items-center gap-2" @click="capturedImages = null">
268
268
  <span class="i-carbon:trash-can inline-block text-xl" />
269
269
  Clear Captured Images
270
270
  </button>
271
- <button v-else class="flex justify-center items-center gap-2" @click="capturePngs">
271
+ <button v-else class="slidev-form-button flex justify-center items-center gap-2" @click="capturePngs">
272
272
  <div class="i-carbon:camera-action inline-block text-xl" />
273
273
  Pre-capture Slides as Images
274
274
  </button>
@@ -325,10 +325,6 @@ if (import.meta.hot) {
325
325
  }
326
326
  }
327
327
 
328
- button {
329
- --uno: 'w-full rounded bg-gray:10 px-4 py-2 hover:bg-gray/20';
330
- }
331
-
332
328
  label {
333
329
  --uno: text-xl flex gap-2 items-center select-none;
334
330
 
package/pages/notes.vue CHANGED
@@ -19,7 +19,7 @@ const { isFullscreen, toggle: toggleFullscreen } = fullscreen
19
19
 
20
20
  const scroller = ref<HTMLDivElement>()
21
21
  const fontSize = useLocalStorage('slidev-notes-font-size', 18)
22
- const pageNo = computed(() => sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerPage : sharedState.page)
22
+ const pageNo = computed(() => sharedState.page)
23
23
  const currentRoute = computed(() => slides.value.find(i => i.no === pageNo.value))
24
24
 
25
25
  watch(pageNo, () => {
@@ -36,8 +36,8 @@ function decreaseFontSize() {
36
36
  }
37
37
 
38
38
  const clicksContext = computed(() => {
39
- const clicks = sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerClicks : sharedState.clicks
40
- const total = sharedState.lastUpdate?.type === 'viewer' ? sharedState.viewerClicksTotal : sharedState.clicksTotal
39
+ const clicks = sharedState.clicks
40
+ const total = sharedState.clicksTotal
41
41
  return createClicksContextBase(ref(clicks), undefined, total)
42
42
  })
43
43
  </script>
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useHead } from '@unhead/vue'
3
- import { useMouse, useWindowFocus } from '@vueuse/core'
3
+ import { useLocalStorage, useMouse, useWindowFocus } from '@vueuse/core'
4
4
  import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
5
5
  import { createFixedClicks } from '../composables/useClicks'
6
6
  import { useDrawings } from '../composables/useDrawings'
@@ -18,6 +18,8 @@ import NavControls from '../internals/NavControls.vue'
18
18
  import NoteEditable from '../internals/NoteEditable.vue'
19
19
  import NoteStatic from '../internals/NoteStatic.vue'
20
20
  import QuickOverview from '../internals/QuickOverview.vue'
21
+ import ScreenCaptureMirror from '../internals/ScreenCaptureMirror.vue'
22
+ import SegmentControl from '../internals/SegmentControl.vue'
21
23
  import SlideContainer from '../internals/SlideContainer.vue'
22
24
  import SlidesShow from '../internals/SlidesShow.vue'
23
25
  import SlideWrapper from '../internals/SlideWrapper.vue'
@@ -26,6 +28,7 @@ import { registerShortcuts } from '../logic/shortcuts'
26
28
  import { decreasePresenterFontSize, increasePresenterFontSize, presenterLayout, presenterNotesFontSize, showEditor, showPresenterCursor } from '../state'
27
29
  import { sharedState } from '../state/shared'
28
30
 
31
+ const inFocus = useWindowFocus()
29
32
  const main = ref<HTMLDivElement>()
30
33
 
31
34
  registerShortcuts()
@@ -49,7 +52,7 @@ useHead({ title: `Presenter - ${slidesTitle}` })
49
52
 
50
53
  const notesEditing = ref(false)
51
54
 
52
- const { timer, isTimerAvctive, resetTimer, toggleTimer } = useTimer()
55
+ const { timer, isTimerActive, resetTimer, toggleTimer } = useTimer()
53
56
 
54
57
  const clicksCtxMap = computed(() => slides.value.map(route => createFixedClicks(route)))
55
58
  const nextFrame = computed(() => {
@@ -74,6 +77,7 @@ watch(
74
77
  { immediate: true },
75
78
  )
76
79
 
80
+ const mainSlideMode = useLocalStorage<'slides' | 'mirror'>('slidev-presenter-main-slide-mode', 'slides')
77
81
  const SideEditor = shallowRef<any>()
78
82
  if (__DEV__ && __SLIDEV_FEATURE_EDITOR__)
79
83
  import('../internals/SideEditor.vue').then(v => SideEditor.value = v.default)
@@ -86,7 +90,7 @@ onMounted(() => {
86
90
 
87
91
  watch(
88
92
  () => {
89
- if (!focus.value || isDrawing.value || !showPresenterCursor.value)
93
+ if (!focus.value || isDrawing.value || !showPresenterCursor.value || !slidesContainer)
90
94
  return undefined
91
95
 
92
96
  const rect = slidesContainer.getBoundingClientRect()
@@ -106,25 +110,39 @@ onMounted(() => {
106
110
  </script>
107
111
 
108
112
  <template>
109
- <div class="bg-main h-full slidev-presenter">
113
+ <div class="bg-main h-full slidev-presenter" pt-2px>
110
114
  <div class="grid-container" :class="`layout${presenterLayout}`">
111
115
  <div ref="main" class="relative grid-section main flex flex-col">
112
- <SlideContainer
113
- key="main"
114
- class="p-2 lg:p-4 flex-auto"
115
- is-main
116
- @contextmenu="onContextMenu"
117
- >
118
- <SlidesShow render-context="presenter" />
119
- </SlideContainer>
116
+ <div flex="~ gap-4 items-center" border="b main" p1>
117
+ <span op50 px2>Current</span>
118
+ <div flex-auto />
119
+ <SegmentControl
120
+ v-model="mainSlideMode"
121
+ :options="[
122
+ { label: 'Slides', value: 'slides' },
123
+ { label: 'Screen Mirror', value: 'mirror' },
124
+ ]"
125
+ />
126
+ </div>
127
+ <template v-if="mainSlideMode === 'mirror'">
128
+ <ScreenCaptureMirror />
129
+ </template>
130
+ <template v-else>
131
+ <SlideContainer
132
+ key="main"
133
+ class="p-2 lg:p-4 flex-auto"
134
+ is-main
135
+ @contextmenu="onContextMenu"
136
+ >
137
+ <SlidesShow render-context="presenter" />
138
+ </SlideContainer>
139
+ </template>
140
+
120
141
  <ClicksSlider
121
142
  :key="currentSlideRoute?.no"
122
143
  :clicks-context="getPrimaryClicks(currentSlideRoute)"
123
144
  class="w-full pb2 px4 flex-none"
124
145
  />
125
- <div class="absolute left-0 top-0 bg-main border-b border-r border-main px2 py1 op50 text-sm">
126
- Current
127
- </div>
128
146
  </div>
129
147
  <div class="relative grid-section next flex flex-col p-2 lg:p-4">
130
148
  <SlideContainer v-if="nextFrame && nextFrameClicksCtx" key="next">
@@ -165,7 +183,8 @@ onMounted(() => {
165
183
  :style="{ fontSize: `${presenterNotesFontSize}em` }"
166
184
  :clicks-context="clicksContext"
167
185
  />
168
- <div class="border-t border-main py-1 px-2 text-sm">
186
+ <div border-t border-main />
187
+ <div class="py-1 px-2 text-sm transition" :class="inFocus ? '' : 'op25'">
169
188
  <IconButton title="Increase font size" @click="increasePresenterFontSize">
170
189
  <div class="i-carbon:zoom-in" />
171
190
  </IconButton>
@@ -182,14 +201,14 @@ onMounted(() => {
182
201
  </div>
183
202
  </div>
184
203
  <div class="grid-section bottom flex">
185
- <NavControls :persist="true" />
204
+ <NavControls :persist="true" class="transition" :class="inFocus ? '' : 'op25'" />
186
205
  <div flex-auto />
187
206
  <div class="group flex items-center justify-center pl-4 select-none">
188
207
  <div class="w-22px cursor-pointer">
189
208
  <div class="i-carbon:time group-hover:hidden text-xl" />
190
209
  <div class="group-not-hover:hidden flex flex-col items-center">
191
210
  <div class="relative op-80 hover:op-100" @click="toggleTimer">
192
- <div v-if="isTimerAvctive" class="i-carbon:pause text-lg" />
211
+ <div v-if="isTimerActive" class="i-carbon:pause text-lg" />
193
212
  <div v-else class="i-carbon:play" />
194
213
  </div>
195
214
  <div class="op-80 hover:op-100" @click="resetTimer">
package/setup/root.ts CHANGED
@@ -11,6 +11,7 @@ import { configs, slidesTitle } from '../env'
11
11
  import { skipTransition } from '../logic/hmr'
12
12
  import { getSlidePath } from '../logic/slides'
13
13
  import { makeId } from '../logic/utils'
14
+ import { syncDirections } from '../state'
14
15
  import { initDrawingState } from '../state/drawings'
15
16
  import { initSharedState, onPatch, patch } from '../state/shared'
16
17
 
@@ -58,30 +59,28 @@ export default function setupRoot() {
58
59
  initDrawingState(`${slidesTitle} - drawings`)
59
60
 
60
61
  const id = `${location.origin}_${makeId()}`
62
+ const syncType = computed(() => isPresenter.value ? 'presenter' : 'viewer')
61
63
 
62
64
  // update shared state
63
65
  function updateSharedState() {
66
+ const shouldSend = isPresenter.value
67
+ ? syncDirections.value.presenterSend
68
+ : syncDirections.value.viewerSend
69
+
70
+ if (!shouldSend)
71
+ return
64
72
  if (isNotesViewer.value || isPrintMode.value)
65
73
  return
66
-
67
74
  // we allow Presenter mode, or Viewer mode from trusted origins to update the shared state
68
75
  if (!isPresenter.value && !TRUST_ORIGINS.includes(location.host.split(':')[0]))
69
76
  return
70
77
 
71
- if (isPresenter.value) {
72
- patch('page', +currentSlideNo.value)
73
- patch('clicks', clicksContext.value.current)
74
- patch('clicksTotal', clicksContext.value.total)
75
- }
76
- else {
77
- patch('viewerPage', +currentSlideNo.value)
78
- patch('viewerClicks', clicksContext.value.current)
79
- patch('viewerClicksTotal', clicksContext.value.total)
80
- }
81
-
78
+ patch('page', +currentSlideNo.value)
79
+ patch('clicks', clicksContext.value.current)
80
+ patch('clicksTotal', clicksContext.value.total)
82
81
  patch('lastUpdate', {
83
82
  id,
84
- type: isPresenter.value ? 'presenter' : 'viewer',
83
+ type: syncType.value,
85
84
  time: new Date().getTime(),
86
85
  })
87
86
  }
@@ -90,17 +89,26 @@ export default function setupRoot() {
90
89
  watch(clicksContext, updateSharedState)
91
90
 
92
91
  onPatch((state) => {
92
+ const shouldReceive = isPresenter.value
93
+ ? syncDirections.value.presenterReceive
94
+ : syncDirections.value.viewerReceive
95
+ if (!shouldReceive)
96
+ return
93
97
  if (!hasPrimarySlide.value || isPrintMode.value)
94
98
  return
95
- if (state.lastUpdate?.type === 'presenter' && (+state.page !== +currentSlideNo.value || +clicksContext.value.current !== +state.clicks)) {
96
- skipTransition.value = false
97
- router.replace({
98
- path: getSlidePath(state.page, isPresenter.value),
99
- query: {
100
- ...router.currentRoute.value.query,
101
- clicks: state.clicks || 0,
102
- },
103
- })
104
- }
99
+ if (state.lastUpdate?.type === syncType.value)
100
+ return
101
+ if ((+state.page === +currentSlideNo.value && +clicksContext.value.current === +state.clicks))
102
+ return
103
+ // if (state.lastUpdate?.type === 'presenter') {
104
+ skipTransition.value = false
105
+ router.replace({
106
+ path: getSlidePath(state.page, isPresenter.value),
107
+ query: {
108
+ ...router.currentRoute.value.query,
109
+ clicks: state.clicks || 0,
110
+ },
111
+ })
112
+ // }
105
113
  })
106
114
  }
package/state/drawings.ts CHANGED
@@ -9,4 +9,8 @@ export const {
9
9
  onUpdate: onDrawingUpdate,
10
10
  patch: patchDrawingState,
11
11
  state: drawingState,
12
- } = createSyncState<DrawingsState>(serverDrawingState, serverDrawingState, __SLIDEV_FEATURE_DRAWINGS_PERSIST__)
12
+ } = createSyncState<DrawingsState>(
13
+ serverDrawingState,
14
+ serverDrawingState,
15
+ __SLIDEV_FEATURE_DRAWINGS_PERSIST__,
16
+ )
package/state/index.ts CHANGED
@@ -1,56 +1 @@
1
- import type { DragElementState } from '../composables/useDragElements'
2
- import { breakpointsTailwind, isClient, useActiveElement, useBreakpoints, useFullscreen, useLocalStorage, useMagicKeys, useToggle, useWindowSize } from '@vueuse/core'
3
- import { computed, ref, shallowRef } from 'vue'
4
- import { slideAspect } from '../env'
5
-
6
- export const showRecordingDialog = ref(false)
7
- export const showInfoDialog = ref(false)
8
- export const showGotoDialog = ref(false)
9
- export const showOverview = ref(false)
10
-
11
- export const shortcutsEnabled = ref(true)
12
- export const breakpoints = useBreakpoints({
13
- xs: 460,
14
- ...breakpointsTailwind,
15
- })
16
- export const windowSize = useWindowSize()
17
- export const magicKeys = useMagicKeys()
18
- export const isScreenVertical = computed(() => windowSize.height.value - windowSize.width.value / slideAspect.value > 120)
19
- export const fullscreen = useFullscreen(isClient ? document.body : null)
20
-
21
- export const activeElement = useActiveElement()
22
- export const isInputting = computed(() => ['INPUT', 'TEXTAREA'].includes(activeElement.value?.tagName || ''))
23
- export const isOnFocus = computed(() => ['BUTTON', 'A'].includes(activeElement.value?.tagName || ''))
24
-
25
- export const currentCamera = useLocalStorage<string>('slidev-camera', 'default', { listenToStorageChanges: false })
26
- export const currentMic = useLocalStorage<string>('slidev-mic', 'default', { listenToStorageChanges: false })
27
- export const slideScale = useLocalStorage<number>('slidev-scale', 0)
28
- export const wakeLockEnabled = useLocalStorage('slidev-wake-lock', true)
29
- export const skipExportPdfTip = useLocalStorage('slidev-skip-export-pdf-tip', false)
30
-
31
- export const showPresenterCursor = useLocalStorage('slidev-presenter-cursor', true, { listenToStorageChanges: false })
32
- export const showEditor = useLocalStorage('slidev-show-editor', false, { listenToStorageChanges: false })
33
- export const isEditorVertical = useLocalStorage('slidev-editor-vertical', false, { listenToStorageChanges: false })
34
- export const editorWidth = useLocalStorage('slidev-editor-width', isClient ? window.innerWidth * 0.4 : 318, { listenToStorageChanges: false })
35
- export const editorHeight = useLocalStorage('slidev-editor-height', isClient ? window.innerHeight * 0.4 : 300, { listenToStorageChanges: false })
36
-
37
- export const activeDragElement = shallowRef<DragElementState | null>(null)
38
-
39
- export const presenterNotesFontSize = useLocalStorage('slidev-presenter-font-size', 1, { listenToStorageChanges: false })
40
- export const presenterLayout = useLocalStorage('slidev-presenter-layout', 1, { listenToStorageChanges: false })
41
-
42
- export function togglePresenterLayout() {
43
- presenterLayout.value = presenterLayout.value + 1
44
- if (presenterLayout.value > 3)
45
- presenterLayout.value = 1
46
- }
47
-
48
- export function increasePresenterFontSize() {
49
- presenterNotesFontSize.value = Math.min(2, presenterNotesFontSize.value + 0.1)
50
- }
51
-
52
- export function decreasePresenterFontSize() {
53
- presenterNotesFontSize.value = Math.max(0.5, presenterNotesFontSize.value - 0.1)
54
- }
55
-
56
- export const toggleOverview = useToggle(showOverview)
1
+ export * from './storage'
package/state/shared.ts CHANGED
@@ -11,10 +11,6 @@ export interface SharedState {
11
11
  y: number
12
12
  }
13
13
 
14
- viewerPage: number
15
- viewerClicks: number
16
- viewerClicksTotal: number
17
-
18
14
  lastUpdate?: {
19
15
  id: string
20
16
  type: 'presenter' | 'viewer'
@@ -26,9 +22,6 @@ const { init, onPatch, onUpdate, patch, state } = createSyncState<SharedState>(s
26
22
  page: 1,
27
23
  clicks: 0,
28
24
  clicksTotal: 0,
29
- viewerPage: 1,
30
- viewerClicks: 0,
31
- viewerClicksTotal: 0,
32
25
  })
33
26
 
34
27
  export {
@@ -0,0 +1,70 @@
1
+ import type { DragElementState } from '../composables/useDragElements'
2
+ import { breakpointsTailwind, isClient, useActiveElement, useBreakpoints, useFullscreen, useLocalStorage, useMagicKeys, useToggle, useWindowSize } from '@vueuse/core'
3
+ import { computed, ref, shallowRef } from 'vue'
4
+ import { slideAspect } from '../env'
5
+
6
+ export const showRecordingDialog = ref(false)
7
+ export const showInfoDialog = ref(false)
8
+ export const showGotoDialog = ref(false)
9
+ export const showOverview = ref(false)
10
+
11
+ export const shortcutsEnabled = ref(true)
12
+ export const breakpoints = useBreakpoints({
13
+ xs: 460,
14
+ ...breakpointsTailwind,
15
+ })
16
+ export const windowSize = useWindowSize()
17
+ export const magicKeys = useMagicKeys()
18
+ export const isScreenVertical = computed(() => windowSize.height.value - windowSize.width.value / slideAspect.value > 120)
19
+ export const fullscreen = useFullscreen(isClient ? document.body : null)
20
+
21
+ export const activeElement = useActiveElement()
22
+ export const isInputting = computed(() => ['INPUT', 'TEXTAREA'].includes(activeElement.value?.tagName || ''))
23
+ export const isOnFocus = computed(() => ['BUTTON', 'A'].includes(activeElement.value?.tagName || ''))
24
+
25
+ export const currentCamera = useLocalStorage<string>('slidev-camera', 'default', { listenToStorageChanges: false })
26
+ export const currentMic = useLocalStorage<string>('slidev-mic', 'default', { listenToStorageChanges: false })
27
+ export const slideScale = useLocalStorage<number>('slidev-scale', 0)
28
+ export const wakeLockEnabled = useLocalStorage('slidev-wake-lock', true)
29
+ export const skipExportPdfTip = useLocalStorage('slidev-skip-export-pdf-tip', false)
30
+
31
+ export const showPresenterCursor = useLocalStorage('slidev-presenter-cursor', true, { listenToStorageChanges: false })
32
+ export const showEditor = useLocalStorage('slidev-show-editor', false, { listenToStorageChanges: false })
33
+ export const isEditorVertical = useLocalStorage('slidev-editor-vertical', false, { listenToStorageChanges: false })
34
+ export const editorWidth = useLocalStorage('slidev-editor-width', isClient ? window.innerWidth * 0.4 : 318, { listenToStorageChanges: false })
35
+ export const editorHeight = useLocalStorage('slidev-editor-height', isClient ? window.innerHeight * 0.4 : 300, { listenToStorageChanges: false })
36
+
37
+ export const activeDragElement = shallowRef<DragElementState | null>(null)
38
+
39
+ export const presenterNotesFontSize = useLocalStorage('slidev-presenter-font-size', 1, { listenToStorageChanges: false })
40
+ export const presenterLayout = useLocalStorage('slidev-presenter-layout', 1, { listenToStorageChanges: false })
41
+
42
+ export function togglePresenterLayout() {
43
+ presenterLayout.value = presenterLayout.value + 1
44
+ if (presenterLayout.value > 3)
45
+ presenterLayout.value = 1
46
+ }
47
+
48
+ export function increasePresenterFontSize() {
49
+ presenterNotesFontSize.value = Math.min(2, presenterNotesFontSize.value + 0.1)
50
+ }
51
+
52
+ export function decreasePresenterFontSize() {
53
+ presenterNotesFontSize.value = Math.max(0.5, presenterNotesFontSize.value - 0.1)
54
+ }
55
+
56
+ export const toggleOverview = useToggle(showOverview)
57
+
58
+ export const syncDirections = useLocalStorage(
59
+ 'slidev-sync-directions',
60
+ {
61
+ viewerSend: true,
62
+ viewerReceive: true,
63
+ presenterSend: true,
64
+ presenterReceive: true,
65
+ },
66
+ {
67
+ listenToStorageChanges: false,
68
+ mergeDefaults: true,
69
+ },
70
+ )
package/styles/index.css CHANGED
@@ -121,6 +121,16 @@ html {
121
121
  transform: translateY(0.1em);
122
122
  }
123
123
 
124
+ .slidev-form-button {
125
+ --uno: text-white px-4 py-1 rounded border-b-2;
126
+ --uno: 'bg-gray-400:50 border-gray-800:50';
127
+ --uno: 'hover:(bg-gray-400:75 border-gray8:75)';
128
+ }
129
+ .slidev-form-button.primary {
130
+ --uno: bg-teal-600 border-teal-800;
131
+ --uno: 'hover:(bg-teal-500 border-teal-700)';
132
+ }
133
+
124
134
  /* Transform the position back for Rough Notation (v-mark) */
125
135
  .rough-annotation {
126
136
  transform: scale(calc(1 / var(--slidev-slide-scale)));
package/uno.config.ts CHANGED
@@ -39,6 +39,7 @@ export default defineConfig({
39
39
  'z-nav': 'z-50',
40
40
  'z-context-menu': 'z-60',
41
41
  'z-modal': 'z-70',
42
+ 'z-focus-indicator': 'z-200',
42
43
 
43
44
  'slidev-glass-effect': 'shadow-xl backdrop-blur-8 border border-main bg-main bg-opacity-75!',
44
45
  },