Globally search between pages with meta + J

This commit is contained in:
2025-10-13 20:19:13 +02:00
parent 5842a16c9b
commit 9c5d8f3bdc
6 changed files with 162 additions and 79 deletions

View File

@@ -1,12 +1,17 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { clickOutside } from '$lib/utils/mouseEvents';
export let title: string;
export let description: string | null = null;
const dispatch = createEventDispatcher();
const close = () => dispatch('close');
interface Props {
title: string;
description: string | null;
close(): void
}
const {
title,
description,
close
}: Props = $props()
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
@@ -40,7 +45,7 @@
class="dialog"
>
<div tabindex="-1" id="dialog-title" class="title">
{#if title.length || description.length}
{#if title.length || description?.length}
<header>
<button on:click={close} aria-disabled="false" aria-label="Close" type="button" tabindex="0"
><svg viewBox="0 0 24 24" aria-hidden="true" tabindex="-1" height="100%" width="100%"

View File

@@ -1,19 +1,135 @@
<script lang="ts">
import { goto, pushState } from '$app/navigation';
import { onMount } from 'svelte';
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import Dialog from './Dialog.svelte';
import Input from './Input.svelte';
import { allRoutes } from '$lib/remote/filesystem.remote.ts';
import type { PageRoute } from '$lib/remote/filesystem.remote.ts';
let open = $state(false);
type ShortcutHandler = (event: KeyboardEvent) => void;
interface Shortcut {
keys: string[];
handler: ShortcutHandler;
description?: string;
}
interface MinimalElement {
name: string;
link: string;
}
interface OverlayData {
type: 'elements' | 'pages' | null;
content: MinimalElement | unknown;
}
class KeyboardShortcutManager {
private shortcuts: Shortcut[] = [];
constructor() {
window.addEventListener('keydown', this.handleKeydown);
}
register(shortcut: Shortcut) {
this.shortcuts.push(shortcut);
}
unregisterAll() {
this.shortcuts = [];
window.removeEventListener('keydown', this.handleKeydown);
}
private handleKeydown = (event: KeyboardEvent) => {
const pressedKeys = [
event.metaKey ? 'Meta' : '',
event.ctrlKey ? 'Control' : '',
event.shiftKey ? 'Shift' : '',
event.altKey ? 'Alt' : '',
event.key.toUpperCase()
].filter(Boolean);
for (const shortcut of this.shortcuts) {
if (this.isMatch(shortcut.keys, pressedKeys)) {
event.preventDefault();
shortcut.handler(event);
return;
}
}
// some other key, but not overlay is not open. Nothing to do
if (!overlayStore.type) return;
// listen for text, any letter should reset focusIndex
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
if (singleLetter) {
updateFocus(0);
}
// listen for number as shortcut actions
const digit = event.key.match(/\d/)?.[0];
if (digit?.length && digit?.length > 0) {
setTimeout(() => {
filterString = String(filterString)?.replaceAll(digit, '');
}, 1);
updateFocus(Number(digit) - 1);
}
// listen for arrow keys
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
const direction = event.key === 'ArrowDown' ? 1 : -1;
updateFocus(focusIndex + 1 * direction);
}
// listen for enter key
if (event.key === 'Enter' && filteredchildren.length > 0) {
const { link, path } = filteredchildren[focusIndex];
hideOverlay();
openElement(link || path);
}
};
private isMatch(shortcutKeys: string[], pressedKeys: string[]) {
return (
shortcutKeys.length === pressedKeys.length &&
shortcutKeys.every((key) => pressedKeys.includes(key))
);
}
}
let overlayStore: OverlayData = $state({ type: null, content: null });
let pages: Array<PageRoute> = $state([]);
function showOverlay(type: 'elements' | 'pages', content: unknown) {
if (type === overlayStore.type) return hideOverlay();
overlayStore = { type, content };
focusSearchInput();
filterString = '';
updateFocus(0);
}
function hideOverlay() {
overlayStore = { type: null, content: null };
}
async function resolvePages() {
pages = await allRoutes();
}
// call as soon as possible, even if blocking
resolvePages();
// setup managers
let manager: KeyboardShortcutManager;
const className = 'search-container';
/* search & filter */
// search & filter
let filterString = $state('');
let focusIndex = $state(0);
let children = $state([]);
let filteredchildren = $derived.by(() => {
return children.filter((a) => a?.name.toLowerCase().includes(filterString));
return overlayStore.content?.filter((a) => a?.name.toLowerCase().includes(filterString));
});
const updateFocus = (index: number) => {
@@ -25,12 +141,10 @@
focusIndex = index;
};
/* setup & register */
const toggleSearchDialog = () => (open = !open);
// setup & register
const focusSearchInput = () => {
setTimeout(() => {
const input = document.getElementsByClassName(className)[0]?.getElementsByTagName('input')[0];
input.focus();
}, 50);
};
@@ -43,64 +157,28 @@
}
}
function registerShortcutKey() {
document.addEventListener('keydown', function (event) {
// listen for open/close command + k
if ((event.metaKey && event.key === 'k') || (event.ctrlKey && event.key === 'k')) {
event.preventDefault();
console.log('Command + K / Ctrl + K was pressed');
toggleSearchDialog();
// initial state
if (open) {
focusSearchInput();
filterString = '';
updateFocus(0);
children = window?.elements || [{ name: 'empty' }];
}
return;
}
// listen for text, any letter should reset focusIndex
const singleLetter = (event.key.length == 1 && event.key.match(/\D/)) || 0 > 0;
if (open && singleLetter) {
updateFocus(0);
}
// listen for number as shortcut actions
const digit = event.key.match(/\d/)?.[0];
if (open && digit?.length && digit?.length > 0) {
setTimeout(() => {
filterString = String(filterString)?.replaceAll(digit, '');
}, 1);
updateFocus(Number(digit) - 1);
}
// listen for arrow keys
if (open && (event.key === 'ArrowUp' || event.key === 'ArrowDown')) {
const direction = event.key === 'ArrowDown' ? 1 : -1;
updateFocus(focusIndex + 1 * direction);
}
// listen for enter key
if (open && event.key === 'Enter' && filteredchildren.length > 0) {
const { link } = filteredchildren[focusIndex];
toggleSearchDialog();
openElement(link);
onMount(() => {
manager = new KeyboardShortcutManager();
manager.register({
keys: ['Meta', 'K'],
handler: () => {
if (!window || !('elements' in window)) return;
const elements = window?.elements;
showOverlay('elements', elements);
}
});
}
onMount(() => {
registerShortcutKey();
manager.register({
keys: ['Meta', 'J'],
handler: () => showOverlay('pages', pages)
});
});
onDestroy(() => manager?.unregisterAll());
</script>
{#if open}
<Dialog on:close={() => (open = false)} title="" description="">
{#if overlayStore.type}
<Dialog close={hideOverlay} title="" description="">
<div class={className}>
<Input label="" bind:value={filterString} placeholder="Search anything..." />
@@ -108,7 +186,7 @@
{#each filteredchildren as element, index (element)}
<li
class={index === focusIndex ? 'focus' : ''}
on:click={() => openElement(element?.link) ?? '/'}
on:click={() => 'link' in element && openElement(element.link ?? '/')}
>
<div class="header">
<h3>{element?.name}</h3>

View File

@@ -78,12 +78,12 @@
});
</script>
<div>
<div class="liveimage">
{#if !fullscreen}
<img on:click={() => (fullscreen = !fullscreen)} src={String(imageSource)} id="live-image" />
{:else}
<div class="fullscreen-container">
<Dialog title="Live stream of printer" on:close={() => (fullscreen = false)}>
<Dialog title="Live stream of printer" close={() => (fullscreen = false)}>
<img style="width: 100%;" src={String(imageSource)} id="live-image" />
<span>Last update {timestamp}s ago</span>
</Dialog>
@@ -95,8 +95,9 @@
</div>
<style lang="scss">
img {
width: 400px;
.liveimage img {
width: 100%;
max-width: 400px;
border-radius: 0.5rem;
}

View File

@@ -22,7 +22,7 @@
columns={['Domain', 'SSL', 'Status', 'Code']}
>
<tbody slot="tbody">
{#each httpHealth as row, i (row)}
{#each httpHealth as row (row)}
<tr>
<td>{row.domain}</td>
<td>
@@ -48,7 +48,7 @@
</Table>
{#if selectedSSL !== null}
<Dialog on:close={() => (selectedSSL = null)} title="SSL Certificate info">
<Dialog close={() => (selectedSSL = null)} title="SSL Certificate info">
<JsonViewer json={selectedSSL} />
</Dialog>
{/if}

View File

@@ -247,7 +247,7 @@
</div>
<tbody slot="tbody">
{#each filament as row, i (row)}
{#each filament as row (row)}
<tr class="link" on:click={() => goto(filamentLink(row))}>
<td><span class="color" style={`background: ${row.hex}`} /></td>
<td class="info">
@@ -268,7 +268,7 @@
{#if open}
<Dialog
on:close={() => (open = false)}
close={() => (open = false)}
title="Add new filament"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
>
@@ -335,7 +335,6 @@
&.info {
display: table-cell;
vertical-align: middle;
padding-left: 25px;
h2 {
width: 100%;

View File

@@ -33,7 +33,7 @@
{#if open}
<Dialog
on:close={() => (open = false)}
close={() => (open = false)}
title="Add new site"
description="You can select anything deployed in <b>Belgium (europe-west1) datacenter</b> and create an internal connection with your service."
>