176 lines
4.8 KiB
Vue
176 lines
4.8 KiB
Vue
|
|
<script setup lang="ts">
|
||
|
|
import type { ClicksContext } from '@slidev/types'
|
||
|
|
import { computed, nextTick, onMounted, ref, watch, watchEffect } from 'vue'
|
||
|
|
import { CLICKS_MAX } from '../constants'
|
||
|
|
|
||
|
|
const props = withDefaults(
|
||
|
|
defineProps<{
|
||
|
|
class?: string | string[]
|
||
|
|
noteHtml?: string
|
||
|
|
note?: string
|
||
|
|
highlight?: boolean
|
||
|
|
placeholder?: string
|
||
|
|
clicksContext?: ClicksContext
|
||
|
|
autoScroll?: boolean
|
||
|
|
}>(),
|
||
|
|
{
|
||
|
|
highlight: true,
|
||
|
|
},
|
||
|
|
)
|
||
|
|
|
||
|
|
const emit = defineEmits<{
|
||
|
|
(type: 'markerDblclick', e: MouseEvent, clicks: number): void
|
||
|
|
(type: 'markerClick', e: MouseEvent, clicks: number): void
|
||
|
|
}>()
|
||
|
|
|
||
|
|
const withClicks = computed(() => props.clicksContext != null && props.noteHtml?.includes('slidev-note-click-mark'))
|
||
|
|
const noteDisplay = ref<HTMLElement | null>(null)
|
||
|
|
|
||
|
|
const CLASS_FADE = 'slidev-note-fade'
|
||
|
|
const CLASS_MARKER = 'slidev-note-click-mark'
|
||
|
|
|
||
|
|
function processNote() {
|
||
|
|
if (!noteDisplay.value || !withClicks.value)
|
||
|
|
return
|
||
|
|
|
||
|
|
const markers = Array.from(noteDisplay.value.querySelectorAll(`.${CLASS_MARKER}`)) as HTMLElement[]
|
||
|
|
|
||
|
|
const markersMap = new Map<HTMLElement, number>()
|
||
|
|
const parentsMap = new Map<HTMLElement, [divider: HTMLElement | null, dividerClicks: number][]>()
|
||
|
|
let lastClicks = 0
|
||
|
|
for (const marker of markers) {
|
||
|
|
const clicks = Number(marker.dataset!.clicks)
|
||
|
|
markersMap.set(marker, clicks)
|
||
|
|
|
||
|
|
// Set parent clicks map
|
||
|
|
let n = marker
|
||
|
|
let p = marker.parentElement
|
||
|
|
while (p && n !== noteDisplay.value) {
|
||
|
|
if (!parentsMap.has(p))
|
||
|
|
parentsMap.set(p, [[null, lastClicks]])
|
||
|
|
parentsMap.get(p)!.push([n, clicks])
|
||
|
|
n = p
|
||
|
|
p = p.parentElement
|
||
|
|
}
|
||
|
|
|
||
|
|
lastClicks = clicks
|
||
|
|
}
|
||
|
|
|
||
|
|
const siblingsMap = new Map<HTMLElement, number>()
|
||
|
|
for (const [parent, dividers] of parentsMap) {
|
||
|
|
let hasPrefix = false
|
||
|
|
let dividerIdx = 0
|
||
|
|
for (const sibling of Array.from(parent.childNodes)) {
|
||
|
|
let skip = false
|
||
|
|
while (sibling === dividers[dividerIdx + 1]?.[0]) {
|
||
|
|
skip = true
|
||
|
|
dividerIdx++
|
||
|
|
}
|
||
|
|
if (skip)
|
||
|
|
continue
|
||
|
|
|
||
|
|
// Convert sibling text nodes to spans
|
||
|
|
let siblingEl = sibling as HTMLElement
|
||
|
|
if (sibling.nodeType === 3 /* text node */) {
|
||
|
|
if (!sibling.textContent?.trim())
|
||
|
|
continue
|
||
|
|
siblingEl = document.createElement('span')
|
||
|
|
siblingEl.textContent = sibling.textContent
|
||
|
|
parent.insertBefore(siblingEl, sibling)
|
||
|
|
sibling.remove()
|
||
|
|
}
|
||
|
|
|
||
|
|
hasPrefix ||= dividerIdx === 0
|
||
|
|
siblingsMap.set(siblingEl, dividers[dividerIdx][1])
|
||
|
|
}
|
||
|
|
if (!hasPrefix)
|
||
|
|
dividers[0][1] = -1
|
||
|
|
}
|
||
|
|
|
||
|
|
// Apply
|
||
|
|
return (current: number) => {
|
||
|
|
const enabled = props.highlight
|
||
|
|
for (const [parent, clicks] of parentsMap)
|
||
|
|
parent.classList.toggle(CLASS_FADE, enabled && !clicks.some(([_, c]) => c === current))
|
||
|
|
for (const [parent, clicks] of siblingsMap)
|
||
|
|
parent.classList.toggle(CLASS_FADE, enabled && clicks !== current)
|
||
|
|
for (const [marker, clicks] of markersMap) {
|
||
|
|
marker.classList.remove(CLASS_FADE)
|
||
|
|
marker.classList.toggle(`${CLASS_MARKER}-past`, enabled && clicks < current)
|
||
|
|
marker.classList.toggle(`${CLASS_MARKER}-active`, enabled && clicks === current)
|
||
|
|
marker.classList.toggle(`${CLASS_MARKER}-next`, enabled && clicks === current + 1)
|
||
|
|
marker.classList.toggle(`${CLASS_MARKER}-future`, enabled && clicks > current + 1)
|
||
|
|
marker.ondblclick = enabled
|
||
|
|
? (e) => {
|
||
|
|
emit('markerDblclick', e, clicks)
|
||
|
|
if (e.defaultPrevented)
|
||
|
|
return
|
||
|
|
props.clicksContext!.current = clicks
|
||
|
|
e.stopPropagation()
|
||
|
|
e.stopImmediatePropagation()
|
||
|
|
}
|
||
|
|
: null
|
||
|
|
marker.onclick = enabled
|
||
|
|
? (e) => {
|
||
|
|
emit('markerClick', e, clicks)
|
||
|
|
}
|
||
|
|
: null
|
||
|
|
|
||
|
|
if (enabled && props.autoScroll && clicks === current)
|
||
|
|
marker.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const applyHighlight = ref<ReturnType<typeof processNote>>()
|
||
|
|
|
||
|
|
watch(
|
||
|
|
() => [props.noteHtml, props.highlight],
|
||
|
|
() => {
|
||
|
|
nextTick(() => {
|
||
|
|
applyHighlight.value = processNote()
|
||
|
|
})
|
||
|
|
},
|
||
|
|
{ immediate: true },
|
||
|
|
)
|
||
|
|
|
||
|
|
onMounted(() => {
|
||
|
|
processNote()
|
||
|
|
})
|
||
|
|
|
||
|
|
watchEffect(() => {
|
||
|
|
const current = props.clicksContext?.current ?? CLICKS_MAX
|
||
|
|
applyHighlight.value?.(current)
|
||
|
|
})
|
||
|
|
</script>
|
||
|
|
|
||
|
|
<template>
|
||
|
|
<div
|
||
|
|
v-if="noteHtml"
|
||
|
|
ref="noteDisplay"
|
||
|
|
class="prose overflow-auto outline-none slidev-note"
|
||
|
|
:class="[props.class, withClicks ? 'slidev-note-with-clicks' : '']"
|
||
|
|
v-html="noteHtml"
|
||
|
|
/>
|
||
|
|
<div
|
||
|
|
v-else-if="note"
|
||
|
|
class="prose overflow-auto outline-none slidev-note"
|
||
|
|
:class="props.class"
|
||
|
|
>
|
||
|
|
<p v-text="note" />
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
v-else
|
||
|
|
class="prose overflow-auto outline-none opacity-50 italic select-none slidev-note"
|
||
|
|
:class="props.class"
|
||
|
|
>
|
||
|
|
<p v-text="props.placeholder || 'No notes.'" />
|
||
|
|
</div>
|
||
|
|
</template>
|
||
|
|
|
||
|
|
<style>
|
||
|
|
.slidev-note :first-child {
|
||
|
|
margin-top: 0;
|
||
|
|
}
|
||
|
|
</style>
|