Move search bar into modal
This commit is contained in:
315
svelte/src/filesystem/SearchBar.svelte
Normal file
315
svelte/src/filesystem/SearchBar.svelte
Normal file
@@ -0,0 +1,315 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from "svelte";
|
||||
import { fs_search, fs_encode_path, fs_thumbnail_url, FSNode } from "lib/FilesystemAPI.svelte";
|
||||
import type { FSNavigator } from "filesystem/FSNavigator";
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
import Modal from "util/Modal.svelte";
|
||||
|
||||
let { nav }: {
|
||||
nav: FSNavigator;
|
||||
} = $props();
|
||||
|
||||
let modal: Modal
|
||||
let search_bar: HTMLInputElement = $state()
|
||||
let error = $state("")
|
||||
let search_term = $state("")
|
||||
let search_results: string[] = $state([])
|
||||
let selected_result = $state(0)
|
||||
let searching = false
|
||||
let last_searched_term = ""
|
||||
let last_limit = $state(10)
|
||||
|
||||
// The path in which we're searching. This is used for stripping the path prefix
|
||||
// of search results, and showing the title of the Modal
|
||||
let search_prefix_node: FSNode = $state({} as FSNode)
|
||||
|
||||
onMount(() => {
|
||||
// Clear results when the user moves to a new directory
|
||||
return nav.subscribe(nav => {
|
||||
if (nav.initialized) {
|
||||
clear_search(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const open = async () => {
|
||||
search_prefix_node = nav.base
|
||||
modal.show()
|
||||
await tick()
|
||||
search_bar.focus()
|
||||
}
|
||||
|
||||
const search = async (limit = 10) => {
|
||||
if (search_term.length < 2 || search_term.length > 100) {
|
||||
// These are the length limits defined by the API
|
||||
search_results = []
|
||||
return
|
||||
} else if (search_term === last_searched_term && limit === last_limit) {
|
||||
// If the term is the same we don't have to search
|
||||
return
|
||||
} else if (searching) {
|
||||
// If a search is already running we also don't search
|
||||
return
|
||||
}
|
||||
|
||||
console.debug("Searching for", search_term)
|
||||
|
||||
error = ""
|
||||
last_searched_term = search_term
|
||||
last_limit = limit
|
||||
|
||||
searching = true
|
||||
|
||||
try {
|
||||
loading_start()
|
||||
let search_node = nav.base
|
||||
// If the base is not a directory, we use the parent
|
||||
if (search_node.type !== "dir") {
|
||||
search_node = nav.path[nav.path.length-2]
|
||||
}
|
||||
|
||||
let search_results_tmp = await fs_search(search_node.path, search_term, limit)
|
||||
|
||||
// If there are no results, and we are not searching the filesystem
|
||||
// root, we will search the filesystem root
|
||||
if (search_results_tmp.length === 0 && search_node.path != nav.path[0].path) {
|
||||
search_node = nav.path[0]
|
||||
search_results_tmp = await fs_search(search_node.path, search_term, limit)
|
||||
}
|
||||
|
||||
search_prefix_node = search_node
|
||||
search_results = search_results_tmp
|
||||
} catch (err) {
|
||||
if (err.value) {
|
||||
error = err.value
|
||||
} else {
|
||||
alert(err)
|
||||
console.error(err)
|
||||
}
|
||||
} finally {
|
||||
loading_finish()
|
||||
}
|
||||
|
||||
if (search_results.length > 0 && selected_result > search_results.length-1) {
|
||||
selected_result = search_results.length-1
|
||||
}
|
||||
|
||||
searching = false
|
||||
|
||||
// It's possible that the user entered another letter while we were
|
||||
// performing the search request. If this happens we run the search function
|
||||
// again
|
||||
if (last_searched_term !== search_term) {
|
||||
console.debug("Search term changed during search. Searching again")
|
||||
await search()
|
||||
}
|
||||
}
|
||||
|
||||
const clear_search = (blur: boolean) => {
|
||||
error = ""
|
||||
search_term = ""
|
||||
search_results = []
|
||||
selected_result = 0
|
||||
searching = false
|
||||
last_searched_term = ""
|
||||
last_limit = 10
|
||||
|
||||
// If blur is true we unfocus the search field. This should only happen when
|
||||
// the user presses escape
|
||||
if (blur) {
|
||||
search_bar.blur()
|
||||
}
|
||||
|
||||
modal.hide()
|
||||
}
|
||||
|
||||
// Cursor navigation events can only be prevented with keydown. But we want to
|
||||
// use keyup for searching, so we use two listeners here
|
||||
const input_keydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
const input_keyup = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
clear_search(true)
|
||||
} else if (e.key === "ArrowUp") {
|
||||
if (selected_result > 0) {
|
||||
selected_result--
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (selected_result+1 < search_results.length) {
|
||||
selected_result++
|
||||
}
|
||||
} else {
|
||||
search()
|
||||
}
|
||||
}
|
||||
|
||||
// Submitting opens the selected result
|
||||
const submit_search = (e: Event) => {
|
||||
e.preventDefault()
|
||||
if (search_results.length !== 0) {
|
||||
open_result(selected_result)
|
||||
}
|
||||
}
|
||||
|
||||
const open_result = (index: number, e?: MouseEvent) => {
|
||||
if (e !== undefined) {
|
||||
e.preventDefault()
|
||||
}
|
||||
nav.navigate(search_results[index], true)
|
||||
clear_search(false)
|
||||
}
|
||||
|
||||
const window_keydown = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) {
|
||||
return // prevent custom shortcuts from interfering with system shortcuts
|
||||
} else if ((document.activeElement as any).type !== undefined && (document.activeElement as any).type === "text") {
|
||||
return // Prevent shortcuts from interfering with input fields
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && search_term !== "") {
|
||||
clear_search(true)
|
||||
e.preventDefault()
|
||||
} else if (e.key === "/" || e.key === "f") {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// search_bar.focus()
|
||||
open()
|
||||
return
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={window_keydown} />
|
||||
|
||||
|
||||
<Modal bind:this={modal} title="Searching in {search_prefix_node.name}">
|
||||
{#if error === "path_not_found" || error === "node_is_a_directory"}
|
||||
<div class="highlight_yellow center">
|
||||
Search index not found. The search index is a file called
|
||||
'.search_index.zstd' in your home directory. If you delete this file
|
||||
then search will not work. The file is regenerated 10 minutes after
|
||||
modifying a file in your filesystem.
|
||||
</div>
|
||||
{:else if error !== ""}
|
||||
<div class="highlight_red center">
|
||||
An error ocurred while executing the search request: {error}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="center">
|
||||
<form class="search_form" onsubmit={submit_search}>
|
||||
<i class="icon">search</i>
|
||||
<input
|
||||
bind:this={search_bar}
|
||||
class="term"
|
||||
type="text"
|
||||
placeholder="Enter search term"
|
||||
style="width: 100%;"
|
||||
bind:value={search_term}
|
||||
onkeydown={input_keydown}
|
||||
onkeyup={input_keyup}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<div class="results">
|
||||
{#if search_term !== "" && search_results.length === 0}
|
||||
No results found
|
||||
{/if}
|
||||
|
||||
{#each search_results as result, index}
|
||||
<a
|
||||
href={"/d"+fs_encode_path(result)}
|
||||
onclick={(e) => open_result(index, e)}
|
||||
class="node"
|
||||
class:node_selected={selected_result === index}
|
||||
>
|
||||
<img src={fs_thumbnail_url(result, 32, 32)} class="node_icon" alt="icon"/>
|
||||
<span class="node_name">
|
||||
<!-- Remove the search directory from the result -->
|
||||
{result.slice(search_prefix_node.path.length+1)}
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
|
||||
{#if search_results.length === last_limit}
|
||||
<div class="node">
|
||||
<div class="node_name" style="text-align: center;">
|
||||
<button onclick={() => {search(last_limit + 100)}}>
|
||||
<i class="icon">expand_more</i>
|
||||
More results
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.center {
|
||||
margin: auto;
|
||||
width: 1000px;
|
||||
max-width: 100%;
|
||||
padding-top: 2px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.search_form {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
.search_form > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.search_form > .term {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
max-height: 80vh;
|
||||
text-align: initial;
|
||||
}
|
||||
.results > * {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.node {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
padding: 2px;
|
||||
}
|
||||
.node_selected {
|
||||
background: var(--highlight_background);
|
||||
color: var(--highlight_text_color);
|
||||
}
|
||||
.node:hover:not(.node_selected) {
|
||||
background: var(--input_background);
|
||||
color: var(--input_text);
|
||||
text-decoration: none;
|
||||
}
|
||||
.node_icon {
|
||||
flex: 0 0 auto;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
vertical-align: middle;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.node_name {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
line-height: 1.2em;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user