@slidev/client 52.2.4 → 52.3.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.
@@ -3,6 +3,8 @@ import { isDark } from '../logic/dark'
3
3
  </script>
4
4
 
5
5
  <template>
6
- <slot v-if="isDark" name="dark" />
7
- <slot v-else name="light" />
6
+ <div>
7
+ <slot v-if="isDark" name="dark" />
8
+ <slot v-else name="light" />
9
+ </div>
8
10
  </template>
@@ -2,6 +2,7 @@
2
2
  import type { KeyedTokensInfo } from 'shiki-magic-move/types'
3
3
  import type { PropType } from 'vue'
4
4
  import { sleep } from '@antfu/utils'
5
+ import { useClipboard } from '@vueuse/core'
5
6
  import lz from 'lz-string'
6
7
  import { ShikiMagicMovePrecompiled } from 'shiki-magic-move/vue'
7
8
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -43,6 +44,33 @@ const id = makeId()
43
44
  const stepIndex = ref(0)
44
45
  const container = ref<HTMLElement>()
45
46
 
47
+ const showCopyButton = computed(() => {
48
+ if (!configs.codeCopy)
49
+ return false
50
+
51
+ const magicCopy = configs.magicMoveCopy
52
+ if (!magicCopy)
53
+ return false
54
+
55
+ if (magicCopy === true || magicCopy === 'always')
56
+ return true
57
+
58
+ if (magicCopy === 'final')
59
+ return stepIndex.value === steps.length - 1
60
+
61
+ return false
62
+ })
63
+ const { copied, copy } = useClipboard()
64
+
65
+ function copyCode() {
66
+ // Use the code property directly from KeyedTokensInfo
67
+ const currentStep = steps[stepIndex.value]
68
+ if (!currentStep || !currentStep.code)
69
+ return
70
+
71
+ copy(currentStep.code.trim())
72
+ }
73
+
46
74
  // Normalized the ranges, to at least have one range
47
75
  const ranges = computed(() => props.stepRanges.map(i => i.length ? i : ['all']))
48
76
 
