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

@@ -79,6 +79,7 @@ const config = {
"palette", "palette",
"translate", "translate",
"smart_toy", "smart_toy",
"visibility_off",
"play_arrow", "play_arrow",
"extension", "extension",
"upload_file", "upload_file",
@@ -165,6 +166,7 @@ const config = {
routine: "e20c", routine: "e20c",
experiment: "e686", experiment: "e686",
dictionary: "f539", dictionary: "f539",
visibility_off: "e8f5",
}, },
}; };

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 type { KeyInfo } from "$lib/serial/keymap-codes";
import { osLayout } from "$lib/os-layout"; import { osLayout } from "$lib/os-layout";
import { tooltip } from "$lib/hover-popover"; import { tooltip } from "$lib/hover-popover";
import { isVerbose } from "./verbose-action";
import { actionTooltip } from "$lib/title";
let { let {
action, action,
display, display,
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props(); }: { action: number | KeyInfo; display: "inline-keys" | "keys" | "verbose" } =
$props();
let info = $derived( let info = $derived(
typeof action === "number" typeof action === "number"
@@ -15,52 +18,56 @@
: action, : action,
); );
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode)); let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
let hasPopover = $derived(!info.id || info.title || info.description);
let popover: HTMLElement | undefined = $state(undefined);
</script> </script>
{#snippet popoverSnippet()} {#snippet popover()}
<div bind:this={popover} popover="hint"> {#if info.icon || info.display || !info.id}
&lt;{info.id ?? `0x${info.code.toString(16)}`}&gt; &lt;<b>{info.id ?? `0x${info.code.toString(16)}`}</b>&gt;
{#if info.title} {/if}
{info.title} {#if info.title}
{/if} {info.title}
{#if info.variant === "left"} {/if}
(Left) {#if info.variant === "left"}
{:else if info.variant === "right"} (Left)
(Right) {:else if info.variant === "right"}
{/if} (Right)
</div> {/if}
{#if info.description}
<br />
<small>{info.description}</small>
{/if}
{/snippet} {/snippet}
{#if display === "keys"} {#snippet kbdText()}
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{/snippet}
{#snippet kbdSnippet(withPopover = true)}
<kbd <kbd
class:icon={!!info.icon} class:icon={!!info.icon}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}
{@attach tooltip(popover)} {@attach withPopover && hasPopover ? actionTooltip(popover) : null}
> >
{dynamicMapping ?? {@render kbdText()}
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}
{@render popoverSnippet()}
</kbd> </kbd>
{:else if display === "inline-keys"} {/snippet}
{#snippet inlineKbdSnippet()}
{#if !info.icon && dynamicMapping?.length === 1} {#if !info.icon && dynamicMapping?.length === 1}
<span <span
{@attach tooltip(popover)} {@attach hasPopover ? actionTooltip(popover) : null}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}>{dynamicMapping}</span
>{dynamicMapping}{@render popoverSnippet()}</span
> >
{:else if !info.icon && info.id?.length === 1} {:else if !info.icon && info.id?.length === 1}
<span <span
{@attach tooltip(popover)} {@attach hasPopover ? actionTooltip(popover) : null}
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}>{info.id}</span
>{info.id}{@render popoverSnippet()}</span
> >
{:else} {:else}
<kbd <kbd
@@ -68,15 +75,26 @@
class:left={info.variant === "left"} class:left={info.variant === "left"}
class:right={info.variant === "right"} class:right={info.variant === "right"}
class:icon={!!info.icon} class:icon={!!info.icon}
{@attach tooltip(popover)} {@attach hasPopover ? actionTooltip(popover) : null}
>
{dynamicMapping ??
info.icon ??
info.display ??
info.id ??
`0x${info.code.toString(16)}`}{@render popoverSnippet()}</kbd
> >
{@render kbdText()}
</kbd>
{/if} {/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} {/if}
<style lang="scss"> <style lang="scss">
@@ -100,4 +118,44 @@
:global(span) + .inline-kbd { :global(span) + .inline-kbd {
margin-inline-start: 2px; 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> </style>

View File

@@ -1,9 +1,14 @@
<script lang="ts"> <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> </script>
{#if title} {#if typeof title === "string"}
<p>{@html title}</p> <p>{@html title}</p>
{:else}
{@render title?.()}
{/if} {/if}
{#if shortcut} {#if shortcut}

View File

@@ -11,6 +11,9 @@
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { get } from "svelte/store"; import { get } from "svelte/store";
import type { KeymapCategory } from "$lib/meta/types/actions";
import Action from "../Action.svelte";
import { isVerbose } from "../verbose-action";
let { let {
currentAction = undefined, currentAction = undefined,
@@ -26,6 +29,7 @@
onMount(() => { onMount(() => {
searchBox.focus(); searchBox.focus();
search();
}); });
const index = new FlexSearch.Index({ tokenize: "full" }); const index = new FlexSearch.Index({ tokenize: "full" });
@@ -46,7 +50,29 @@
} }
async function search() { 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; exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
code = Number(searchBox.value); code = Number(searchBox.value);
} }
@@ -81,13 +107,12 @@
event.preventDefault(); event.preventDefault();
} }
let results: number[] = $state([]); let results: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
let exact: number | undefined = $state(undefined); let exact: number | undefined = $state(undefined);
let code: number = $state(Number.NaN); let code: number = $state(Number.NaN);
let searchBox: HTMLInputElement; let searchBox: HTMLInputElement;
let resultList: HTMLUListElement; let resultList: HTMLUListElement;
let filter: Set<number> | undefined = $state(undefined);
</script> </script>
<svelte:window on:keydown={keyboardNavigation} /> <svelte:window on:keydown={keyboardNavigation} />
@@ -122,29 +147,6 @@
onclick={onclose}>close</button onclick={onclose}>close</button
> >
</div> </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} {#if currentAction !== undefined}
<aside> <aside>
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3> <h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
@@ -171,15 +173,21 @@
<li>Action code is out of range</li> <li>Action code is out of range</li>
{/if} {/if}
{/if} {/if}
{#if filter !== undefined || results.length > 0} {#each results as [category, actions] (category)}
{@const resultValue = {#if actions.length > 0}
results.length === 0 <div class="category">
? Array.from($KEYMAP_CODES, ([it]) => it) <h3>{category.name}</h3>
: results} <div class="description">{category.description}</div>
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)} <ul>
<li><ActionListItem {id} onclick={() => select(id)} /></li> {#each actions as action (action.code)}
{/each} <button class="action-item" onclick={() => select(action.code)}>
{/if} <Action {action} display="verbose"></Action>
</button>
{/each}
</ul>
</div>
{/if}
{/each}
</ul> </ul>
</div> </div>
</dialog> </dialog>
@@ -210,6 +218,13 @@
} }
} }
.action-item {
margin: 0;
padding: 0;
height: auto;
font: inherit;
}
dialog { dialog {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -314,6 +329,22 @@
scrollbar-gutter: both-edges stable; 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 { li {
display: contents; 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("mouseenter", show);
node.addEventListener("focus", show); node.addEventListener("focus", show);
node.addEventListener("mouseout", hide); node.addEventListener("mouseleave", hide);
node.addEventListener("blur", hide); node.addEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) { if (shortcut && node instanceof HTMLElement) {
@@ -28,7 +28,7 @@ export function tooltip(
return () => { return () => {
node.removeEventListener("mouseenter", show); node.removeEventListener("mouseenter", show);
node.removeEventListener("focus", show); node.removeEventListener("focus", show);
node.removeEventListener("mouseout", hide); node.removeEventListener("mouseleave", hide);
node.removeEventListener("blur", hide); node.removeEventListener("blur", hide);
if (shortcut && node instanceof HTMLElement) { if (shortcut && node instanceof HTMLElement) {

View File

@@ -1,7 +1,8 @@
import type { Action } from "svelte/action"; import type { Action } from "svelte/action";
import tippy from "tippy.js"; 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 Tooltip from "$lib/components/Tooltip.svelte";
import type { Attachment } from "svelte/attachments";
export const hotkeys = new Map<string, HTMLElement>(); 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);
}
};
};
}

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import { preference } from "$lib/preferences"; import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection"; import { initSerial } from "$lib/serial/connection";
import { import {
getPortName, getPortName,
@@ -9,13 +9,26 @@
} from "$lib/serial/device"; } from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog"; import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage";
let ports = $state<SerialPort[]>([]); let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state();
onMount(() => { onMount(() => {
refreshPorts(); refreshPorts();
}); });
let hasDiscoveredAutoConnect = persistentWritable(
"hasDiscoveredAutoConnect",
false,
);
$effect(() => {
if ($userPreferences.backup || $userPreferences.autoConnect) {
$hasDiscoveredAutoConnect = true;
}
});
async function refreshPorts() { async function refreshPorts() {
ports = await navigator.serial.getPorts(); ports = await navigator.serial.getPorts();
} }
@@ -28,62 +41,79 @@
await showConnectionFailedDialog(String(error)); await showConnectionFailedDialog(String(error));
} }
} }
function closePopover() {
element?.closest<HTMLElement>("[popover]")?.hidePopover();
}
async function connectCC0(event: MouseEvent) {
const { fetchCCOS } = await import("$lib/ccos/ccos");
closePopover();
const ccos = await fetchCCOS();
if (ccos) {
connect(ccos, !event.shiftKey);
}
}
async function connectDevice(event: MouseEvent) {
const port = await navigator.serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
});
if (!port) return;
closePopover();
refreshPorts();
connect(port, true);
}
</script> </script>
<div class="device-list"> <div
<fieldset> bind:this={element}
<label class="device-list"
><input type="checkbox" use:preference={"autoConnect"} /> onmouseenter={() => refreshPorts()}
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div> role="region"
</label> >
{#if ports.length === 1}
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label <label
><input type="checkbox" use:preference={"backup"} /> ><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div> <div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label> </label>
</fieldset> </fieldset>
<button {/if}
onclick={async (event) => { {#if ports.length !== 0}
const { fetchCCOS } = await import("$lib/ccos/ccos"); <h4>Recent Devices</h4>
const ccos = await fetchCCOS(); <div class="devices">
if (ccos) { <div class="device">
connect(ccos, !event.shiftKey); <button onclick={connectCC0}> CC0</button>
} </div>
}} {#each ports as port}
> <div class="device">
<span class="icon">history</span> <button
CC0</button onclick={(event) => {
> connect(port, !event.shiftKey);
{#each ports as port} }}
<div class="device"> >
<button {getPortName(port)}</button
onclick={(event) => { >
connect(port, !event.shiftKey); <button
}} class="error"
> onclick={() => {
<span class="icon">history</span> port.forget();
{getPortName(port)}</button refreshPorts();
> }}><span class="icon">visibility_off</span> Hide</button
<button >
class="error" </div>
onclick={() => { {/each}
port.forget();
refreshPorts();
}}><span class="icon">link_off</span></button
>
</div> </div>
{/each} {/if}
<div class="pair"> <div class="pair">
<button <button onclick={connectDevice} class="primary"
onclick={async (event) => { ><span class="icon">add</span>Connect</button
const port = await navigator.serial.requestPort({
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
});
if (!port) return;
refreshPorts();
connect(port, true);
}}
class="primary"><span class="icon">add</span>Pair</button
> >
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a> <a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
</div> </div>
@@ -93,7 +123,8 @@
button, button,
a { a {
padding: 10px; padding: 10px;
height: 32px; padding-inline-end: 16px;
height: 38px;
font-size: 12px; font-size: 12px;
.icon { .icon {
@@ -101,18 +132,33 @@
} }
} }
h4 {
margin-block-start: 16px;
margin-block-end: 8px;
font-weight: 600;
}
.device-list {
margin: 8px;
}
.pair { .pair {
display: flex; display: flex;
} }
.devices {
margin-bottom: 16px;
}
.device { .device {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 8px;
button { button {
flex: 1; flex: 1;
justify-content: flex-start;
font-size: 14px;
} }
} }
@@ -129,12 +175,57 @@
gap: 8px; gap: 8px;
appearance: none; appearance: none;
padding: 0; padding: 0;
height: auto;
overflow: hidden;
.title { .title {
font-weight: 600; font-weight: 600;
} }
} }
@keyframes attention {
0%,
100% {
filter: brightness(1);
}
50% {
filter: brightness(0.6);
}
}
@keyframes swoosh {
0% {
transform: translateX(-200%) skewX(-20deg);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateX(200%) skewX(-20deg);
opacity: 0;
}
}
.promote {
label:not(:has(input:checked)) {
animation: attention 1s ease;
&::after {
position: absolute;
z-index: -1;
animation: swoosh 1s ease forwards;
background-color: var(--md-sys-color-surface-variant);
width: 25%;
height: 200%;
content: "";
}
}
}
fieldset { fieldset {
display: flex; display: flex;
gap: 16px; gap: 16px;

View File

@@ -77,10 +77,15 @@
</a> </a>
</li> </li>
</ul> </ul>
<div class="sync-box"> <div
class="sync-box"
class:primary={!$serialPort}
class:attention={$syncStatus !== "done"}
>
{#if !$serialPort} {#if !$serialPort}
<button <button
class="warning" class="no-connection"
id="connect-button"
popovertarget="connect-popup" popovertarget="connect-popup"
transition:slide={{ axis: "x" }} transition:slide={{ axis: "x" }}
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button ><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
@@ -103,7 +108,7 @@
> >
{/if} {/if}
{#if $syncStatus !== "done"} {#if $syncStatus === "downloading"}
<progress <progress
transition:fade transition:fade
max={$syncProgress?.max ?? 1} max={$syncProgress?.max ?? 1}
@@ -168,26 +173,54 @@
</footer> </footer>
<style lang="scss"> <style lang="scss">
@keyframes attention {
0%,
100% {
filter: brightness(0.5);
}
50% {
filter: brightness(1);
}
}
$sync-border-radius: 16px;
.sync-box { .sync-box {
display: flex; display: flex;
position: relative; position: relative;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
translate: 0;
transition: all 250ms ease;
border-radius: 24px;
overflow: hidden;
button { button {
text-wrap: nowrap; text-wrap: nowrap;
} }
&.primary {
translate: 0 -32px;
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
&.attention {
animation: attention 2s infinite;
border-radius: $sync-border-radius;
color: var(--md-sys-color-primary);
}
} }
progress { progress {
$inset: 8px;
position: absolute; position: absolute;
right: 16px; opacity: 0.3;
bottom: 0;
left: 16px;
z-index: -1; z-index: -1;
border-radius: 4px; inset: $inset;
width: calc(100% - 32px); border-radius: #{$sync-border-radius - $inset};
height: 8px; width: calc(100% - $inset * 2);
height: calc(100% - $inset * 2);
overflow: hidden; overflow: hidden;
} }
@@ -241,7 +274,6 @@
justify-content: center; justify-content: center;
align-items: center; align-items: center;
opacity: 0.4;
padding: 8px; padding: 8px;
padding-inline-end: 16px; padding-inline-end: 16px;
padding-block-start: 0; padding-block-start: 0;

View File

@@ -6,8 +6,8 @@
[ [
{ {
href: "/config/settings/", href: "/config/settings/",
icon: "cable", icon: "tune",
title: "Device", title: "Settings",
primary: true, primary: true,
}, },
{ href: "/config/chords/", icon: "dictionary", title: "Library" }, { href: "/config/chords/", icon: "dictionary", title: "Library" },

View File

@@ -4,6 +4,7 @@
import { fade, slide } from "svelte/transition"; import { fade, slide } from "svelte/transition";
import { lt as semverLt } from "semver"; import { lt as semverLt } from "semver";
import type { LoaderOptions, ESPLoader } from "esptool-js"; import type { LoaderOptions, ESPLoader } from "esptool-js";
import ProgressButton from "$lib/ProgressButton.svelte";
let { data } = $props(); let { data } = $props();
@@ -205,18 +206,13 @@
{#if data.meta.update.ota && !data.meta.device.endsWith("m0")} {#if data.meta.update.ota && !data.meta.device.endsWith("m0")}
{@const buttonError = error || (!success && isCorrectDevice === false)} {@const buttonError = error || (!success && isCorrectDevice === false)}
<section> <section>
<button <ProgressButton
class="update-button" {working}
class:working={working && (progress <= 0 || progress >= 1)} {progress}
class:progress={working && progress > 0 && progress < 1} style="--height: 42px; --border-radius: 8px; margin-block: 16px;"
style:--progress="{progress * 100}%" error={buttonError ? buttonError.toString() : undefined}
class:primary={!buttonError} disabled={isTooOld || $serialPort === undefined || !isCorrectDevice}
class:error={buttonError} onclick={update}>Apply Update</ProgressButton
disabled={isTooOld ||
working ||
$serialPort === undefined ||
!isCorrectDevice}
onclick={update}>Apply Update</button
> >
{#if isTooOld} {#if isTooOld}
<div class="error" transition:slide> <div class="error" transition:slide>
@@ -433,27 +429,6 @@
overflow: auto; overflow: auto;
} }
@keyframes rotate {
0% {
transform: rotate(120deg);
opacity: 0;
}
20% {
transform: rotate(120deg);
opacity: 0;
}
60% {
opacity: 1;
}
100% {
transform: rotate(270deg);
opacity: 0;
}
}
button.inline-button { button.inline-button {
display: inline; display: inline;
margin: 0; margin: 0;
@@ -479,67 +454,6 @@
opacity: 0.8; opacity: 0.8;
} }
button.update-button {
position: relative;
transition:
border 200ms ease,
color 200ms ease;
margin: 6px;
margin-block: 16px;
outline: 2px dashed currentcolor;
outline-offset: 4px;
border: 2px solid currentcolor;
border-radius: 8px;
background: var(--md-sys-color-background);
height: 42px;
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: 8px;
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: "";
}
}
.version { .version {
color: var(--md-sys-color-secondary); color: var(--md-sys-color-secondary);
} }

View File

@@ -20,6 +20,7 @@
syncStatus, syncStatus,
} from "$lib/serial/connection"; } from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog"; import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
import ProgressButton from "$lib/ProgressButton.svelte";
function undo(event: MouseEvent) { function undo(event: MouseEvent) {
if (event.shiftKey) { if (event.shiftKey) {
@@ -40,11 +41,27 @@
} }
} }
let redoQueue: Change[][] = $state([]); let redoQueue: Change[][] = $state([]);
let error = $state<Error | undefined>(undefined);
let progressButton: HTMLButtonElement | undefined = $state();
let shouldSaveNext = $state(false);
$effect(() => {
if ($serialPort && $syncStatus == "done" && shouldSaveNext) {
shouldSaveNext = false;
save();
}
});
async function save() { async function save() {
try { try {
const port = $serialPort; const port = $serialPort;
if (!port) return; if (!port) {
document
.getElementById("connect-popup")
?.showPopover({ source: progressButton });
shouldSaveNext = true;
return;
}
$syncStatus = "uploading"; $syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce( const layoutChanges = $overlay.layout.reduce(
@@ -160,12 +177,14 @@
); );
$changes = []; $changes = [];
} catch (e) { } catch (e) {
alert(e); error = e as Error;
console.error(e); console.error("Error while saving changes:", error);
} finally { } finally {
$syncStatus = "done"; $syncStatus = "done";
} }
} }
let progressPopover: HTMLElement | undefined = $state();
</script> </script>
<button <button
@@ -180,32 +199,28 @@
disabled={redoQueue.length === 0} disabled={redoQueue.length === 0}
onclick={redo}>redo</button onclick={redo}>redo</button
> >
{#if $changes.length !== 0} {#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<button <div
transition:fly={{ x: 10 }} transition:fly={{ x: 10 }}
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }} use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
onclick={save}
class="click-me"
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
> >
<ProgressButton
disabled={$syncStatus !== "done"}
working={$syncStatus === "uploading" || $syncStatus === "downloading"}
progress={$syncProgress && $syncStatus === "uploading"
? $syncProgress.current / $syncProgress.max
: 0}
style="--height: 36px"
error={error !== undefined
? (error.message ?? error.toString())
: undefined}
onclick={save}
bind:element={progressButton}
>
<span class="icon">save</span>{$LL.saveActions.SAVE()}
</ProgressButton>
<div bind:this={progressPopover} popover="hint">
{$LL.saveActions.SAVE()}
</div>
</div>
{/if} {/if}
<style lang="scss">
.click-me {
display: flex;
justify-content: center;
align-items: center;
margin-inline: 8px;
outline: 2px dashed var(--md-sys-color-primary);
outline-offset: 2px;
border: 2px solid var(--md-sys-color-primary);
border-radius: 18px;
padding-inline-start: 8px;
padding-inline-end: 12px;
padding-block: 2px;
height: fit-content;
color: var(--md-sys-color-primary);
font-weight: bold;
font-family: inherit;
}
</style>

View File

@@ -1,10 +1,12 @@
<script lang="ts"> <script lang="ts">
import { fly } from "svelte/transition"; import { fade, fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share"; import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title"; import { action } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection"; import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte"; import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte"; import EditActions from "./EditActions.svelte";
import { page } from "$app/state";
import { expoOut } from "svelte/easing";
</script> </script>
<nav> <nav>
@@ -12,9 +14,12 @@
<EditActions /> <EditActions />
</div> </div>
<div class="profiles"> <div>
{#if $serialPort} {#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
{#if $serialPort.profileCount > 1} <div
transition:fade={{ duration: 250, easing: expoOut }}
class="profiles"
>
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile} {#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
<label <label
><input ><input
@@ -25,7 +30,7 @@
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label />{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
> >
{/each} {/each}
{/if} </div>
{/if} {/if}
</div> </div>

View File

@@ -460,7 +460,7 @@
} }
.results { .results {
min-width: min(90vw, 16.5cm); min-width: min(90vw, 20cm);
height: 100%; height: 100%;
} }

View File

@@ -9,7 +9,7 @@
import { inputToAction } from "./input-converter"; import { inputToAction } from "./input-converter";
import { deviceMeta, serialPort } from "$lib/serial/connection"; import { deviceMeta, serialPort } from "$lib/serial/connection";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { action } from "$lib/title"; import { action, actionTooltip } from "$lib/title";
import semverGte from "semver/functions/gte"; import semverGte from "semver/functions/gte";
import Action from "$lib/components/Action.svelte"; import Action from "$lib/components/Action.svelte";
@@ -37,7 +37,7 @@
} else if (event.key === "Delete") { } else if (event.key === "Delete") {
deleteAction(cursorPosition, 1, true); deleteAction(cursorPosition, 1, true);
} else { } else {
if (event.key === "Shift") return; if (event.key === "Shift" || event.key === "Meta") return;
const action = inputToAction(event, get(serialPort)?.device === "X"); const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) { if (action !== undefined) {
insertAction(cursorPosition, action); insertAction(cursorPosition, action);
@@ -197,32 +197,40 @@
} }
</script> </script>
<!-- svelte-ignore a11y_interactive_supports_focus -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div <div
role="textbox"
class="wrapper" class="wrapper"
class:edited={!chord.deleted && chord.phraseChanged} class:edited={!chord.deleted && chord.phraseChanged}
onclick={() => { onclick={() => {
box.focus(); box?.focus();
}} }}
> >
{#if supportsAutospace} {#if supportsAutospace}
<label {#snippet tooltip()}
class="auto-space-edit" {#if chord.phrase[0] === JOIN_ACTION}
use:action={{ title: "Remove previous concatenator" }} <b>Remove</b> preceding space
><span class="icon">join_inner</span><input {:else}
checked={chord.phrase[0] === JOIN_ACTION} <b>Keep</b> preceding space
{/if}
{/snippet}
<label class="auto-space-edit" {@attach actionTooltip(tooltip)}
><span class="icon">space_bar</span><input
checked={chord.phrase[0] !== JOIN_ACTION}
onchange={async (event) => { onchange={async (event) => {
const autospace = hasAutospace; const autospace = hasAutospace;
if ((event.target as HTMLInputElement).checked) { if ((event.target as HTMLInputElement).checked) {
if (chord.phrase[0] !== JOIN_ACTION) {
insertAction(0, JOIN_ACTION);
moveCursor(cursorPosition + 1, true);
}
} else {
if (chord.phrase[0] === JOIN_ACTION) { if (chord.phrase[0] === JOIN_ACTION) {
deleteAction(0, 1); deleteAction(0, 1);
await tick(); await tick();
moveCursor(cursorPosition - 1, true); moveCursor(cursorPosition - 1, true);
} }
} else {
if (chord.phrase[0] !== JOIN_ACTION) {
insertAction(0, JOIN_ACTION);
moveCursor(cursorPosition + 1, true);
}
} }
await tick(); await tick();
resolveAutospace(autospace); resolveAutospace(autospace);
@@ -260,7 +268,14 @@
{/each} {/each}
</div> </div>
{#if supportsAutospace} {#if supportsAutospace}
<label class="auto-space-edit" use:action={{ title: "Add concatenator" }} {#snippet tooltip()}
{#if hasAutospace}
<b>Add</b> trailing space
{:else}
<b>Don't add</b> trailing space
{/if}
{/snippet}
<label class="auto-space-edit" {@attach actionTooltip(tooltip)}
><span class="icon">space_bar</span><input ><span class="icon">space_bar</span><input
checked={hasAutospace} checked={hasAutospace}
onchange={(event) => onchange={(event) =>
@@ -324,8 +339,7 @@
color: var(--md-sys-color-on-tertiary-container); color: var(--md-sys-color-on-tertiary-container);
font-size: 1.3em; font-size: 1.3em;
&:first-of-type:not(:has(:checked)), &:has(:checked) {
&:last-of-type:has(:checked) {
opacity: 0; opacity: 0;
} }
} }

View File

@@ -41,7 +41,6 @@
<section> <section>
<nav> <nav>
<a href="#connection">Connection</a>
{#if $deviceMeta} {#if $deviceMeta}
{#each $deviceMeta?.settings as category} {#each $deviceMeta?.settings as category}
<a href={`#${category.name}`}>{titlecase(category.name)}</a> <a href={`#${category.name}`}>{titlecase(category.name)}</a>
@@ -50,19 +49,6 @@
<a href="#backup">Backup</a> <a href="#backup">Backup</a>
</nav> </nav>
<div class="content"> <div class="content">
<fieldset id="connection">
<legend>Connection</legend>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
<div class="description">{@html $LL.backup.DISCLAIMER()}</div>
</label>
</fieldset>
{#if $deviceMeta} {#if $deviceMeta}
{#each $deviceMeta.settings as category} {#each $deviceMeta.settings as category}
<fieldset id={category.name}> <fieldset id={category.name}>
@@ -70,7 +56,7 @@
{titlecase(category.name)} {titlecase(category.name)}
</legend> </legend>
{#if category.description} {#if category.description}
<p>{category.description}</p> <p class="category-description">{@html category.description}</p>
{/if} {/if}
{#each category.items as item} {#each category.items as item}
{#if item.unit === "H"} {#if item.unit === "H"}
@@ -112,7 +98,7 @@
{/if} {/if}
<div class="title">{titlecase(item.name)}</div> <div class="title">{titlecase(item.name)}</div>
{#if item.description} {#if item.description}
<div class="description">{item.description}</div> <div class="description">{@html item.description}</div>
{/if} {/if}
</label> </label>
{/if} {/if}
@@ -183,6 +169,13 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
.category-description {
margin-inline: 16px;
margin-block: 24px;
color: var(--md-sys-color-on-surface-variant);
font-size: 12px;
}
legend { legend {
position: relative; position: relative;
padding: 0; padding: 0;
@@ -213,7 +206,7 @@
display: flex; display: flex;
position: relative; position: relative;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-start; justify-content: flex-start !important;
align-items: center; align-items: center;
appearance: none; appearance: none;