diff options
Diffstat (limited to 'src/components/SearchModal.vue')
| -rw-r--r-- | src/components/SearchModal.vue | 429 |
1 files changed, 215 insertions, 214 deletions
diff --git a/src/components/SearchModal.vue b/src/components/SearchModal.vue index 7406634..a035cac 100644 --- a/src/components/SearchModal.vue +++ b/src/components/SearchModal.vue @@ -1,8 +1,8 @@ <template> -<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose"> +<div class="fixed left-0 top-0 w-full h-full bg-black/40" @click="onClose" ref="rootRef"> <div class="absolute left-1/2 -translate-x-1/2 top-1/4 -translate-y-1/2 min-w-3/4 md:min-w-[12cm] flex" :style="styles"> - <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="searchInput" + <input type="text" class="block border p-1 px-2 rounded-l-[inherit] grow" ref="inputRef" @keyup.enter="onSearch" @keyup.esc="onClose" @input="onInput" @keydown.down.prevent="onDownKey" @keydown.up.prevent="onUpKey"/> <div class="p-1 hover:cursor-pointer"> @@ -32,225 +32,226 @@ </div> </template> -<script lang="ts"> -import {defineComponent, PropType} from 'vue'; +<script setup lang="ts"> +import {ref, computed, onMounted, onUnmounted, PropType} from 'vue'; import SearchIcon from './icon/SearchIcon.vue'; -import LogInIcon from './icon/LogInIcon.vue'; import InfoIcon from './icon/InfoIcon.vue'; import {TolNode, TolMap} from '../tol'; import {LayoutNode, LayoutMap, LayoutOptions} from '../layout'; import {queryServer, SearchSugg, SearchSuggResponse, UiOptions} from '../lib'; -export default defineComponent({ - props: { - lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists - activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size - tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added - lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, - uiOpts: {type: Object as PropType<UiOptions>, required: true}, - }, - data(){ - return { - // Search-suggestion data - searchSuggs: [] as SearchSugg[], - searchHadMoreSuggs: false, - suggsInput: '', // The input that resulted in the current suggestions (used to highlight matching text) - // For search-suggestion requests - lastSuggReqTime: 0, // Set when a search-suggestions request is initiated - pendingSuggReqParams: null as null | URLSearchParams, - // Used by a search-suggestion requester to request with the latest user input - pendingDelayedSuggReq: 0, // Set via setTimeout() for a non-initial search-suggestions request - pendingSuggInput: '', // Used to remember what input triggered a suggestions request - // Other - focusedSuggIdx: null as null | number, // Index of a search-suggestion selected using the arrow keys - }; - }, - computed: { - styles(): Record<string,string> { - let br = this.uiOpts.borderRadius; - return { - backgroundColor: this.uiOpts.bgColorAlt, - borderRadius: (this.searchSuggs.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, - boxShadow: this.uiOpts.shadowNormal, - }; - }, - suggContainerStyles(): Record<string,string> { - let br = this.uiOpts.borderRadius; - return { - backgroundColor: this.uiOpts.bgColorAlt, - color: this.uiOpts.textColorAlt, - borderRadius: `0 0 ${br}px ${br}px`, - }; - }, - animateLabelStyles(): Record<string,string> { - return { - position: 'absolute', - top: -this.lytOpts.headerSz - 2 + 'px', - right: '0', - height: this.lytOpts.headerSz + 'px', - color: this.uiOpts.textColor, - }; - }, - suggDisplayStrings(): [string, string, string, string][] { - let result: [string, string, string, string][] = []; - let input = this.suggsInput.toLowerCase(); - // For each SearchSugg - for (let sugg of this.searchSuggs){ - let idx = sugg.name.indexOf(input); - // Split suggestion text into parts before/within/after an input match - let strings: [string, string, string, string]; - if (idx != -1){ - strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), '']; - } else { - strings = [input, '', '', '']; - } - // Indicate any distinct canonical-name - if (sugg.canonicalName != null){ - strings[3] = ` (aka ${sugg.canonicalName})`; - } - // - result.push(strings); - } - return result; - }, - }, - methods: { - // Search-suggestion events - async onInput(){ - let input = this.$refs.searchInput as HTMLInputElement; - // Check for empty input - if (input.value.length == 0){ - this.searchSuggs = []; - this.searchHadMoreSuggs = false; - this.focusedSuggIdx = null; - return; - } - // Get URL params to use for querying search-suggestions - let urlParams = new URLSearchParams({ - type: 'sugg', - name: input.value, - limit: String(this.uiOpts.searchSuggLimit), - tree: this.uiOpts.tree, - }); - // Query server, delaying/skipping if a request was recently sent - this.pendingSuggReqParams = urlParams; - this.pendingSuggInput = input.value; - let doReq = async () => { - let suggInput = this.pendingSuggInput; - let responseObj: SearchSuggResponse = - await queryServer(this.pendingSuggReqParams!); - if (responseObj == null){ - return; - } - this.searchSuggs = responseObj.suggs; - this.searchHadMoreSuggs = responseObj.hasMore; - this.suggsInput = suggInput; - // Auto-select first result if present - if (this.searchSuggs.length > 0){ - this.focusedSuggIdx = 0; - } else { - this.focusedSuggIdx = null; - } - }; - let currentTime = new Date().getTime(); - if (this.lastSuggReqTime == 0){ - this.lastSuggReqTime = currentTime; - await doReq(); - if (this.lastSuggReqTime == currentTime){ - this.lastSuggReqTime = 0; - } - } else if (this.pendingDelayedSuggReq == 0){ - this.lastSuggReqTime = currentTime; - this.pendingDelayedSuggReq = setTimeout(async () => { - this.pendingDelayedSuggReq = 0; - await doReq(); - if (this.lastSuggReqTime == currentTime){ - this.lastSuggReqTime = 0; - } - }, 300); - } - }, - onInfoIconClick(nodeName: string){ - this.$emit('info-click', nodeName); - }, - onDownKey(){ - if (this.focusedSuggIdx != null){ - this.focusedSuggIdx = (this.focusedSuggIdx + 1) % this.searchSuggs.length; - } - }, - onUpKey(){ - if (this.focusedSuggIdx != null){ - this.focusedSuggIdx = (this.focusedSuggIdx - 1 + this.searchSuggs.length) % this.searchSuggs.length; - // The addition after '-1' is to avoid becoming negative - } - }, - // Search events - onSearch(){ - if (this.focusedSuggIdx == null){ - let input = (this.$refs.searchInput as HTMLInputElement).value.toLowerCase(); - this.resolveSearch(input) - } else { - let sugg = this.searchSuggs[this.focusedSuggIdx] - this.resolveSearch(sugg.canonicalName || sugg.name); - } - }, - async resolveSearch(tolNodeName: string){ - if (tolNodeName == ''){ - return; - } - // Check if the node data is already here - if (this.lytMap.has(tolNodeName)){ - this.$emit('search', tolNodeName); - return; - } - // Ask server for nodes in parent-chain, updates tolMap, then emits search event - let urlParams = new URLSearchParams({ - type: 'node', - name: tolNodeName, - toroot: '1', - excl: this.activeRoot.name, - tree: this.uiOpts.tree, - }); - this.$emit('net-wait'); // Allows the parent component to show a loading-indicator - let responseObj: {[x: string]: TolNode} = await queryServer(urlParams); - this.$emit('net-get'); - if (responseObj == null){ - return; - } - let keys = Object.getOwnPropertyNames(responseObj); - if (keys.length > 0){ - keys.forEach(key => { - if (!this.tolMap.has(key)){ - this.tolMap.set(key, responseObj[key]) - } - }); - this.$emit('search', tolNodeName); - } else { - // Trigger failure animation - let input = this.$refs.searchInput as HTMLInputElement; - input.classList.remove('animate-red-then-fade'); - input.offsetWidth; // Triggers reflow - input.classList.add('animate-red-then-fade'); +// Refs +const rootRef = ref(null as HTMLDivElement | null); +const inputRef = ref(null as HTMLInputElement | null); + +// Props + events +const props = defineProps({ + lytMap: {type: Object as PropType<LayoutMap>, required: true}, // Used to check if a searched-for node exists + activeRoot: {type: Object as PropType<LayoutNode>, required: true}, // Sent to server to reduce response size + tolMap: {type: Object as PropType<TolMap>, required: true}, // Upon a search response, gets new nodes added + lytOpts: {type: Object as PropType<LayoutOptions>, required: true}, + uiOpts: {type: Object as PropType<UiOptions>, required: true}, +}); +const emit = defineEmits(['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get']); + +// Search-suggestion data +const searchSuggs = ref([] as SearchSugg[]); +const searchHadMoreSuggs = ref(false); +const suggDisplayStrings = computed((): [string, string, string, string][] => { + let result: [string, string, string, string][] = []; + let input = suggsInput.value.toLowerCase(); + // For each SearchSugg + for (let sugg of searchSuggs.value){ + let idx = sugg.name.indexOf(input); + // Split suggestion text into parts before/within/after an input match + let strings: [string, string, string, string]; + if (idx != -1){ + strings = [sugg.name.substring(0, idx), input, sugg.name.substring(idx + input.length), '']; + } else { + strings = [input, '', '', '']; + } + // Indicate any distinct canonical-name + if (sugg.canonicalName != null){ + strings[3] = ` (aka ${sugg.canonicalName})`; + } + // + result.push(strings); + } + return result; +}); +const suggsInput = ref(''); // The input that resulted in the current suggestions (used to highlight matching text) +const focusedSuggIdx = ref(null as null | number); // Index of a search-suggestion selected using the arrow keys + +// For search-suggestion requests +const lastSuggReqTime = ref(0); // Set when a search-suggestions request is initiated +const pendingSuggReqParams = ref(null as null | URLSearchParams); + // Used by a search-suggestion requester to request with the latest user input +const pendingDelayedSuggReq = ref(0); // Set via setTimeout() for a non-initial search-suggestions request +const pendingSuggInput = ref(''); // Used to remember what input triggered a suggestions request +async function onInput(){ + let input = inputRef.value!; + // Check for empty input + if (input.value.length == 0){ + searchSuggs.value = []; + searchHadMoreSuggs.value = false; + focusedSuggIdx.value = null; + return; + } + // Get URL params to use for querying search-suggestions + let urlParams = new URLSearchParams({ + type: 'sugg', + name: input.value, + limit: String(props.uiOpts.searchSuggLimit), + tree: props.uiOpts.tree, + }); + // Query server, delaying/skipping if a request was recently sent + pendingSuggReqParams.value = urlParams; + pendingSuggInput.value = input.value; + let doReq = async () => { + let suggInput = pendingSuggInput.value; + let responseObj: SearchSuggResponse = + await queryServer(pendingSuggReqParams.value!); + if (responseObj == null){ + return; + } + searchSuggs.value = responseObj.suggs; + searchHadMoreSuggs.value = responseObj.hasMore; + suggsInput.value = suggInput; + // Auto-select first result if present + if (searchSuggs.value.length > 0){ + focusedSuggIdx.value = 0; + } else { + focusedSuggIdx.value = null; + } + }; + let currentTime = new Date().getTime(); + if (lastSuggReqTime.value == 0){ + lastSuggReqTime.value = currentTime; + await doReq(); + if (lastSuggReqTime.value == currentTime){ + lastSuggReqTime.value = 0; + } + } else if (pendingDelayedSuggReq.value == 0){ + lastSuggReqTime.value = currentTime; + pendingDelayedSuggReq.value = setTimeout(async () => { + pendingDelayedSuggReq.value = 0; + await doReq(); + if (lastSuggReqTime.value == currentTime){ + lastSuggReqTime.value = 0; } - }, - // Other - onSearchModeChg(){ - this.uiOpts.searchJumpMode = !this.uiOpts.searchJumpMode; - this.$emit('setting-chg', 'searchJumpMode'); - }, - onClose(evt: Event){ - if (evt.target == this.$el){ - this.$emit('close'); + }, 300); + } +} + +// For search events +function onSearch(){ + if (focusedSuggIdx.value == null){ + let input = inputRef.value!.value.toLowerCase(); + resolveSearch(input) + } else { + let sugg = searchSuggs.value[focusedSuggIdx.value] + resolveSearch(sugg.canonicalName || sugg.name); + } +} +async function resolveSearch(tolNodeName: string){ + if (tolNodeName == ''){ + return; + } + // Check if the node data is already here + if (props.lytMap.has(tolNodeName)){ + emit('search', tolNodeName); + return; + } + // Ask server for nodes in parent-chain, updates tolMap, then emits search event + let urlParams = new URLSearchParams({ + type: 'node', + name: tolNodeName, + toroot: '1', + excl: props.activeRoot.name, + tree: props.uiOpts.tree, + }); + emit('net-wait'); // Allows the parent component to show a loading-indicator + let responseObj: {[x: string]: TolNode} = await queryServer(urlParams); + emit('net-get'); + if (responseObj == null){ + return; + } + let keys = Object.getOwnPropertyNames(responseObj); + if (keys.length > 0){ + keys.forEach(key => { + if (!props.tolMap.has(key)){ + props.tolMap.set(key, responseObj[key]) } - }, - focusInput(){ // Used from external component - (this.$refs.searchInput as HTMLInputElement).focus(); - }, - }, - mounted(){ - (this.$refs.searchInput as HTMLInputElement).focus(); - }, - components: {SearchIcon, InfoIcon, LogInIcon, }, - emits: ['search', 'close', 'info-click', 'setting-chg', 'net-wait', 'net-get', ], + }); + emit('search', tolNodeName); + } else { + // Trigger failure animation + let input = inputRef.value!; + input.classList.remove('animate-red-then-fade'); + input.offsetWidth; // Triggers reflow + input.classList.add('animate-red-then-fade'); + } +} + +// More event handling +function onClose(evt: Event){ + if (evt.target == rootRef.value){ + emit('close'); + } +} +function onDownKey(){ + if (focusedSuggIdx.value != null){ + focusedSuggIdx.value = (focusedSuggIdx.value + 1) % searchSuggs.value.length; + } +} +function onUpKey(){ + if (focusedSuggIdx.value != null){ + focusedSuggIdx.value = (focusedSuggIdx.value - 1 + searchSuggs.value.length) % searchSuggs.value.length; + // The addition after '-1' is to avoid becoming negative + } +} +function onInfoIconClick(nodeName: string){ + emit('info-click', nodeName); +} + +// For keyboard shortcuts +function onKeyDown(evt: KeyboardEvent){ + if (props.uiOpts.disableShortcuts){ + return; + } + if (evt.key == 'f' && evt.ctrlKey){ + evt.preventDefault(); + inputRef.value!.focus(); + } +} +onMounted(() => window.addEventListener('keydown', onKeyDown)) +onUnmounted(() => window.removeEventListener('keydown', onKeyDown)) + +// Focus input on mount +onMounted(() => inputRef.value!.focus()) + +// Styles +const styles = computed((): Record<string,string> => { + let br = props.uiOpts.borderRadius; + return { + backgroundColor: props.uiOpts.bgColorAlt, + borderRadius: (searchSuggs.value.length == 0) ? `${br}px` : `${br}px ${br}px 0 0`, + boxShadow: props.uiOpts.shadowNormal, + }; +}); +const suggContainerStyles = computed((): Record<string,string> => { + let br = props.uiOpts.borderRadius; + return { + backgroundColor: props.uiOpts.bgColorAlt, + color: props.uiOpts.textColorAlt, + borderRadius: `0 0 ${br}px ${br}px`, + }; }); +const animateLabelStyles = computed(() => ({ + position: 'absolute', + top: -props.lytOpts.headerSz - 2 + 'px', + right: '0', + height: props.lytOpts.headerSz + 'px', + color: props.uiOpts.textColor, +})); </script> |