@@ -116,7 +144,7 @@ onMounted(() => {
116
144
  </script>
117
145
 
118
146
  <template>
119
- <div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative">
147
+ <div ref="container" class="slidev-code-wrapper slidev-code-magic-move relative group">
120
148
  <div v-if="title" class="slidev-code-block-title">
121
149
  <TitleIcon :title="title" />
122
150
  <div class="leading-1em">
@@ -135,6 +163,15 @@ onMounted(() => {
135
163
  stagger: 1,
136
164
  }"
137
165
  />
166
+ <button
167
+ v-if="showCopyButton"
168
+ class="slidev-code-copy absolute right-0 transition opacity-0 group-hover:opacity-20 hover:!opacity-100"
169
+ :class="title ? 'top-10' : 'top-0'"
170
+ :title="copied ? 'Copied' : 'Copy'" @click="copyCode()"
171
+ >
172
+ <ph-check-circle v-if="copied" class="p-2 w-8 h-8" />
173
+ <ph-clipboard v-else class="p-2 w-8 h-8" />
174
+ </button>
138
175
  </div>
139
176
  </template>
140
177
 
@@ -0,0 +1,30 @@
1
+ import type { ModelRef } from 'vue'
2
+ import { ref, watch } from 'vue'
3
+
4
+ export function useIME(content: ModelRef<string>) {
5
+ const composingContent = ref(content.value)
6
+ watch(content, (v) => {
7
+ if (v !== composingContent.value) {
8
+ composingContent.value = v
9
+ }
10
+ })
11
+
12
+ function onInput(e: Event) {
13
+ if (!(e instanceof InputEvent) || !(e.target instanceof HTMLTextAreaElement)) {
14
+ return
15
+ }
16
+
17
+ if (e.isComposing) {
18
+ composingContent.value = e.target.value
19
+ }
20
+ else {
21
+ content.value = e.target.value
22
+ }
23
+ }
24
+
25
+ function onCompositionEnd() {
26
+ content.value = composingContent.value
27
+ }
28
+
29
+ return { composingContent, onInput, onCompositionEnd }
30
+ }
@@ -148,20 +148,20 @@ watchEffect(() => {
148
148
  <div
149
149
  v-if="noteHtml"
150
150
  ref="noteDisplay"
151
- class="prose overflow-auto outline-none slidev-note"
151
+ class="prose dark:prose-invert overflow-auto outline-none slidev-note"
152
152
  :class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
153
153
  v-html="noteHtml"
154
154
  />
155
155
  <div
156
156
  v-else-if="note"
157
- class="prose overflow-auto outline-none slidev-note"
157
+ class="prose dark:prose-invert overflow-auto outline-none slidev-note"
158
158
  :class="props.class"
159
159
  >
160
160
  <p v-text="note" />
161
161
  </div>
162
162
  <div
163
163
  v-else
164
- class="prose overflow-auto outline-none opacity-50 italic select-none slidev-note"
164
+ class="prose dark:prose-invert overflow-auto outline-none opacity-50 italic select-none slidev-note"
165
165
  :class="props.class"
166
166
  >
167
167
  <p v-text="props.placeholder || 'No notes.'" />
@@ -131,7 +131,7 @@ watch(
131
131
  v-else
132
132
  ref="inputEl"
133
133
  v-model="note"
134
- class="prose resize-none overflow-auto outline-none bg-transparent block border-primary border-2"
134
+ class="prose dark:prose-invert resize-none overflow-auto outline-none bg-transparent block border-primary border-2"
135
135
  style="line-height: 1.75;"
136
136
  :style="[props.style, inputHeight != null ? { height: `${inputHeight}px` } : {}]"
137
137
  :class="props.class"
@@ -1,11 +1,13 @@
1
1
  <script setup lang="ts">
2
2
  import { getHighlighter } from '#slidev/shiki'
3
3
  import { ref, shallowRef } from 'vue'
4
+ import { useIME } from '../composables/useIME'
4
5
 
5
6
  const props = defineProps<{
6
7
  placeholder?: string
7
8
  }>()
8
9
  const content = defineModel<string>({ required: true })
10
+ const { composingContent, onInput, onCompositionEnd } = useIME(content)
9
11
 
10
12
  const textareaEl = ref<HTMLTextAreaElement | null>(null)
11
13
 
@@ -16,10 +18,12 @@ getHighlighter().then(h => highlight.value = h)
16
18
  <template>
17
19
  <div class="absolute left-3 right-0 inset-y-2 font-mono overflow-x-hidden overflow-y-auto cursor-text">
18
20
  <div v-if="highlight" class="relative w-full h-max min-h-full">
19
- <div class="relative w-full h-max" v-html="`${highlight(content, 'markdown')}&nbsp;`" />
21
+ <div class="relative w-full h-max" v-html="`${highlight(composingContent, 'markdown')}&nbsp;`" />
20
22
  <textarea
21
- ref="textareaEl" v-model="content" :placeholder="props.placeholder"
23
+ ref="textareaEl" v-model="composingContent" :placeholder="props.placeholder"
22
24
  class="absolute inset-0 resize-none text-transparent bg-transparent focus:outline-none caret-black dark:caret-white overflow-y-hidden"
25
+ @input="onInput"
26
+ @compositionend="onCompositionEnd"
23
27
  />
24
28
  </div>
25
29
  </div>
@@ -6,11 +6,11 @@ import { and, not } from '@vueuse/math'
6
6
  import { watch } from 'vue'
7
7
  import { useNav } from '../composables/useNav'
8
8
  import setupShortcuts from '../setup/shortcuts'
9
- import { fullscreen, isInputting, isOnFocus, magicKeys, shortcutsEnabled } from '../state'
9
+ import { fullscreen, isInputting, isOnFocus, magicKeys, shortcutsEnabled, shortcutsLocked } from '../state'
10
10
 
11
11
  export function registerShortcuts() {
12
12
  const { isPrintMode } = useNav()
13
- const enabled = and(not(isInputting), not(isOnFocus), not(isPrintMode), shortcutsEnabled)
13
+ const enabled = and(not(isInputting), not(isOnFocus), not(isPrintMode), shortcutsEnabled, not(shortcutsLocked))
14
14
 
15
15
  const allShortcuts = setupShortcuts()
16
16
  const shortcuts = new Map<string | Ref<boolean>, ShortcutOptions>(
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@slidev/client",
3
3
  "type": "module",
4
- "version": "52.2.4",
4
+ "version": "52.3.0",
5
5
  "description": "Presentation slides for developers",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -28,8 +28,8 @@
28
28
  "node": ">=18.0.0"
29
29
  },
30
30
  "dependencies": {
31
- "@antfu/utils": "^9.2.0",
32
- "@iconify-json/carbon": "^1.2.13",
31
+ "@antfu/utils": "^9.3.0",
32
+ "@iconify-json/carbon": "^1.2.14",
33
33
  "@iconify-json/ph": "^1.2.2",
34
34
  "@iconify-json/svg-spinners": "^1.2.4",
35
35
  "@shikijs/engine-javascript": "^3.13.0",
@@ -37,8 +37,8 @@
37
37
  "@shikijs/vitepress-twoslash": "^3.13.0",
38
38
  "@slidev/rough-notation": "^0.1.0",
39
39
  "@typescript/ata": "^0.9.8",
40
- "@unhead/vue": "^2.0.17",
41
- "@unocss/reset": "^66.5.1",
40
+ "@unhead/vue": "^2.0.19",
41
+ "@unocss/reset": "^66.5.4",
42
42
  "@vueuse/core": "^13.9.0",
43
43
  "@vueuse/math": "^13.9.0",
44
44
  "@vueuse/motion": "^3.0.3",
@@ -46,7 +46,7 @@
46
46
  "file-saver": "^2.0.5",
47
47
  "floating-vue": "^5.2.2",
48
48
  "fuse.js": "^7.1.0",
49
- "katex": "^0.16.22",
49
+ "katex": "^0.16.25",
50
50
  "lz-string": "^1.5.0",
51
51
  "mermaid": "^11.12.0",
52
52
  "monaco-editor": "^0.53.0",
@@ -55,16 +55,16 @@
55
55
  "prettier": "^3.6.2",
56
56
  "recordrtc": "^5.6.2",
57
57
  "shiki": "^3.13.0",
58
- "shiki-magic-move": "^1.1.0",
59
- "typescript": "^5.9.2",
60
- "unocss": "^66.5.1",
61
- "vue": "^3.5.21",
62
- "vue-router": "^4.5.1",
58
+ "shiki-magic-move": "^1.2.0",
59
+ "typescript": "^5.9.3",
60
+ "unocss": "^66.5.4",
61
+ "vue": "^3.5.22",
62
+ "vue-router": "^4.6.3",
63
63
  "yaml": "^2.8.1",
64
- "@slidev/parser": "52.2.4",
65
- "@slidev/types": "52.2.4"
64
+ "@slidev/parser": "52.3.0",
65
+ "@slidev/types": "52.3.0"
66
66
  },
67
67
  "devDependencies": {
68
- "vite": "^7.1.6"
68
+ "vite": "^7.1.10"
69
69
  }
70
70
  }
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ import type { SlideRoute } from '@slidev/types'
3
+ import { useHead } from '@unhead/vue'
4
+ import { debouncedWatch } from '@vueuse/core'
5
+ import { ref } from 'vue'
6
+ import { useNav } from '../composables/useNav'
7
+ import { useDynamicSlideInfo } from '../composables/useSlideInfo'
8
+ import { slidesTitle } from '../env'
9
+ import IconButton from '../internals/IconButton.vue'
10
+ import Modal from '../internals/Modal.vue'
11
+
12
+ useHead({ title: `Notes Edit - ${slidesTitle}` })
13
+
14
+ const { slides } = useNav()
15
+
16
+ const showHelp = ref(false)
17
+ const note = ref(serializeNotes(slides.value))
18
+
19
+ function serializeNotes(slides: SlideRoute[]) {
20
+ const lines: string[] = []
21
+
22
+ for (const slide of slides) {
23
+ if (!slide.meta.slide.note?.trim())
24
+ continue
25
+ lines.push(`--- #${slide.no}`)
26
+ lines.push('')
27
+ lines.push(slide.meta.slide.note)
28
+ lines.push('')
29
+ }
30
+
31
+ return lines.join('\n')
32
+ }
33
+
34
+ function deserializeNotes(notes: string, slides: SlideRoute[]) {
35
+ const lines = notes.split(/^(---\s*#\d+\s*)$/gm)
36
+
37
+ lines.forEach((line, index) => {
38
+ const match = line.match(/^---\s*#(\d+)\s*$/)
39
+ if (match) {
40
+ const no = Number.parseInt(match[1])
41
+ const note = lines[index + 1].trim()
42
+ const slide = slides.find(s => s.no === no)
43
+ if (slide) {
44
+ slide.meta.slide.note = note
45
+ useDynamicSlideInfo(no).update({ note })
46
+ }
47
+ }
48
+ })
49
+ }
50
+
51
+ debouncedWatch(note, (value) => {
52
+ deserializeNotes(value, slides.value)
53
+ }, { debounce: 300 })
54
+ </script>
55
+
56
+ <template>
57
+ <Modal v-model="showHelp" class="px-6 py-4 flex flex-col gap-2">
58
+ <div class="flex gap-2 text-xl">
59
+ <div class="i-carbon:information my-auto" /> Help
60
+ </div>
61
+ <div class="prose dark:prose-invert">
62
+ <p>This is the batch notes editor. You can edit the notes for all the slides at once here.</p>
63
+
64
+ <p>The note for each slide are separated by <code>--- #[no]</code> lines, you might want to keep them while editing.</p>
65
+ </div>
66
+ <div class="flex my-1">
67
+ <button class="slidev-form-button" @click="showHelp = false">
68
+ Close
69
+ </button>
70
+ </div>
71
+ </Modal>
72
+ <div class="h-full">
73
+ <div class="slidev-glass-effect fixed bottom-5 right-5 rounded-full border border-main">
74
+ <IconButton title="Help" class="rounded-full" @click="showHelp = true">
75
+ <div class="i-carbon:help text-2xl" />
76
+ </IconButton>
77
+ </div>
78
+ <textarea
79
+ v-model="note"
80
+ class="prose dark:prose-invert resize-none p5 outline-none bg-transparent block h-full w-full! max-w-full! max-h-full! min-h-full! min-w-full!"
81
+ />
82
+ </div>
83
+ </template>
package/pages/notes.vue CHANGED
@@ -5,9 +5,9 @@ import { computed, ref, watch } from 'vue'
5
5
  import { createClicksContextBase } from '../composables/useClicks'
6
6
  import { useNav } from '../composables/useNav'
7
7
  import { slidesTitle } from '../env'
8
-
9
8
  import ClicksSlider from '../internals/ClicksSlider.vue'
10
9
  import IconButton from '../internals/IconButton.vue'
10
+ import Modal from '../internals/Modal.vue'
11
11
  import NoteDisplay from '../internals/NoteDisplay.vue'
12
12
  import { fullscreen } from '../state'
13
13
  import { sharedState } from '../state/shared'
@@ -20,6 +20,7 @@ const { isFullscreen, toggle: toggleFullscreen } = fullscreen
20
20
  const scroller = ref<HTMLDivElement>()
21
21
  const fontSize = useLocalStorage('slidev-notes-font-size', 18)
22
22
  const pageNo = computed(() => sharedState.page)
23
+ const showHelp = ref(false)
23
24
  const currentRoute = computed(() => slides.value.find(i => i.no === pageNo.value))
24
25
 
25
26
  watch(pageNo, () => {
@@ -43,6 +44,20 @@ const clicksContext = computed(() => {
43
44
  </script>
44
45
 
45
46
  <template>
47
+ <Modal v-model="showHelp" class="px-6 py-4 flex flex-col gap-2">
48
+ <div class="flex gap-2 text-xl">
49
+ <div class="i-carbon:information my-auto" /> Help
50
+ </div>
51
+ <div class="prose dark:prose-invert">
52
+ <p>This is the hands-free live notes viewer.</p>
53
+ <p>It's designed to be used in a separate view or device. The progress is controlled by and auto synced with the main presenter or slide.</p>
54
+ </div>
55
+ <div class="flex my-1">
56
+ <button class="slidev-form-button" @click="showHelp = false">
57
+ Close
58
+ </button>
59
+ </div>
60
+ </Modal>
46
61
  <div
47
62
  class="fixed top-0 left-0 h-3px bg-primary transition-all duration-500"
48
63
  :style="{ width: `${(pageNo - 1) / (total - 1) * 100 + 1}%` }"
@@ -76,6 +91,12 @@ const clicksContext = computed(() => {
76
91
  <IconButton title="Decrease font size" @click="decreaseFontSize">
77
92
  <div class="i-carbon:zoom-out" />
78
93
  </IconButton>
94
+ <IconButton title="Edit notes" to="/notes-edit" target="_blank">
95
+ <div class="i-carbon:edit" />
96
+ </IconButton>
97
+ <IconButton title="Help" class="rounded-full" @click="showHelp = true">
98
+ <div class="i-carbon:help" />
99
+ </IconButton>
79
100
  <div class="flex-auto" />
80
101
  <div class="p2 text-center">
81
102
  {{ pageNo }} / {{ total }}
package/setup/monaco.ts CHANGED
@@ -20,6 +20,7 @@ import { SyncDescriptor } from 'monaco-editor/esm/vs/platform/instantiation/comm
20
20
  import ts from 'typescript'
21
21
  import { watchEffect } from 'vue'
22
22
  import { isDark } from '../logic/dark'
23
+ import { lockShortcuts } from '../state'
23
24
 
24
25
  window.MonacoEnvironment = {
25
26
  getWorker(_, label) {
@@ -97,6 +98,18 @@ const setup = createSingletonPromise(async () => {
97
98
  Object.assign(editorOptions, result?.editorOptions)
98
99
  }
99
100
 
101
+ // Disable shortcuts when focusing Monaco editor.
102
+ monaco.editor.onDidCreateEditor((editor) => {
103
+ let release: (() => void) | null = null
104
+ editor.onDidFocusEditorWidget(() => {
105
+ release = lockShortcuts()
106
+ })
107
+ editor.onDidBlurEditorWidget(() => {
108
+ release?.()
109
+ release = null
110
+ })
111
+ })
112
+
100
113
  // Use Shiki to highlight Monaco
101
114
  shikiToMonaco(highlighter, monaco)
102
115
  if (typeof themes === 'string') {
package/setup/routes.ts CHANGED
@@ -39,6 +39,12 @@ export default function setupRoutes() {
39
39
  component: () => import('../pages/notes.vue'),
40
40
  beforeEnter: passwordGuard,
41
41
  },
42
+ {
43
+ name: 'notes-edit',
44
+ path: '/notes-edit',
45
+ component: () => import('../pages/notes-edit.vue'),
46
+ beforeEnter: passwordGuard,
47
+ },
42
48
  {
43
49
  name: 'presenter',
44
50
  path: '/presenter/:no',
package/state/storage.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { DragElementState } from '../composables/useDragElements'
2
2
  import { breakpointsTailwind, isClient, useActiveElement, useBreakpoints, useFullscreen, useLocalStorage, useMagicKeys, useToggle, useWindowSize } from '@vueuse/core'
3
- import { computed, ref, shallowRef } from 'vue'
3
+ import { computed, reactive, ref, shallowRef } from 'vue'
4
4
  import { slideAspect } from '../env'
5
5
 
6
6
  export const showRecordingDialog = ref(false)
@@ -16,6 +16,28 @@ export const hmrSkipTransition = ref(false)
16
16
  export const disableTransition = ref(false)
17
17
 
18
18
  export const shortcutsEnabled = ref(true)
19
+
20
+ /**
21
+ * Whether the keyboard shortcuts are enabled. Readonly,
22
+ * use `lockShortcuts` and `releaseShortcuts` to modify.
23
+ */
24
+
25
+ // Use a locking mechanism to support multiple simultaneous locks
26
+ // and avoid race conditions. Race conditions may occur, for example,
27
+ // when locking shortcuts on editor focus and moving from one editor
28
+ // to another, as blur events can be triggered after focus.
29
+ const shortcutsLocks = reactive(new Set<symbol>())
30
+
31
+ export const shortcutsLocked = computed(() => shortcutsLocks.size > 0)
32
+
33
+ export function lockShortcuts() {
34
+ const lock = Symbol('shortcuts lock')
35
+ shortcutsLocks.add(lock)
36
+ return () => {
37
+ shortcutsLocks.delete(lock)
38
+ }
39
+ }
40
+
19
41
  export const breakpoints = useBreakpoints({
20
42
  xs: 460,
21
43
  ...breakpointsTailwind,
package/styles/index.css CHANGED
@@ -80,7 +80,11 @@ html {
80
80
  /* Note Clicks */
81
81
 
82
82
  .slidev-note-with-clicks .slidev-note-fade {
83
- color: #888888ab;
83
+ color: #888888cc;
84
+ }
85
+
86
+ .dark .slidev-note-with-clicks .slidev-note-fade {
87
+ color: #a1a1a1cc;
84
88
  }
85
89
 
86
90
  .slidev-note-click-mark {