mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-04-25 07:38:57 +00:00
Compare commits
9 Commits
v2.6.0
...
fe42dcd2ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
fe42dcd2ab
|
|||
|
b13c34ca15
|
|||
|
4023ab9bd5
|
|||
|
2893afa2ba
|
|||
|
7beab5ac07
|
|||
|
6895fa4a82
|
|||
|
245dd97532
|
|||
|
d84495894a
|
|||
|
1de52f7f81
|
@@ -34,6 +34,7 @@ const config = {
|
||||
"abc",
|
||||
"function",
|
||||
"cloud_done",
|
||||
"counter_4",
|
||||
"backup",
|
||||
"cloud_download",
|
||||
"cloud_off",
|
||||
@@ -46,9 +47,11 @@ const config = {
|
||||
"step_over",
|
||||
"step_into",
|
||||
"step_out",
|
||||
"timer_play",
|
||||
"settings_backup_restore",
|
||||
"sound_detection_loud_sound",
|
||||
"ring_volume",
|
||||
"skillet",
|
||||
"wifi",
|
||||
"power_settings_circle",
|
||||
"graphic_eq",
|
||||
@@ -75,9 +78,13 @@ const config = {
|
||||
"light_mode",
|
||||
"palette",
|
||||
"translate",
|
||||
"smart_toy",
|
||||
"visibility_off",
|
||||
"play_arrow",
|
||||
"extension",
|
||||
"upload_file",
|
||||
"file_export",
|
||||
"file_save",
|
||||
"commit",
|
||||
"bug_report",
|
||||
"delete",
|
||||
@@ -148,6 +155,7 @@ const config = {
|
||||
counter_1: "f784",
|
||||
counter_2: "f783",
|
||||
counter_3: "f782",
|
||||
counter_4: "f781",
|
||||
ios_share: "e6b8",
|
||||
light_mode: "e518",
|
||||
upload_file: "e9fc",
|
||||
@@ -160,6 +168,8 @@ const config = {
|
||||
routine: "e20c",
|
||||
experiment: "e686",
|
||||
dictionary: "f539",
|
||||
visibility_off: "e8f5",
|
||||
file_save: "f17f",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
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>
|
||||
@@ -72,22 +72,26 @@ export function createSettingsBackup(): CharaSettingsFile {
|
||||
};
|
||||
}
|
||||
|
||||
export async function restoreBackup(event: Event) {
|
||||
export async function restoreBackup(
|
||||
event: Event,
|
||||
only?: "chords" | "layout" | "settings",
|
||||
) {
|
||||
const input = (event.target as HTMLInputElement).files![0];
|
||||
if (!input) return;
|
||||
const text = await input.text();
|
||||
if (input.name.endsWith(".json")) {
|
||||
restoreFromFile(JSON.parse(text));
|
||||
restoreFromFile(JSON.parse(text), only);
|
||||
} else if (isCsvLayout(text)) {
|
||||
restoreFromFile(csvLayoutToJson(text));
|
||||
restoreFromFile(csvLayoutToJson(text), only);
|
||||
} else if (isCsvChords(text)) {
|
||||
restoreFromFile(csvChordsToJson(text));
|
||||
restoreFromFile(csvChordsToJson(text), only);
|
||||
} else {
|
||||
}
|
||||
}
|
||||
|
||||
export function restoreFromFile(
|
||||
file: CharaBackupFile | CharaSettingsFile | CharaLayoutFile | CharaChordFile,
|
||||
only?: "chords" | "layout" | "settings",
|
||||
) {
|
||||
if (file.charaVersion !== 1) throw new Error("Incompatible backup");
|
||||
switch (file.type) {
|
||||
@@ -97,9 +101,13 @@ export function restoreFromFile(
|
||||
let backupDevice = recent[1].device;
|
||||
if (backupDevice === "TWO" || backupDevice === "M4G")
|
||||
backupDevice = "ONE";
|
||||
else if (backupDevice === "ZERO" || backupDevice === "ENGINE")
|
||||
backupDevice = "X";
|
||||
let currentDevice = get(serialPort)?.device;
|
||||
if (currentDevice === "TWO" || currentDevice === "M4G")
|
||||
currentDevice = "ONE";
|
||||
else if (currentDevice === "ZERO" || currentDevice === "ENGINE")
|
||||
currentDevice = "X";
|
||||
|
||||
if (backupDevice !== currentDevice) {
|
||||
alert("Backup is incompatible with this device");
|
||||
@@ -108,33 +116,45 @@ export function restoreFromFile(
|
||||
|
||||
changes.update((changes) => {
|
||||
changes.push([
|
||||
...getChangesFromChordFile(recent[0]),
|
||||
...getChangesFromLayoutFile(recent[1]),
|
||||
...getChangesFromSettingsFile(recent[2]),
|
||||
...(!only || only === "chords"
|
||||
? getChangesFromChordFile(recent[0])
|
||||
: []),
|
||||
...(!only || only === "layout"
|
||||
? getChangesFromLayoutFile(recent[1])
|
||||
: []),
|
||||
...(!only || only === "settings"
|
||||
? getChangesFromSettingsFile(recent[2])
|
||||
: []),
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "chords": {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromChordFile(file));
|
||||
return changes;
|
||||
});
|
||||
if (!only || only === "chords") {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromChordFile(file));
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "layout": {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromLayoutFile(file));
|
||||
return changes;
|
||||
});
|
||||
if (!only || only === "layout") {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromLayoutFile(file));
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "settings": {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromSettingsFile(file));
|
||||
return changes;
|
||||
});
|
||||
if (!only || only === "settings") {
|
||||
changes.update((changes) => {
|
||||
changes.push(getChangesFromSettingsFile(file));
|
||||
return changes;
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface CCOSKeyReleaseEvent {
|
||||
|
||||
export interface CCOSSerialEvent {
|
||||
type: "serial";
|
||||
data: number;
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export type CCOSInEvent =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import { connectable, from, multicast, Subject } from "rxjs";
|
||||
import type { SerialPortLike } from "$lib/serial/device";
|
||||
import type {
|
||||
CCOSInEvent,
|
||||
CCOSInitEvent,
|
||||
CCOSKeyPressEvent,
|
||||
CCOSKeyReleaseEvent,
|
||||
@@ -8,7 +9,7 @@ import type {
|
||||
} from "./ccos-events";
|
||||
import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||
|
||||
const device = ".zero_wasm";
|
||||
const device = "zero_wasm";
|
||||
|
||||
class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||
@@ -22,14 +23,17 @@ const MASK_ALT = 0b0100_0100;
|
||||
const MASK_ALT_GRAPH = 0b0000_0100;
|
||||
const MASK_GUI = 0b1000_1000;
|
||||
|
||||
export class CCOS {
|
||||
export class CCOS implements SerialPortLike {
|
||||
private readonly currKeys = new Set<number>();
|
||||
|
||||
private readonly layout = new Map<string, string>();
|
||||
|
||||
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||
|
||||
private ready = false;
|
||||
private resolveReady!: () => void;
|
||||
private ready = new Promise<void>((resolve) => {
|
||||
this.resolveReady = resolve;
|
||||
});
|
||||
|
||||
private lastEvent?: KeyboardEvent;
|
||||
|
||||
@@ -109,33 +113,29 @@ export class CCOS {
|
||||
this.currKeys.delete(0);
|
||||
}
|
||||
|
||||
private outStream = new Subject<number>();
|
||||
private controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||
|
||||
private readonly buffer: number[] = [];
|
||||
private readonly outStream = new WritableStream<number>({
|
||||
start(controller) {},
|
||||
});
|
||||
|
||||
readonly readable = connectable()
|
||||
readonly writable = new WritableStream<string>();
|
||||
readable!: ReadableStream<Uint8Array>;
|
||||
writable!: WritableStream<Uint8Array>;
|
||||
|
||||
constructor(url: string) {
|
||||
this.worker.addEventListener(
|
||||
"message",
|
||||
(event: MessageEvent<CCOSOutEvent>) => {
|
||||
if (event.data instanceof Uint8Array) {
|
||||
this.controller?.enqueue(event.data);
|
||||
return;
|
||||
}
|
||||
console.log("CCOS worker message", event.data);
|
||||
switch (event.data.type) {
|
||||
case "ready": {
|
||||
this.ready = true;
|
||||
this.resolveReady();
|
||||
break;
|
||||
}
|
||||
case "report": {
|
||||
this.onReport(event.data.modifiers, event.data.keys);
|
||||
break;
|
||||
}
|
||||
case "serial": {
|
||||
this.outStream.next(event.data.data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -152,7 +152,29 @@ export class CCOS {
|
||||
} satisfies CCOSInitEvent);
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
getInfo(): SerialPortInfo {
|
||||
return {};
|
||||
}
|
||||
|
||||
async open(_options: SerialOptions) {
|
||||
this.readable = new ReadableStream<Uint8Array>({
|
||||
start: (controller) => {
|
||||
this.controller = controller;
|
||||
},
|
||||
});
|
||||
this.writable = new WritableStream<Uint8Array>({
|
||||
write: (chunk) => {
|
||||
this.worker.postMessage(chunk, [chunk.buffer]);
|
||||
},
|
||||
});
|
||||
return this.ready;
|
||||
}
|
||||
async close() {
|
||||
await this.ready;
|
||||
}
|
||||
async forget() {
|
||||
await this.ready;
|
||||
this.close();
|
||||
this.worker.terminate();
|
||||
}
|
||||
|
||||
@@ -198,7 +220,7 @@ export class CCOS {
|
||||
}
|
||||
|
||||
export async function fetchCCOS(
|
||||
version = ".test",
|
||||
version = ".2.2.0-beta.12+266bdda",
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<CCOS | undefined> {
|
||||
const meta = await getMeta(device, version, fetch);
|
||||
|
||||
@@ -10,14 +10,18 @@
|
||||
replay,
|
||||
cursor = false,
|
||||
keys = false,
|
||||
paused = false,
|
||||
children,
|
||||
ondone,
|
||||
ontick,
|
||||
}: {
|
||||
replay: ReplayPlayer | Replay;
|
||||
cursor?: boolean;
|
||||
keys?: boolean;
|
||||
paused?: boolean;
|
||||
children?: Snippet;
|
||||
ondone?: () => void;
|
||||
ontick?: (time: number) => void;
|
||||
} = $props();
|
||||
|
||||
let replayPlayer: ReplayPlayer | undefined = $state();
|
||||
@@ -45,6 +49,10 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!svg || !text) return;
|
||||
if (paused) {
|
||||
text.textContent = finalText ?? "";
|
||||
return;
|
||||
}
|
||||
const player =
|
||||
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
|
||||
replayPlayer = player;
|
||||
@@ -63,6 +71,7 @@
|
||||
const unsubscribePlayer = player.subscribe(apply);
|
||||
textRenderer = renderer;
|
||||
|
||||
player.onTick = ontick;
|
||||
player.onDone = ondone;
|
||||
player.start();
|
||||
apply();
|
||||
@@ -70,8 +79,11 @@
|
||||
renderer.animated = true;
|
||||
});
|
||||
return () => {
|
||||
textRenderer = undefined;
|
||||
replayPlayer = undefined;
|
||||
unsubscribePlayer();
|
||||
player?.destroy();
|
||||
player.destroy();
|
||||
renderer.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
@@ -88,7 +100,7 @@
|
||||
{#key replay}
|
||||
<svg bind:this={svg}></svg>
|
||||
{#if browser}
|
||||
<span use:innerText={text}></span>
|
||||
<span use:innerText={text} style:opacity={paused ? 1 : 0}></span>
|
||||
{:else if !(replay instanceof ReplayPlayer)}
|
||||
{finalText}
|
||||
{/if}
|
||||
@@ -104,7 +116,6 @@
|
||||
}
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,10 @@ export class ReplayPlayer {
|
||||
|
||||
startTime = performance.now();
|
||||
|
||||
private animationFrameId: number | null = null;
|
||||
private animationFrameId: ReturnType<typeof requestAnimationFrame> | null =
|
||||
null;
|
||||
|
||||
private timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
timescale = 1;
|
||||
|
||||
@@ -20,6 +23,8 @@ export class ReplayPlayer {
|
||||
|
||||
onDone?: () => void;
|
||||
|
||||
onTick?: (time: number) => void;
|
||||
|
||||
constructor(
|
||||
readonly replay: Replay,
|
||||
plugins: ReplayPlugin[] = [],
|
||||
@@ -47,6 +52,7 @@ export class ReplayPlayer {
|
||||
}
|
||||
|
||||
const now = performance.now() - this.startTime;
|
||||
this.onTick?.(now);
|
||||
|
||||
while (
|
||||
this.replayCursor < this.replay.keys.length &&
|
||||
@@ -131,7 +137,7 @@ export class ReplayPlayer {
|
||||
}
|
||||
return this;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.startTime = performance.now();
|
||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||
}, delay);
|
||||
@@ -139,6 +145,9 @@ export class ReplayPlayer {
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.timeoutId) {
|
||||
clearTimeout(this.timeoutId);
|
||||
}
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ export class TextRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cursorNode.remove();
|
||||
for (const node of this.nodes.values()) {
|
||||
node.remove();
|
||||
}
|
||||
for (const node of this.heldNodes.values()) {
|
||||
node.remove();
|
||||
}
|
||||
this.nodes.clear();
|
||||
this.heldNodes.clear();
|
||||
}
|
||||
|
||||
private isShiny(char: TextToken, index: number) {
|
||||
return (
|
||||
this.shiny?.includes(index) ||
|
||||
|
||||
@@ -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>
|
||||
|
||||
367
src/lib/components/ChordPhraseDisplay.svelte
Normal file
367
src/lib/components/ChordPhraseDisplay.svelte
Normal file
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { onMount, tick } from "svelte";
|
||||
import { scale } from "svelte/transition";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import { deviceMeta, serialPort } from "$lib/serial/connection";
|
||||
import { get } from "svelte/store";
|
||||
import { action } from "$lib/title";
|
||||
import semverGte from "semver/functions/gte";
|
||||
import { inputToAction } from "../../routes/(app)/config/chords/input-converter";
|
||||
import { selectAction } from "../../routes/(app)/config/chords/action-selector";
|
||||
|
||||
interface InteractiveProps {
|
||||
interactive: true;
|
||||
ondeleteaction: (at: number, count?: number) => void;
|
||||
oninsertaction: (at: number, action: number) => void;
|
||||
}
|
||||
|
||||
interface NonInteractiveProps {
|
||||
interactive: false;
|
||||
ondeleteaction?: never;
|
||||
oninsertaction?: never;
|
||||
}
|
||||
|
||||
let {
|
||||
phrase,
|
||||
edited,
|
||||
interactive,
|
||||
oninsertaction,
|
||||
ondeleteaction,
|
||||
}: { phrase: number[]; edited: boolean } & (
|
||||
| NonInteractiveProps
|
||||
| InteractiveProps
|
||||
) = $props();
|
||||
|
||||
const JOIN_ACTION = 574;
|
||||
const NO_CONCATENATOR_ACTION = 256;
|
||||
|
||||
onMount(() => {
|
||||
if (interactive && phrase.length === 0) {
|
||||
box?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function keypress(event: KeyboardEvent) {
|
||||
if (!event.shiftKey && event.key === "ArrowUp") {
|
||||
addSpecial(event);
|
||||
} else if (!event.shiftKey && event.key === "ArrowLeft") {
|
||||
moveCursor(cursorPosition - 1);
|
||||
} else if (!event.shiftKey && event.key === "ArrowRight") {
|
||||
moveCursor(cursorPosition + 1);
|
||||
} else if (event.key === "Backspace") {
|
||||
if (interactive) {
|
||||
ondeleteaction!(cursorPosition - 1);
|
||||
}
|
||||
moveCursor(cursorPosition - 1);
|
||||
} else if (event.key === "Delete") {
|
||||
if (interactive) {
|
||||
ondeleteaction!(cursorPosition);
|
||||
}
|
||||
} else {
|
||||
if (event.key === "Shift") return;
|
||||
const action = inputToAction(event, get(serialPort)?.device === "X");
|
||||
if (action !== undefined) {
|
||||
oninsertaction!(cursorPosition, action);
|
||||
tick().then(() => moveCursor(cursorPosition + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function moveCursor(to: number) {
|
||||
if (!box) return;
|
||||
cursorPosition = Math.max(0, Math.min(to, phrase.length));
|
||||
const item = box.children.item(cursorPosition) as HTMLElement;
|
||||
cursorOffset = item.offsetLeft + item.offsetWidth;
|
||||
}
|
||||
|
||||
function clickCursor(event: MouseEvent) {
|
||||
if (box === undefined || event.target === button) return;
|
||||
const distance = (event as unknown as { layerX: number }).layerX;
|
||||
|
||||
let i = 0;
|
||||
for (const child of box.children) {
|
||||
const { offsetLeft, offsetWidth } = child as HTMLElement;
|
||||
if (distance < offsetLeft + offsetWidth / 2) {
|
||||
moveCursor(i - 1);
|
||||
return;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
moveCursor(i - 1);
|
||||
}
|
||||
|
||||
function addSpecial(event: MouseEvent | KeyboardEvent) {
|
||||
selectAction(
|
||||
event,
|
||||
(action) => {
|
||||
oninsertaction!(cursorPosition, action);
|
||||
tick().then(() => moveCursor(cursorPosition + 1));
|
||||
},
|
||||
() => box?.focus(),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAutospace(autospace: boolean) {
|
||||
if (autospace) {
|
||||
if (phrase.at(-1) === JOIN_ACTION) {
|
||||
if (
|
||||
phrase.every(
|
||||
(action, i, arr) =>
|
||||
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
|
||||
)
|
||||
) {
|
||||
ondeleteaction!(phrase.length - 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (isPrintable) {
|
||||
return;
|
||||
} else if (phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
ondeleteaction!(phrase.length - 1);
|
||||
} else {
|
||||
oninsertaction!(phrase.length, JOIN_ACTION);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (phrase.at(-1) === JOIN_ACTION) {
|
||||
ondeleteaction!(phrase.length - 1);
|
||||
} else {
|
||||
if (phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
if (
|
||||
phrase.every(
|
||||
(action, i, arr) =>
|
||||
$KEYMAP_CODES.get(action)?.printable || i === arr.length - 1,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
} else {
|
||||
ondeleteaction!(phrase.length - 1);
|
||||
}
|
||||
} else {
|
||||
oninsertaction!(phrase.length, NO_CONCATENATOR_ACTION);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let button: HTMLButtonElement | undefined = $state();
|
||||
let box: HTMLDivElement | undefined = $state();
|
||||
let cursorPosition = 0;
|
||||
let cursorOffset = $state(0);
|
||||
|
||||
let hasFocus = $state(false);
|
||||
|
||||
let isPrintable = $derived(
|
||||
phrase.every((action) => $KEYMAP_CODES.get(action)?.printable === true),
|
||||
);
|
||||
let supportsAutospace = $derived(
|
||||
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
|
||||
);
|
||||
let hasAutospace = $derived(isPrintable || phrase.at(-1) === JOIN_ACTION);
|
||||
|
||||
let displayPhrase = $derived(
|
||||
phrase.filter(
|
||||
(it, i, arr) =>
|
||||
!(
|
||||
(i === 0 && it === JOIN_ACTION) ||
|
||||
(i === arr.length - 1 &&
|
||||
(it === JOIN_ACTION || it === NO_CONCATENATOR_ACTION))
|
||||
),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="wrapper"
|
||||
class:edited
|
||||
onclick={interactive
|
||||
? () => {
|
||||
box.focus();
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#if supportsAutospace}
|
||||
<label
|
||||
class="auto-space-edit"
|
||||
use:action={{ title: "Remove previous concatenator" }}
|
||||
><span class="icon">join_inner</span><input
|
||||
checked={phrase[0] === JOIN_ACTION}
|
||||
disabled={!interactive}
|
||||
onchange={interactive
|
||||
? (event) => {
|
||||
const autospace = hasAutospace;
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
if (phrase[0] !== JOIN_ACTION) {
|
||||
oninsertaction!(0, JOIN_ACTION);
|
||||
}
|
||||
} else {
|
||||
if (phrase[0] === JOIN_ACTION) {
|
||||
ondeleteaction!(0, 1);
|
||||
}
|
||||
}
|
||||
tick().then(() => resolveAutospace(autospace));
|
||||
}
|
||||
: undefined}
|
||||
type="checkbox"
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<div
|
||||
onkeydown={interactive ? keypress : undefined}
|
||||
onmousedown={interactive ? clickCursor : undefined}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
bind:this={box}
|
||||
onfocusin={interactive ? () => (hasFocus = true) : undefined}
|
||||
onfocusout={interactive
|
||||
? (event) => {
|
||||
if (event.relatedTarget !== button) hasFocus = false;
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{#if hasFocus}
|
||||
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
||||
<button class="icon" bind:this={button} onclick={addSpecial}>add</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<div></div>
|
||||
<!-- placeholder for cursor placement -->
|
||||
{/if}
|
||||
<ActionString actions={displayPhrase} />
|
||||
</div>
|
||||
{#if supportsAutospace}
|
||||
<label class="auto-space-edit" use:action={{ title: "Add concatenator" }}
|
||||
><span class="icon">space_bar</span><input
|
||||
checked={hasAutospace}
|
||||
disabled={!interactive}
|
||||
onchange={interactive
|
||||
? (event) =>
|
||||
resolveAutospace((event.target as HTMLInputElement).checked)
|
||||
: undefined}
|
||||
type="checkbox"
|
||||
/></label
|
||||
>
|
||||
{/if}
|
||||
<sup>•</sup>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
sup {
|
||||
translate: 0 -40%;
|
||||
opacity: 0;
|
||||
transition: opacity 250ms ease;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
translate: 0 0;
|
||||
|
||||
transition: translate 50ms ease;
|
||||
|
||||
background: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 0;
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 12px 12px 12px 0;
|
||||
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
padding: 0;
|
||||
|
||||
height: 24px;
|
||||
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
.edited {
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
sup {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-space-edit {
|
||||
margin-inline: 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-tertiary-container);
|
||||
padding-inline: 0;
|
||||
height: 1em;
|
||||
color: var(--md-sys-color-on-tertiary-container);
|
||||
font-size: 1.3em;
|
||||
|
||||
&:first-of-type:not(:has(:checked)),
|
||||
&:last-of-type:has(:checked) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .auto-space-edit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
align-items: center;
|
||||
|
||||
padding-block: 4px;
|
||||
|
||||
height: 1em;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
position: absolute;
|
||||
bottom: -4px;
|
||||
|
||||
opacity: 0;
|
||||
|
||||
transition:
|
||||
opacity 150ms ease,
|
||||
scale 250ms ease;
|
||||
background: currentcolor;
|
||||
|
||||
width: calc(100% - 8px);
|
||||
height: 1px;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::after {
|
||||
scale: 0 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
&:hover::before {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&:has(> :focus-within)::after {
|
||||
scale: 1;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
[role="textbox"] {
|
||||
display: flex;
|
||||
|
||||
position: relative;
|
||||
align-items: center;
|
||||
cursor: text;
|
||||
white-space: pre;
|
||||
|
||||
&:focus-within {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { serialLog, serialPort } from "$lib/serial/connection";
|
||||
import { onMount } from "svelte";
|
||||
import { slide } from "svelte/transition";
|
||||
|
||||
onMount(() => {
|
||||
io.scrollTo({ top: io.scrollHeight });
|
||||
});
|
||||
|
||||
function submit(event: Event) {
|
||||
event.preventDefault();
|
||||
$serialPort?.send(0, value.trim());
|
||||
|
||||
@@ -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,27 +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}
|
||||
<label
|
||||
>{category.name}<input
|
||||
name="category"
|
||||
type="radio"
|
||||
value={new Set(Object.keys(category.actions).map(Number))}
|
||||
bind:group={filter}
|
||||
/></label
|
||||
>
|
||||
{/each}
|
||||
</fieldset>
|
||||
{#if currentAction !== undefined}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||
@@ -169,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>
|
||||
@@ -208,6 +218,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -312,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;
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
style:rotate="{rotate}deg"
|
||||
use:action={{ title: tooltip }}
|
||||
>
|
||||
{#if code !== 0}
|
||||
{#if code !== 0 && code != 1023}
|
||||
{dynamicMapping || icon || display || id || `0x${code.toString(16)}`}
|
||||
{/if}
|
||||
{#if !isApplied}
|
||||
|
||||
@@ -103,6 +103,7 @@
|
||||
[-rotY, -rotX],
|
||||
[-rotX, -rotY],
|
||||
[rotX, rotY],
|
||||
[rotY, rotX],
|
||||
]}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
ZERO: () =>
|
||||
import("$lib/assets/layouts/generic/103-key.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
),
|
||||
M4G: () =>
|
||||
import("$lib/assets/layouts/m4g.yml").then(
|
||||
(it) => it.default as VisualLayout,
|
||||
|
||||
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) {
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function getMeta(
|
||||
try {
|
||||
if (!browser) return fetchMeta(device, version, fetch);
|
||||
|
||||
const dbRequest = indexedDB.open("version-meta", 4);
|
||||
const dbRequest = indexedDB.open("version-meta", 6);
|
||||
const db = await new Promise<IDBDatabase>((resolve, reject) => {
|
||||
dbRequest.onsuccess = () => resolve(dbRequest.result);
|
||||
dbRequest.onerror = () => reject(dbRequest.error);
|
||||
@@ -130,6 +130,9 @@ async function fetchMeta(
|
||||
async (load) => load().then((it) => (it as any).default),
|
||||
),
|
||||
)),
|
||||
recipes: await (meta?.recipes
|
||||
? fetch(`${path}/${meta.recipes}`).then((it) => it.json())
|
||||
: undefined),
|
||||
update: {
|
||||
uf2:
|
||||
meta?.update?.uf2 ??
|
||||
@@ -144,6 +147,10 @@ async function fetchMeta(
|
||||
)?.name ??
|
||||
undefined,
|
||||
esptool: meta?.update?.esptool ?? undefined,
|
||||
js: meta?.update?.js ?? undefined,
|
||||
wasm: meta?.update?.wasm ?? undefined,
|
||||
dll: meta?.update?.dll ?? undefined,
|
||||
so: meta?.update?.so ?? undefined,
|
||||
},
|
||||
spiFlash: meta?.spi_flash ?? undefined,
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SettingsMeta {
|
||||
|
||||
export interface SettingsItemMeta {
|
||||
id: number;
|
||||
name?: string;
|
||||
description?: string;
|
||||
enum?: string[];
|
||||
range: [number, number];
|
||||
@@ -43,6 +44,7 @@ export interface RawVersionMeta {
|
||||
actions: string;
|
||||
settings: string;
|
||||
changelog: string;
|
||||
recipes: string;
|
||||
factory_defaults: {
|
||||
layout: string;
|
||||
settings: string;
|
||||
@@ -52,11 +54,47 @@ export interface RawVersionMeta {
|
||||
ota: string | null;
|
||||
uf2: string | null;
|
||||
esptool: EspToolData | null;
|
||||
js: string | null;
|
||||
wasm: string | null;
|
||||
dll: string | null;
|
||||
so: string | null;
|
||||
};
|
||||
files: string[];
|
||||
spi_flash: SPIFlashInfo | null;
|
||||
}
|
||||
|
||||
export interface E2eAddChord {
|
||||
input: string[][];
|
||||
output: string[];
|
||||
}
|
||||
|
||||
export interface E2eTestItem {
|
||||
keys?: string[];
|
||||
modifiers?: Record<string, boolean>;
|
||||
press?: string[];
|
||||
release?: string[];
|
||||
step?: number;
|
||||
idle?: boolean;
|
||||
clearChords?: boolean;
|
||||
addChords?: E2eAddChord[];
|
||||
settings: Record<string, Record<string, string | number>>;
|
||||
}
|
||||
|
||||
export interface E2eTest {
|
||||
matrix?: string[];
|
||||
test: E2eTestItem[];
|
||||
}
|
||||
|
||||
export interface E2eDemo {
|
||||
demo?: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
};
|
||||
matrix?: string[];
|
||||
tests: E2eTest[];
|
||||
}
|
||||
|
||||
export interface VersionMeta {
|
||||
version: string;
|
||||
device: string;
|
||||
@@ -69,6 +107,7 @@ export interface VersionMeta {
|
||||
actions: KeymapCategory[];
|
||||
settings: SettingsMeta[];
|
||||
changelog: Changelog;
|
||||
recipes?: E2eTest[];
|
||||
factoryDefaults?: {
|
||||
layout: CharaLayoutFile;
|
||||
settings: CharaSettingsFile;
|
||||
@@ -78,6 +117,10 @@ export interface VersionMeta {
|
||||
ota?: string;
|
||||
uf2?: string;
|
||||
esptool?: EspToolData;
|
||||
js?: string;
|
||||
wasm?: string;
|
||||
dll?: string;
|
||||
so?: string;
|
||||
};
|
||||
spiFlash?: SPIFlashInfo;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ async function updateLayout() {
|
||||
layout.size !== currentLayout.size ||
|
||||
[...layout.keys()].some((key) => layout.get(key) !== currentLayout.get(key))
|
||||
) {
|
||||
console.log(layout);
|
||||
osLayout.set(layout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { get, writable } from "svelte/store";
|
||||
import { CharaDevice } from "$lib/serial/device";
|
||||
import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
|
||||
import type { Chord } from "$lib/serial/chord";
|
||||
import type { Writable } from "svelte/store";
|
||||
import type { CharaLayout } from "$lib/serialization/layout";
|
||||
@@ -10,6 +10,10 @@ import type { VersionMeta } from "$lib/meta/types/meta";
|
||||
|
||||
export const serialPort = writable<CharaDevice | undefined>();
|
||||
|
||||
navigator.serial?.addEventListener("disconnect", async (event) => {
|
||||
serialPort.set(undefined);
|
||||
});
|
||||
|
||||
export interface SerialLogEntry {
|
||||
type: "input" | "output" | "system";
|
||||
value: string;
|
||||
@@ -59,9 +63,13 @@ export interface ProgressInfo {
|
||||
}
|
||||
export const syncProgress = writable<ProgressInfo | undefined>(undefined);
|
||||
|
||||
export async function initSerial(manual = false, withSync = true) {
|
||||
const device = get(serialPort) ?? new CharaDevice();
|
||||
await device.init(manual);
|
||||
export async function initSerial(port: SerialPortLike, withSync: boolean) {
|
||||
const prev = get(serialPort);
|
||||
try {
|
||||
prev?.close();
|
||||
} catch {}
|
||||
const device = new CharaDevice(port);
|
||||
await device.init();
|
||||
serialPort.set(device);
|
||||
if (withSync) {
|
||||
await sync();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { browser } from "$app/environment";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
import semverGte from "semver/functions/gte";
|
||||
|
||||
const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
export const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
["ONE M0", { usbProductId: 32783, usbVendorId: 9114 }],
|
||||
["TWO S3 (pre-production)", { usbProductId: 0x8252, usbVendorId: 0x303a }],
|
||||
["TWO S3", { usbProductId: 0x8253, usbVendorId: 0x303a }],
|
||||
@@ -23,14 +23,52 @@ const PORT_FILTERS: Map<string, SerialPortFilter> = new Map([
|
||||
["T4G S2", { usbProductId: 0x82f2, usbVendorId: 0x303a }],
|
||||
]);
|
||||
|
||||
const DEVICE_ALIASES = new Map<string, Set<string>>([
|
||||
["CC1", new Set(["ONE M0", "one_m0"])],
|
||||
["CC2", new Set(["TWO S3", "two_s3", "TWO S3 (pre-production)"])],
|
||||
["Lite (S2)", new Set(["LITE S2", "lite_s2"])],
|
||||
["Lite (M0)", new Set(["LITE M0", "lite_m0"])],
|
||||
["CCX", new Set(["X", "ccx"])],
|
||||
["M4G", new Set(["M4G S3", "m4g_s3", "M4G S3 (pre-production)"])],
|
||||
["M4G (right)", new Set(["M4GR S3", "m4gr_s3"])],
|
||||
["T4G", new Set(["T4G S2", "t4g_s2"])],
|
||||
]);
|
||||
|
||||
export function getName(alias: string): string {
|
||||
for (const [name, aliases] of DEVICE_ALIASES.entries()) {
|
||||
if (aliases.has(alias)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return alias;
|
||||
}
|
||||
|
||||
export function getPortName(port: SerialPort): string {
|
||||
const { usbProductId, usbVendorId } = port.getInfo();
|
||||
console.log(port.getInfo());
|
||||
for (const [name, filter] of PORT_FILTERS.entries()) {
|
||||
if (
|
||||
filter.usbProductId === usbProductId &&
|
||||
filter.usbVendorId === usbVendorId
|
||||
) {
|
||||
return getName(name);
|
||||
}
|
||||
}
|
||||
return `Unknown Device (0x${usbVendorId?.toString(
|
||||
16,
|
||||
)}/0x${usbProductId?.toString(16)})`;
|
||||
}
|
||||
|
||||
const KEY_COUNTS = {
|
||||
ONE: 90,
|
||||
TWO: 90,
|
||||
LITE: 67,
|
||||
X: 256,
|
||||
ENGINE: 256,
|
||||
M4G: 90,
|
||||
M4GR: 90,
|
||||
T4G: 7,
|
||||
ZERO: 256,
|
||||
} as const;
|
||||
|
||||
if (
|
||||
@@ -88,8 +126,12 @@ async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||
]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
export type SerialPortLike = Pick<
|
||||
SerialPort,
|
||||
"readable" | "writable" | "open" | "close" | "getInfo" | "forget"
|
||||
>;
|
||||
|
||||
export class CharaDevice {
|
||||
private port!: SerialPort;
|
||||
private reader!: ReadableStreamDefaultReader<string>;
|
||||
|
||||
private readonly abortController1 = new AbortController();
|
||||
@@ -104,7 +146,7 @@ export class CharaDevice {
|
||||
|
||||
version!: string;
|
||||
company!: "CHARACHORDER" | "FORGE";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
||||
chipset!: "M0" | "S2" | "S3";
|
||||
keyCount!: 90 | 67 | 256;
|
||||
layerCount = 3;
|
||||
@@ -114,18 +156,13 @@ export class CharaDevice {
|
||||
return this.port.getInfo();
|
||||
}
|
||||
|
||||
constructor(private readonly baudRate = 115200) {}
|
||||
constructor(
|
||||
private readonly port: SerialPortLike,
|
||||
public baudRate = 115200,
|
||||
) {}
|
||||
|
||||
async init(manual = false) {
|
||||
async init() {
|
||||
try {
|
||||
const ports = await getViablePorts();
|
||||
this.port =
|
||||
!manual && ports.length === 1
|
||||
? ports[0]!
|
||||
: await navigator.serial.requestPort({
|
||||
filters: [...PORT_FILTERS.values()],
|
||||
});
|
||||
|
||||
await this.port.open({ baudRate: this.baudRate });
|
||||
const info = this.port.getInfo();
|
||||
serialLog.update((it) => {
|
||||
@@ -142,13 +179,16 @@ export class CharaDevice {
|
||||
this.version = await this.send(1, ["VERSION"]).then(
|
||||
([version]) => version,
|
||||
);
|
||||
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||
this.profileCount = 3;
|
||||
}
|
||||
const [company, device, chipset] = await this.send(3, ["ID"]);
|
||||
this.company = company as typeof this.company;
|
||||
this.device = device as typeof this.device;
|
||||
this.chipset = chipset as typeof this.chipset;
|
||||
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||
this.profileCount = this.chipset === "M0" ? 2 : 3;
|
||||
}
|
||||
if (semverGte(this.version, "2.2.0-beta.20")) {
|
||||
this.layerCount = this.chipset === "M0" ? 3 : 4;
|
||||
}
|
||||
this.keyCount = KEY_COUNTS[this.device];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -242,6 +282,10 @@ export class CharaDevice {
|
||||
await this.port.forget();
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.port.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write to serial port
|
||||
*/
|
||||
@@ -298,23 +342,24 @@ export class CharaDevice {
|
||||
.join(" ")
|
||||
.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
||||
const readResult = await read(timeout);
|
||||
if (readResult === undefined) {
|
||||
return readResult
|
||||
?.replace(new RegExp(`^${commandString} `), "")
|
||||
.split(" ");
|
||||
}).then((it) => {
|
||||
if (it === undefined) {
|
||||
console.error("No response");
|
||||
return Array(expectedLength).fill("NO_RESPONSE") as LengthArray<
|
||||
string,
|
||||
T
|
||||
>;
|
||||
}
|
||||
const array = readResult
|
||||
.replace(new RegExp(`^${commandString} `), "")
|
||||
.split(" ");
|
||||
if (array.length < expectedLength) {
|
||||
if (it.length < expectedLength) {
|
||||
console.error("Response too short");
|
||||
return array.concat(
|
||||
Array(expectedLength - array.length).fill("TOO_SHORT"),
|
||||
return it.concat(
|
||||
Array(expectedLength - it.length).fill("TOO_SHORT"),
|
||||
) as LengthArray<string, T>;
|
||||
}
|
||||
return array as LengthArray<string, T>;
|
||||
return it as LengthArray<string, T>;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -357,7 +402,7 @@ export class CharaDevice {
|
||||
stringifyChordActions(chord.actions),
|
||||
stringifyPhrase(chord.phrase),
|
||||
]);
|
||||
if (status !== "0") console.error(`Failed with status ${status}`);
|
||||
if (status !== "0") throw new Error(`Failed with status ${status}`);
|
||||
}
|
||||
|
||||
async deleteChord(chord: Pick<Chord, "actions">) {
|
||||
|
||||
@@ -29,5 +29,10 @@ export async function fromBase64(
|
||||
.replaceAll(".", "+")
|
||||
.replaceAll("_", "/")
|
||||
.replaceAll("-", "=")}`,
|
||||
).then((it) => it.blob());
|
||||
)
|
||||
.then((it) => {
|
||||
console.log(it);
|
||||
return it;
|
||||
})
|
||||
.then((it) => it.blob());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +179,22 @@ export const chords = derived(
|
||||
},
|
||||
);
|
||||
|
||||
export const duplicateChords = derived(chords, (chords) => {
|
||||
const duplicates = new Set<string>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const chord of chords) {
|
||||
const key = JSON.stringify(chord.actions);
|
||||
if (seen.has(key)) {
|
||||
duplicates.add(key);
|
||||
} else {
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
return duplicates;
|
||||
});
|
||||
|
||||
export const chordHashes = derived(
|
||||
chords,
|
||||
(chords) =>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
argbFromHex,
|
||||
themeFromSourceColor,
|
||||
} from "@material/material-color-utilities";
|
||||
import { canAutoConnect } from "$lib/serial/device";
|
||||
import { canAutoConnect, getViablePorts } from "$lib/serial/device";
|
||||
import { initSerial } from "$lib/serial/connection";
|
||||
import type { LayoutData } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
@@ -63,7 +63,8 @@
|
||||
}
|
||||
|
||||
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
|
||||
await initSerial();
|
||||
const [port] = await getViablePorts();
|
||||
await initSerial(port!, true);
|
||||
}
|
||||
|
||||
if (data.importFile) {
|
||||
|
||||
235
src/routes/(app)/ConnectPopup.svelte
Normal file
235
src/routes/(app)/ConnectPopup.svelte
Normal file
@@ -0,0 +1,235 @@
|
||||
<script lang="ts">
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { preference, userPreferences } from "$lib/preferences";
|
||||
import { initSerial } from "$lib/serial/connection";
|
||||
import {
|
||||
getPortName,
|
||||
PORT_FILTERS,
|
||||
type SerialPortLike,
|
||||
} 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();
|
||||
}
|
||||
|
||||
async function connect(port: SerialPortLike, withSync: boolean) {
|
||||
try {
|
||||
await initSerial(port, withSync);
|
||||
} catch (error) {
|
||||
console.error(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>
|
||||
|
||||
<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>
|
||||
{/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>
|
||||
{/if}
|
||||
<div class="pair">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
button,
|
||||
a {
|
||||
padding: 10px;
|
||||
padding-inline-end: 16px;
|
||||
height: 38px;
|
||||
font-size: 12px;
|
||||
|
||||
.icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
button.error {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
label {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
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;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -7,16 +7,14 @@
|
||||
import { detectLocale, locales } from "$i18n/i18n-util";
|
||||
import { loadLocaleAsync } from "$i18n/i18n-util.async";
|
||||
import { tick } from "svelte";
|
||||
import SyncOverlay from "./SyncOverlay.svelte";
|
||||
import {
|
||||
initSerial,
|
||||
serialPort,
|
||||
sync,
|
||||
syncProgress,
|
||||
syncStatus,
|
||||
} from "$lib/serial/connection";
|
||||
import { fade, slide } from "svelte/transition";
|
||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||
import ConnectPopup from "./ConnectPopup.svelte";
|
||||
|
||||
let locale = $state(
|
||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||
@@ -48,20 +46,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
await initSerial(true);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
await showConnectionFailedDialog(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function disconnect(event: MouseEvent) {
|
||||
if (event.shiftKey) {
|
||||
sync();
|
||||
} else {
|
||||
$serialPort?.forget();
|
||||
$serialPort?.close();
|
||||
$serialPort = undefined;
|
||||
}
|
||||
}
|
||||
@@ -88,11 +77,22 @@
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="sync-box">
|
||||
<div
|
||||
class="sync-box"
|
||||
class:primary={!$serialPort}
|
||||
class:attention={$syncStatus !== "done"}
|
||||
>
|
||||
{#if !$serialPort}
|
||||
<button class="warning" onclick={connect} transition:slide={{ axis: "x" }}
|
||||
<button
|
||||
class="no-connection"
|
||||
id="connect-button"
|
||||
popovertarget="connect-popup"
|
||||
transition:slide={{ axis: "x" }}
|
||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||
>
|
||||
<div popover id="connect-popup">
|
||||
<ConnectPopup />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
transition:slide={{ axis: "x" }}
|
||||
@@ -108,7 +108,7 @@
|
||||
>
|
||||
{/if}
|
||||
|
||||
{#if $syncStatus !== "done"}
|
||||
{#if $syncStatus === "downloading"}
|
||||
<progress
|
||||
transition:fade
|
||||
max={$syncProgress?.max ?? 1}
|
||||
@@ -173,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;
|
||||
}
|
||||
|
||||
@@ -246,7 +274,6 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
opacity: 0.4;
|
||||
padding: 8px;
|
||||
padding-inline-end: 16px;
|
||||
padding-block-start: 0;
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { deviceMeta } from "$lib/serial/connection";
|
||||
|
||||
const routes = [
|
||||
let routes = $derived([
|
||||
[
|
||||
{
|
||||
href: "/config/settings/",
|
||||
icon: "cable",
|
||||
title: "Device",
|
||||
icon: "tune",
|
||||
title: "Settings",
|
||||
primary: true,
|
||||
},
|
||||
{ href: "/config/chords/", icon: "dictionary", title: "Library" },
|
||||
{ href: "/config/layout/", icon: "keyboard", title: "Layout" },
|
||||
...($deviceMeta?.recipes
|
||||
? [{ href: "/recipes", icon: "skillet", title: "Cookbook" }]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
{
|
||||
@@ -47,7 +51,7 @@
|
||||
wip?: boolean;
|
||||
external?: boolean;
|
||||
primary?: boolean;
|
||||
}[][];
|
||||
}[][]);
|
||||
|
||||
let connectButton: HTMLButtonElement;
|
||||
</script>
|
||||
|
||||
@@ -19,12 +19,7 @@
|
||||
{#each data.versions as version}
|
||||
{@const isPrerelease = version.name.includes("-")}
|
||||
<li class:pre-release={isPrerelease}>
|
||||
<a href="./{version.name}/"
|
||||
>{version.name}
|
||||
<time datetime={version.mtime}
|
||||
>{new Date(version.mtime).toLocaleDateString()}</time
|
||||
></a
|
||||
>
|
||||
<a href="./{version.name}/">{version.name}</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -70,14 +65,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
opacity: 0.5;
|
||||
&:before {
|
||||
padding-inline: 0.4ch;
|
||||
content: "•";
|
||||
}
|
||||
}
|
||||
|
||||
div.title:has(input:not(:checked)) ~ ul .pre-release {
|
||||
opacity: 0;
|
||||
height: 0;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -31,21 +32,45 @@
|
||||
success = false;
|
||||
const port = $serialPort!;
|
||||
$serialPort = undefined;
|
||||
try {
|
||||
const file = await fetch(
|
||||
`${data.meta.path}/${data.meta.update.ota}`,
|
||||
).then((it) => it.arrayBuffer());
|
||||
|
||||
await port.updateFirmware(file, (transferred, total) => {
|
||||
progress = transferred / total;
|
||||
});
|
||||
let file: ArrayBuffer | undefined;
|
||||
let retries = 3;
|
||||
let err: Error | undefined = undefined;
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
} finally {
|
||||
working = false;
|
||||
while (!file && retries-- > 0) {
|
||||
try {
|
||||
file = await fetch(`${data.meta.path}/${data.meta.update.ota}`).then(
|
||||
(it) => it.arrayBuffer(),
|
||||
);
|
||||
} catch (e) {
|
||||
err = e as Error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!file) {
|
||||
error = err;
|
||||
working = false;
|
||||
return;
|
||||
}
|
||||
|
||||
retries = 2;
|
||||
while (retries-- > 0 && !success) {
|
||||
try {
|
||||
await port.updateFirmware(file, (transferred, total) => {
|
||||
progress = transferred / total;
|
||||
});
|
||||
|
||||
success = true;
|
||||
} catch (e) {
|
||||
err = e as Error;
|
||||
port.baudRate = 9600;
|
||||
}
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
error = err;
|
||||
}
|
||||
working = false;
|
||||
}
|
||||
|
||||
let currentDevice = $derived(
|
||||
@@ -72,7 +97,8 @@
|
||||
|
||||
async function connect() {
|
||||
try {
|
||||
await initSerial(true, false);
|
||||
const port = await navigator.serial.requestPort();
|
||||
await initSerial(port!, true);
|
||||
step = 1;
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
@@ -197,21 +223,20 @@
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
{#if data.meta.update.js && data.meta.update.wasm}
|
||||
<button>Add Virtual Device</button>
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
@@ -236,7 +261,32 @@
|
||||
{:else if success}
|
||||
<div class="primary" transition:slide>Update successful</div>
|
||||
{:else if error}
|
||||
<div class="error" transition:slide>{error.message}</div>
|
||||
<div class="error" transition:slide>
|
||||
{#if error.message.includes("ESP_ERR_OTA_VALIDATE_FAILED")}
|
||||
<b>Update corrupted during transmission</b>
|
||||
<ul>
|
||||
<li>
|
||||
Double-check your USB cable is <b>fully seated</b> on both ends
|
||||
</li>
|
||||
<li>Remove any USB hubs between the device and the computer</li>
|
||||
<li>Unplug all other USB devices</li>
|
||||
<li>Don't touch the device or your computer during the update</li>
|
||||
<li>Try using a different USB cable</li>
|
||||
<li>Try using a different USB Port</li>
|
||||
<li>Try the update again a few times</li>
|
||||
{#if navigator.userAgent.includes("Macintosh")}
|
||||
<li>
|
||||
Try updating on either Windows, Linux or ChromeOS instead of
|
||||
MacOS
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
<b>DO NOT USE THE UNSAFE RECOVERY OPTIONS</b>, they bypass
|
||||
corruption checks an can soft-brick your device.
|
||||
{:else}
|
||||
{error.message}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if working}
|
||||
<div class="primary" transition:slide>Updating your device...</div>
|
||||
{:else}
|
||||
@@ -260,47 +310,49 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
<ol>
|
||||
<li>
|
||||
<button class="inline-button" onclick={connect}
|
||||
><span class="icon">usb</span>Connect</button
|
||||
>
|
||||
your device
|
||||
{#if step >= 1}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
{#if data.meta.update.uf2}
|
||||
<section>
|
||||
<ol>
|
||||
<li>
|
||||
<button class="inline-button" onclick={connect}
|
||||
><span class="icon">usb</span>Connect</button
|
||||
>
|
||||
your device
|
||||
{#if step >= 1}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 1}>
|
||||
Make a <button class="inline-button" onclick={backup}
|
||||
><span class="icon">download</span>Backup</button
|
||||
>
|
||||
{#if step >= 2}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
<li class:faded={step < 1}>
|
||||
Make a <button class="inline-button" onclick={backup}
|
||||
><span class="icon">download</span>Backup</button
|
||||
>
|
||||
{#if step >= 2}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 2}>
|
||||
Reboot to <button class="inline-button" onclick={bootloader}
|
||||
><span class="icon">restart_alt</span>Bootloader</button
|
||||
>
|
||||
{#if step >= 3}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
<li class:faded={step < 2}>
|
||||
Reboot to <button class="inline-button" onclick={bootloader}
|
||||
><span class="icon">restart_alt</span>Bootloader</button
|
||||
>
|
||||
{#if step >= 3}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<li class:faded={step < 3}>
|
||||
Replace <button class="inline-button" onclick={getFileSystem}
|
||||
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
|
||||
>
|
||||
on the new drive
|
||||
{#if step >= 4}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
<li class:faded={step < 3}>
|
||||
Replace <button class="inline-button" onclick={getFileSystem}
|
||||
><span class="icon">deployed_code_update</span>CURRENT.UF2</button
|
||||
>
|
||||
on the new drive
|
||||
{#if step >= 4}
|
||||
<span class="icon ok" transition:fade>check_circle</span>
|
||||
{/if}
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if false && data.meta.update.esptool}
|
||||
<section>
|
||||
@@ -337,6 +389,15 @@
|
||||
|
||||
<section class="changelog">
|
||||
<h2>Changelog</h2>
|
||||
|
||||
<time datetime={data.meta.date.toISOString()}
|
||||
>Published {data.meta.date.toLocaleDateString()}</time
|
||||
>
|
||||
|
||||
{#if data.meta.recipes}
|
||||
<p>Includes {data.meta.recipes.length} recipes</p>
|
||||
{/if}
|
||||
|
||||
{#if data.meta.changelog.features}
|
||||
<h3>Features</h3>
|
||||
<ul>
|
||||
@@ -417,27 +478,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;
|
||||
@@ -463,67 +503,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);
|
||||
}
|
||||
|
||||
@@ -7,19 +7,24 @@
|
||||
layout,
|
||||
overlay,
|
||||
settings,
|
||||
duplicateChords,
|
||||
} from "$lib/undo-redo";
|
||||
import type { Change } from "$lib/undo-redo";
|
||||
import type { Change, ChordChange } from "$lib/undo-redo";
|
||||
import { fly } from "svelte/transition";
|
||||
import { action } from "$lib/title";
|
||||
import { action, actionTooltip } from "$lib/title";
|
||||
import {
|
||||
deviceChords,
|
||||
deviceLayout,
|
||||
deviceSettings,
|
||||
serialLog,
|
||||
serialPort,
|
||||
sync,
|
||||
syncProgress,
|
||||
syncStatus,
|
||||
} from "$lib/serial/connection";
|
||||
import { askForConfirmation } from "$lib/dialogs/confirm-dialog";
|
||||
import ProgressButton from "$lib/ProgressButton.svelte";
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
function undo(event: MouseEvent) {
|
||||
if (event.shiftKey) {
|
||||
@@ -40,77 +45,13 @@
|
||||
}
|
||||
}
|
||||
let redoQueue: Change[][] = $state([]);
|
||||
let error = $state<Error | undefined>(undefined);
|
||||
let progressButton: HTMLButtonElement | undefined = $state();
|
||||
|
||||
async function save() {
|
||||
async function saveLayoutChanges(progress: () => void): Promise<boolean> {
|
||||
const port = $serialPort;
|
||||
if (!port) return false;
|
||||
try {
|
||||
const port = $serialPort;
|
||||
if (!port) return;
|
||||
$syncStatus = "uploading";
|
||||
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(acc, profile) =>
|
||||
acc + profile.reduce((acc, layer) => acc + layer.size, 0),
|
||||
0,
|
||||
);
|
||||
const settingChanges = $overlay.settings.reduce(
|
||||
(acc, profile) => acc + profile.size,
|
||||
0,
|
||||
);
|
||||
const chordChanges = $overlay.chords.size;
|
||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent,
|
||||
});
|
||||
|
||||
console.log($overlay);
|
||||
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (!chord.deleted) {
|
||||
if (id !== JSON.stringify(chord.actions)) {
|
||||
const existingChord = await port.getChordPhrase(chord.actions);
|
||||
if (
|
||||
existingChord !== undefined &&
|
||||
!(await askForConfirmation(
|
||||
$LL.configure.chords.conflict.TITLE(),
|
||||
$LL.configure.chords.conflict.DESCRIPTION(),
|
||||
$LL.configure.chords.conflict.CONFIRM(),
|
||||
$LL.configure.chords.conflict.ABORT(),
|
||||
chord,
|
||||
))
|
||||
) {
|
||||
changes.update((changes) =>
|
||||
changes
|
||||
.map((it) =>
|
||||
it.filter(
|
||||
(it) =>
|
||||
!(
|
||||
it.type === ChangeType.Chord &&
|
||||
JSON.stringify(it.id) === id
|
||||
),
|
||||
),
|
||||
)
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
await port.deleteChord({ actions: JSON.parse(id) });
|
||||
}
|
||||
await port.setChord({ actions: chord.actions, phrase: chord.phrase });
|
||||
} else {
|
||||
await port.deleteChord({ actions: chord.actions });
|
||||
}
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
}
|
||||
|
||||
for (const [profile, layout] of $overlay.layout.entries()) {
|
||||
if (layout === undefined) continue;
|
||||
for (const [layer, actions] of layout.entries()) {
|
||||
@@ -118,94 +59,271 @@
|
||||
for (const [id, action] of actions) {
|
||||
if (action === undefined) continue;
|
||||
await port.setLayoutKey(profile, layer + 1, id, action);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
progress();
|
||||
}
|
||||
}
|
||||
}
|
||||
$deviceLayout = $layout.map((profile) =>
|
||||
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
||||
);
|
||||
changes.update((changes) =>
|
||||
changes
|
||||
.map((it) => it.filter((it) => it.type !== ChangeType.Layout))
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
await tick();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveSettings(progress: () => void): Promise<boolean> {
|
||||
const port = $serialPort;
|
||||
if (!port) return false;
|
||||
try {
|
||||
for (const [profile, settings] of $overlay.settings.entries()) {
|
||||
if (settings === undefined) continue;
|
||||
for (const [id, setting] of settings.entries()) {
|
||||
if (setting === undefined) continue;
|
||||
await port.setSetting(profile, id, setting);
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: progressCurrent++,
|
||||
});
|
||||
progress();
|
||||
}
|
||||
}
|
||||
|
||||
// Yes, this is a completely arbitrary and unnecessary delay.
|
||||
// The only purpose of it is to create a sense of weight,
|
||||
// aka make it more "energy intensive" to click.
|
||||
// The only conceivable way users could reach the commit limit in this case
|
||||
// would be if they click it every time they change a setting.
|
||||
// Because of that, we don't need to show a fearmongering message such as
|
||||
// "Your device will break after you click this 10,000 times!"
|
||||
if (needsCommit) {
|
||||
await port.commit();
|
||||
}
|
||||
|
||||
$deviceLayout = $layout.map((profile) =>
|
||||
profile.map((layer) => layer.map<number>(({ action }) => action)),
|
||||
);
|
||||
$deviceChords = $chords
|
||||
.filter(({ deleted }) => !deleted)
|
||||
.map(({ actions, phrase }) => ({ actions, phrase }));
|
||||
$deviceSettings = $settings.map((profile) =>
|
||||
profile.map(({ value }) => value),
|
||||
);
|
||||
$changes = [];
|
||||
changes.update((changes) =>
|
||||
changes
|
||||
.map((it) => it.filter((it) => it.type !== ChangeType.Setting))
|
||||
.filter((it) => it.length > 0),
|
||||
);
|
||||
} catch (e) {
|
||||
alert(e);
|
||||
console.error(e);
|
||||
return false;
|
||||
}
|
||||
await tick();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function safeDeleteChord(actions: number[]): Promise<boolean> {
|
||||
const port = $serialPort;
|
||||
if (!port) return false;
|
||||
try {
|
||||
await port.deleteChord({ actions });
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
try {
|
||||
if ((await port.getChordPhrase(actions)) === undefined) {
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function saveChords(progress: () => void): Promise<boolean> {
|
||||
const port = $serialPort;
|
||||
if (!port) return false;
|
||||
let ok = true;
|
||||
|
||||
const empty = new Set<string>();
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (chord.actions.length === 0 || chord.phrase.length === 0) {
|
||||
empty.add(id);
|
||||
}
|
||||
}
|
||||
changes.update((changes) => {
|
||||
changes.push([
|
||||
...empty.keys().map(
|
||||
(id) =>
|
||||
({
|
||||
type: ChangeType.Chord,
|
||||
id: JSON.parse(id),
|
||||
deleted: true,
|
||||
actions: [],
|
||||
phrase: [],
|
||||
}) satisfies ChordChange,
|
||||
),
|
||||
]);
|
||||
return changes;
|
||||
});
|
||||
await tick();
|
||||
|
||||
const deleted = new Set<string>();
|
||||
const changed = new Map<string, number[]>();
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (!chord.deleted) continue;
|
||||
if (await safeDeleteChord(JSON.parse(id))) {
|
||||
deleted.add(id);
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
progress();
|
||||
}
|
||||
deviceChords.update((chords) =>
|
||||
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions))),
|
||||
);
|
||||
deleted.clear();
|
||||
await tick();
|
||||
|
||||
for (const [id, chord] of $overlay.chords) {
|
||||
if (chord.deleted) continue;
|
||||
if ($duplicateChords.has(JSON.stringify(chord.actions))) {
|
||||
ok = false;
|
||||
} else {
|
||||
let skip = false;
|
||||
if (id !== JSON.stringify(chord.actions)) {
|
||||
if (await safeDeleteChord(JSON.parse(id))) {
|
||||
deleted.add(id);
|
||||
} else {
|
||||
skip = true;
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
if (!skip) {
|
||||
try {
|
||||
await port.setChord({
|
||||
actions: chord.actions,
|
||||
phrase: chord.phrase,
|
||||
});
|
||||
deleted.add(JSON.stringify(chord.actions));
|
||||
changed.set(JSON.stringify(chord.actions), chord.phrase);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ok = false;
|
||||
}
|
||||
} else {
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
progress();
|
||||
}
|
||||
deviceChords.update((chords) => {
|
||||
chords.filter((chord) => !deleted.has(JSON.stringify(chord.actions)));
|
||||
for (const [id, phrase] of changed) {
|
||||
chords.push({ actions: JSON.parse(id), phrase });
|
||||
}
|
||||
return chords;
|
||||
});
|
||||
await tick();
|
||||
return ok;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
let needsSync = false;
|
||||
try {
|
||||
const port = $serialPort;
|
||||
if (!port) {
|
||||
document
|
||||
.getElementById("connect-popup")
|
||||
?.showPopover({ source: progressButton });
|
||||
return;
|
||||
}
|
||||
$syncStatus = "uploading";
|
||||
|
||||
const layoutChanges = $overlay.layout.reduce(
|
||||
(acc, profile) =>
|
||||
acc +
|
||||
(profile?.reduce((acc, layer) => acc + (layer?.size ?? 0), 0) ?? 0),
|
||||
0,
|
||||
);
|
||||
const settingChanges = $overlay.settings.reduce(
|
||||
(acc, profile) => acc + (profile?.size ?? 0),
|
||||
0,
|
||||
);
|
||||
const chordChanges = $overlay.chords.size;
|
||||
needsSync = chordChanges > 0;
|
||||
const needsCommit = settingChanges > 0 || layoutChanges > 0;
|
||||
const progressMax = layoutChanges + settingChanges + chordChanges;
|
||||
|
||||
let progressCurrent = 0;
|
||||
|
||||
function updateProgress() {
|
||||
syncProgress.set({
|
||||
max: progressMax,
|
||||
current: Math.min(progressMax, progressCurrent++),
|
||||
});
|
||||
}
|
||||
updateProgress();
|
||||
|
||||
let layoutSuccess = await saveLayoutChanges(updateProgress);
|
||||
let settingsSuccess = await saveSettings(updateProgress);
|
||||
|
||||
if (needsCommit) {
|
||||
try {
|
||||
await port.commit();
|
||||
} catch (e) {
|
||||
console.error("Error during commit:", e);
|
||||
layoutSuccess = false;
|
||||
}
|
||||
}
|
||||
let chordsSuccess = await saveChords(updateProgress);
|
||||
|
||||
if (layoutSuccess && settingsSuccess && chordsSuccess) {
|
||||
changes.set([]);
|
||||
needsSync = true;
|
||||
} else {
|
||||
throw new Error("Some changes could not be saved.");
|
||||
}
|
||||
} catch (e) {
|
||||
error = e as Error;
|
||||
console.error("Error while saving changes:", error);
|
||||
serialLog.update((log) => {
|
||||
log.push({ type: "system", value: error?.message ?? "Error" });
|
||||
return log;
|
||||
});
|
||||
goto("/terminal");
|
||||
} finally {
|
||||
$syncStatus = "done";
|
||||
}
|
||||
|
||||
if (needsSync) {
|
||||
await sync();
|
||||
}
|
||||
}
|
||||
|
||||
let progressPopover: HTMLElement | undefined = $state();
|
||||
</script>
|
||||
|
||||
<button
|
||||
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
|
||||
{@attach actionTooltip($LL.saveActions.UNDO(), "ctrl+z")}
|
||||
class="icon"
|
||||
disabled={$changes.length === 0}
|
||||
onclick={undo}>undo</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
|
||||
{@attach actionTooltip($LL.saveActions.REDO(), "ctrl+y")}
|
||||
class="icon"
|
||||
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
|
||||
{@attach actionTooltip($LL.saveActions.SAVE(), "ctrl+shift+s")}
|
||||
>
|
||||
<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,75 @@
|
||||
<script lang="ts">
|
||||
import { fly } from "svelte/transition";
|
||||
import { fly, slide } from "svelte/transition";
|
||||
import { canShare, triggerShare } from "$lib/share";
|
||||
import { action } from "$lib/title";
|
||||
import { actionTooltip } 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";
|
||||
import {
|
||||
createChordBackup,
|
||||
createLayoutBackup,
|
||||
createSettingsBackup,
|
||||
downloadFile,
|
||||
restoreBackup,
|
||||
} from "$lib/backup/backup";
|
||||
|
||||
const routeOrder = [
|
||||
"/(app)/config/settings",
|
||||
"/(app)/config/chords",
|
||||
"/(app)/config/layout",
|
||||
];
|
||||
|
||||
let pageIndex = $derived(
|
||||
routeOrder.findIndex((it) => page.route.id?.startsWith(it)),
|
||||
);
|
||||
let importExport: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
$effect(() => {
|
||||
pageIndex;
|
||||
importExport?.animate(
|
||||
[
|
||||
{ transform: "translateX(0)", opacity: 1 },
|
||||
{ transform: "translateX(-8px)", opacity: 0, offset: 0.2 },
|
||||
{ transform: "translateX(8px)", opacity: 0, offset: 0.7 },
|
||||
{ transform: "translateX(0)", opacity: 1 },
|
||||
],
|
||||
{
|
||||
duration: 1500,
|
||||
easing: "cubic-bezier(0.19, 1, 0.22, 1)",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
function importBackup(event: Event) {
|
||||
switch (pageIndex) {
|
||||
case 0:
|
||||
restoreBackup(event, "settings");
|
||||
break;
|
||||
case 1:
|
||||
restoreBackup(event, "chords");
|
||||
break;
|
||||
case 2:
|
||||
restoreBackup(event, "layout");
|
||||
break;
|
||||
}
|
||||
(event.target as HTMLInputElement).value = "";
|
||||
}
|
||||
|
||||
function exportBackup() {
|
||||
switch (pageIndex) {
|
||||
case 0:
|
||||
downloadFile(createSettingsBackup());
|
||||
break;
|
||||
case 1:
|
||||
downloadFile(createChordBackup());
|
||||
break;
|
||||
case 2:
|
||||
downloadFile(createLayoutBackup());
|
||||
break;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
@@ -13,8 +78,11 @@
|
||||
</div>
|
||||
|
||||
<div class="profiles">
|
||||
{#if $serialPort}
|
||||
{#if $serialPort.profileCount > 1}
|
||||
{#if $serialPort && $serialPort.profileCount > 1 && !page.route.id?.startsWith("/(app)/config/chords")}
|
||||
<div
|
||||
transition:fly={{ y: -8, duration: 250, easing: expoOut }}
|
||||
class="profiles"
|
||||
>
|
||||
{#each Array.from({ length: $serialPort.profileCount }, (_, i) => i) as profile}
|
||||
<label
|
||||
><input
|
||||
@@ -25,20 +93,20 @@
|
||||
/>{String.fromCodePoint("A".codePointAt(0)! + profile)}</label
|
||||
>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#if $canShare}
|
||||
<button
|
||||
use:action={{ title: $LL.share.TITLE() }}
|
||||
{@attach actionTooltip($LL.share.TITLE())}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon"
|
||||
onclick={triggerShare}>share</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.print.TITLE() }}
|
||||
{@attach actionTooltip($LL.print.TITLE())}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon"
|
||||
onclick={() => print()}>print</button
|
||||
@@ -49,10 +117,32 @@
|
||||
<PwaStatus />
|
||||
{/await}
|
||||
{/if}
|
||||
<div class="import-export" bind:this={importExport}>
|
||||
<label
|
||||
><input type="file" oninput={importBackup} />
|
||||
<span class="icon">upload_file</span>Import</label
|
||||
>
|
||||
<button onclick={exportBackup}
|
||||
><span class="icon">file_save</span>Export</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style lang="scss">
|
||||
.profiles {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.import-export {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
|
||||
@@ -321,7 +321,7 @@
|
||||
><td></td><td></td></tr
|
||||
>
|
||||
{/if}
|
||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord]}
|
||||
{#if chord}
|
||||
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
||||
{/if}
|
||||
@@ -460,7 +460,7 @@
|
||||
}
|
||||
|
||||
.results {
|
||||
min-width: min(90vw, 16.5cm);
|
||||
min-width: min(90vw, 20cm);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
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";
|
||||
|
||||
let { chord }: { chord: ChordInfo } = $props();
|
||||
|
||||
@@ -27,16 +28,16 @@
|
||||
if (!event.shiftKey && event.key === "ArrowUp") {
|
||||
addSpecial(event);
|
||||
} else if (!event.shiftKey && event.key === "ArrowLeft") {
|
||||
moveCursor(cursorPosition - 1);
|
||||
moveCursor(cursorPosition - 1, true);
|
||||
} else if (!event.shiftKey && event.key === "ArrowRight") {
|
||||
moveCursor(cursorPosition + 1);
|
||||
moveCursor(cursorPosition + 1, true);
|
||||
} else if (event.key === "Backspace") {
|
||||
deleteAction(cursorPosition - 1);
|
||||
moveCursor(cursorPosition - 1);
|
||||
deleteAction(cursorPosition - 1, 1, true);
|
||||
moveCursor(cursorPosition - 1, true);
|
||||
} else if (event.key === "Delete") {
|
||||
deleteAction(cursorPosition);
|
||||
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);
|
||||
@@ -45,14 +46,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function moveCursor(to: number) {
|
||||
function moveCursor(to: number, user = false) {
|
||||
if (!box) return;
|
||||
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
|
||||
cursorPosition = Math.max(
|
||||
user ? chord.phrase.findIndex((it, i, arr) => !isHidden(it, i, arr)) : 0,
|
||||
Math.min(
|
||||
to,
|
||||
user
|
||||
? chord.phrase.findLastIndex((it, i, arr) => !isHidden(it, i, arr)) +
|
||||
1 || chord.phrase.length
|
||||
: chord.phrase.length,
|
||||
),
|
||||
);
|
||||
const item = box.children.item(cursorPosition) as HTMLElement;
|
||||
cursorOffset = item.offsetLeft + item.offsetWidth;
|
||||
}
|
||||
|
||||
function deleteAction(at: number, count = 1) {
|
||||
function deleteAction(at: number, count = 1, user = false) {
|
||||
if (user && isHidden(chord.phrase[at]!, at, chord.phrase)) return;
|
||||
if (!(at in chord.phrase)) return;
|
||||
changes.update((changes) => {
|
||||
changes.push([
|
||||
@@ -89,12 +100,12 @@
|
||||
for (const child of box.children) {
|
||||
const { offsetLeft, offsetWidth } = child as HTMLElement;
|
||||
if (distance < offsetLeft + offsetWidth / 2) {
|
||||
moveCursor(i - 1);
|
||||
moveCursor(i - 1, true);
|
||||
return;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
moveCursor(i - 1);
|
||||
moveCursor(i - 1, true);
|
||||
}
|
||||
|
||||
function addSpecial(event: MouseEvent | KeyboardEvent) {
|
||||
@@ -118,6 +129,7 @@
|
||||
)
|
||||
) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
moveCursor(cursorPosition, true);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
@@ -126,13 +138,16 @@
|
||||
return;
|
||||
} else if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
moveCursor(cursorPosition, true);
|
||||
} else {
|
||||
insertAction(chord.phrase.length, JOIN_ACTION);
|
||||
moveCursor(cursorPosition, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (chord.phrase.at(-1) === JOIN_ACTION) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
moveCursor(cursorPosition, true);
|
||||
} else {
|
||||
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
if (
|
||||
@@ -144,9 +159,11 @@
|
||||
return;
|
||||
} else {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
moveCursor(cursorPosition, true);
|
||||
}
|
||||
} else {
|
||||
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
|
||||
moveCursor(cursorPosition, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,43 +188,52 @@
|
||||
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||
);
|
||||
|
||||
let displayPhrase = $derived(
|
||||
chord.phrase.filter(
|
||||
(it, i, arr) =>
|
||||
!(
|
||||
(i === 0 && it === JOIN_ACTION) ||
|
||||
(i === arr.length - 1 &&
|
||||
(it === JOIN_ACTION || it === NO_CONCATENATOR_ACTION))
|
||||
),
|
||||
),
|
||||
);
|
||||
function isHidden(action: number, index: number, array: number[]) {
|
||||
return (
|
||||
(index === 0 && action === JOIN_ACTION) ||
|
||||
(index === array.length - 1 &&
|
||||
(action === JOIN_ACTION || action === NO_CONCATENATOR_ACTION))
|
||||
);
|
||||
}
|
||||
</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}
|
||||
onchange={(event) => {
|
||||
{#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);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
tick().then(() => resolveAutospace(autospace));
|
||||
await tick();
|
||||
resolveAutospace(autospace);
|
||||
}}
|
||||
type="checkbox"
|
||||
/></label
|
||||
@@ -233,10 +259,23 @@
|
||||
<div></div>
|
||||
<!-- placeholder for cursor placement -->
|
||||
{/if}
|
||||
<ActionString actions={displayPhrase} />
|
||||
{#each chord.phrase as action, i}
|
||||
{#if isHidden(action, i, chord.phrase)}
|
||||
<span style:display="none"></span>
|
||||
{:else}
|
||||
<Action display="inline-keys" {action} />
|
||||
{/if}
|
||||
{/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) =>
|
||||
@@ -300,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;
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
compileLayout,
|
||||
type VisualLayout,
|
||||
} from "$lib/serialization/visual-layout";
|
||||
import ccxLayout from "$lib/assets/layouts/generic/103-key.yml";
|
||||
import keycodes from "./keycodes.json";
|
||||
|
||||
let width = $state(16);
|
||||
let height = $state(16);
|
||||
|
||||
let layout = $state(compileLayout(ccxLayout as VisualLayout));
|
||||
let layoutMargin = $state(0.2);
|
||||
|
||||
let timelineCanvas = $state<HTMLCanvasElement | undefined>(undefined);
|
||||
|
||||
interface Report {
|
||||
modifiers?: number;
|
||||
keys?: number[];
|
||||
}
|
||||
|
||||
interface Tick {
|
||||
ms?: number;
|
||||
reports?: Report[];
|
||||
keys?: number[];
|
||||
}
|
||||
|
||||
let test: Tick[] = $state([
|
||||
{ ms: 1, reports: [{ keys: [4] }], keys: [4] },
|
||||
{ ms: 2, reports: [{ keys: [4, 2] }], keys: [4, 12] },
|
||||
]);
|
||||
|
||||
function timelineData<T extends { ms: number }>(
|
||||
ticks: T[],
|
||||
value: (tick: T) => number[],
|
||||
) {
|
||||
let totalTicks = 0;
|
||||
const result = new Map<number, [number, number][]>();
|
||||
for (const tick of ticks) {
|
||||
const key = value(tick);
|
||||
}
|
||||
}
|
||||
|
||||
let timelineData = $derived.by(() => {
|
||||
const result = new Map<number, [number, number][]>();
|
||||
for (const tick of test) {
|
||||
if (!tick.keys) continue;
|
||||
if (Array.isArray(action)) {
|
||||
if (typeof action[0] === "number") {
|
||||
ticks.push([action[0]]);
|
||||
totalTicks++;
|
||||
} else if (action.length === 0) {
|
||||
ticks.push([1]);
|
||||
totalTicks++;
|
||||
}
|
||||
}
|
||||
if (typeof action !== "number") continue;
|
||||
if (action >= 0) {
|
||||
if (!result.has(action)) {
|
||||
result.set(action, []);
|
||||
}
|
||||
result.get(action)!.push([totalTicks, test.length - 1]);
|
||||
} else {
|
||||
const value = result.get(~action)?.at(-1);
|
||||
if (!value || value[1] !== test.length - 1) continue;
|
||||
value[1] = totalTicks;
|
||||
}
|
||||
}
|
||||
return {
|
||||
totalTicks,
|
||||
ticks,
|
||||
presses: [...result.entries()].sort(([a], [b]) => a - b),
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>E2E Testing</h1>
|
||||
|
||||
{#snippet Layout(keys: Set<number>)}
|
||||
<svg viewBox="0 0 {layout.size[0]} {layout.size[1]}">
|
||||
{#each layout.keys as key}
|
||||
{#if key.shape === "square"}
|
||||
<rect
|
||||
x={key.pos[0] + layoutMargin / 2}
|
||||
y={key.pos[1] + layoutMargin / 2}
|
||||
rx={0.5 - layoutMargin / 2}
|
||||
width={key.size[0] - layoutMargin}
|
||||
height={key.size[1] - layoutMargin}
|
||||
fill={keys.has(key.id)
|
||||
? "var(--md-sys-color-primary)"
|
||||
: "var(--md-sys-color-on-surface)"}
|
||||
opacity={keys.has(key.id) ? 1 : 0.1}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<canvas bind:this={timelineCanvas}></canvas>
|
||||
|
||||
<div class="t">
|
||||
{#each test as { ms, reports, keys }}
|
||||
<div class="tick">
|
||||
{ms}ms
|
||||
<div class="keys">
|
||||
{#each keys ?? [] as key}
|
||||
<kbd>{keycodes[key] ?? key}</kbd>
|
||||
{/each}
|
||||
<button class="icon">+</button>
|
||||
</div>
|
||||
{@render Layout(new Set(keys))}
|
||||
{#each reports ?? [] as report}
|
||||
<div class="report">
|
||||
<div class="modifiers">{report.modifiers}</div>
|
||||
<div class="keys">
|
||||
{#each report.keys ?? [] as key}
|
||||
<kbd>{keycodes[key] ?? key}</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
{#each test as action, i}
|
||||
{@const isActionTick = Array.isArray(action)}
|
||||
{@const isActionPress = typeof action === "number" && action >= 0}
|
||||
{@const isActionRelease = typeof action === "number" && action < 0}
|
||||
{#if isActionTick}
|
||||
<div class="tick">
|
||||
<span class="icon">step_over</span>
|
||||
{action[0]}ms
|
||||
</div>
|
||||
{#if action[1]}
|
||||
<div class="report">
|
||||
{#each Array.from({ length: 8 }) as _, j}
|
||||
<div class="modifier">{j}</div>
|
||||
{/each}
|
||||
{#each action[1][1] as key}
|
||||
<div class="key">
|
||||
{key}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if typeof action === "string"}
|
||||
<div>Command: {action}</div>
|
||||
{:else if isActionPress}
|
||||
<button class="release" onclick={() => (test[i] = ~action)}
|
||||
>{action}</button
|
||||
>
|
||||
{:else if isActionRelease}
|
||||
<button class="press" onclick={() => (test[i] = ~action)}
|
||||
>{~action}</button
|
||||
>
|
||||
{:else}
|
||||
<div>Unsupported {action}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
$shadow-inset: 1px;
|
||||
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-rows: auto repeat(auto-fit, minmax(var(--height), 1fr));
|
||||
}
|
||||
|
||||
.timeline-press {
|
||||
margin-inline: calc(var(--width) / 2);
|
||||
border-radius: calc(var(--height) / 2);
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
height: var(--height);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: ew-resize;
|
||||
padding: 0.5rem;
|
||||
user-select: none;
|
||||
|
||||
span.icon {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
aspect-ratio: 1;
|
||||
user-select: none;
|
||||
|
||||
&.release {
|
||||
box-shadow:
|
||||
inset #{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(0, 0, 0, 0.6),
|
||||
inset -#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
&.press {
|
||||
box-shadow:
|
||||
#{$shadow-inset} #{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(0, 0, 0, 0.6),
|
||||
-#{$shadow-inset} -#{$shadow-inset} #{$shadow-inset * 2}
|
||||
rgba(255, 255, 255, 0.2);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,251 +0,0 @@
|
||||
[
|
||||
"reserved",
|
||||
"esc",
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"9",
|
||||
"0",
|
||||
"-",
|
||||
"=",
|
||||
"bksp",
|
||||
"tab",
|
||||
"q",
|
||||
"w",
|
||||
"e",
|
||||
"r",
|
||||
"t",
|
||||
"y",
|
||||
"u",
|
||||
"i",
|
||||
"o",
|
||||
"p",
|
||||
"[",
|
||||
"]",
|
||||
"enter",
|
||||
"lctrl",
|
||||
"a",
|
||||
"s",
|
||||
"d",
|
||||
"f",
|
||||
"g",
|
||||
"h",
|
||||
"j",
|
||||
"k",
|
||||
"l",
|
||||
";",
|
||||
"'",
|
||||
"`",
|
||||
"lshift",
|
||||
"\\",
|
||||
"z",
|
||||
"x",
|
||||
"c",
|
||||
"v",
|
||||
"b",
|
||||
"n",
|
||||
"m",
|
||||
",",
|
||||
".",
|
||||
"/",
|
||||
"rshift",
|
||||
"kp*",
|
||||
"lalt",
|
||||
"_",
|
||||
"capslock",
|
||||
"f1",
|
||||
"f2",
|
||||
"f3",
|
||||
"f4",
|
||||
"f5",
|
||||
"f6",
|
||||
"f7",
|
||||
"f8",
|
||||
"f9",
|
||||
"f10",
|
||||
"numlock",
|
||||
"scrolllock",
|
||||
"kp7",
|
||||
"kp8",
|
||||
"kp9",
|
||||
"kp-",
|
||||
"kp4",
|
||||
"kp5",
|
||||
"kp6",
|
||||
"kp+",
|
||||
"kp1",
|
||||
"kp2",
|
||||
"kp3",
|
||||
"kp0",
|
||||
"kp.",
|
||||
"ksc_84",
|
||||
"zenkaku_hankaku",
|
||||
"102nd",
|
||||
"f11",
|
||||
"f12",
|
||||
"ro",
|
||||
"katakana",
|
||||
"hiragana",
|
||||
"henkan",
|
||||
"katakana_hiragana",
|
||||
"muhenkan",
|
||||
"kp,",
|
||||
"kp_enter",
|
||||
"rctrl",
|
||||
"kp/",
|
||||
"sysrq",
|
||||
"ralt",
|
||||
"linefeed",
|
||||
"home",
|
||||
"up",
|
||||
"pageup",
|
||||
"left",
|
||||
"right",
|
||||
"end",
|
||||
"down",
|
||||
"pagedown",
|
||||
"insert",
|
||||
"delete",
|
||||
"macro",
|
||||
"mute",
|
||||
"volume_down",
|
||||
"volume_up",
|
||||
"power",
|
||||
"kp=",
|
||||
"kp+-",
|
||||
"pause",
|
||||
"scale",
|
||||
"kp,",
|
||||
"hangeul",
|
||||
"hanja",
|
||||
"yen",
|
||||
"lmeta",
|
||||
"rmeta",
|
||||
"compose",
|
||||
"stop",
|
||||
"again",
|
||||
"props",
|
||||
"undo",
|
||||
"front",
|
||||
"copy",
|
||||
"open",
|
||||
"paste",
|
||||
"find",
|
||||
"cut",
|
||||
"help",
|
||||
"menu",
|
||||
"calc",
|
||||
"setup",
|
||||
"sleep",
|
||||
"wakeup",
|
||||
"file",
|
||||
"sendfile",
|
||||
"deletefile",
|
||||
"xfer",
|
||||
"prog1",
|
||||
"prog2",
|
||||
"www",
|
||||
"msdos",
|
||||
"coffee",
|
||||
"rotate_display",
|
||||
"cyclewindows",
|
||||
"mail",
|
||||
"bookmarks",
|
||||
"computer",
|
||||
"back",
|
||||
"forward",
|
||||
"close_cd",
|
||||
"eject_cd",
|
||||
"eject_close_cd",
|
||||
"next_song",
|
||||
"play_pause",
|
||||
"prev_song",
|
||||
"stop_cd",
|
||||
"record",
|
||||
"rewind",
|
||||
"phone",
|
||||
"iso",
|
||||
"config",
|
||||
"homepage",
|
||||
"refresh",
|
||||
"exit",
|
||||
"move",
|
||||
"edit",
|
||||
"scroll_up",
|
||||
"scroll_down",
|
||||
"kp_left_paren",
|
||||
"kp_right_paren",
|
||||
"new",
|
||||
"redo",
|
||||
"f13",
|
||||
"f14",
|
||||
"f15",
|
||||
"f16",
|
||||
"f17",
|
||||
"f18",
|
||||
"f19",
|
||||
"f20",
|
||||
"f21",
|
||||
"f22",
|
||||
"f23",
|
||||
"f24",
|
||||
"sc_195",
|
||||
"sc_196",
|
||||
"sc_197",
|
||||
"sc_198",
|
||||
"sc_199",
|
||||
"play_cd",
|
||||
"pause_cd",
|
||||
"prog3",
|
||||
"prog4",
|
||||
"all_applications",
|
||||
"suspend",
|
||||
"close",
|
||||
"play",
|
||||
"fastforward",
|
||||
"bass_boost",
|
||||
"print",
|
||||
"hp",
|
||||
"camera",
|
||||
"sound",
|
||||
"question",
|
||||
"email",
|
||||
"chat",
|
||||
"search",
|
||||
"connect",
|
||||
"finance",
|
||||
"sport",
|
||||
"shop",
|
||||
"alterase",
|
||||
"cancel",
|
||||
"brightness_down",
|
||||
"brightness_up",
|
||||
"media",
|
||||
"switch_video_mode",
|
||||
"kbd_illum_toggle",
|
||||
"kbd_illum_down",
|
||||
"kbd_illum_up",
|
||||
"send",
|
||||
"reply",
|
||||
"forward_mail",
|
||||
"save",
|
||||
"documents",
|
||||
"battery",
|
||||
"bluetooth",
|
||||
"wlan",
|
||||
"uwb",
|
||||
"unknown",
|
||||
"video_next",
|
||||
"video_prev",
|
||||
"brightness_cycle",
|
||||
"brightness_auto",
|
||||
"display_off",
|
||||
"wwan",
|
||||
"rfkill",
|
||||
"mic_mute"
|
||||
]
|
||||
85
src/routes/(app)/e2e/replay/+page.svelte
Normal file
85
src/routes/(app)/e2e/replay/+page.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
$effect(() => {
|
||||
console.log(data);
|
||||
});
|
||||
</script>
|
||||
|
||||
<details>
|
||||
<summary>Full Log</summary>
|
||||
{#each data.data as item, i}
|
||||
{#if "press" in item}
|
||||
<div class="press">{item.press}</div>
|
||||
{:else if "release" in item}
|
||||
<div class="release">{item.release}</div>
|
||||
{:else if "keys" in item}
|
||||
<div class="report">
|
||||
<span class="icon">keyboard</span>
|
||||
<div class="modifiers">
|
||||
{item.modifiers.toString(2)}
|
||||
</div>
|
||||
<div class="keys">{item.keys.join(", ")}</div>
|
||||
</div>
|
||||
{:else if "out" in item}
|
||||
<pre class="out">{item.out}</pre>
|
||||
{:else if "in" in item}
|
||||
<pre class="in">{item.in}</pre>
|
||||
{:else if "tick" in item}
|
||||
<div class="tick"><span class="icon">timer_play</span>{item.tick}ms</div>
|
||||
{:else}
|
||||
<div>Unknown log item at index {i}</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</details>
|
||||
|
||||
<style lang="scss">
|
||||
details {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.report {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--md-sys-color-primary-container);
|
||||
padding: 0.5rem;
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
|
||||
.out {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.in {
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
|
||||
.tick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
width: fit-content;
|
||||
color: var(--md-sys-color-tertiary);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
margin-bottom: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
49
src/routes/(app)/e2e/replay/+page.ts
Normal file
49
src/routes/(app)/e2e/replay/+page.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { PageLoad } from "./$types";
|
||||
import { browser } from "$app/environment";
|
||||
import { fromBase64 } from "$lib/serialization/base64";
|
||||
|
||||
export interface ReplaySerialIn {
|
||||
in: string;
|
||||
}
|
||||
|
||||
export interface ReplaySerialOut {
|
||||
out: string;
|
||||
}
|
||||
|
||||
export interface ReplaySerialReport {
|
||||
modifiers: number;
|
||||
keys: number[];
|
||||
}
|
||||
|
||||
export interface ReplaySerialPress {
|
||||
press: number;
|
||||
}
|
||||
|
||||
export interface ReplaySerialRelease {
|
||||
release: number;
|
||||
}
|
||||
|
||||
export interface ReplayTick {
|
||||
tick: number;
|
||||
}
|
||||
|
||||
export type ReplayDataItem =
|
||||
| ReplayTick
|
||||
| ReplaySerialIn
|
||||
| ReplaySerialOut
|
||||
| ReplaySerialReport
|
||||
| ReplaySerialPress
|
||||
| ReplaySerialRelease;
|
||||
|
||||
export const load = (async ({ url, fetch }) => {
|
||||
const replay = browser && new URLSearchParams(url.search).get("data");
|
||||
if (!replay) {
|
||||
return undefined;
|
||||
}
|
||||
const stream = (await fromBase64(replay, fetch))
|
||||
.stream()
|
||||
.pipeThrough(new DecompressionStream("deflate"));
|
||||
return {
|
||||
data: JSON.parse(await new Response(stream).text()) as ReplayDataItem[],
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
64
src/routes/(app)/recipes/+page.svelte
Normal file
64
src/routes/(app)/recipes/+page.svelte
Normal file
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { deviceMeta } from "$lib/serial/connection";
|
||||
import Demo from "./Demo.svelte";
|
||||
|
||||
let recipes = $derived(
|
||||
$deviceMeta?.recipes?.toSorted((a, b) => {
|
||||
if (a.demo == null) return 1;
|
||||
if (b.demo == null) return -1;
|
||||
return a.demo.title.localeCompare(b.demo.title);
|
||||
}),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
<nav>
|
||||
{#each recipes as demo, i}
|
||||
{#if demo.demo?.title}
|
||||
<a href="#demo-{i}">
|
||||
{demo.demo?.title}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="recipes">
|
||||
<h1>Cookbook</h1>
|
||||
{#if recipes}
|
||||
{#each recipes as demo, i}
|
||||
<Demo {demo} {i} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 2rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.recipes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 60px;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
min-width: 200px;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
86
src/routes/(app)/recipes/Demo.svelte
Normal file
86
src/routes/(app)/recipes/Demo.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { E2eDemo } from "$lib/meta/types/meta";
|
||||
import Recipe from "./Recipe.svelte";
|
||||
|
||||
let {
|
||||
demo,
|
||||
i,
|
||||
}: {
|
||||
demo: E2eDemo;
|
||||
i: number;
|
||||
} = $props();
|
||||
|
||||
let paused = $state(true);
|
||||
|
||||
let config: boolean[] = $state([]);
|
||||
|
||||
let test = $derived.by(() => {
|
||||
if (!demo.matrix) return demo.tests[0];
|
||||
|
||||
const configuration = demo.matrix?.filter((_, i) => config[i]);
|
||||
return demo.tests.find(
|
||||
(test) => test && test.matrix?.toString() === configuration?.toString(),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section
|
||||
id={"demo-" + i}
|
||||
onmouseenter={() => (paused = false)}
|
||||
onmouseleave={() => (paused = true)}
|
||||
>
|
||||
{#if demo.demo}
|
||||
<h2>{demo.demo?.title}</h2>
|
||||
<p>{demo.demo?.description}</p>
|
||||
{/if}
|
||||
{#if demo.matrix}
|
||||
<div class="configuration">
|
||||
{#each demo.matrix as item, i}
|
||||
<label><input type="checkbox" bind:checked={config[i]} />{item}</label>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if test}
|
||||
<Recipe {test} {paused} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
section:target h2 {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
section {
|
||||
max-width: 20cm;
|
||||
scroll-margin-top: 80px;
|
||||
}
|
||||
|
||||
section > :global(:not(h2)) {
|
||||
margin-inline-start: 24px;
|
||||
}
|
||||
|
||||
.configuration {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-block: 8px;
|
||||
|
||||
label {
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
padding-inline: 8px;
|
||||
padding-block: 4px;
|
||||
height: auto;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
&:has(:checked) {
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
331
src/routes/(app)/recipes/Recipe.svelte
Normal file
331
src/routes/(app)/recipes/Recipe.svelte
Normal file
@@ -0,0 +1,331 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayPlayer } from "$lib/charrecorder/core/player";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import ChordPhraseDisplay from "$lib/components/ChordPhraseDisplay.svelte";
|
||||
import type { E2eTest, E2eTestItem } from "$lib/meta/types/meta";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { deviceMeta } from "$lib/serial/connection";
|
||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
import type { ChordInfo } from "$lib/undo-redo";
|
||||
|
||||
let { test, paused = false }: { test: E2eTest; paused?: boolean } = $props();
|
||||
|
||||
let timescale = $state(10);
|
||||
let idleHold = $state(500);
|
||||
let pressHold = $state(250);
|
||||
let replayDelay = $state(1000);
|
||||
|
||||
let time = $state(0);
|
||||
|
||||
function getKeyRaw(key: string): string {
|
||||
return $KEYMAP_IDS.get(key)?.keyCode ?? key;
|
||||
}
|
||||
|
||||
const keyMap = new Map<string, string>(
|
||||
Object.entries({
|
||||
ENTER: "\n",
|
||||
SPACE: " ",
|
||||
TAB: "\t",
|
||||
BKSP: "Backspace",
|
||||
}),
|
||||
);
|
||||
|
||||
function getKeyMapped(key: string, shift: boolean): string {
|
||||
let value = $osLayout.get(getKeyRaw(key)) ?? key;
|
||||
value = keyMap.get(value) ?? value;
|
||||
return shift ? value.toUpperCase() : value;
|
||||
}
|
||||
|
||||
function advanceTime(
|
||||
step: E2eTestItem,
|
||||
timescale: number,
|
||||
idleHold: number,
|
||||
): number {
|
||||
return (
|
||||
(step.skip ?? step.step ?? 1) * timescale +
|
||||
(step.idle ? idleHold : 0) +
|
||||
(step.press ? pressHold : 0)
|
||||
);
|
||||
}
|
||||
|
||||
let replay: Replay = $derived.by(() => {
|
||||
const replay: Replay = {
|
||||
start: 0,
|
||||
finish: 0,
|
||||
keys: [],
|
||||
};
|
||||
let timeIndex = 0;
|
||||
let held = new Map<string, any>();
|
||||
for (const it of test.test) {
|
||||
if (it.keys) {
|
||||
for (const key of it.keys) {
|
||||
if (held.has(key)) continue;
|
||||
const raw = getKeyRaw(key);
|
||||
const mapped = getKeyMapped(key, it.modifiers?.["lshift"] == true);
|
||||
held.set(key, [mapped, raw, timeIndex, 0]);
|
||||
replay.keys.push(held.get(key));
|
||||
}
|
||||
for (const [key, value] of held) {
|
||||
if (!it.keys.includes(key)) {
|
||||
value[3] = timeIndex - value[2];
|
||||
held.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
timeIndex += advanceTime(it, timescale, idleHold);
|
||||
}
|
||||
|
||||
replay.finish = timeIndex;
|
||||
return replay;
|
||||
});
|
||||
|
||||
let graph = $derived.by(() => {
|
||||
const rows: string[][] = [[]];
|
||||
for (const it of test.test) {
|
||||
if (it.keys?.includes("BKSP")) {
|
||||
if (rows.at(-1)!.at(-1) === " ") {
|
||||
rows.at(-1)!.pop();
|
||||
} else {
|
||||
rows.push(Array.from({ length: rows.at(-1)!.length - 1 }, () => " "));
|
||||
}
|
||||
} else {
|
||||
for (const key of it.keys ?? []) {
|
||||
if (key === "SPACE") {
|
||||
rows.at(-1)!.push("␣");
|
||||
} else if (key === "ENTER") {
|
||||
rows.at(-1)!.push("↵");
|
||||
} else {
|
||||
rows
|
||||
.at(-1)!
|
||||
.push(getKeyMapped(key, it.modifiers?.["lshift"] == true));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
let chords = $derived(
|
||||
test.test
|
||||
.flatMap((step) => step.addChords ?? [])
|
||||
.map(({ input, output }) => ({
|
||||
input: input.map((chord) =>
|
||||
chord.map((it) => $KEYMAP_IDS.get(it)?.code ?? 0),
|
||||
),
|
||||
output: output.map((it) => $KEYMAP_IDS.get(it)?.code ?? 0),
|
||||
})),
|
||||
);
|
||||
|
||||
let settings = $derived(
|
||||
test.test
|
||||
.flatMap((step) => (step.settings ? Object.entries(step.settings) : []))
|
||||
.flatMap(([key, value]) => {
|
||||
const category = $deviceMeta?.settings.find((it) => it.name === key);
|
||||
return Object.entries(value).map(([subkey, subvalue]) => [
|
||||
category?.items.find((it) => it.name === subkey),
|
||||
subvalue,
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
let keysUsed = $derived.by(() => {
|
||||
const keys = new Map<number, number[]>();
|
||||
let time = 0;
|
||||
for (const step of test.test) {
|
||||
for (const key of step.press ?? []) {
|
||||
const keyCode = $KEYMAP_IDS.get(key)?.code ?? 0;
|
||||
if (!keys.has(keyCode)) {
|
||||
keys.set(keyCode, []);
|
||||
}
|
||||
keys.get(keyCode)!.push(time);
|
||||
}
|
||||
|
||||
for (const key of step.release ?? []) {
|
||||
const keyCode = $KEYMAP_IDS.get(key)?.code ?? 0;
|
||||
keys.get(keyCode)!.push(time);
|
||||
}
|
||||
|
||||
time += advanceTime(step, timescale, idleHold);
|
||||
}
|
||||
|
||||
return keys;
|
||||
});
|
||||
|
||||
function isKeyPressed(times: number[], time: number): boolean {
|
||||
return (
|
||||
times.findIndex(
|
||||
(t, i, arr) => time >= t && (arr[i + 1] ?? Infinity) > time,
|
||||
) %
|
||||
2 ===
|
||||
0
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="replay">
|
||||
<CharRecorder
|
||||
{replay}
|
||||
{paused}
|
||||
cursor={true}
|
||||
keys={false}
|
||||
ondone={() => setTimeout(() => (replay = { ...replay }), replayDelay)}
|
||||
ontick={(t) => (time = t)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="keystaff">
|
||||
{#each keysUsed as [action, times]}
|
||||
<div class="keystaff-item" class:active={isKeyPressed(times, time)}>
|
||||
<ActionString actions={[action]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<div class="graph">
|
||||
{#each graph as row, i}
|
||||
{#each row as char, j}
|
||||
{#if char !== " "}
|
||||
<div
|
||||
class:deleted={(graph[i + 1]?.findIndex((it) => it !== " ") ??
|
||||
Infinity) <= j}
|
||||
style:grid-row={i + 1}
|
||||
style:grid-column={j + 1}
|
||||
>
|
||||
{char}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if chords.length > 0}
|
||||
<h3>Chords Used</h3>
|
||||
<div class="chords">
|
||||
{#each chords as { input, output }}
|
||||
<div class="input">
|
||||
{#each input as actions}
|
||||
<div class="compound">
|
||||
<ActionString display="keys" {actions} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="output">
|
||||
<ActionString actions={output} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if settings.length > 0}
|
||||
<h3>Settings Changed</h3>
|
||||
<ul>
|
||||
{#each settings as [item, value]}
|
||||
<li>
|
||||
{item?.name ?? "Unknown Setting"}: {value?.toString()}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</details>
|
||||
|
||||
<style lang="scss">
|
||||
.chords {
|
||||
display: grid;
|
||||
column-gap: 1rem;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
width: fit-content;
|
||||
|
||||
.compound {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
grid-column: 1;
|
||||
align-items: center;
|
||||
justify-self: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.output {
|
||||
grid-column: 2;
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.keystaff {
|
||||
$expo-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
$time: 1s;
|
||||
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
|
||||
.keystaff-item {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
translate: 0 -8px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity $time $expo-out,
|
||||
translate $time $expo-out;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
|
||||
&.active {
|
||||
translate: 0;
|
||||
opacity: 1;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
position: absolute;
|
||||
transform-origin: top;
|
||||
scale: 1 0.5;
|
||||
z-index: 1;
|
||||
margin-left: -17px;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-top: none;
|
||||
background-color: var(--md-sys-color-surface);
|
||||
padding: 16px;
|
||||
width: calc(100% + 2px);
|
||||
}
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.replay {
|
||||
border-radius: 0.4rem;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
padding: 0.6rem;
|
||||
width: fit-content;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.graph {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
width: min-content;
|
||||
}
|
||||
|
||||
.deleted {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
</style>
|
||||
@@ -33,6 +33,7 @@ const ccosFsPath = "/CCOS";
|
||||
|
||||
/** @type {any} */
|
||||
let ccos;
|
||||
let startTime = 0;
|
||||
|
||||
const semaphore = new AsyncSemaphore();
|
||||
|
||||
@@ -40,10 +41,17 @@ const semaphore = new AsyncSemaphore();
|
||||
* @param {MessageEvent<CCOSInEvent>} event
|
||||
*/
|
||||
self.addEventListener("message", async (event) => {
|
||||
if (event.data instanceof Uint8Array) {
|
||||
await semaphore.run(() => serialWrite(event.data));
|
||||
return;
|
||||
}
|
||||
switch (event.data.type) {
|
||||
case "init": {
|
||||
const url = event.data.url;
|
||||
await semaphore.run(() => init(url));
|
||||
/** @type {CCOSReadyEvent} */
|
||||
const readyMsg = { type: "ready" };
|
||||
self.postMessage(readyMsg);
|
||||
break;
|
||||
}
|
||||
case "press": {
|
||||
@@ -56,9 +64,6 @@ self.addEventListener("message", async (event) => {
|
||||
await semaphore.run(() => keyRelease(code));
|
||||
break;
|
||||
}
|
||||
case "serial": {
|
||||
await semaphore.run(() => serialWrite(event.data.data));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -108,37 +113,44 @@ async function init(url) {
|
||||
* @param {number} data
|
||||
*/
|
||||
(data) => {
|
||||
/** @type {CCOSInEvent}) */
|
||||
const msg = { type: "serial", data };
|
||||
self.postMessage(msg);
|
||||
const array = new Uint8Array([data]);
|
||||
self.postMessage(array, { transfer: [array.buffer] });
|
||||
},
|
||||
"vi",
|
||||
);
|
||||
|
||||
ccos._init(onReport, onSerial);
|
||||
startTime = performance.now();
|
||||
await ccos.ccall(
|
||||
"init",
|
||||
"void",
|
||||
["string", "number", "number"],
|
||||
[ccosFsPath, onReport, onSerial],
|
||||
{ async: true },
|
||||
);
|
||||
|
||||
async function update() {
|
||||
if (ccos) {
|
||||
await semaphore.run(() => ccos.update());
|
||||
await semaphore.run(() => {
|
||||
ccos.update(performance.now());
|
||||
});
|
||||
}
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
update();
|
||||
|
||||
/** @type {CCOSReadyEvent} */
|
||||
const readyMsg = { type: "ready" };
|
||||
self.postMessage(readyMsg);
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} data
|
||||
* @param {Uint8Array} data
|
||||
*/
|
||||
async function serialWrite(data) {
|
||||
if (!ccos) {
|
||||
console.warn("Serial write ignored, CCOS is not initialized.");
|
||||
return;
|
||||
}
|
||||
await ccos.serialWrite(data);
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
await ccos.serialWrite(data[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user