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",
"translate",
"smart_toy",
"visibility_off",
"play_arrow",
"extension",
"upload_file",
@@ -165,6 +166,7 @@ const config = {
routine: "e20c",
experiment: "e686",
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 { 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);
}
};
};
}

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import LL from "$i18n/i18n-svelte";
import { preference } from "$lib/preferences";
import { preference, userPreferences } from "$lib/preferences";
import { initSerial } from "$lib/serial/connection";
import {
getPortName,
@@ -9,13 +9,26 @@
} from "$lib/serial/device";
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
import { onMount } from "svelte";
import { persistentWritable } from "$lib/storage";
let ports = $state<SerialPort[]>([]);
let element: HTMLDivElement | undefined = $state();
onMount(() => {
refreshPorts();
});
let hasDiscoveredAutoConnect = persistentWritable(
"hasDiscoveredAutoConnect",
false,
);
$effect(() => {
if ($userPreferences.backup || $userPreferences.autoConnect) {
$hasDiscoveredAutoConnect = true;
}
});
async function refreshPorts() {
ports = await navigator.serial.getPorts();
}
@@ -28,62 +41,79 @@
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>
<div class="device-list">
<fieldset>
<label
><input type="checkbox" use:preference={"autoConnect"} />
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
</label>
<div
bind:this={element}
class="device-list"
onmouseenter={() => refreshPorts()}
role="region"
>
{#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
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label>
</fieldset>
<button
onclick={async (event) => {
const { fetchCCOS } = await import("$lib/ccos/ccos");
const ccos = await fetchCCOS();
if (ccos) {
connect(ccos, !event.shiftKey);
}
}}
>
<span class="icon">history</span>
CC0</button
>
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
<span class="icon">history</span>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts();
}}><span class="icon">link_off</span></button
>
<label
><input type="checkbox" use:preference={"backup"} />
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
</label>
</fieldset>
{/if}
{#if ports.length !== 0}
<h4>Recent Devices</h4>
<div class="devices">
<div class="device">
<button onclick={connectCC0}> CC0</button>
</div>
{#each ports as port}
<div class="device">
<button
onclick={(event) => {
connect(port, !event.shiftKey);
}}
>
{getPortName(port)}</button
>
<button
class="error"
onclick={() => {
port.forget();
refreshPorts();
}}><span class="icon">visibility_off</span> Hide</button
>
</div>
{/each}
</div>
{/each}
{/if}
<div class="pair">
<button
onclick={async (event) => {
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
<button onclick={connectDevice} class="primary"
><span class="icon">add</span>Connect</button
>
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
</div>
@@ -93,7 +123,8 @@
button,
a {
padding: 10px;
height: 32px;
padding-inline-end: 16px;
height: 38px;
font-size: 12px;
.icon {
@@ -101,18 +132,33 @@
}
}
h4 {
margin-block-start: 16px;
margin-block-end: 8px;
font-weight: 600;
}
.device-list {
margin: 8px;
}
.pair {
display: flex;
}
.devices {
margin-bottom: 16px;
}
.device {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
button {
flex: 1;
justify-content: flex-start;
font-size: 14px;
}
}
@@ -129,12 +175,57 @@
gap: 8px;
appearance: none;
padding: 0;
height: auto;
overflow: hidden;
.title {
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 {
display: flex;
gap: 16px;

View File

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

View File

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

View File

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

View File

@@ -20,6 +20,7 @@
syncStatus,
} from "$lib/serial/connection";
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
import ProgressButton from "$lib/ProgressButton.svelte";
function undo(event: MouseEvent) {
if (event.shiftKey) {
@@ -40,11 +41,27 @@
}
}
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() {
try {
const port = $serialPort;
if (!port) return;
if (!port) {
document
.getElementById("connect-popup")
?.showPopover({ source: progressButton });
shouldSaveNext = true;
return;
}
$syncStatus = "uploading";
const layoutChanges = $overlay.layout.reduce(
@@ -160,12 +177,14 @@
);
$changes = [];
} catch (e) {
alert(e);
console.error(e);
error = e as Error;
console.error("Error while saving changes:", error);
} finally {
$syncStatus = "done";
}
}
let progressPopover: HTMLElement | undefined = $state();
</script>
<button
@@ -180,32 +199,28 @@
disabled={redoQueue.length === 0}
onclick={redo}>redo</button
>
{#if $changes.length !== 0}
<button
{#if $changes.length !== 0 || $syncStatus === "uploading" || $syncStatus === "error"}
<div
transition:fly={{ x: 10 }}
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}
<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">
import { fly } from "svelte/transition";
import { fade, fly } from "svelte/transition";
import { canShare, triggerShare } from "$lib/share";
import { action } from "$lib/title";
import { activeProfile, serialPort } from "$lib/serial/connection";
import LL from "$i18n/i18n-svelte";
import EditActions from "./EditActions.svelte";
import { page } from "$app/state";
import { expoOut } from "svelte/easing";
</script>
<nav>
@@ -12,9 +14,12 @@
<EditActions />
</div>
<div class="profiles">
{#if $serialPort}
{#if $serialPort.profileCount > 1}
<div>
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
<div
transition:fade={{ duration: 250, easing: expoOut }}
class="profiles"
>
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
<label
><input
@@ -25,7 +30,7 @@
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
>
{/each}
{/if}
</div>
{/if}
</div>

View File

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

View File

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

View File

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