feat: qol improvements

This commit is contained in:
2025-12-11 20:51:32 +01:00
parent 7beab5ac07
commit 2893afa2ba
17 changed files with 632 additions and 304 deletions

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import type { Snippet } from "svelte";
import type { HTMLButtonAttributes } from "svelte/elements";
let {
onclick,
children,
working,
progress,
error,
disabled = false,
element = $bindable(),
...restProps
}: {
onclick: () => void;
children: Snippet;
working: boolean;
progress: number;
error?: string;
disabled?: boolean;
element?: HTMLButtonElement;
} & HTMLButtonAttributes = $props();
</script>
<button
class:working={working && (progress <= 0 || progress >= 1)}
class:progress={working && progress > 0 && progress < 1}
style:--progress="{progress * 100}%"
class:primary={!error}
class:error={!!error}
disabled={disabled || working}
bind:this={element}
{...restProps}
{onclick}>{@render children()}</button
>
<style lang="scss">
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button {
--height: 42px;
--border-radius: calc(var(--height) / 2);
position: relative;
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
border: 2px solid currentcolor;
border-radius: var(--border-radius);
background: var(--md-sys-color-background);
height: var(--height);
overflow: hidden;
&.primary {
background: none;
color: var(--md-sys-color-primary);
}
&.progress,
&.working {
border-color: transparent;
}
&.working::before {
position: absolute;
z-index: -1;
border-radius: calc(var(--border-radius) - 2px);
background: var(--md-sys-color-background);
width: calc(100% - 4px);
height: calc(100% - 4px);
content: "";
}
&.working::after {
position: absolute;
z-index: -2;
animation: rotate 1s ease-out forwards infinite;
background: var(--md-sys-color-primary);
width: 120%;
height: 30%;
content: "";
}
&.progress::after {
position: absolute;
left: 0;
opacity: 0.2;
z-index: -2;
background: var(--md-sys-color-primary);
width: var(--progress);
height: 100%;
content: "";
}
}
</style>

View File

