mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-18 07:52:50 +00:00
feat: qol improvements
This commit is contained in:
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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 { 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">
|
||||
<{info.id ?? `0x${info.code.toString(16)}`}>
|
||||
{#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}
|
||||
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||
{/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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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("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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
[
|
||||
{
|
||||
href: "/config/settings/",
|
||||
icon: "cable",
|
||||
title: "Device",
|
||||
icon: "tune",
|
||||
title: "Settings",
|
||||
primary: true,
|
||||
},
|
||||
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -460,7 +460,7 @@
|
||||
}
|
||||
|
||||
.results {
|
||||
min-width: min(90vw, 16.5cm);
|
||||
min-width: min(90vw, 20cm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user