mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-04-21 21:59:03 +00:00
feat: qol improvements
This commit is contained in:
@@ -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",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
121
src/lib/ProgressButton.svelte
Normal file
121
src/lib/ProgressButton.svelte
Normal 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>
|
||||||
@@ -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}
|
||||||
<{info.id ?? `0x${info.code.toString(16)}`}>
|
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||||
{#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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
9
src/lib/components/verbose-action.ts
Normal file
9
src/lib/components/verbose-action.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -460,7 +460,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.results {
|
.results {
|
||||||
min-width: min(90vw, 16.5cm);
|
min-width: min(90vw, 20cm);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user