返回 slidev
NoteDisplay.vue
根目录 / packages / client / internals / NoteDisplay.vue
1 <script setup lang="ts">
2 import type { ClicksContext } from '@slidev/types'
3 import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
4 import { CLICKS_MAX } from '../constants'
5
6 const props = withDefaults(
7 defineProps<{
8 class?: string | string[]
9 noteHtml?: string
10 note?: string
11 highlight?: boolean
12 placeholder?: string
13 clicksContext?: ClicksContext
14 autoScroll?: boolean
15 }>(),
16 {
17 highlight: true,
18 },
19 )
20
21 const emit = defineEmits<{
22 (type: 'markerDblclick', e: MouseEvent, clicks: number): void
23 (type: 'markerClick', e: MouseEvent, clicks: number): void
24 }>()
25
26 const CLASS_FADE = 'slidev-note-fade'
27 const CLASS_MARKER = 'slidev-note-click-mark'
28
29 const withClicks = computed(() => props.clicksContext != null && props.noteHtml?.includes(CLASS_MARKER))
30 const noteDisplay = ref<HTMLElement | null>(null)
31
32 function processNote() {
33 if (!noteDisplay.value || !withClicks.value)
34 return
35
36 const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
37
38 const markersMap = new Map<HTMLElement, number>()
39 const parentsMap = new Map<HTMLElement, [divider: HTMLElement | null, dividerClicks: number][]>()
40 let lastClicks = 0
41 for (const marker of markers) {
42 const clicks = Number(marker.dataset!.clicks)
43 markersMap.set(marker, clicks)
44
45 // Set parent clicks map
46 let n = marker
47 let p = marker.parentElement
48 while (p && n !== noteDisplay.value) {
49 if (!parentsMap.has(p))
50 parentsMap.set(p, [[null, lastClicks]])
51 parentsMap.get(p)!.push([n, clicks])
52 n = p
53 p = p.parentElement
54 }
55
56 lastClicks = clicks
57 }
58
59 const siblingsMap = new Map<HTMLElement, number>()
60 for (const [parent, dividers] of parentsMap) {
61 let hasPrefix = false
62 let dividerIdx = 0
63 for (const sibling of Array.from(parent.childNodes)) {
64 let skip = false
65 while (sibling === dividers[dividerIdx + 1]?.[0]) {
66 skip = true
67 dividerIdx++
68 }
69 if (skip)
70 continue
71
72 // Convert sibling text nodes to spans
73 let siblingEl = sibling as HTMLElement
74 if (sibling.nodeType === 3 /* text node */) {
75 if (!sibling.textContent?.trim())
76 continue
77 siblingEl = document.createElement('span')
78 siblingEl.textContent = sibling.textContent
79 parent.insertBefore(siblingEl, sibling)
80 sibling.remove()
81 }
82
83 hasPrefix ||= dividerIdx === 0
84 siblingsMap.set(siblingEl, dividers[dividerIdx][1])
85 }
86 if (!hasPrefix)
87 dividers[0][1] = -1
88 }
89
90 // Apply
91 return (current: number) => {
92 const enabled = props.highlight
93 for (const [parent, clicks] of parentsMap)
94 parent.classList.toggle(CLASS_FADE, enabled && !clicks.some(([_, c]) => c === current))
95 for (const [parent, clicks] of siblingsMap)
96 parent.classList.toggle(CLASS_FADE, enabled && clicks !== current)
97 for (const [marker, clicks] of markersMap) {
98 marker.classList.remove(CLASS_FADE)
99 marker.classList.toggle(`${CLASS_MARKER}-past`, enabled && clicks < current)
100 marker.classList.toggle(`${CLASS_MARKER}-active`, enabled && clicks === current)
101 marker.classList.toggle(`${CLASS_MARKER}-next`, enabled && clicks === current + 1)
102 marker.classList.toggle(`${CLASS_MARKER}-future`, enabled && clicks > current + 1)
103 marker.ondblclick = (e) => {
104 if (!enabled)
105 return
106 emit('markerDblclick', e, clicks)
107 if (e.defaultPrevented)
108 return
109 props.clicksContext!.current = clicks
110 e.stopPropagation()
111 e.stopImmediatePropagation()
112 }
113 marker.onclick = (e) => {
114 if (enabled) {
115 emit('markerClick', e, clicks)
116 }
117 }
118
119 if (enabled && props.autoScroll && clicks === current)
120 marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
121 }
122 }
123 }
124
125 const applyHighlight = ref<ReturnType<typeof processNote>>()
126
127 watch(
128 () => [props.noteHtml, props.highlight],
129 () => {
130 nextTick(() => {
131 applyHighlight.value = processNote()
132 })
133 },
134 { immediate: true },
135 )
136
137 onMounted(() => {
138 processNote()
139 })
140
141 watchEffect(() => {
142 const current = props.clicksContext?.current ?? CLICKS_MAX
143 applyHighlight.value?.(current)
144 })
145 </script>
146
147 <template>
148 <div
149 v-if="noteHtml"
150 ref="noteDisplay"
151 class="prose dark:prose-invert overflow-auto outline-none slidev-note"
152 :class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
153 v-html="noteHtml"
154 />
155 <div
156 v-else-if="note"
157 class="prose dark:prose-invert overflow-auto outline-none slidev-note"
158 :class="props.class"
159 >
160 <p v-text="note" />
161 </div>
162 <div
163 v-else
164 class="prose dark:prose-invert overflow-auto outline-none opacity-50 italic select-none slidev-note"
165 :class="props.class"
166 >
167 <p v-text="props.placeholder || 'No notes.'" />
168 </div>
169 </template>
170
171 <style>
172 .slidev-note :first-child {
173 margin-top: 0;
174 }
175
176 .slidev-note {
177 overflow-wrap: anywhere;
178 word-break: break-word;
179 }
180 </style>
181
181 lines Plain Text