| 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 |