Skip to content

Commit 4f62e89

Browse files
authored
fix: notes highlighting algorithm (#1683)
1 parent 9f0aa67 commit 4f62e89

File tree

1 file changed

+88
-78
lines changed

1 file changed

+88
-78
lines changed

packages/client/internals/NoteDisplay.vue

+88-78
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { computed, nextTick, onMounted, ref, watch } from 'vue'
2+
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
33
import type { ClicksContext } from '@slidev/types'
44
import { CLICKS_MAX } from '../constants'
55
@@ -29,108 +29,118 @@ const noteDisplay = ref<HTMLElement | null>(null)
2929
const CLASS_FADE = 'slidev-note-fade'
3030
const CLASS_MARKER = 'slidev-note-click-mark'
3131
32-
function highlightNote() {
32+
function processNote() {
3333
if (!noteDisplay.value || !withClicks.value)
3434
return
3535
3636
const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
3737
38-
const current = +(props.clicksContext?.current ?? CLICKS_MAX)
39-
const disabled = current < 0 || current >= CLICKS_MAX || !props.highlight
40-
41-
const nodeToIgnores = new Set<Element>()
42-
function ignoreParent(node: Element) {
43-
if (!node || node === noteDisplay.value)
44-
return
45-
nodeToIgnores.add(node)
46-
if (node.parentElement)
47-
ignoreParent(node.parentElement)
48-
}
49-
50-
const markersMap = new Map<number, HTMLElement>()
51-
52-
// Convert all sibling text nodes to spans, so we attach classes to them
38+
const markersMap = new Map<HTMLElement, number>()
39+
const parentsMap = new Map<HTMLElement, [divider: HTMLElement | null, dividerClicks: number][]>()
40+
let lastClicks = 0
5341
for (const marker of markers) {
54-
const parent = marker.parentElement!
5542
const clicks = Number(marker.dataset!.clicks)
56-
markersMap.set(clicks, marker)
57-
// Ignore the parents of the marker, so the class only applies to the children
58-
ignoreParent(parent)
59-
Array.from(parent!.childNodes)
60-
.forEach((node) => {
61-
if (node.nodeType === 3) { // text node
62-
const span = document.createElement('span')
63-
span.textContent = node.textContent
64-
parent.insertBefore(span, node)
65-
node.remove()
66-
}
67-
})
68-
}
69-
const children = Array.from(noteDisplay.value.querySelectorAll('*'))
70-
71-
let count = 0
72-
73-
// Segmenting notes by clicks
74-
const segments = new Map<number, Element[]>()
75-
for (const child of children) {
76-
if (!segments.has(count))
77-
segments.set(count, [])
78-
segments.get(count)!.push(child)
79-
// Update count when reach marker
80-
if (child.classList.contains(CLASS_MARKER))
81-
count = Number((child as HTMLElement).dataset.clicks) || (count + 1)
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
8257
}
8358
84-
// Apply
85-
for (const [count, els] of segments) {
86-
if (disabled) {
87-
els.forEach(el => el.classList.remove(CLASS_FADE))
88-
}
89-
else {
90-
els.forEach(el => el.classList.toggle(
91-
CLASS_FADE,
92-
nodeToIgnores.has(el)
93-
? false
94-
: count !== current,
95-
))
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])
9685
}
86+
if (!hasPrefix)
87+
dividers[0][1] = -1
9788
}
9889
99-
for (const [clicks, marker] of markersMap) {
100-
marker.classList.remove(CLASS_FADE)
101-
marker.classList.toggle(`${CLASS_MARKER}-past`, disabled ? false : clicks < current)
102-
marker.classList.toggle(`${CLASS_MARKER}-active`, disabled ? false : clicks === current)
103-
marker.classList.toggle(`${CLASS_MARKER}-next`, disabled ? false : clicks === current + 1)
104-
marker.classList.toggle(`${CLASS_MARKER}-future`, disabled ? false : clicks > current + 1)
105-
marker.ondblclick = (e) => {
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-
emit('markerClick', e, clicks)
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 = enabled
104+
? (e) => {
105+
emit('markerDblclick', e, clicks)
106+
if (e.defaultPrevented)
107+
return
108+
props.clicksContext!.current = clicks
109+
e.stopPropagation()
110+
e.stopImmediatePropagation()
111+
}
112+
: null
113+
marker.onclick = enabled
114+
? (e) => {
115+
emit('markerClick', e, clicks)
116+
}
117+
: null
118+
119+
if (!enabled && props.autoScroll && clicks === current)
120+
marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
115121
}
116-
117-
if (props.autoScroll && clicks === current)
118-
marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
119122
}
120123
}
121124
125+
const applyHighlight = ref<ReturnType<typeof processNote>>()
126+
122127
watch(
123-
() => [props.noteHtml, props.clicksContext?.current, props.highlight],
128+
() => [props.noteHtml, props.highlight],
124129
() => {
125130
nextTick(() => {
126-
highlightNote()
131+
applyHighlight.value = processNote()
127132
})
128133
},
129134
{ immediate: true },
130135
)
131136
132137
onMounted(() => {
133-
highlightNote()
138+
processNote()
139+
})
140+
141+
watchEffect(() => {
142+
const current = props.clicksContext?.current ?? CLICKS_MAX
143+
applyHighlight.value?.(current)
134144
})
135145
</script>
136146

0 commit comments

Comments
 (0)