|
1 | 1 | <script setup lang="ts">
|
2 |
| -import { computed, nextTick, onMounted, ref, watch } from 'vue' |
| 2 | +import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue' |
3 | 3 | import type { ClicksContext } from '@slidev/types'
|
4 | 4 | import { CLICKS_MAX } from '../constants'
|
5 | 5 |
|
@@ -29,108 +29,118 @@ const noteDisplay = ref<HTMLElement | null>(null)
|
29 | 29 | const CLASS_FADE = 'slidev-note-fade'
|
30 | 30 | const CLASS_MARKER = 'slidev-note-click-mark'
|
31 | 31 |
|
32 |
| -function highlightNote() { |
| 32 | +function processNote() { |
33 | 33 | if (!noteDisplay.value || !withClicks.value)
|
34 | 34 | return
|
35 | 35 |
|
36 | 36 | const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
|
37 | 37 |
|
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 |
53 | 41 | for (const marker of markers) {
|
54 |
| - const parent = marker.parentElement! |
55 | 42 | 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 |
82 | 57 | }
|
83 | 58 |
|
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]) |
96 | 85 | }
|
| 86 | + if (!hasPrefix) |
| 87 | + dividers[0][1] = -1 |
97 | 88 | }
|
98 | 89 |
|
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' }) |
115 | 121 | }
|
116 |
| -
|
117 |
| - if (props.autoScroll && clicks === current) |
118 |
| - marker.scrollIntoView({ block: 'center', behavior: 'smooth' }) |
119 | 122 | }
|
120 | 123 | }
|
121 | 124 |
|
| 125 | +const applyHighlight = ref<ReturnType<typeof processNote>>() |
| 126 | +
|
122 | 127 | watch(
|
123 |
| - () => [props.noteHtml, props.clicksContext?.current, props.highlight], |
| 128 | + () => [props.noteHtml, props.highlight], |
124 | 129 | () => {
|
125 | 130 | nextTick(() => {
|
126 |
| - highlightNote() |
| 131 | + applyHighlight.value = processNote() |
127 | 132 | })
|
128 | 133 | },
|
129 | 134 | { immediate: true },
|
130 | 135 | )
|
131 | 136 |
|
132 | 137 | onMounted(() => {
|
133 |
| - highlightNote() |
| 138 | + processNote() |
| 139 | +}) |
| 140 | +
|
| 141 | +watchEffect(() => { |
| 142 | + const current = props.clicksContext?.current ?? CLICKS_MAX |
| 143 | + applyHighlight.value?.(current) |
134 | 144 | })
|
135 | 145 | </script>
|
136 | 146 |
|
|
0 commit comments