@slidev/client 52.14.2 → 52.15.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.
Files changed (43) hide show
  1. package/builtin/BlueSky.vue +167 -0
  2. package/builtin/CodeBlockWrapper.vue +1 -1
  3. package/builtin/Link.vue +2 -2
  4. package/builtin/Monaco.vue +1 -2
  5. package/builtin/Tweet.vue +1 -1
  6. package/builtin/VClick.ts +8 -0
  7. package/builtin/VClicks.ts +24 -4
  8. package/builtin/VDrag.vue +1 -0
  9. package/composables/useDragElements.ts +5 -2
  10. package/composables/useDrawings.ts +6 -6
  11. package/composables/useMousePosInSlide.ts +21 -0
  12. package/composables/usePreloadImages.ts +2 -1
  13. package/composables/useSlideInfo.ts +1 -2
  14. package/constants.ts +2 -1
  15. package/internals/DrawingControls.vue +25 -7
  16. package/internals/IconButton.vue +3 -1
  17. package/internals/LaserPointer.vue +66 -0
  18. package/internals/MenuButton.vue +1 -1
  19. package/internals/NavControls.vue +28 -18
  20. package/internals/PresenterMouse.vue +1 -1
  21. package/internals/Settings.vue +10 -1
  22. package/internals/ShikiEditor.vue +2 -4
  23. package/internals/SideEditor.vue +3 -1
  24. package/internals/SlideContainer.vue +18 -1
  25. package/logic/recording.ts +16 -8
  26. package/logic/slides.ts +20 -7
  27. package/modules/v-click.ts +42 -8
  28. package/package.json +22 -21
  29. package/pages/404.vue +3 -1
  30. package/pages/export.vue +5 -1
  31. package/pages/notes-edit.vue +3 -1
  32. package/pages/play.vue +2 -0
  33. package/pages/presenter.vue +18 -23
  34. package/setup/main.ts +1 -2
  35. package/setup/monaco.ts +4 -4
  36. package/setup/root.ts +1 -1
  37. package/shim-vue.d.ts +3 -1
  38. package/state/shared.ts +1 -0
  39. package/state/storage.ts +7 -0
  40. package/styles/index.css +8 -22
  41. package/styles/index.ts +23 -0
  42. package/styles/layouts-base.css +1 -1
  43. package/styles/vclick.css +54 -0
@@ -5,7 +5,7 @@ import { useDrawings } from '../composables/useDrawings'
5
5
  import { useNav } from '../composables/useNav'
6
6
  import { configs } from '../env'
7
7
  import { isColorSchemaConfigured, isDark, toggleDark } from '../logic/dark'
8
- import { activeElement, breakpoints, fullscreen, hasViewerCssFilter, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterLayout } from '../state'
8
+ import { activeElement, breakpoints, cursorStyle, fullscreen, hasViewerCssFilter, presenterLayout, showEditor, showInfoDialog, showPresenterCursor, toggleOverview, togglePresenterCursor, togglePresenterLayout } from '../state'
9
9
  import { downloadPDF } from '../utils'
10
10
  import IconButton from './IconButton.vue'
11
11
  import MenuButton from './MenuButton.vue'
@@ -67,10 +67,10 @@ if (__SLIDEV_FEATURE_RECORD__)
67
67
  <div v-if="isFullscreen" class="i-carbon:minimize" />
68
68
  <div v-else class="i-carbon:maximize" />
69
69
  </IconButton>
70
- <IconButton :class="{ disabled: !hasPrev }" title="Go to previous slide" @click="prev">
70
+ <IconButton :disabled="!hasPrev" title="Go to previous slide" @click="prev">
71
71
  <div class="i-carbon:arrow-left" />
72
72
  </IconButton>
73
- <IconButton :class="{ disabled: !hasNext }" title="Go to next slide" @click="next">
73
+ <IconButton :disabled="!hasNext" title="Go to next slide" @click="next">
74
74
  <div class="i-carbon:arrow-right" />
75
75
  </IconButton>
76
76
  <IconButton v-if="!isEmbedded" title="Show slide overview" @click="toggleOverview()">
