@slidev/client 52.14.1 → 52.15.0

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 (44) 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 +2 -2
  16. package/internals/Goto.vue +1 -1
  17. package/internals/IconButton.vue +3 -1
  18. package/internals/LaserPointer.vue +66 -0
  19. package/internals/MenuButton.vue +1 -1
  20. package/internals/NavControls.vue +28 -18
  21. package/internals/PresenterMouse.vue +1 -1
  22. package/internals/Settings.vue +10 -1
  23. package/internals/ShikiEditor.vue +2 -4
  24. package/internals/SideEditor.vue +3 -1
  25. package/internals/SlideContainer.vue +18 -1
  26. package/logic/recording.ts +16 -8
  27. package/logic/slides.ts +23 -11
  28. package/modules/v-click.ts +42 -8
  29. package/package.json +23 -22
  30. package/pages/404.vue +3 -1
  31. package/pages/export.vue +5 -1
  32. package/pages/notes-edit.vue +3 -1
  33. package/pages/play.vue +2 -0
  34. package/pages/presenter.vue +18 -23
  35. package/setup/main.ts +1 -2
  36. package/setup/monaco.ts +4 -4
  37. package/setup/root.ts +1 -1
  38. package/shim-vue.d.ts +3 -1
  39. package/state/shared.ts +1 -0
  40. package/state/storage.ts +7 -0
  41. package/styles/index.css +8 -22
  42. package/styles/index.ts +23 -0
  43. package/styles/layouts-base.css +1 -1
  44. package/styles/vclick.css +54 -0
