| 1 | <script setup lang="ts"> |
| 2 | import Fuse from 'fuse.js' |
| 3 | import { computed, ref, watch } from 'vue' |
| 4 | import TitleRenderer from '#slidev/title-renderer' |
| 5 | import { useNav } from '../composables/useNav' |
| 6 | import { activeElement, showGotoDialog } from '../state' |
| 7 | |
| 8 | const container = ref<HTMLDivElement>() |
| 9 | const input = ref<HTMLInputElement>() |
| 10 | const list = ref<HTMLUListElement>() |
| 11 | const items = ref<HTMLLIElement[]>() |
| 12 | const text = ref('') |
| 13 | const selectedIndex = ref(0) |
| 14 | |
| 15 | const { go, slides } = useNav() |
| 16 | |
| 17 | function notNull<T>(value: T | null | undefined): value is T { |
| 18 | return value !== null && value !== undefined |
| 19 | } |
| 20 | |
| 21 | const fuse = computed(() => new Fuse(slides.value.map(i => i.meta?.slide).filter(notNull), { |
| 22 | keys: ['no', 'title'], |
| 23 | threshold: 0.3, |
| 24 | shouldSort: true, |
| 25 | minMatchCharLength: 1, |
| 26 | })) |
| 27 | |
| 28 | const path = computed(() => text.value.startsWith('/') ? text.value.substring(1) : text.value) |
| 29 | const result = computed(() => path.value ? fuse.value.search(path.value).map(result => result.item) : []) |
| 30 | const valid = computed(() => !!result.value.length) |
| 31 | |
| 32 | function goTo() { |
| 33 | if (valid.value) { |
| 34 | const item = result.value.at(selectedIndex.value || 0) |
| 35 | if (item) |
| 36 | go(item.no) |
| 37 | } |
| 38 | close() |
| 39 | } |
| 40 | |
| 41 | function close() { |
| 42 | text.value = '' |
| 43 | showGotoDialog.value = false |
| 44 | } |
| 45 | |
| 46 | function focusDown(event: Event) { |
| 47 | event.preventDefault() |
| 48 | selectedIndex.value++ |
| 49 | if (selectedIndex.value >= result.value.length) |
| 50 | selectedIndex.value = 0 |
| 51 | scroll() |
| 52 | } |
| 53 | |
| 54 | function focusUp(event: Event) { |
| 55 | event.preventDefault() |
| 56 | selectedIndex.value-- |
| 57 | if (selectedIndex.value <= -2) |
| 58 | selectedIndex.value = result.value.length - 1 |
| 59 | scroll() |
| 60 | } |
| 61 | |
| 62 | function scroll() { |
| 63 | const item = items.value?.[selectedIndex.value] |
| 64 | if (item && list.value) { |
| 65 | if (item.offsetTop + item.offsetHeight > list.value.offsetHeight + list.value.scrollTop) { |
| 66 | list.value.scrollTo({ |
| 67 | behavior: 'smooth', |
| 68 | top: item.offsetTop + item.offsetHeight - list.value.offsetHeight + 1, |
| 69 | }) |
| 70 | } |
| 71 | else if (item.offsetTop < list.value.scrollTop) { |
| 72 | list.value.scrollTo({ |
| 73 | behavior: 'smooth', |
| 74 | top: item.offsetTop, |
| 75 | }) |
| 76 | } |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | function updateText(event: Event) { |
| 81 | selectedIndex.value = 0 |
| 82 | text.value = (event.target as HTMLInputElement).value |
| 83 | } |
| 84 | |
| 85 | function select(no: number) { |
| 86 | go(no) |
| 87 | close() |
| 88 | } |
| 89 | |
| 90 | watch(showGotoDialog, async (show) => { |
| 91 | if (show) { |
| 92 | text.value = '' |
| 93 | selectedIndex.value = 0 |
| 94 | // delay the focus to avoid the g character coming from the key that triggered showGotoDialog |
| 95 | setTimeout(() => input.value?.focus(), 0) |
| 96 | } |
| 97 | else { |
| 98 | input.value?.blur() |
| 99 | } |
| 100 | }) |
| 101 | |
| 102 | watch(activeElement, () => { |
| 103 | if (!container.value?.contains(activeElement.value as Node)) |
| 104 | close() |
| 105 | }) |
| 106 | </script> |
| 107 | |
| 108 | <template> |
| 109 | <div |
| 110 | id="slidev-goto-dialog" |
| 111 | ref="container" |
| 112 | class="fixed right-5 transition-all" |
| 113 | w-90 max-w-90 min-w-90 |
| 114 | :class="showGotoDialog ? 'top-5' : '-top-20 op0 pointer-events-none'" |
| 115 | > |
| 116 | <div |
| 117 | class="bg-main transform" |
| 118 | shadow="~" |
| 119 | p="x-4 y-2" |
| 120 | border="~ transparent rounded dark:main" |
| 121 | > |
| 122 | <input |
| 123 | id="slidev-goto-input" |
| 124 | ref="input" |
| 125 | :value="text" |
| 126 | type="text" |
| 127 | :disabled="!showGotoDialog" |
| 128 | class="outline-none bg-transparent" |
| 129 | placeholder="Goto..." |
| 130 | :class="{ 'text-red-400': !valid && text }" |
| 131 | @keydown.enter="goTo" |
| 132 | @keydown.escape="close" |
| 133 | @keydown.down="focusDown" |
| 134 | @keydown.up="focusUp" |
| 135 | @input="updateText" |
| 136 | > |
| 137 | </div> |
| 138 | <div |
| 139 | v-if="showGotoDialog && result.length > 0" |
| 140 | ref="list" |
| 141 | class="autocomplete-list" |
| 142 | shadow="~" |
| 143 | border="~ transparent rounded dark:main" |
| 144 | > |
| 145 | <ul table w-full border-collapse> |
| 146 | <li |
| 147 | v-for="(item, index) of result" |
| 148 | ref="items" |
| 149 | :key="item.id" |
| 150 | role="button" |
| 151 | tabindex="0" |
| 152 | cursor-pointer |
| 153 | hover="op100" |
| 154 | table-row |
| 155 | items-center |
| 156 | :border="index === 0 ? undefined : 't main'" |
| 157 | :class="selectedIndex === index ? 'bg-active op100' : 'op80'" |
| 158 | @click.stop.prevent="select(item.no)" |
| 159 | > |
| 160 | <div text-right op50 text-sm table-cell py-2 pl-4 pr-3 vertical-middle> |
| 161 | {{ item.no }} |
| 162 | </div> |
| 163 | <TitleRenderer table-cell py-2 pr-4 w-full :no="item.no" /> |
| 164 | </li> |
| 165 | </ul> |
| 166 | </div> |
| 167 | </div> |
| 168 | </template> |
| 169 | |
| 170 | <style scoped> |
| 171 | .autocomplete-list { |
| 172 | --uno: bg-main mt-1; |
| 173 | overflow: auto; |
| 174 | max-height: calc(100vh - 100px); |
| 175 | } |
| 176 | |
| 177 | .autocomplete { |
| 178 | cursor: pointer; |
| 179 | } |
| 180 | </style> |
| 181 |