@@ -81,8 +81,8 @@ if (__SLIDEV_FEATURE_RECORD__)
81
81
  :title="isDark ? 'Switch to light mode theme' : 'Switch to dark mode theme'"
82
82
  @click="toggleDark()"
83
83
  >
84
- <carbon-moon v-if="isDark" />
85
- <carbon-sun v-else />
84
+ <div v-if="isDark" class="i-carbon-moon" />
85
+ <div v-else class="i-carbon-sun" />
86
86
  </IconButton>
87
87
 
88
88
  <VerticalDivider />
@@ -95,16 +95,28 @@ if (__SLIDEV_FEATURE_RECORD__)
95
95
 
96
96
  <IconButton
97
97
  v-if="isPresenter"
98
- :title="showPresenterCursor ? 'Hide presenter cursor' : 'Show presenter cursor'"
99
- @click="showPresenterCursor = !showPresenterCursor"
98
+ :title="cursorStyle === 'laser'
99
+ ? (showPresenterCursor ? 'Disable laser pointer' : 'Enable laser pointer')
100
+ : (showPresenterCursor ? 'Hide presenter cursor' : 'Show presenter cursor')"
101
+ :active="showPresenterCursor"
102
+ @click="togglePresenterCursor()"
100
103
  >
101
- <ph-cursor-fill v-if="showPresenterCursor" />
102
- <ph-cursor-duotone v-else />
104
+ <template v-if="cursorStyle === 'laser'">
105
+ <div v-if="showPresenterCursor" class="i-carbon-magic-wand-filled color-red-600 dark:color-red-400" />
106
+ <div v-else class="i-carbon-magic-wand" />
107
+ </template>
108
+ <template v-else>
109
+ <div v-if="showPresenterCursor" class="i-ph-cursor-fill" />
110
+ <div v-else class="i-ph-cursor-duotone" />
111
+ </template>
103
112
  </IconButton>
104
- </template>
105
-
106
- <template v-if="__SLIDEV_FEATURE_DRAWINGS__ && (!configs.drawings.presenterOnly || isPresenter) && !isEmbedded">
107
- <IconButton class="relative" :title="drawingEnabled ? 'Hide drawing toolbar' : 'Show drawing toolbar'" @click="drawingEnabled = !drawingEnabled">
113
+ <IconButton
114
+ v-if="__SLIDEV_FEATURE_DRAWINGS__ && (!configs.drawings.presenterOnly || isPresenter)"
115
+ class="relative"
116
+ :title="drawingEnabled ? 'Hide drawing toolbar' : 'Show drawing toolbar'"
117
+ :active="drawingEnabled"
118
+ @click="drawingEnabled = !drawingEnabled"
119
+ >
108
120
  <div class="i-carbon:pen" />
109
121
  <div
110
122
  v-if="drawingEnabled"
@@ -112,17 +124,15 @@ if (__SLIDEV_FEATURE_RECORD__)
112
124
  :style="{ background: brush.color }"
113
125
  />
114
126
  </IconButton>
127
+
115
128
  <VerticalDivider />
116
- </template>
117
129
 
118
- <template v-if="!isEmbedded">
119
130
  <IconButton v-if="isPresenter" title="Play Mode" @click="exitPresenter">
120
131
  <div class="i-carbon:presentation-file" />
121
132
  </IconButton>
122
133
  <IconButton v-if="__SLIDEV_FEATURE_PRESENTER__ && isPresenterAvailable" title="Presenter Mode" @click="enterPresenter">
123
134
  <div class="i-carbon:user-speaker" />
124
135
  </IconButton>
125
-
126
136
  <IconButton
127
137
  v-if="__DEV__ && __SLIDEV_FEATURE_EDITOR__"
128
138
  :title="showEditor ? 'Hide editor' : 'Show editor'"
@@ -164,8 +174,8 @@ if (__SLIDEV_FEATURE_RECORD__)
164
174
  <SyncControls v-if="__SLIDEV_FEATURE_PRESENTER__" />
165
175
 
166
176
  <MenuButton>
