2026-05-31 13:54:31 +02:00

181 lines
4.4 KiB
Vue

<script setup lang="ts">
import TitleRenderer from '#slidev/title-renderer'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import { useNav } from '../composables/useNav'
import { activeElement, showGotoDialog } from '../state'
const container = ref<HTMLDivElement>()
const input = ref<HTMLInputElement>()
const list = ref<HTMLUListElement>()
const items = ref<HTMLLIElement[]>()
const text = ref('')
const selectedIndex = ref(0)
const { go, slides } = useNav()
function notNull<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined
}
const fuse = computed(() => new Fuse(slides.value.map(i => i.meta?.slide).filter(notNull), {
keys: ['no', 'title'],
threshold: 0.3,
shouldSort: true,
minMatchCharLength: 1,
}))
const path = computed(() => text.value.startsWith('/') ? text.value.substring(1) : text.value)
const result = computed(() => fuse.value.search(path.value).map(result => result.item))
const valid = computed(() => !!result.value.length)
function goTo() {
if (valid.value) {
const item = result.value.at(selectedIndex.value || 0)
if (item)
go(item.no)
}
close()
}
function close() {
text.value = ''
showGotoDialog.value = false
}
function focusDown(event: Event) {
event.preventDefault()
selectedIndex.value++
if (selectedIndex.value >= result.value.length)
selectedIndex.value = 0
scroll()
}
function focusUp(event: Event) {
event.preventDefault()
selectedIndex.value--
if (selectedIndex.value <= -2)
selectedIndex.value = result.value.length - 1
scroll()
}
function scroll() {
const item = items.value?.[selectedIndex.value]
if (item && list.value) {
if (item.offsetTop + item.offsetHeight > list.value.offsetHeight + list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop + item.offsetHeight - list.value.offsetHeight + 1,
})
}
else if (item.offsetTop < list.value.scrollTop) {
list.value.scrollTo({
behavior: 'smooth',
top: item.offsetTop,
})
}
}
}
function updateText(event: Event) {
selectedIndex.value = 0
text.value = (event.target as HTMLInputElement).value
}
function select(no: number) {
go(no)
close()
}
watch(showGotoDialog, async (show) => {
if (show) {
text.value = ''
selectedIndex.value = 0
// delay the focus to avoid the g character coming from the key that triggered showGotoDialog
setTimeout(() => input.value?.focus(), 0)
}
else {
input.value?.blur()
}
})
watch(activeElement, () => {
if (!container.value?.contains(activeElement.value as Node))
close()
})
</script>
<template>
<div
id="slidev-goto-dialog"
ref="container"
class="fixed right-5 transition-all"
w-90 max-w-90 min-w-90
:class="showGotoDialog ? 'top-5' : '-top-20'"
>
<div
class="bg-main transform"
shadow="~"
p="x-4 y-2"
border="~ transparent rounded dark:main"
>
<input
id="slidev-goto-input"
ref="input"
:value="text"
type="text"
:disabled="!showGotoDialog"
class="outline-none bg-transparent"
placeholder="Goto..."
:class="{ 'text-red-400': !valid && text }"
@keydown.enter="goTo"
@keydown.escape="close"
@keydown.down="focusDown"
@keydown.up="focusUp"
@input="updateText"
>
</div>
<div
v-if="result.length > 0"
ref="list"
class="autocomplete-list"
shadow="~"
border="~ transparent rounded dark:main"
>
<ul table w-full border-collapse>
<li
v-for="(item, index) of result"
ref="items"
:key="item.id"
role="button"
tabindex="0"
cursor-pointer
hover="op100"
table-row
items-center
:border="index === 0 ? undefined : 't main'"
:class="selectedIndex === index ? 'bg-active op100' : 'op80'"
@click.stop.prevent="select(item.no)"
>
<div text-right op50 text-sm table-cell py-2 pl-4 pr-3 vertical-middle>
{{ item.no }}
</div>
<TitleRenderer table-cell py-2 pr-4 w-full :no="item.no" />
</li>
</ul>
</div>
</div>
</template>
<style scoped>
.autocomplete-list {
--uno: bg-main mt-1;
overflow: auto;
max-height: calc(100vh - 100px);
}
.autocomplete {
cursor: pointer;
}
</style>