@@ -3,11 +3,14 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
import { osLayout } from "$lib/os-layout";
import { tooltip } from "$lib/hover-popover";
import { isVerbose } from "./verbose-action";
import { actionTooltip } from "$lib/title";
let {
action,
display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
}: { action: number | KeyInfo; display: "inline-keys" | "keys" | "verbose" } =
$props();
let info = $derived(
typeof action === "number"
@@ -15,52 +18,56 @@
: action,
);
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let popover: HTMLElement | undefined = $state(undefined);
let hasPopover = $derived(!info.id || info.title || info.description);
</script>
{#snippet popoverSnippet()}
<div bind:this={popover} popover="hint">
&lt;{info.id ?? `0x${info.code.toString(16)}`}&gt;
{#if info.title}
{info.title}
{/if}
{#if info.variant === "left"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
</div>
{#snippet popover()}
{#if info.icon || info.display || !info.id}
&lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt;
{/if}
{#if info.title}
{info.title}
{/if}
{#if info.variant === "left"}
(Left)
{:else if info.variant === "right"}
(Right)
{/if}
{#if info.description}
<br />
<small>{info.description}</small>
{/if}
{/snippet}
{#if display === "keys"}
{#snippet kbdText()}
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{/snippet}
{#snippet kbdSnippet(withPopover = true)}
<kbd
class:icon={!!info.icon}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
{@attach tooltip(popover)}
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render popoverSnippet()}
{@render kbdText()}
</kbd>
{:else if display === "inline-keys"}
{/snippet}
{#snippet inlineKbdSnippet()}
{#if !info.icon && dynamicMapping?.length === 1}
<span
{@attach tooltip(popover)}
{@attach hasPopover ? actionTooltip(popover) : null}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
>{dynamicMapping}{@render popoverSnippet()}</span
class:right={info.variant === "right"}>{dynamicMapping}</span
>
{:else if !info.icon && info.id?.length === 1}
<span
{@attach tooltip(popover)}
{@attach hasPopover ? actionTooltip(popover) : null}
class:left={info.variant === "left"}
class:right={info.variant === "right"}
>{info.id}{@render popoverSnippet()}</span
class:right={info.variant === "right"}>{info.id}</span
>
{:else}
<kbd
@@ -68,15 +75,26 @@
class:left={info.variant === "left"}
class:right={info.variant === "right"}
class:icon={!!info.icon}
{@attach tooltip(popover)}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
{@attach hasPopover ? actionTooltip(popover) : null}
>
{@render kbdText()}
</kbd>
{/if}
{/snippet}
{#if display === "keys"}
{@render kbdSnippet()}
{:else if display === "verbose"}
{#if isVerbose(info)}
<div class="verbose" {@attach hasPopover ? actionTooltip(popover) : null}>
{@render kbdSnippet(false)}
<div class="verbose-title">{info.title}</div>
</div>
{:else}
{@render inlineKbdSnippet()}
{/if}
{:else if display === "inline-keys"}
{@render inlineKbdSnippet()}
{/if}
<style lang="scss">
@@ -100,4 +118,44 @@
:global(span) + .inline-kbd {
margin-inline-start: 2px;
}
div[popover] {
width: fit-content;
max-width: 200px;
height: fit-content;
text-align: left;
text-wrap: break-word;
small {
opacity: 0.8;
font-size: 0.9em;
}
}
.verbose {
display: flex;
align-items: center;
gap: 8px;
margin-inline: 2px;
min-width: 160px;
height: 32px;
kbd {
justify-content: flex-start;
}
.verbose-title {
display: -webkit-box;
opacity: 0.9;
max-width: 15ch;
overflow: hidden;
font-style: italic;
font-size: 12px;
text-align: left;
text-overflow: ellipsis;
-webkit-line-clamp: 2; /* number of lines to show */
line-clamp: 2;
-webkit-box-orient: vertical;
}
}
</style>

View File

@@ -1,9 +1,14 @@
<script lang="ts">
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
import type { Snippet } from "svelte";
let { title, shortcut }: { title?: string | Snippet; shortcut?: string } =
$props();
</script>
{#if title}
{#if typeof title === "string"}
<p>{@html title}</p>
{:else}
{@render title?.()}
{/if}
{#if shortcut}

View File

@@ -11,6 +11,9 @@
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
import { get } from "svelte/store";
import type { KeymapCategory } from "$lib/meta/types/actions";
import Action from "../Action.svelte";
import { isVerbose } from "../verbose-action";
let {
currentAction = undefined,
@@ -26,6 +29,7 @@
onMount(() => {
searchBox.focus();
search();
});
const index = new FlexSearch.Index({ tokenize: "full" });
@@ -46,7 +50,29 @@
}
async function search() {
results = (await index!.searchAsync(searchBox.value)) as number[];
const groups = new Map(
$KEYMAP_CATEGORIES.map(
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
),
);
const result =
searchBox.value === ""
? Array.from($KEYMAP_CODES.keys())
: await index!.searchAsync(searchBox.value);
for (const id of result) {
const action = $KEYMAP_CODES.get(id as number);
if (action?.category) {
groups.get(action.category)?.push(action);
}
}
function sortValue(action: KeyInfo): number {
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
}
for (const actions of groups.values()) {
actions.sort((a, b) => sortValue(b) - sortValue(a));
}
results = groups;
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value);
}
@@ -81,13 +107,12 @@
event.preventDefault();
}
let results: number[] = $state([]);
let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement;
let resultList: HTMLUListElement;
let filter: Set<number> | undefined = $state(undefined);
</script>
<svelte:window on:keydown={keyboardNavigation} />
@@ -122,29 +147,6 @@
onclick={onclose}>close</button
>
</div>
<fieldset class="filters">
<label
>{$LL.actionSearch.filter.ALL()}<input
checked
name="category"
type="radio"
value={undefined}
bind:group={filter}
/></label
>
{#each $KEYMAP_CATEGORIES as category}
{#if category.name !== "Internal"}
<label
>{category.name}<input
name="category"
type="radio"
value={new Set(Object.keys(category.actions).map(Number))}
bind:group={filter}
/></label
>
{/if}
{/each}
</fieldset>
{#if currentAction !== undefined}
<aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
@@ -171,15 +173,21 @@
<li>Action code is out of range</li>
{/if}
{/if}
{#if filter !== undefined || results.length > 0}
{@const resultValue =
results.length === 0
? Array.from($KEYMAP_CODES, ([it]) => it)
: results}
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
<li><ActionListItem {id} onclick={() => select(id)} /></li>
{/each}
{/if}
{#each results as [category, actions] (category)}
{#if actions.length > 0}
<div class="category">
<h3>{category.name}</h3>
<div class="description">{category.description}</div>
<ul>
{#each actions as action (action.code)}
<button class="action-item" onclick={() => select(action.code)}>
<Action {action} display="verbose"></Action>
</button>
{/each}
</ul>
</div>
{/if}
{/each}
</ul>
</div>
</dialog>
@@ -210,6 +218,13 @@
}
}
.action-item {
margin: 0;
padding: 0;
height: auto;
font: inherit;
}
dialog {
display: flex;
justify-content: center;
@@ -314,6 +329,22 @@
scrollbar-gutter: both-edges stable;
}
.category {
.description {
opacity: 0.8;
margin-block-start: -16px;
font-style: italic;
font-size: 14px;
}
ul {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-block: 24px;
overflow: hidden;
}
}
li {
display: contents;
}

View File

@@ -0,0 +1,9 @@
import type { KeyInfo } from "$lib/serial/keymap-codes";
export function isVerbose(info: KeyInfo) {
return (
info.id?.length !== 1 &&
info.title &&
(!info.id || /F\d{1,2}/.test(info.id) === false)
);
}

View File

@@ -18,7 +18,7 @@ export function tooltip(
node.addEventListener("mouseenter", show);
node.addEventListener("focus", show);
node.addEventListener("mouseout", hide);
node.addEventListener("mouseleave", hide);
node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {
@@ -28,7 +28,7 @@ export function tooltip(
return () => {
node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show);
node.removeEventListener("mouseout", hide);
node.removeEventListener("mouseleave", hide);
node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) {

View File

@@ -1,7 +1,8 @@
import type { Action } from "svelte/action";
import tippy from "tippy.js";
import { mount, unmount, type SvelteComponent } from "svelte";
import { mount, unmount, type Snippet } from "svelte";
import Tooltip from "$lib/components/Tooltip.svelte";
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>();
@@ -44,3 +45,40 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
},
};
};
export function actionTooltip(
title: string | Snippet,
shortcut?: string,
): Attachment<Element> {
return (node: Element) => {
let component: {} | undefined;
const tooltip = tippy(node, {
arrow: false,
theme: "tooltip",
animation: "fade",
onShow(instance) {
component ??= mount(Tooltip, {
target: instance.popper.querySelector(".tippy-content") as Element,
props: { shortcut, title },
});
},
onHidden() {
if (component) {
unmount(component);
component = undefined;
}
},
});
if (shortcut && node instanceof HTMLElement) {
hotkeys.set(shortcut, node);
}
return () => {
tooltip.destroy();
if (shortcut && node instanceof HTMLElement) {
hotkeys.delete(shortcut);
}
};
};
}