167
- <template #button>
168
- <IconButton title="More Options">
177
+ <template #button="{ value }">
178
+ <IconButton title="More Options" :active="value">
169
179
  <div class="i-carbon:settings-adjust" />
170
180
  <div v-if="hasViewerCssFilter" w-2 h-2 bg-primary rounded-full absolute top-0.5 right-0.5 />
171
181
  </IconButton>
@@ -4,7 +4,7 @@ import { sharedState } from '../state/shared'
4
4
 
5
5
  <template>
6
6
  <div
7
- v-if="sharedState.cursor"
7
+ v-if="sharedState.cursor?.style === 'cursor'"
8
8
  class="absolute top-0 left-0 right-0 bottom-0 pointer-events-none text-xl"
9
9
  >
10
10
  <ph-cursor-fill
@@ -1,7 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { useWakeLock } from '@vueuse/core'
3
3
  import { useNav } from '../composables/useNav'
4
- import { hideCursorIdle, slideScale, viewerCssFilter, viewerCssFilterDefaults, wakeLockEnabled } from '../state'
4
+ import { cursorStyle, hideCursorIdle, slideScale, viewerCssFilter, viewerCssFilterDefaults, wakeLockEnabled } from '../state'
5
5
  import FormCheckbox from './FormCheckbox.vue'
6
6
  import FormItem from './FormItem.vue'
7
7
  import FormSlider from './FormSlider.vue'
@@ -86,6 +86,15 @@ const { isSupported } = useWakeLock()
86
86
  />
87
87
  </FormItem>
88
88
  <div class="h-1px opacity-5 bg-current w-full my2" />
89
+ <FormItem title="Cursor Style">
90
+ <SegmentControl
91
+ v-model="cursorStyle"
92
+ :options="[
93
+ { label: 'Cursor', value: 'cursor' },
94
+ { label: 'Laser', value: 'laser' },
95
+ ]"
96
+ />
97
+ </FormItem>
89
98
  <FormItem
90
99
  v-if="!isPresenter"
91
100
  title="Slide Scale"
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { ref, shallowRef } from 'vue'
2
+ import { shallowRef } from 'vue'
3
3
  import { useIME } from '../composables/useIME'
4
4
 