@@ -0,0 +1,167 @@
1
+ <!--
2
+ A simple wrapper for embedded Bluesky posts
3
+
4
+ Usage:
5
+
6
+ <BlueSky uri="at://did:plc:432mbsu2xucyvxl6sluohidu/app.bsky.feed.post/3lgwg2vy44s2q" />
7
+ <BlueSky uri="https://bsky.app/profile/sli.dev/post/3lgwg2vy44s2q" />
8
+ -->
9
+
10
+ <script setup lang="ts">
11
+ import { nextTick, onMounted, ref } from 'vue'
12
+ import { isDark } from '../logic/dark'
13
+
14
+ const props = defineProps<{
15
+ uri: string
16
+ scale?: string | number
17
+ }>()
18
+
19
+ const RE_BLUESKY_POST_URL = /^\/profile\/([^/]+)\/post\/([^/?#]+)$/
20
+
21
+ const container = ref<HTMLElement | null>(null)
22
+ const loaded = ref(false)
23
+ const postNotFound = ref(false)
24
+ const resolvedUri = ref('')
25
+ const resolvedPostUrl = ref('')
26
+
27
+ const RESOLVE_HANDLE_URL = 'https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle'
28
+ const GET_POST_THREAD_URL = 'https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread'
29
+
30
+ interface BlueskyThreadResponse {
31
+ thread?: {
32
+ post?: {
33
+ uri?: string
34
+ }
35
+ }
36
+ }
37
+
38
+ function scan(retries = 10) {
39
+ // @ts-expect-error global
40
+ if (!window.bluesky?.scan) {
41
+ if (retries <= 0)
42
+ console.error('Failed to load Bluesky embed widget after 10 retries.')
43
+ if (retries <= 0)
44
+ return
45
+ setTimeout(scan, 1000, retries - 1)
46
+ return
47
+ }
48
+ // @ts-expect-error global
49
+ window.bluesky.scan(container.value || undefined)
50
+ }
51
+
52
+ function extractPostInfoFromUrl(input: string) {
53
+ try {
54
+ const url = new URL(input)
55
+ const match = url.pathname.match(RE_BLUESKY_POST_URL)
56
+ if (!match)
57
+ return
58
+ return {
59
+ handle: decodeURIComponent(match[1]),
60
+ postId: decodeURIComponent(match[2]),
61
+ postUrl: url.toString(),
62
+ }
63
+ }
64
+ catch {}
65
+ }
66
+
67
+ async function resolvePostUri(input: string) {
68
+ try {
69
+ if (input.startsWith('at://')) {
70
+ resolvedUri.value = input
71
+ resolvedPostUrl.value = ''
72
+ return true
73
+ }
74
+
75
+ const info = extractPostInfoFromUrl(input)
76
+ if (!info)
77
+ return false
78
+
79
+ const handleResponse = await fetch(`${RESOLVE_HANDLE_URL}?handle=${encodeURIComponent(info.handle)}`)
80
+ if (!handleResponse.ok)
81
+ return false
82
+ const handleData = await handleResponse.json() as { did?: string }
83
+ if (!handleData.did)
84
+ return false
85
+
86
+ const uri = `at://${handleData.did}/app.bsky.feed.post/${info.postId}`
87
+ const threadResponse = await fetch(
88
+ `${GET_POST_THREAD_URL}?uri=${encodeURIComponent(uri)}&depth=0&parentHeight=0`,
89
+ )
90
+ if (!threadResponse.ok)
91
+ return false
92
+
93
+ const threadData = await threadResponse.json() as BlueskyThreadResponse
94
+ const canonicalUri = threadData.thread?.post?.uri
95
+ if (!canonicalUri)
96
+ return false
97
+
98
+ resolvedUri.value = canonicalUri
99
+ resolvedPostUrl.value = info.postUrl
100
+ return true
101
+ }
102
+ catch {
103
+ return false
104
+ }
105
+ }
106
+
107
+ async function create(retries = 10) {
108
+ loaded.value = false
109
+ postNotFound.value = false
110
+
111
+ if (!resolvedUri.value) {
112
+ const ok = await resolvePostUri(props.uri)
113
+ if (!ok) {
114
+ postNotFound.value = true
115
+ loaded.value = true
116
+ return
117
+ }
118
+ }
119
+
120
+ await nextTick()
121
+
122
+ // @ts-expect-error global
123
+ if (!window.bluesky?.scan) {
124
+ if (retries <= 0) {
125
+ console.error('Failed to load Bluesky embed widget after 10 retries.')
126
+ postNotFound.value = true
127
+ loaded.value = true
128
+ return
129
+ }
130
+ setTimeout(create, 1000, retries - 1)
131
+ return
132
+ }
133
+
134
+ scan()
135
+ loaded.value = true
136
+ }
137
+
138
+ onMounted(async () => {
139
+ await create()
140
+ })
141
+ </script>
142
+
143
+ <template>
144
+ <Transform :scale="scale || 1">
145
+ <div ref="container" class="slidev-bluesky">
146
+ <blockquote
147
+ v-if="resolvedUri"
148
+ class="bluesky-embed"
149
+ :data-bluesky-uri="resolvedUri"
150
+ :data-bluesky-embed-color-mode="isDark ? 'dark' : 'light'"
151
+ />
152
+ <div v-if="!loaded || postNotFound" class="h-30 w-30 my-10px bg-gray-400 bg-opacity-10 rounded-lg flex opacity-50">
153
+ <div class="m-auto animate-pulse text-4xl">
154
+ <div class="i-simple-icons:bluesky" />
155
+ <span v-if="postNotFound">Could not load Bluesky post</span>
156
+ </div>
157
+ </div>
158
+ </div>
159
+ </Transform>
160
+ </template>
161
+
162
+ <style>
163
+ .slidev-bluesky .bluesky-embed iframe {
164
+ border-radius: 12px;
165
+ overflow: hidden;
166
+ }
167
+ </style>
@@ -48,7 +48,7 @@ const props = defineProps({
48
48
  },
49
49
  title: {
50
50
  type: String,
51
- default: undefined,
51
+ default: '',
52
52
  },
53
53
  })
54
54
 
package/builtin/Link.vue CHANGED
@@ -19,8 +19,8 @@ const { isPrintMode } = useNav()
19
19
  </script>
20
20
 
21
21
  <template>
22
- <RouterLink v-if="!isPrintMode && title" :to="String(to)" @click="$event.target.blur()" v-html="title" />
23
- <RouterLink v-else-if="!isPrintMode && !title" :to="String(to)" @click="$event.target.blur()">
22
+ <RouterLink v-if="!isPrintMode && title" :to="String(to)" @click="($event.target as HTMLElement)?.blur()" v-html="title" />
23
+ <RouterLink v-else-if="!isPrintMode && !title" :to="String(to)" @click="($event.target as HTMLElement)?.blur()">
24
24
  <slot />
25
25
  </RouterLink>
26
26
  <a v-else-if="isPrintMode && title" :href="`#${to}`" v-html="title" />
@@ -72,7 +72,6 @@ const extMap: Record<string, string> = {
72
72
  }
73
73
  const ext = extMap[props.lang] ?? props.lang
74
74
 
75
- const outer = ref<HTMLDivElement>()
76
75
  const container = ref<HTMLDivElement>()
77
76
 
78
77
  const contentHeight = ref(0)
@@ -206,7 +205,7 @@ onMounted(async () => {
206
205
 
207
206
  <template>
208
207
  <div class="relative slidev-monaco-container">
209
- <div ref="outer" class="relative slidev-monaco-container-inner" :style="{ height }">
208
+ <div class="relative slidev-monaco-container-inner" :style="{ height }">
210
209
  <div ref="container" class="absolute inset-0.5" />
211
210
  </div>
212
211
  <CodeRunner
package/builtin/Tweet.vue CHANGED
@@ -27,7 +27,7 @@ async function create(retries = 10) {
27
27
  if (!window.twttr?.widgets?.createTweet) {
28
28
  if (retries <= 0)
29
29
  return console.error('Failed to load Twitter widget after 10 retries.')
30
- setTimeout(() => create(retries - 1), 1000)
30
+ setTimeout(create, 1000, retries - 1)
31
31
  return
32
32
  }
33
33
  // @ts-expect-error global
package/builtin/VClick.ts CHANGED
@@ -19,10 +19,17 @@ export default defineComponent({
19
19
  type: Boolean,
20
20
  default: false,
21
21
  },
22
+ /**
23
+ * @deprecated use `animation` instead
24
+ */
22
25
  fade: {
23
26
  type: Boolean,
24
27
  default: false,
25
28
  },
29
+ animation: {
30
+ type: String,
31
+ default: undefined,
32
+ },
26
33
  wrapText: {
27
34
  type: Function as PropType<(text: VNode) => VNode>,
28
35
  default: (text: VNode) => h('span', text),
@@ -36,6 +43,7 @@ export default defineComponent({
36
43
  at: this.at,
37
44
  hide: this.hide,
38
45
  fade: this.fade,
46
+ animation: this.animation,
39
47
  handleSpecialElements: false,
40
48
  },
41
49
  {
@@ -11,6 +11,7 @@ import { normalizeSingleAtValue } from '../composables/useClicks'
11
11
  import VClickGap from './VClickGap.vue'
12
12
 
13
13
  const listTags = ['ul', 'ol']
14
+ const RE_WHITESPACE_OR_COMMA = /[\s,]+/
14
15
 
15
16
  export default defineComponent({
16
17
  props: {
@@ -30,10 +31,17 @@ export default defineComponent({
30
31
  type: Boolean,
31
32
  default: false,
32
33
  },
34
+ /**
35
+ * @deprecated use `animation` instead
36
+ */
33
37
  fade: {
34
38
  type: Boolean,
35
39
  default: false,
36
40
  },
41
+ animation: {
42
+ type: [String, Array<string>],
43
+ default: undefined,
44
+ },
37
45
  handleSpecialElements: {
38
46
  type: Boolean,
39
47
  default: true,
@@ -53,14 +61,26 @@ export default defineComponent({
53
61
  const click = resolveDirective('click')!
54
62
 
55
63
  const applyDirective = (node: VNode, value: number | string) => {
64
+ const modifiers: Record<string, boolean> = {
65
+ hide: this.hide,
66
+ }
67
+ if (typeof this.animation === 'string') {
68
+ this.animation.split(RE_WHITESPACE_OR_COMMA).forEach((a) => {
69
+ if (a)
70
+ modifiers[a] = true
71
+ })
72
+ }
73
+ else if (Array.isArray(this.animation)) {
74
+ this.animation.forEach(a => modifiers[a] = true)
75
+ }
76
+ if (this.fade)
77
+ modifiers.fade = true
78
+
56
79
  return withDirectives(node, [[
57
80
  click,
58
81
  value,
59
82
  '',
60
- {
61
- hide: this.hide,
62
- fade: this.fade,
63
- },
83
+ modifiers,
64
84
  ]])
65
85
  }
66
86
 
package/builtin/VDrag.vue CHANGED
@@ -8,6 +8,7 @@ const props = defineProps<{
8
8
  markdownSource?: DragElementMarkdownSource
9
9
  }>()
10
10
 
11
+ // @ts-expect-error TS6133 container is used as template ref
11
12
  const { dragId, container, containerStyle, mounted, unmounted, startDragging } = useDragElement(null, props.pos, props.markdownSource)
12
13
 
13
14
  onMounted(mounted)
@@ -11,6 +11,9 @@ import { useNav } from './useNav'
11
11
  import { useSlideBounds } from './useSlideBounds'
12
12
  import { useDynamicSlideInfo } from './useSlideInfo'
13
13
 
14
+ const RE_NEWLINE = /\r?\n/g
15
+ const RE_POS_ATTR = /pos=".*?"/
16
+
14
17
  export type DragElementDataSource = 'frontmatter' | 'prop' | 'directive'
15
18
  /**
16
19
  * Markdown source position, injected by markdown-it plugin
@@ -63,7 +66,7 @@ export function useDragElementsUpdater(no: number) {
63
66
  throw new Error(`[Slidev] VDrag Element ${id} is missing markdown source`)
64
67
 
65
68
  const [startLine, endLine, idx] = markdownSource
66
- const lines = info.value.content.split(/\r?\n/g)
69
+ const lines = info.value.content.split(RE_NEWLINE)
67
70
 
68
71
  let section = lines.slice(startLine, endLine).join('\n')
69
72
  let replaced = false
@@ -73,7 +76,7 @@ export function useDragElementsUpdater(no: number) {
73
76
  ? section.replace(/<(v-?drag-?\w*)(.*?)(\/)?>/gi, (full, tag, attrs, selfClose = '', index) => {
74
77
  if (index === idx) {
75
78
  replaced = true
76
- const posMatch = attrs.match(/pos=".*?"/)
79
+ const posMatch = attrs.match(RE_POS_ATTR)
77
80
  if (!posMatch)
78
81
  return `<${tag}${ensureSuffix(' ', attrs)}pos="${posStr}"${selfClose}>`
79
82
  const start = posMatch.index
@@ -1,5 +1,5 @@
1
1
  import type { Brush, Options as DrauuOptions, DrawingMode } from 'drauu'
2
- import { createSharedComposable, toReactive, useLocalStorage } from '@vueuse/core'
2
+ import { createSharedComposable, useLocalStorage } from '@vueuse/core'
3
3
  import { createDrauu } from 'drauu'
4
4
  import { computed, markRaw, nextTick, reactive, ref, watch } from 'vue'
5
5
  import { configs } from '../env'
@@ -22,11 +22,11 @@ export const useDrawings = createSharedComposable(() => {
22
22
 
23
23
  const drawingEnabled = useLocalStorage('slidev-drawing-enabled', false)
24
24
  const drawingPinned = useLocalStorage('slidev-drawing-pinned', false)
25
- const brush = toReactive(useLocalStorage<Brush>('slidev-drawing-brush', {
25
+ const brush = useLocalStorage<Brush>('slidev-drawing-brush', {
26
26
  color: brushColors[0],
27
27
  size: 4,
28
28
  mode: 'stylus',
29
- }))
29
+ })
30
30
 
31
31
  const isDrawing = ref(false)
32
32
  const canUndo = ref(false)
@@ -46,12 +46,12 @@ export const useDrawings = createSharedComposable(() => {
46
46
  if (v === 'arrow') {
47
47
  // eslint-disable-next-line ts/no-use-before-define
48
48
  drauu.mode = 'line'
49
- brush.arrowEnd = true
49
+ brush.value.arrowEnd = true
50
50
  }
51
51
  else {
52
52
  // eslint-disable-next-line ts/no-use-before-define
53
53
  drauu.mode = v
54
- brush.arrowEnd = false
54
+ brush.value.arrowEnd = false
55
55
  }
56
56
  },
57
57
  })
@@ -150,7 +150,7 @@ export const useDrawings = createSharedComposable(() => {
150
150
  clearDrauu()
151
151
  }
152
152
  else if (e.code.startsWith('Digit') && noModifier && +e.code[5] <= brushColors.length) {
153
- brush.color = brushColors[+e.code[5] - 1]
153
+ brush.value.color = brushColors[+e.code[5] - 1]
154
154
  }
155
155
  else {
156
156
  handled = false
@@ -0,0 +1,21 @@
1
+ import { useMouse } from '@vueuse/core'
2
+ import { computed } from 'vue'
3
+ import { mainSlideElement } from '../internals/SlideContainer.vue'
4
+
5
+ export function useMousePosInSlide() {
6
+ const mouse = useMouse()
7
+
8
+ return computed(() => {
9
+ const rect = mainSlideElement.value?.getBoundingClientRect()
10
+ if (!rect)
11
+ return undefined
12
+
13
+ const x = (mouse.x.value - rect.left) / rect.width * 100
14
+ const y = (mouse.y.value - rect.top) / rect.height * 100
15
+
16
+ if (x < 0 || x > 100 || y < 0 || y > 100)
17
+ return undefined
18
+
19
+ return { x, y }
20
+ })
21
+ }
@@ -5,11 +5,12 @@ import { watchEffect } from 'vue'
5
5
 
6
6
  const loaded = new Set<string>()
7
7
  const loading = new Set<string>()
8
+ const RE_TRAILING_SLASH = /\/$/
8
9
 
9
10
  function resolveUrl(url: string): string {
10
11
  if (url.startsWith('http') || url.startsWith('//'))
11
12
  return url
12
- const base = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
13
+ const base = (import.meta.env.BASE_URL || '/').replace(RE_TRAILING_SLASH, '')
13
14
  return `${base}${url.startsWith('/') ? url : `/${url}`}`
14
15
  }
15
16
 
@@ -1,6 +1,5 @@
1
1
  import type { SlideInfo, SlidePatch } from '@slidev/types'
2
- import type { MaybeRef } from '@vueuse/core'
3
- import type { Ref } from 'vue'
2
+ import type { MaybeRef, Ref } from 'vue'
4
3
  import { useFetch } from '@vueuse/core'
5
4
  import { computed, ref, unref } from 'vue'
6
5
  import { getSlide } from '../logic/slides'
package/constants.ts CHANGED
@@ -16,12 +16,12 @@ export const injectionSlideZoom = '$$slidev-slide-zoom' as unknown as InjectionK
16
16
 
17
17
  export const CLASS_VCLICK_TARGET = 'slidev-vclick-target'
18
18
  export const CLASS_VCLICK_HIDDEN = 'slidev-vclick-hidden'
19
- export const CLASS_VCLICK_FADE = 'slidev-vclick-fade'
20
19
  export const CLASS_VCLICK_GONE = 'slidev-vclick-gone'
21
20
  export const CLASS_VCLICK_HIDDEN_EXP = 'slidev-vclick-hidden-explicitly'
22
21
  export const CLASS_VCLICK_CURRENT = 'slidev-vclick-current'
23
22
  export const CLASS_VCLICK_PRIOR = 'slidev-vclick-prior'
24
23
  export const CLASS_VCLICK_DISPLAY_NONE = 'slidev-vclick-display-none'
24
+ export const CLASS_VCLICK_ANIMATION_PREFIX = 'slidev-vclick-anim-'
25
25
 
26
26
  export const CLICKS_MAX = 999999
27
27
 
@@ -46,6 +46,7 @@ export const FRONTMATTER_FIELDS = [
46
46
  'zoom',
47
47
  'dragPos',
48
48
  'lang',
49
+ 'clickAnimation',
49
50
  ]
50
51
 
51
52
  export const HEADMATTER_FIELDS = [
@@ -32,8 +32,8 @@ function setDrawingMode(mode: typeof drawingMode.value) {
32
32
  if (mode !== 'eraseLine')
33
33
  lastDrawingMode = mode
34
34
  }
35
- function setBrushColor(color: typeof brush.color) {
36
- brush.color = color
35
+ function setBrushColor(color: string) {
36
+ brush.value.color = color
37
37
  drawingEnabled.value = true
38
38
  drawingMode.value = lastDrawingMode
39
39
  }
@@ -26,7 +26,7 @@ const fuse = computed(() => new Fuse(slides.value.map(i => i.meta?.slide).filter
26
26
  }))
27
27
 
28
28
  const path = computed(() => text.value.startsWith('/') ? text.value.substring(1) : text.value)
29
- const result = computed(() => fuse.value.search(path.value).map(result => result.item))
29
+ const result = computed(() => path.value ? fuse.value.search(path.value).map(result => result.item) : [])
30
30
  const valid = computed(() => !!result.value.length)
31
31
 
32
32
  function goTo() {
@@ -6,13 +6,15 @@ const props = defineProps<{
6
6
  icon?: string
7
7
  as?: string
8
8
  to?: string
9
+ disabled?: boolean
10
+ active?: boolean
9
11
  }>()
10
12
 
11
13
  const type = computed(() => props.as || (props.to ? 'router-link' : 'button'))
12
14
  </script>
13
15
 
14
16
  <template>
15
- <component :is="type" class="slidev-icon-btn" :title="title" :to="to">
17
+ <component :is="type" class="slidev-icon-btn" :class="{ disabled, active }" :title="title" :to="to">
16
18
  <span class="sr-only">{{ title }}</span>
17
19
  <slot>
18
20
  <div :class="icon" />
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, watchEffect } from 'vue'
3
+ import { useMousePosInSlide } from '../composables/useMousePosInSlide'
4
+ import { useNav } from '../composables/useNav'
5
+ import { cursorStyle } from '../state'
6
+ import { sharedState } from '../state/shared'
7
+
8
+ const selfMouse = useMousePosInSlide()
9
+ const { isPresenter } = useNav()
10
+
11
+ const laserPointer = computed(() => {
12
+ if (!isPresenter.value && sharedState.cursor?.style === 'laser') {
13
+ return sharedState.cursor
14
+ }
15
+ if (cursorStyle.value === 'laser') {
16
+ return selfMouse.value
17
+ }
18
+ return null
19
+ })
20
+
21
+ onMounted(() => {
22
+ watchEffect(() => {
23
+ document.body.classList.toggle('slidev-self-laser-active', cursorStyle.value === 'laser' && !!laserPointer.value)
24
+ })
25
+ })
26
+ </script>
27
+
28
+ <template>
29
+ <div
30
+ v-if="laserPointer"
31
+ class="absolute top-0 left-0 right-0 bottom-0 pointer-events-none text-xl"
32
+ >
33
+ <div
34
+ class="laser-pointer"
35
+ :style="{
36
+ left: `${laserPointer.x}%`,
37
+ top: `${laserPointer.y}%`,
38
+ }"
39
+ />
40
+ </div>
41
+ </template>
42
+
43
+ <style scoped>
44
+ .laser-pointer {
45
+ position: absolute;
46
+ width: 6px;
47
+ height: 6px;
48
+ border-radius: 9999px;
49
+ transform: translate(-50%, -50%);
50
+ background: radial-gradient(
51
+ circle at center,
52
+ rgba(255, 80, 80, 1) 0%,
53
+ rgba(255, 0, 0, 0.95) 55%,
54
+ rgba(255, 0, 0, 0.1) 100%
55
+ );
56
+ box-shadow:
57
+ 0 0 10px rgba(255, 0, 0, 0.9),
58
+ 0 0 22px rgba(255, 0, 0, 0.55);
59
+ }
60
+ </style>
61
+
62
+ <style>
63
+ .slidev-self-laser-active .slidev-slide-container {
64
+ cursor: none;
65
+ }
66
+ </style>
@@ -25,7 +25,7 @@ onClickOutside(el, () => {
25
25
  <template>
26
26
  <div ref="el" class="flex relative">
27
27
  <button :class="{ disabled }" @click="value = !value">
28
- <slot name="button" :class="{ disabled }" />
28
+ <slot name="button" :value :class="{ disabled }" />
29
29
  </button>
30
30
  <KeepAlive>
31
31
  <div
@@ -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>