5
5
  const props = defineProps<{
@@ -8,8 +8,6 @@ const props = defineProps<{
8
8
  const content = defineModel<string>({ required: true })
9
9
  const { composingContent, onInput, onCompositionEnd } = useIME(content)
10
10
 
11
- const textareaEl = ref<HTMLTextAreaElement | null>(null)
12
-
13
11
  const highlight = shallowRef<((code: string) => string) | null>(null)
14
12
  import('../setup/shiki').then(async (m) => {
15
13
  const { getEagerHighlighter, defaultHighlightOptions } = await m.default()
@@ -26,7 +24,7 @@ import('../setup/shiki').then(async (m) => {
26
24
  <div v-if="highlight" class="relative w-full h-max min-h-full">
27
25
  <div class="relative w-full h-max" v-html="`${highlight(composingContent)}&nbsp;`" />
28
26
  <textarea
29
- ref="textareaEl" v-model="composingContent" :placeholder="props.placeholder"
27
+ v-model="composingContent" :placeholder="props.placeholder"
30
28
  class="absolute inset-0 resize-none text-transparent bg-transparent focus:outline-none caret-black dark:caret-white overflow-y-hidden"
31
29
  @input="onInput"
32
30
  @compositionend="onCompositionEnd"
@@ -11,6 +11,8 @@ const props = defineProps<{
11
11
  resize?: boolean
12
12
  }>()
13
13
 
14
+ const RE_FRONTMATTER_BLOCK = /^---\n([\s\S]*?)\n---\n/
15
+
14
16
  const { currentSlideNo, openInEditor } = useNav()
15
17
 
16
18
  const tab = ref<'content' | 'note'>('content')
@@ -37,7 +39,7 @@ async function save() {
37
39
  dirty.value = false
38
40
 
39
41
  let frontmatterRaw: string | undefined
40
- const contentOnly = content.value.trim().replace(/^---\n([\s\S]*?)\n---\n/, (_, f) => {
42
+ const contentOnly = content.value.trim().replace(RE_FRONTMATTER_BLOCK, (_, f) => {
41
43
  frontmatterRaw = f
42
44
  return ''
43
45
  })
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import { provideLocal, useElementSize } from '@vueuse/core'
3
- import { computed, onUnmounted, ref, watchEffect } from 'vue'
3
+ import { recomputeAllPoppers } from 'floating-vue'
4
+ import { computed, onMounted, onUnmounted, ref, watch, watchEffect, watchSyncEffect } from 'vue'
4
5
  import { useNav } from '../composables/useNav'
5
6
  import { injectionSlideElement, injectionSlideScale } from '../constants'
6
7
  import { slideAspect, slideHeight, slideWidth } from '../env'
@@ -72,6 +73,18 @@ if (props.isMain) {
72
73
  provideLocal(injectionSlideScale, scale)
73
74
  provideLocal(injectionSlideElement, slideElement)
74
75
 
76
+ watchSyncEffect(() => {
77
+ if (props.isMain) {
78
+ mainSlideElement.value = slideElement.value
79
+ }
80
+ })
81
+
82
+ onMounted(() => {
83
+ watch(() => props.isMain && scale.value, () => {
84
+ recomputeAllPoppers()
85
+ })
86
+ })
87
+
75
88
  const snapshot = computed(() => {
76
89
  if (props.no == null || !props.useSnapshot)
77
90
  return undefined
@@ -79,6 +92,10 @@ const snapshot = computed(() => {
79
92
  })
80
93
  </script>
81
94
 
95
+ <script lang="ts">
96
+ export const mainSlideElement = ref<HTMLElement | null>(null)
97
+ </script>
98
+
82
99
  <template>
83
100
  <div
84
101
  v-if="!snapshot"
@@ -2,6 +2,7 @@ import type RecorderType from 'recordrtc'
2
2
  import type { Options as RecorderOptions } from 'recordrtc'
3
3
  import type { Ref } from 'vue'
4
4
  import { isTruthy } from '@antfu/utils'
5
+ import { fixWebmDuration } from '@fix-webm-duration/fix'
5
6
  import { useDevicesList, useEventListener, useLocalStorage } from '@vueuse/core'
6
7
  import { nextTick, ref, shallowRef, watch } from 'vue'
7
8
  import { currentCamera, currentMic } from '../state'
@@ -50,11 +51,11 @@ export const {
50
51
  } = useDevicesList({
51
52
  onUpdated() {
52
53
  if (currentCamera.value !== 'none') {
53
- if (!cameras.value.find(i => i.deviceId === currentCamera.value))
54
+ if (!cameras.value.some(i => i.deviceId === currentCamera.value))
54
55
  currentCamera.value = cameras.value[0]?.deviceId || 'default'
55
56
  }
56
57
  if (currentMic.value !== 'none') {
57
- if (!microphones.value.find(i => i.deviceId === currentMic.value))
58
+ if (!microphones.value.some(i => i.deviceId === currentMic.value))
58
59
  currentMic.value = microphones.value[0]?.deviceId || 'default'
59
60
  }
60
61
  },
@@ -78,6 +79,7 @@ export function useRecording() {
78
79
  const streamCamera: Ref<MediaStream | undefined> = shallowRef()
79
80
  const streamCapture: Ref<MediaStream | undefined> = shallowRef()
80
81
  const streamSlides: Ref<MediaStream | undefined> = shallowRef()
82
+ let recordingStartTime = 0
81
83
 
82
84
  const config: RecorderOptions = {
83
85
  type: 'video',
@@ -183,17 +185,18 @@ export function useRecording() {
183
185
  )
184
186
 
185
187
  recorderSlides.value.startRecording()
188
+ recordingStartTime = Date.now()
186
189
  recording.value = true
187
190
  }
188
191
 
189
192
  async function stopRecording() {
190
193
  recording.value = false
194
+ const duration = Date.now() - recordingStartTime
195
+
191
196
  recorderCamera.value?.stopRecording(() => {
192
197
  if (recordCamera.value) {
193
198
  const blob = recorderCamera.value!.getBlob()
194
- const url = URL.createObjectURL(blob)
195
- download(getFilename('camera', config.mimeType), url)
196
- window.URL.revokeObjectURL(url)
199
+ downloadBlob(blob, duration, getFilename('camera', config.mimeType))
197
200
  }
198
201
  recorderCamera.value = undefined
199
202
  if (!showAvatar.value)
@@ -201,15 +204,20 @@ export function useRecording() {
201
204
  })
202
205
  recorderSlides.value?.stopRecording(() => {
203
206
  const blob = recorderSlides.value!.getBlob()
204
- const url = URL.createObjectURL(blob)
205
- download(getFilename('screen', config.mimeType), url)
206
- window.URL.revokeObjectURL(url)
207
+ downloadBlob(blob, duration, getFilename('screen', config.mimeType))
207
208
  closeStream(streamCapture)
208
209
  closeStream(streamSlides)
209
210
  recorderSlides.value = undefined
210
211
  })
211
212
  }
212
213
 
214
+ async function downloadBlob(blob: Blob, duration: number, filename: string) {
215
+ const fixedBlob = await fixWebmDuration(blob, duration, { logger: false })
216
+ const url = URL.createObjectURL(fixedBlob)
217
+ download(filename, url)
218
+ window.URL.revokeObjectURL(url)
219
+ }
220
+
213
221
  function closeStream(stream: Ref<MediaStream | undefined>) {
214
222
  const s = stream.value
215
223
  if (!s)
package/logic/slides.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { SlideRoute } from '@slidev/types'
2
2
  import { slides } from '#slidev/slides'
3
- import { computed, watch, watchEffect } from 'vue'
3
+ import { tryOnMounted } from '@vueuse/core'
4
+ import { computed, watch } from 'vue'
4
5
  import { useSlideContext } from '../context'
5
6
 
6
7
  export { slides }
@@ -28,12 +29,24 @@ export function useIsSlideActive() {
28
29
  return computed(() => $page.value === $nav.value.currentSlideNo)
29
30
  }
30
31
 
31
- export function onSlideEnter(cb: () => void) {
32
- const isActive = useIsSlideActive()
33
- watchEffect(() => isActive.value && cb())
32
+ export function onSlideEnter(cb: (to: number, from: number | undefined) => any) {
33
+ const { $page, $nav } = useSlideContext()
34
+
35
+ tryOnMounted(() => {
36
+ watch(() => $nav.value.currentSlideNo, (to, from) => {
37
+ if ($page.value === to)
38
+ cb(to, from)
39
+ }, { immediate: true })
40
+ })
34
41
  }
35
42
 
36
- export function onSlideLeave(cb: () => void) {
37
- const isActive = useIsSlideActive()
38
- watch(isActive, () => !isActive.value && cb())
43
+ export function onSlideLeave(cb: (to: number, from: number | undefined) => any) {
44
+ const { $page, $nav } = useSlideContext()
45
+
46
+ tryOnMounted(() => {
47
+ watch(() => $nav.value.currentSlideNo, (to, from) => {
48
+ if ($page.value === from)
49
+ cb(to, from)
50
+ })
51
+ })
39
52
  }
@@ -2,16 +2,29 @@ import type { ClicksElement, RawAtValue } from '@slidev/types'
2
2
  import type { App, DirectiveBinding } from 'vue'
3
3
  import { computed, watchEffect } from 'vue'
4
4
  import {
5
+ CLASS_VCLICK_ANIMATION_PREFIX,
5
6
  CLASS_VCLICK_CURRENT,
6
- CLASS_VCLICK_FADE,
7
7
  CLASS_VCLICK_HIDDEN,
8
8
  CLASS_VCLICK_HIDDEN_EXP,
9
9
  CLASS_VCLICK_PRIOR,
10
10
  CLASS_VCLICK_TARGET,
11
11
  injectionClicksContext,
12
+ injectionFrontmatter,
12
13
  } from '../constants'
14
+ import { configs } from '../env'
13
15
  import { directiveInject } from '../utils'
14
16
 
17
+ const RE_WHITESPACE_OR_COMMA = /[\s,]+/
18
+
19
+ function syncAnimationClasses(el: HTMLElement, animations: string[]) {
20
+ const targetClasses = animations.map(a => `${CLASS_VCLICK_ANIMATION_PREFIX}${a}`)
21
+ el.classList.forEach((c) => {
22
+ if (c.startsWith(CLASS_VCLICK_ANIMATION_PREFIX) && !targetClasses.includes(c))
23
+ el.classList.remove(c)
24
+ })
25
+ targetClasses.forEach(c => el.classList.add(c))
26
+ }
27
+
15
28
  export function createVClickDirectives() {
16
29
  return {
17
30
  install(app: App) {
@@ -38,13 +51,15 @@ export function createVClickDirectives() {
38
51
  const prior = active && !current
39
52
 
40
53
  if (resolved.flagHide) {
41
- el.classList.toggle(resolved.flagFade ? CLASS_VCLICK_FADE : CLASS_VCLICK_HIDDEN, active)
54
+ el.classList.toggle(CLASS_VCLICK_HIDDEN, active)
42
55
  el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active)
43
56
  }
44
57
  else {
45
- el.classList.toggle(resolved.flagFade ? CLASS_VCLICK_FADE : CLASS_VCLICK_HIDDEN, !active)
58
+ el.classList.toggle(CLASS_VCLICK_HIDDEN, !active)
46
59
  }
47
60
 
61
+ syncAnimationClasses(el, resolved.flagAnimations.value)
62
+
48
63
  el.classList.toggle(CLASS_VCLICK_CURRENT, current)
49
64
  el.classList.toggle(CLASS_VCLICK_PRIOR, prior)
50
65
  })
@@ -70,13 +85,15 @@ export function createVClickDirectives() {
70
85
  const prior = active && !current
71
86
 
72
87
  if (resolved.flagHide) {
73
- el.classList.toggle(resolved.flagFade ? CLASS_VCLICK_FADE : CLASS_VCLICK_HIDDEN, active)
88
+ el.classList.toggle(CLASS_VCLICK_HIDDEN, active)
74
89
  el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active)
75
90
  }
76
91
  else {
77
- el.classList.toggle(resolved.flagFade ? CLASS_VCLICK_FADE : CLASS_VCLICK_HIDDEN, !active)
92
+ el.classList.toggle(CLASS_VCLICK_HIDDEN, !active)
78
93
  }
79
94
 
95
+ syncAnimationClasses(el, resolved.flagAnimations.value)
96
+
80
97
  el.classList.toggle(CLASS_VCLICK_CURRENT, current)
81
98
  el.classList.toggle(CLASS_VCLICK_PRIOR, prior)
82
99
  })
@@ -101,9 +118,11 @@ export function createVClickDirectives() {
101
118
  const current = resolved.isCurrent.value
102
119
  const prior = active && !current
103
120
 
104
- el.classList.toggle(resolved.flagFade ? CLASS_VCLICK_FADE : CLASS_VCLICK_HIDDEN, active)
121
+ el.classList.toggle(CLASS_VCLICK_HIDDEN, active)
105
122
  el.classList.toggle(CLASS_VCLICK_HIDDEN_EXP, active)
106
123
 
124
+ syncAnimationClasses(el, resolved.flagAnimations.value)
125
+
107
126
  el.classList.toggle(CLASS_VCLICK_CURRENT, current)
108
127
  el.classList.toggle(CLASS_VCLICK_PRIOR, prior)
109
128
  })
@@ -118,12 +137,27 @@ export const resolvedClickMap = new Map<ClicksElement, ReturnType<typeof resolve
118
137
 
119
138
  export function resolveClick(el: Element | string, dir: DirectiveBinding<any>, value: RawAtValue, explicitHide = false) {
120
139
  const ctx = directiveInject(dir, injectionClicksContext)?.value
140
+ const frontmatter = directiveInject(dir, injectionFrontmatter)
121
141
 
122
142
  if (!el || !ctx)
123
143
  return null
124
144
 
125
145
  const flagHide = explicitHide || (dir.modifiers.hide !== false && dir.modifiers.hide != null)
126
- const flagFade = dir.modifiers.fade !== false && dir.modifiers.fade != null
146
+
147
+ /**
148
+ * Resolves the animation presets for this element.
149
+ * Priority: directive modifiers (stacked) > slide frontmatter > global config.
150
+ * Modifiers allow composition, e.g., v-click.fade.up.scale.
151
+ */
152
+ const elModifiers = Object.keys({ ...dir.modifiers }).filter(m => m !== 'hide')
153
+ const flagAnimations = computed(() => {
154
+ if (elModifiers.length > 0)
155
+ return elModifiers
156
+ const preset = frontmatter?.clickAnimation || configs.clickAnimation
157
+ if (preset)
158
+ return preset.split(RE_WHITESPACE_OR_COMMA).filter(Boolean)
159
+ return []
160
+ })
127
161
 
128
162
  const info = ctx.calculate(value)
129
163
  if (!info)
@@ -145,8 +179,8 @@ export function resolveClick(el: Element | string, dir: DirectiveBinding<any>, v
145
179
  ...info,
146
180
  isShown,
147
181
  visibilityState,
148
- flagFade,
149
182
  flagHide,
183
+ flagAnimations,
150
184
  }
151
185
  resolvedClickMap.set(el, resolved)
152
186
  return resolved
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@slidev/client",
3
3
  "type": "module",
4
- "version": "52.14.2",
4
+ "version": "52.15.1",
5
5
  "description": "Presentation slides for developers",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -29,18 +29,19 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@antfu/utils": "^9.3.0",
32
- "@iconify-json/carbon": "^1.2.19",
32
+ "@fix-webm-duration/fix": "^1.0.1",
33
+ "@iconify-json/carbon": "^1.2.20",
33
34
  "@iconify-json/ph": "^1.2.2",
34
35
  "@iconify-json/svg-spinners": "^1.2.4",
35
- "@shikijs/engine-javascript": "^4.0.1",
36
- "@shikijs/monaco": "^4.0.1",
37
- "@shikijs/vitepress-twoslash": "^4.0.1",
36
+ "@shikijs/engine-javascript": "^4.0.2",
37
+ "@shikijs/monaco": "^4.0.2",
38
+ "@shikijs/vitepress-twoslash": "^4.0.2",
38
39
  "@slidev/rough-notation": "^0.1.0",
39
40
  "@typescript/ata": "^0.9.8",
40
- "@unhead/vue": "^2.1.10",
41
- "@unocss/extractor-mdc": "^66.6.3",
42
- "@unocss/preset-mini": "^66.6.3",
43
- "@unocss/reset": "^66.6.3",
41
+ "@unhead/vue": "^2.1.13",
42
+ "@unocss/extractor-mdc": "^66.6.8",
43
+ "@unocss/preset-mini": "^66.6.8",
44
+ "@unocss/reset": "^66.6.8",
44
45
  "@vueuse/core": "^14.2.1",
45
46
  "@vueuse/math": "^14.2.1",
46
47
  "@vueuse/motion": "^3.0.3",
@@ -48,25 +49,25 @@
48
49
  "drauu": "^1.0.0",
49
50
  "file-saver": "^2.0.5",
50
51
  "floating-vue": "^5.2.2",
51
- "fuse.js": "^7.1.0",
52
- "katex": "^0.16.33",
52
+ "fuse.js": "^7.3.0",
53
+ "katex": "^0.16.45",
53
54
  "lz-string": "^1.5.0",
54
- "mermaid": "^11.12.3",
55
+ "mermaid": "^11.14.0",
55
56
  "monaco-editor": "^0.55.1",
56
57
  "nanotar": "^0.3.0",
57
58
  "pptxgenjs": "^4.0.1",
58
59
  "recordrtc": "^5.6.2",
59
- "shiki": "^4.0.1",
60
- "shiki-magic-move": "^1.2.1",
60
+ "shiki": "^4.0.2",
61
+ "shiki-magic-move": "^1.3.0",
61
62
  "typescript": "^5.9.3",
62
- "unocss": "^66.6.2",
63
- "vue": "^3.5.29",
64
- "vue-router": "^5.0.3",
65
- "yaml": "^2.8.2",
66
- "@slidev/types": "52.14.2",
67
- "@slidev/parser": "52.14.2"
63
+ "unocss": "^66.6.8",
64
+ "vue": "^3.5.33",
65
+ "vue-router": "^5.0.6",
66
+ "yaml": "^2.8.3",
67
+ "@slidev/parser": "52.15.1",
68
+ "@slidev/types": "52.15.1"
68
69
  },
69
70
  "devDependencies": {
70
- "vite": "^7.3.1"
71
+ "vite": "^8.0.10"
71
72
  }
72
73
  }
package/pages/404.vue CHANGED
@@ -3,12 +3,14 @@ import { computed } from 'vue'
3
3
  import { useRouter } from 'vue-router'
4
4
  import { useNav } from '../composables/useNav'
5
5
 
6
+ const RE_DIGITS = /\d+/
7
+
6
8
  const { currentRoute } = useRouter()
7
9
  const { total } = useNav()
8
10
 
9
11
  const guessedSlide = computed(() => {
10
12
  const path = currentRoute.value.path
11
- const match = path.match(/\d+/)
13
+ const match = path.match(RE_DIGITS)
12
14
  if (match) {
13
15
  const slideNo = +match[0]
14
16
  if (slideNo > 0 && slideNo <= total.value)
package/pages/export.vue CHANGED
@@ -161,7 +161,7 @@ async function pngsGz() {
161
161
  })),
162
162
  )
163
163
  const a = document.createElement('a')
164
- const blob = new Blob([data], { type: 'application/gzip' })
164
+ const blob = new Blob([data as any], { type: 'application/gzip' })
165
165
  a.href = URL.createObjectURL(blob)
166
166
  a.download = `${title.value}.tar.gz`
167
167
  a.click()
@@ -351,6 +351,10 @@ h2 {
351
351
  #export-content {
352
352
  --uno: pointer-events-none;
353
353
  }
354
+
355
+ #export-container {
356
+ --slidev-slide-container-scale: var(--slidev-slide-scale);
357
+ }
354
358
  </style>
355
359
 
356
360
  <style>
@@ -9,6 +9,8 @@ import { slidesTitle } from '../env'
9
9
  import IconButton from '../internals/IconButton.vue'
10
10
  import Modal from '../internals/Modal.vue'
11
11
 
12
+ const RE_SLIDE_HEADER = /^---\s*#(\d+)\s*$/
13
+
12
14
  useHead({ title: `Notes Edit - ${slidesTitle}` })
13
15
 
14
16
  const { slides } = useNav()
@@ -35,7 +37,7 @@ function deserializeNotes(notes: string, slides: SlideRoute[]) {
35
37
  const lines = notes.split(/^(---\s*#\d+\s*)$/gm)
36
38
 
37
39
  lines.forEach((line, index) => {
38
- const match = line.match(/^---\s*#(\d+)\s*$/)
40
+ const match = line.match(RE_SLIDE_HEADER)
39
41
  if (match) {
40
42
  const no = Number.parseInt(match[1])
41
43
  const note = lines[index + 1].trim()
package/pages/play.vue CHANGED
@@ -7,6 +7,7 @@ import { useNav } from '../composables/useNav'
7
7
  import { useSwipeControls } from '../composables/useSwipeControls'
8
8
  import { useWakeLock } from '../composables/useWakeLock'
9
9
  import Controls from '../internals/Controls.vue'
10
+ import LaserPointer from '../internals/LaserPointer.vue'
10
11
  import NavControls from '../internals/NavControls.vue'
11
12
  import PresenterMouse from '../internals/PresenterMouse.vue'
12
13
  import SlideContainer from '../internals/SlideContainer.vue'
@@ -97,6 +98,7 @@ const contentStyle = computed(() => {
97
98
  <template #default>
98
99
  <SlidesShow render-context="slide" />
99
100
  <PresenterMouse />
101
+ <LaserPointer />
100
102
  </template>
101
103
  <template #controls>
102
104
  <div