mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-04-21 05:38:56 +00:00
refactor: update to Svelte 5 preview
feat: add charrecorder feat: dynamic os layouts for CC1
This commit is contained in:
@@ -9,145 +9,193 @@ actions:
|
||||
This action is unique in this way. Technically it is "printable", but it is not visible.
|
||||
39:
|
||||
id: "'"
|
||||
keyCode: Quote
|
||||
title: Single Quote
|
||||
44:
|
||||
id: ","
|
||||
keyCode: Comma
|
||||
title: Comma
|
||||
45:
|
||||
id: "-"
|
||||
keyCode: Minus
|
||||
title: Minus
|
||||
46:
|
||||
id: "."
|
||||
keyCode: Period
|
||||
title: Period
|
||||
47:
|
||||
id: "/"
|
||||
keyCode: Slash
|
||||
title: Forward Slash
|
||||
48:
|
||||
id: "0"
|
||||
keyCode: Digit0
|
||||
title: Zero
|
||||
49:
|
||||
id: "1"
|
||||
keyCode: Digit1
|
||||
title: One
|
||||
50:
|
||||
id: "2"
|
||||
keyCode: Digit2
|
||||
title: Two
|
||||
51:
|
||||
id: "3"
|
||||
keyCode: Digit3
|
||||
title: Three
|
||||
52:
|
||||
id: "4"
|
||||
keyCode: Digit4
|
||||
title: Four
|
||||
53:
|
||||
id: "5"
|
||||
keyCode: Digit5
|
||||
title: Five
|
||||
54:
|
||||
id: "6"
|
||||
keyCode: Digit6
|
||||
title: Six
|
||||
55:
|
||||
id: "7"
|
||||
keyCode: Digit7
|
||||
title: Seven
|
||||
56:
|
||||
id: "8"
|
||||
keyCode: Digit8
|
||||
title: Eight
|
||||
57:
|
||||
id: "9"
|
||||
keyCode: Digit9
|
||||
title: Nine
|
||||
59:
|
||||
id: ";"
|
||||
keyCode: Semicolon
|
||||
title: Semicolon
|
||||
61:
|
||||
id: "="
|
||||
keyCode: Equal
|
||||
title: Equals
|
||||
91:
|
||||
id: "["
|
||||
keyCode: BracketLeft
|
||||
title: Left Bracket
|
||||
92:
|
||||
id: "\\"
|
||||
keyCode: Backslash
|
||||
title: Backslash
|
||||
93:
|
||||
id: "]"
|
||||
keyCode: BracketRight
|
||||
title: Right Bracket
|
||||
96:
|
||||
id: "`"
|
||||
keyCode: Backquote
|
||||
title: Backtick
|
||||
97:
|
||||
id: "a"
|
||||
keyCode: KeyA
|
||||
title: Lowercase a
|
||||
98:
|
||||
id: "b"
|
||||
keyCode: KeyB
|
||||
title: Lowercase b
|
||||
99:
|
||||
id: "c"
|
||||
keyCode: KeyC
|
||||
title: Lowercase c
|
||||
100:
|
||||
id: "d"
|
||||
keyCode: KeyD
|
||||
title: Lowercase d
|
||||
101:
|
||||
id: "e"
|
||||
keyCode: KeyE
|
||||
title: Lowercase e
|
||||
102:
|
||||
id: "f"
|
||||
keyCode: KeyF
|
||||
title: Lowercase f
|
||||
103:
|
||||
id: "g"
|
||||
keyCode: KeyG
|
||||
title: Lowercase g
|
||||
104:
|
||||
id: "h"
|
||||
keyCode: KeyH
|
||||
title: Lowercase h
|
||||
105:
|
||||
id: "i"
|
||||
keyCode: KeyI
|
||||
title: Lowercase i
|
||||
106:
|
||||
id: "j"
|
||||
keyCode: KeyJ
|
||||
title: Lowercase j
|
||||
107:
|
||||
id: "k"
|
||||
keyCode: KeyK
|
||||
title: Lowercase k
|
||||
108:
|
||||
id: "l"
|
||||
keyCode: KeyL
|
||||
title: Lowercase l
|
||||
109:
|
||||
id: "m"
|
||||
keyCode: KeyM
|
||||
title: Lowercase m
|
||||
110:
|
||||
id: "n"
|
||||
keyCode: KeyN
|
||||
title: Lowercase n
|
||||
111:
|
||||
id: "o"
|
||||
keyCode: KeyO
|
||||
title: Lowercase o
|
||||
112:
|
||||
id: "p"
|
||||
keyCode: KeyP
|
||||
title: Lowercase p
|
||||
113:
|
||||
id: "q"
|
||||
keyCode: KeyQ
|
||||
title: Lowercase q
|
||||
114:
|
||||
id: "r"
|
||||
keyCode: KeyR
|
||||
title: Lowercase r
|
||||
115:
|
||||
id: "s"
|
||||
keyCode: KeyS
|
||||
title: Lowercase s
|
||||
116:
|
||||
id: "t"
|
||||
keyCode: KeyT
|
||||
title: Lowercase t
|
||||
117:
|
||||
id: "u"
|
||||
keyCode: KeyU
|
||||
title: Lowercase u
|
||||
118:
|
||||
id: "v"
|
||||
keyCode: KeyV
|
||||
title: Lowercase v
|
||||
119:
|
||||
id: "w"
|
||||
KeyCode: KeyW
|
||||
title: Lowercase w
|
||||
120:
|
||||
id: "x"
|
||||
keyCode: KeyX
|
||||
title: Lowercase x
|
||||
121:
|
||||
id: "y"
|
||||
keyCode: KeyY
|
||||
title: Lowercase y
|
||||
122:
|
||||
id: "z"
|
||||
keyCode: KeyZ
|
||||
title: Lowercase z
|
||||
127:
|
||||
id: "DEL"
|
||||
keyCode: Delete
|
||||
title: Delete
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
e + b + a,babe
|
||||
e + c + b,because
|
||||
f + e + c + a,face
|
||||
h + e + c + a,each
|
||||
i + d + ',I'd
|
||||
i + g + b,big
|
||||
i + g + e,give
|
||||
k + b + a,back
|
||||
k + e + a,take
|
||||
l + e + a,late
|
||||
l + e + d + a,lead
|
||||
l + f + e,feel
|
||||
l + g + e + a,large
|
||||
l + h + e,help
|
||||
l + i + a,Lia
|
||||
l + i + f,fill
|
||||
l + i + f + e,life
|
||||
l + i + g + b + a,gitlab
|
||||
l + k + i + e,like
|
||||
m + e + a,make
|
||||
m + i + ',I'm
|
||||
n + c + a,can
|
||||
n + d + a,and
|
||||
n + e + b,been
|
||||
n + e + b + a,enable
|
||||
n + e + d,end
|
||||
137
src/lib/charrecorder/CharRecorder.svelte
Normal file
137
src/lib/charrecorder/CharRecorder.svelte
Normal file
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { ReplayPlayer } from "./core/player.js";
|
||||
import { ReplayStepper } from "./core/step.js";
|
||||
import type { Replay } from "./core/types.js";
|
||||
import { TextRenderer } from "./renderer/renderer.js";
|
||||
import { setContext, type Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
replay,
|
||||
cursor = false,
|
||||
keys = false,
|
||||
children,
|
||||
}: {
|
||||
replay: ReplayPlayer | Replay;
|
||||
cursor: boolean;
|
||||
keys: boolean;
|
||||
children?: Snippet;
|
||||
} = $props();
|
||||
|
||||
let replayPlayer: ReplayPlayer | undefined = $state();
|
||||
setContext("replay", {
|
||||
get player() {
|
||||
return replayPlayer;
|
||||
},
|
||||
});
|
||||
|
||||
let finalText = $derived(
|
||||
replay instanceof ReplayPlayer
|
||||
? undefined
|
||||
: new ReplayStepper(replay.keys).text.map((token) => token.text).join(""),
|
||||
);
|
||||
|
||||
let svg: SVGSVGElement | undefined = $state();
|
||||
let text: Text = (browser ? document.createTextNode("") : undefined)!;
|
||||
|
||||
let textRenderer: TextRenderer | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if (!textRenderer) return;
|
||||
textRenderer.showCursor = cursor;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!svg || !text) return;
|
||||
const player =
|
||||
replay instanceof ReplayPlayer ? replay : new ReplayPlayer(replay);
|
||||
replayPlayer = player;
|
||||
|
||||
const renderer = new TextRenderer(svg.parentNode as HTMLElement, svg, text);
|
||||
const apply = () => {
|
||||
text.textContent =
|
||||
finalText ??
|
||||
(player.stepper.text.map((token) => token.text).join("") || "n");
|
||||
renderer.text = player.stepper.text;
|
||||
renderer.cursor = player.stepper.cursor;
|
||||
if (keys) {
|
||||
renderer.held = player.stepper.held;
|
||||
}
|
||||
};
|
||||
const unsubscribePlayer = player.subscribe(apply);
|
||||
textRenderer = renderer;
|
||||
|
||||
player.start();
|
||||
apply();
|
||||
setTimeout(() => {
|
||||
renderer.animated = true;
|
||||
});
|
||||
return () => {
|
||||
unsubscribePlayer();
|
||||
player?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
export function innerText(node: HTMLElement, text: Text) {
|
||||
node.appendChild(text);
|
||||
return {
|
||||
destroy() {
|
||||
text.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key replay}
|
||||
<svg bind:this={svg}></svg>
|
||||
{#if browser}
|
||||
<span use:innerText={text}></span>
|
||||
{:else if !(replay instanceof ReplayPlayer)}
|
||||
{finalText}
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
:global(*):has(svg) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
span {
|
||||
opacity: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
svg > :global(text) {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
fill: currentColor;
|
||||
dominant-baseline: middle;
|
||||
}
|
||||
|
||||
svg > :global(text[incorrect]) {
|
||||
fill: red;
|
||||
}
|
||||
|
||||
svg > :global(rect) {
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
svg > :global(.animated) {
|
||||
transition: transform 100ms ease;
|
||||
}
|
||||
</style>
|
||||
130
src/lib/charrecorder/ChordHud.svelte
Normal file
130
src/lib/charrecorder/ChordHud.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import { fly, scale } from "svelte/transition";
|
||||
import { KBD_ICONS } from "./renderer/kbd-icon.js";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import type { InferredChord } from "./core/types.js";
|
||||
|
||||
let { chords }: { chords: InferredChord[] } = $props();
|
||||
|
||||
function getPercent(
|
||||
deviation: number,
|
||||
inputCount: number,
|
||||
perfect: number,
|
||||
fail: number,
|
||||
) {
|
||||
const failAdjusted = fail * inputCount;
|
||||
const perfectAdjusted = perfect * inputCount;
|
||||
return Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
Math.max(0, deviation - perfectAdjusted) /
|
||||
(failAdjusted - perfectAdjusted),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getColor(percent: number, alpha = 1) {
|
||||
return `hsl(${(1 - percent) * 120}deg 50% 50% / ${alpha})`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
{#each chords as { input, id, deviation }, i (id)}
|
||||
{@const a = getPercent(deviation[0], input.length, 10, 25)}
|
||||
{@const b = getPercent(deviation[1], input.length, 10, 18)}
|
||||
{@const max = Math.max(a, b)}
|
||||
<div
|
||||
class="chord"
|
||||
out:fly={{ x: -100 }}
|
||||
style:translate="calc(-{(chords.length - i - 1) * 5}em - 50%) 0"
|
||||
style:scale={1 - (chords.length - i) / 6}
|
||||
style:opacity={1 - (chords.length - i - 1) / 6}
|
||||
title="Press: {deviation[0]}ms, Release: {deviation[1]}ms"
|
||||
>
|
||||
<div
|
||||
class="rating"
|
||||
style:color={getColor(max)}
|
||||
style:text-shadow="0 0 {Math.round((1 - max) * 10)}px {getColor(
|
||||
max,
|
||||
0.6,
|
||||
)}"
|
||||
in:scale={{
|
||||
start: 1.5 + 1.2 * (1 - max),
|
||||
easing: expoOut,
|
||||
duration: 1000,
|
||||
}}
|
||||
>
|
||||
{#if max === 1}
|
||||
Close
|
||||
{:else if max > 0.5}
|
||||
Okay
|
||||
{:else if max > 0}
|
||||
Good
|
||||
{:else}
|
||||
Perfect
|
||||
{/if}
|
||||
</div>
|
||||
<div
|
||||
in:fly={{ y: 20, easing: expoOut, duration: 1000 }}
|
||||
class="tile"
|
||||
style:background="linear-gradient(to right, {getColor(a)}, {getColor(
|
||||
b,
|
||||
)})"
|
||||
></div>
|
||||
<div in:fly={{ y: 60, easing: expoOut, duration: 1000 }}>
|
||||
{#each input as token}
|
||||
<kbd>{KBD_ICONS.get(token.code)}</kbd>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
position: relative;
|
||||
margin: 1em;
|
||||
margin-bottom: 0;
|
||||
display: grid;
|
||||
height: 3em;
|
||||
font-size: 2em;
|
||||
}
|
||||
|
||||
.rating {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tile {
|
||||
width: 100%;
|
||||
height: 0.2em;
|
||||
border-radius: 0.1em;
|
||||
}
|
||||
|
||||
kbd {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
kbd + kbd {
|
||||
margin-inline-start: 0.3em;
|
||||
}
|
||||
|
||||
.chord {
|
||||
will-change: transform, opacity, scale;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-inline-end: 1em;
|
||||
padding-inline: 0.1em;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
transition:
|
||||
opacity 0.3s ease,
|
||||
translate 0.3s ease,
|
||||
scale 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
30
src/lib/charrecorder/TrackChords.svelte
Normal file
30
src/lib/charrecorder/TrackChords.svelte
Normal file
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import type { InferredChord } from "./core/types.js";
|
||||
import { ChordsReplayPlugin } from "./core/plugins/chords.js";
|
||||
import type { ReplayPlayer } from "./core/player.js";
|
||||
|
||||
const player: ReplayPlayer | undefined = getContext("replay");
|
||||
|
||||
let {
|
||||
chords = $bindable([]),
|
||||
count = 1,
|
||||
}: {
|
||||
chords: InferredChord[];
|
||||
count: number;
|
||||
} = $props();
|
||||
|
||||
if (browser) {
|
||||
$effect(() => {
|
||||
if (!player) return;
|
||||
const tracker = new ChordsReplayPlugin();
|
||||
tracker.register(player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
chords = value.slice(-count);
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
20
src/lib/charrecorder/TrackRollingWpm.svelte
Normal file
20
src/lib/charrecorder/TrackRollingWpm.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from "svelte";
|
||||
import { RollingWpmReplayPlugin } from "./core/plugins/rolling-wpm";
|
||||
import type { ReplayPlayer } from "./core/player";
|
||||
|
||||
const player: ReplayPlayer | undefined = getContext("replay");
|
||||
|
||||
let { wpm = $bindable(0) } = $props();
|
||||
|
||||
$effect(() => {
|
||||
if (!player) return;
|
||||
const tracker = new RollingWpmReplayPlugin();
|
||||
tracker.register(player);
|
||||
const unsubscribe = tracker.subscribe((value) => {
|
||||
wpm = value;
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
</script>
|
||||
134
src/lib/charrecorder/core/player.ts
Normal file
134
src/lib/charrecorder/core/player.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ReplayStepper } from "./step";
|
||||
import type { ReplayPlugin, Replay, TextToken } from "./types";
|
||||
|
||||
export const ROBOT_THRESHOLD = 20;
|
||||
|
||||
export class ReplayPlayer {
|
||||
stepper = new ReplayStepper();
|
||||
|
||||
private replayCursor = 0;
|
||||
|
||||
private releaseAt = new Map<string, number>();
|
||||
|
||||
startTime = performance.now();
|
||||
|
||||
private animationFrameId: number | null = null;
|
||||
|
||||
timescale = 1;
|
||||
|
||||
private subscribers = new Set<(value: TextToken | undefined) => void>();
|
||||
|
||||
constructor(
|
||||
readonly replay: Replay,
|
||||
plugins: ReplayPlugin[] = [],
|
||||
) {
|
||||
for (const plugin of plugins) {
|
||||
plugin.register(this);
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('./types').StoreContract<import('./types').TextToken | undefined>['subscribe']} */
|
||||
subscribe(subscription: (value: TextToken | undefined) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
|
||||
private updateLoop() {
|
||||
if (
|
||||
this.replayCursor >= this.replay.keys.length &&
|
||||
this.releaseAt.size === 0
|
||||
)
|
||||
return;
|
||||
const now = performance.now() - this.startTime;
|
||||
|
||||
while (
|
||||
this.replayCursor < this.replay.keys.length &&
|
||||
this.replay.keys[this.replayCursor]![2] * this.timescale -
|
||||
this.replay.start <=
|
||||
now
|
||||
) {
|
||||
const [key, code, at, duration] = this.replay.keys[this.replayCursor++]!;
|
||||
this.stepper.held.set(code, duration > ROBOT_THRESHOLD);
|
||||
this.releaseAt.set(code, now + duration * this.timescale);
|
||||
|
||||
const token = this.stepper.step(key, code, at, duration);
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(token);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, releaseAt] of this.releaseAt) {
|
||||
if (releaseAt > now) continue;
|
||||
this.stepper.held.delete(key);
|
||||
this.releaseAt.delete(key);
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||
}
|
||||
|
||||
playLiveEvent(key: string, code: string): (duration: number) => void {
|
||||
this.replay.start = this.startTime;
|
||||
const at = performance.now();
|
||||
this.stepper.held.set(code, false);
|
||||
|
||||
const token = this.stepper.step(key, code, at) ?? {
|
||||
text: key,
|
||||
code,
|
||||
stamp: at,
|
||||
correct: true,
|
||||
source: "robot",
|
||||
};
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(token);
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
token.source = "human";
|
||||
this.stepper.held.set(code, true);
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(undefined);
|
||||
}
|
||||
}, ROBOT_THRESHOLD);
|
||||
|
||||
return (duration) => {
|
||||
clearTimeout(timeout);
|
||||
if (token) {
|
||||
// TODO: will this cause performance issues with long text?
|
||||
const index = this.stepper.text.indexOf(token);
|
||||
if (index >= 0) {
|
||||
this.stepper.text[index]!.duration = duration;
|
||||
this.stepper.text[index]!.source =
|
||||
duration < ROBOT_THRESHOLD ? "robot" : "human";
|
||||
}
|
||||
}
|
||||
this.stepper.held.delete(code);
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
start(delay = 200): this {
|
||||
this.replayCursor = 0;
|
||||
this.stepper = new ReplayStepper([], this.replay.challenge);
|
||||
if (this.replay.keys.length === 0) return this;
|
||||
setTimeout(() => {
|
||||
this.startTime = performance.now();
|
||||
this.animationFrameId = requestAnimationFrame(this.updateLoop.bind(this));
|
||||
}, delay);
|
||||
return this;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
}
|
||||
}
|
||||
}
|
||||
111
src/lib/charrecorder/core/plugins/chords.ts
Normal file
111
src/lib/charrecorder/core/plugins/chords.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
|
||||
import type {
|
||||
StoreContract,
|
||||
ReplayPlugin,
|
||||
InferredChord,
|
||||
TextToken,
|
||||
} from "../types";
|
||||
|
||||
function isValid(human: TextToken[], robot: TextToken[]) {
|
||||
return human.length > 1 && human.length <= 10 && robot.length > 0;
|
||||
}
|
||||
|
||||
export class ChordsReplayPlugin
|
||||
implements StoreContract<InferredChord[]>, ReplayPlugin
|
||||
{
|
||||
private readonly subscribers = new Set<(value: InferredChord[]) => void>();
|
||||
|
||||
private readonly chords: InferredChord[] = [];
|
||||
|
||||
private tokens: TextToken[] = [];
|
||||
|
||||
private timeout: Parameters<typeof clearTimeout>[0] = NaN;
|
||||
|
||||
private infer(human: TextToken[], robo: TextToken[]) {
|
||||
const output = robo
|
||||
.filter((token) => token.text.length === 1)
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
this.chords.push({
|
||||
id: human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0),
|
||||
input: human,
|
||||
output,
|
||||
deviation: [
|
||||
human.reduce((acc, curr) => Math.max(acc, curr.stamp), 0) -
|
||||
human.reduce((acc, curr) => Math.min(acc, curr.stamp), Infinity),
|
||||
human.reduce(
|
||||
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
0,
|
||||
) -
|
||||
human.reduce(
|
||||
(acc, curr) => Math.min(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
Infinity,
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.chords);
|
||||
}
|
||||
}
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe((token) => {
|
||||
if (token) {
|
||||
this.tokens.push(token);
|
||||
}
|
||||
|
||||
let last = NaN;
|
||||
let roboStart = NaN;
|
||||
let roboEnd = NaN;
|
||||
for (let i = 0; i < this.tokens.length; i++) {
|
||||
const token = this.tokens[i]!;
|
||||
if (!token.duration || !token.source) break;
|
||||
|
||||
if (
|
||||
Number.isNaN(roboStart) &&
|
||||
token.source === "human" &&
|
||||
token.stamp > last
|
||||
) {
|
||||
this.tokens = [];
|
||||
}
|
||||
|
||||
if (Number.isNaN(last) || token.stamp + token.duration > last) {
|
||||
last = token.stamp + token.duration;
|
||||
}
|
||||
|
||||
if (Number.isNaN(roboStart) && token.source === "robot") {
|
||||
roboStart = i;
|
||||
} else if (!Number.isNaN(roboStart) && token.source === "human") {
|
||||
roboEnd = i;
|
||||
const human = this.tokens.splice(0, roboStart);
|
||||
const robot = this.tokens.splice(0, roboEnd - roboStart);
|
||||
if (isValid(human, robot)) {
|
||||
this.infer(human, robot);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(this.timeout);
|
||||
if (replay.stepper.held.size === 0) {
|
||||
this.timeout = setTimeout(() => {
|
||||
if (this.tokens.length > 0) {
|
||||
const human = this.tokens.splice(
|
||||
0,
|
||||
this.tokens.findIndex((it) => it.source === "robot"),
|
||||
);
|
||||
const robot = this.tokens.splice(0, this.tokens.length);
|
||||
if (isValid(human, robot)) {
|
||||
this.infer(human, robot);
|
||||
}
|
||||
}
|
||||
}, ROBOT_THRESHOLD);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: InferredChord[]) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
71
src/lib/charrecorder/core/plugins/meta.ts
Normal file
71
src/lib/charrecorder/core/plugins/meta.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { ReplayPlayer, ROBOT_THRESHOLD } from "../player";
|
||||
import type { GraphData, ReplayPlugin, StoreContract } from "../types";
|
||||
|
||||
export class MetaReplayPlugin
|
||||
implements StoreContract<GraphData>, ReplayPlugin
|
||||
{
|
||||
private subscribers = new Set<(value: GraphData) => void>();
|
||||
|
||||
private graphData: GraphData = { min: [0, 0], max: [0, 0], tokens: [] };
|
||||
|
||||
private liveHeldRoboFilter = new Set<string>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe((token) => {
|
||||
if (!token) return;
|
||||
const lastHeld = this.graphData.tokens
|
||||
.at(-1)
|
||||
?.reduce(
|
||||
(acc, curr) => Math.max(acc, curr.stamp + (curr.duration ?? 0)),
|
||||
0,
|
||||
);
|
||||
if (
|
||||
lastHeld &&
|
||||
(lastHeld === -1 || lastHeld > token.stamp + (token.duration ?? 0))
|
||||
) {
|
||||
this.graphData.tokens.at(-1)!.push(token);
|
||||
} else {
|
||||
this.graphData.tokens.push([token]);
|
||||
}
|
||||
if (this.graphData.tokens.length === 1) {
|
||||
this.graphData.min = [token.stamp, 0];
|
||||
}
|
||||
this.graphData.max = [
|
||||
this.graphData.tokens
|
||||
.at(-1)!
|
||||
.reduce(
|
||||
(acc, { stamp, duration }) =>
|
||||
Math.max(acc, stamp + (duration ?? 0)),
|
||||
0,
|
||||
),
|
||||
Math.max(this.graphData.max[1], this.graphData.tokens.at(-1)!.length),
|
||||
];
|
||||
|
||||
this.liveHeldRoboFilter.add(token.code);
|
||||
|
||||
if (token.duration === undefined) {
|
||||
setTimeout(() => {
|
||||
if (this.liveHeldRoboFilter.has(token.code)) {
|
||||
token.source = "human";
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.graphData);
|
||||
}
|
||||
}
|
||||
}, ROBOT_THRESHOLD);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.liveHeldRoboFilter.delete(token.code);
|
||||
}, token.duration);
|
||||
}
|
||||
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(this.graphData);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: GraphData) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
48
src/lib/charrecorder/core/plugins/rolling-wpm.ts
Normal file
48
src/lib/charrecorder/core/plugins/rolling-wpm.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReplayPlayer } from "../player";
|
||||
import type { ReplayPlugin, StoreContract } from "../types";
|
||||
import { avgWordLength } from "./wpm";
|
||||
|
||||
export class RollingWpmReplayPlugin
|
||||
implements StoreContract<number>, ReplayPlugin
|
||||
{
|
||||
subscribers = new Set<(value: number) => void>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe(() => {
|
||||
if (this.subscribers.size === 0) return;
|
||||
let i = 0;
|
||||
const index = Math.max(
|
||||
0,
|
||||
replay.stepper.text.findLastIndex((char) => {
|
||||
if (char.source === "ghost") return false;
|
||||
if (char.text === " " && i < 10) {
|
||||
i++;
|
||||
} else if (char.text === " ") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
const length =
|
||||
replay.stepper.text.length - replay.stepper.ghostCount - index;
|
||||
const msPerChar =
|
||||
((replay.stepper.text[
|
||||
replay.stepper.text.length - replay.stepper.ghostCount - 1
|
||||
]?.stamp ?? 0) -
|
||||
(replay.stepper.text[index]?.stamp ?? 0)) /
|
||||
length;
|
||||
|
||||
const value = 60_000 / (msPerChar * avgWordLength);
|
||||
if (Number.isFinite(value)) {
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(value);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
subscribe(subscription: (value: number) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
26
src/lib/charrecorder/core/plugins/wpm.ts
Normal file
26
src/lib/charrecorder/core/plugins/wpm.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReplayPlayer } from "../player";
|
||||
import type { ReplayPlugin, StoreContract } from "../types";
|
||||
|
||||
export const avgWordLength = 5;
|
||||
|
||||
export class WpmReplayPlugin implements StoreContract<number>, ReplayPlugin {
|
||||
private subscribers = new Set<(value: number) => void>();
|
||||
|
||||
register(replay: ReplayPlayer) {
|
||||
replay.subscribe(() => {
|
||||
if (this.subscribers.size === 0) return;
|
||||
const msPerChar =
|
||||
((replay.stepper.text.at(-1)?.stamp ?? 0) - replay.startTime) /
|
||||
replay.stepper.text.length;
|
||||
|
||||
const value = 60_000 / (msPerChar * avgWordLength);
|
||||
for (const subscription of this.subscribers) {
|
||||
subscription(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
subscribe(subscription: (value: number) => void) {
|
||||
this.subscribers.add(subscription);
|
||||
return () => this.subscribers.delete(subscription);
|
||||
}
|
||||
}
|
||||
67
src/lib/charrecorder/core/recorder.ts
Normal file
67
src/lib/charrecorder/core/recorder.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ReplayPlayer } from "./player.js";
|
||||
import type { Replay, ReplayEvent, TransmittableKeyEvent } from "./types.js";
|
||||
|
||||
export class ReplayRecorder {
|
||||
private held = new Map<string, [string, number]>();
|
||||
|
||||
private heldHandles = new Map<
|
||||
string,
|
||||
ReturnType<ReplayPlayer["playLiveEvent"]>
|
||||
>();
|
||||
|
||||
replay: ReplayEvent[] = [];
|
||||
|
||||
private start = performance.now();
|
||||
|
||||
private isFirstPress = true;
|
||||
|
||||
player: ReplayPlayer;
|
||||
|
||||
constructor(challenge?: Replay["challenge"]) {
|
||||
this.player = new ReplayPlayer({
|
||||
start: this.start,
|
||||
finish: this.start,
|
||||
keys: [],
|
||||
challenge,
|
||||
});
|
||||
}
|
||||
|
||||
next(event: TransmittableKeyEvent) {
|
||||
if (this.isFirstPress) {
|
||||
this.player.startTime = event.timeStamp;
|
||||
this.isFirstPress = false;
|
||||
}
|
||||
this.player.replay.finish = event.timeStamp;
|
||||
if (event.type === "keydown") {
|
||||
this.held.set(event.code, [event.key, event.timeStamp]);
|
||||
this.heldHandles.set(
|
||||
event.code,
|
||||
this.player.playLiveEvent(event.key, event.code),
|
||||
);
|
||||
} else {
|
||||
const [key, start] = this.held.get(event.code)!;
|
||||
const delta = event.timeStamp - start;
|
||||
this.held.delete(event.code);
|
||||
|
||||
const element = Object.freeze([key, event.code, start, delta] as const);
|
||||
this.replay.push(element);
|
||||
this.heldHandles.get(event.code)?.(delta);
|
||||
this.heldHandles.delete(event.code);
|
||||
}
|
||||
}
|
||||
|
||||
finish(trim = true) {
|
||||
return {
|
||||
start: trim ? this.replay[0]?.[2] : this.start,
|
||||
finish: trim
|
||||
? Math.max(...this.replay.map((it) => it[2] + it[3]))
|
||||
: performance.now(),
|
||||
keys: this.replay
|
||||
.map(
|
||||
([key, code, at, duration]) =>
|
||||
[key, code, Math.round(at), Math.round(duration)] as const,
|
||||
)
|
||||
.sort((a, b) => a[2] - b[2]),
|
||||
};
|
||||
}
|
||||
}
|
||||
132
src/lib/charrecorder/core/step.ts
Normal file
132
src/lib/charrecorder/core/step.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { ROBOT_THRESHOLD } from "./player";
|
||||
import type { LiveReplayEvent, ReplayEvent, TextToken } from "./types";
|
||||
|
||||
/**
|
||||
* This is the "heart" of the player logic
|
||||
*/
|
||||
export class ReplayStepper {
|
||||
held = new Map<string, boolean>();
|
||||
|
||||
text: TextToken[];
|
||||
|
||||
cursor = 0;
|
||||
|
||||
challenge: TextToken[];
|
||||
|
||||
ghostCount: number;
|
||||
|
||||
mistakeCount = 0;
|
||||
|
||||
constructor(initialReplay: ReplayEvent[] = [], challenge = "") {
|
||||
this.challenge = challenge.split("").map((text) => ({
|
||||
stamp: 0,
|
||||
duration: 0,
|
||||
code: "",
|
||||
text,
|
||||
source: "ghost",
|
||||
correct: true,
|
||||
}));
|
||||
this.text = [...this.challenge];
|
||||
this.ghostCount = this.challenge.length;
|
||||
for (const key of initialReplay) {
|
||||
this.step(...key);
|
||||
}
|
||||
}
|
||||
|
||||
step(
|
||||
...[output, code, at, duration]: ReplayEvent | LiveReplayEvent
|
||||
): TextToken | undefined {
|
||||
let token: TextToken | undefined = undefined;
|
||||
if (output === "Backspace") {
|
||||
if (this.held.has("ControlLeft") || this.held.has("ControlRight")) {
|
||||
let wordIndex = 0;
|
||||
for (let i = this.cursor - 1; i >= 0; i--) {
|
||||
if (/\w+/.test(/** @type {TextToken} */ this.text[i]!.text)) {
|
||||
wordIndex = i;
|
||||
} else if (wordIndex !== 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.text.splice(wordIndex, this.cursor - wordIndex);
|
||||
} else if (this.cursor !== 0) {
|
||||
this.text.splice(this.cursor - 1, 1);
|
||||
}
|
||||
this.cursor = Math.min(
|
||||
this.cursor,
|
||||
this.text.length - this.ghostCount + 1,
|
||||
);
|
||||
}
|
||||
if (output.length === 1) {
|
||||
token = {
|
||||
stamp: at,
|
||||
duration,
|
||||
code,
|
||||
text: output,
|
||||
source:
|
||||
duration === undefined
|
||||
? undefined
|
||||
: duration < ROBOT_THRESHOLD
|
||||
? "robot"
|
||||
: "human",
|
||||
correct: true,
|
||||
};
|
||||
this.text.splice(this.cursor, 0, token);
|
||||
}
|
||||
|
||||
if (code === "ArrowLeft" || code === "Backspace") {
|
||||
this.cursor = Math.max(this.cursor - 1, 0);
|
||||
}
|
||||
if (code === "ArrowRight" || output.length === 1) {
|
||||
this.cursor = Math.min(
|
||||
this.cursor + 1,
|
||||
this.text.length - this.ghostCount,
|
||||
);
|
||||
}
|
||||
|
||||
if (code === "Enter") {
|
||||
token = {
|
||||
stamp: at,
|
||||
code,
|
||||
duration,
|
||||
text: "\n",
|
||||
source:
|
||||
duration === undefined
|
||||
? undefined
|
||||
: duration < ROBOT_THRESHOLD
|
||||
? "robot"
|
||||
: "human",
|
||||
correct: true,
|
||||
};
|
||||
this.text.splice(this.cursor, 0, token);
|
||||
this.cursor++;
|
||||
}
|
||||
|
||||
if (this.challenge.length > 0) {
|
||||
let challengeIndex = 0;
|
||||
this.mistakeCount = 0;
|
||||
for (let i = 0; i < this.text.length - this.ghostCount; i++) {
|
||||
if (this.text[i]!.text === this.challenge[challengeIndex]?.text) {
|
||||
this.text[i]!.correct = true;
|
||||
} else {
|
||||
this.mistakeCount++;
|
||||
this.text[i]!.correct = false;
|
||||
}
|
||||
challengeIndex++;
|
||||
}
|
||||
|
||||
const currentGhostCount = this.ghostCount;
|
||||
this.ghostCount = Math.max(0, this.challenge.length - challengeIndex);
|
||||
|
||||
this.text.splice(
|
||||
this.text.length - currentGhostCount,
|
||||
Math.max(0, currentGhostCount - this.ghostCount),
|
||||
...this.challenge.slice(
|
||||
challengeIndex,
|
||||
challengeIndex + Math.max(0, this.ghostCount - currentGhostCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
}
|
||||
58
src/lib/charrecorder/core/types.ts
Normal file
58
src/lib/charrecorder/core/types.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ReplayPlayer } from "./player.js";
|
||||
|
||||
export interface Replay {
|
||||
start: number;
|
||||
finish: number;
|
||||
keys: ReplayEvent[];
|
||||
challenge?: string;
|
||||
}
|
||||
export type LiveReplayEvent = readonly [
|
||||
output: string,
|
||||
code: string,
|
||||
at: number,
|
||||
];
|
||||
export type ReplayEvent = readonly [...LiveReplayEvent, duration: number];
|
||||
|
||||
export interface TextToken {
|
||||
stamp: number;
|
||||
duration?: number;
|
||||
text: string;
|
||||
code: string;
|
||||
source?: "human" | "robot" | "ghost";
|
||||
correct: boolean;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
min: [number, number];
|
||||
max: [number, number];
|
||||
tokens: TextToken[][];
|
||||
}
|
||||
|
||||
export interface ReplayStepResult {
|
||||
text: TextToken[];
|
||||
cursor: number;
|
||||
challengeCursor: number;
|
||||
token: TextToken | undefined;
|
||||
}
|
||||
|
||||
export type TransmittableKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"timeStamp" | "type" | "code" | "key"
|
||||
>;
|
||||
|
||||
export interface InferredChord {
|
||||
id: number;
|
||||
input: TextToken[];
|
||||
output: string;
|
||||
deviation: [number, number];
|
||||
}
|
||||
|
||||
export interface ReplayPlugin {
|
||||
register(replay: ReplayPlayer): void;
|
||||
}
|
||||
|
||||
export interface StoreContract<T> {
|
||||
subscribe(subscription: (value: T) => void): () => void;
|
||||
|
||||
set?: (value: T) => void;
|
||||
}
|
||||
96
src/lib/charrecorder/renderer/kbd-icon.ts
Normal file
96
src/lib/charrecorder/renderer/kbd-icon.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export const KBD_ICONS = new Map([
|
||||
["KeyA", "a"],
|
||||
["KeyB", "b"],
|
||||
["KeyC", "c"],
|
||||
["KeyD", "d"],
|
||||
["KeyE", "e"],
|
||||
["KeyF", "f"],
|
||||
["KeyG", "g"],
|
||||
["KeyH", "h"],
|
||||
["KeyI", "i"],
|
||||
["KeyJ", "j"],
|
||||
["KeyK", "k"],
|
||||
["KeyL", "l"],
|
||||
["KeyM", "m"],
|
||||
["KeyN", "n"],
|
||||
["KeyO", "o"],
|
||||
["KeyP", "p"],
|
||||
["KeyQ", "q"],
|
||||
["KeyR", "r"],
|
||||
["KeyS", "s"],
|
||||
["KeyT", "t"],
|
||||
["KeyU", "u"],
|
||||
["KeyV", "v"],
|
||||
["KeyW", "w"],
|
||||
["KeyX", "x"],
|
||||
["KeyY", "y"],
|
||||
["KeyZ", "z"],
|
||||
["Digit0", "0"],
|
||||
["Digit1", "1"],
|
||||
["Digit2", "2"],
|
||||
["Digit3", "3"],
|
||||
["Digit4", "4"],
|
||||
["Digit5", "5"],
|
||||
["Digit6", "6"],
|
||||
["Digit7", "7"],
|
||||
["Digit8", "8"],
|
||||
["Digit9", "9"],
|
||||
["Period", "."],
|
||||
["Comma", ","],
|
||||
["Semicolon", ";"],
|
||||
["Quote", "'"],
|
||||
["BracketLeft", "["],
|
||||
["BracketRight", "]"],
|
||||
["Backslash", "\\"],
|
||||
["Slash", "/"],
|
||||
["Minus", "-"],
|
||||
["Equal", "="],
|
||||
["Backquote", "`"],
|
||||
["IntlBackslash", "¦"],
|
||||
["IntlRo", "ろ"],
|
||||
["IntlYen", "¥"],
|
||||
["IntlHash", "#"],
|
||||
["BracketLeft", "["],
|
||||
["BracketRight", "]"],
|
||||
["NumLock", "⇭"],
|
||||
["ScrollLock", "⇳"],
|
||||
["Backspace", "⌫"],
|
||||
["Delete", "⌦"],
|
||||
["Enter", "↵"],
|
||||
["Space", "␣"],
|
||||
["Tab", "⇥"],
|
||||
["ArrowLeft", "←"],
|
||||
["ArrowRight", "→"],
|
||||
["ArrowUp", "↑"],
|
||||
["ArrowDown", "↓"],
|
||||
["ShiftLeft", "⇧"],
|
||||
["ShiftRight", "⇧"],
|
||||
["ControlLeft", "Ctrl"],
|
||||
["ControlRight", "Ctrl"],
|
||||
["AltLeft", "Alt"],
|
||||
["AltRight", "Alt"],
|
||||
["MetaLeft", "⌘"],
|
||||
["MetaRight", "⌘"],
|
||||
["CapsLock", "⇪"],
|
||||
["Escape", "Esc"],
|
||||
["F1", "F1"],
|
||||
["F2", "F2"],
|
||||
["F3", "F3"],
|
||||
["F4", "F4"],
|
||||
["F5", "F5"],
|
||||
["F6", "F6"],
|
||||
["F7", "F7"],
|
||||
["F8", "F8"],
|
||||
["F9", "F9"],
|
||||
["F10", "F10"],
|
||||
["F11", "F11"],
|
||||
["F12", "F12"],
|
||||
["PrintScreen", "PrtSc"],
|
||||
["Pause", "Pause"],
|
||||
["Insert", "Ins"],
|
||||
["Home", "Home"],
|
||||
["End", "End"],
|
||||
["PageUp", "PgUp"],
|
||||
["PageDown", "PgDn"],
|
||||
["ContextMenu", "Menu"],
|
||||
]);
|
||||
287
src/lib/charrecorder/renderer/renderer.ts
Normal file
287
src/lib/charrecorder/renderer/renderer.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import type { TextToken } from "../core/types";
|
||||
import { KBD_ICONS } from "./kbd-icon";
|
||||
|
||||
export class TextRenderer {
|
||||
shinyChords = true;
|
||||
|
||||
shiny: number[] | undefined;
|
||||
|
||||
readonly cursorNode: SVGRectElement;
|
||||
|
||||
private readonly nodes = new Map<TextToken, SVGTextElement>();
|
||||
|
||||
private readonly heldNodes = new Map<string, SVGTextElement>();
|
||||
|
||||
private readonly occupiedHeld: Array<boolean | undefined> = [];
|
||||
|
||||
private readonly occupied: number[] = [];
|
||||
|
||||
animationOptions: KeyframeAnimationOptions = {
|
||||
duration: 100,
|
||||
easing: "ease",
|
||||
};
|
||||
|
||||
heldKeySize = 0.8;
|
||||
|
||||
ghostText = "";
|
||||
|
||||
constructor(
|
||||
readonly node: HTMLElement,
|
||||
readonly svg: SVGSVGElement,
|
||||
readonly textNode: Text,
|
||||
) {
|
||||
this.cursorNode = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"rect",
|
||||
);
|
||||
this.cursorNode.setAttribute("x", "0");
|
||||
this.cursorNode.setAttribute("y", "0");
|
||||
this.svg.appendChild(this.cursorNode);
|
||||
}
|
||||
|
||||
set showCursor(value: boolean) {
|
||||
this.cursorNode.style.visibility = value ? "visible" : "hidden";
|
||||
}
|
||||
|
||||
getAtRange(i: number): [number, number] {
|
||||
const range = document.createRange();
|
||||
const rangeIndex = Math.max(0, Math.min(i, this.textNode.length - 1));
|
||||
range.setStart(this.textNode, rangeIndex);
|
||||
range.setEnd(
|
||||
this.textNode,
|
||||
this.textNode.length === 0 ? 0 : rangeIndex + 1,
|
||||
);
|
||||
const charBounds = range.getBoundingClientRect();
|
||||
return [
|
||||
i > this.textNode.length - 1
|
||||
? charBounds.x + charBounds.width
|
||||
: charBounds.x,
|
||||
charBounds.y + charBounds.height / 2 + 1,
|
||||
];
|
||||
}
|
||||
|
||||
set held(keys: Map<string, boolean>) {
|
||||
const prev = new Set(this.heldNodes.keys());
|
||||
const fontSize = getComputedStyle(this.node).fontSize;
|
||||
|
||||
for (const [code, isHuman] of keys) {
|
||||
if (!isHuman) continue;
|
||||
prev.delete(code);
|
||||
let node = this.heldNodes.get(code);
|
||||
if (!node) {
|
||||
let i = this.occupiedHeld.findIndex((it) => it === undefined);
|
||||
if (i === -1) {
|
||||
i = this.occupiedHeld.length;
|
||||
this.occupiedHeld.push(true);
|
||||
} else {
|
||||
this.occupiedHeld[i] = true;
|
||||
}
|
||||
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
node.textContent = KBD_ICONS.get(code) ?? null;
|
||||
node.setAttribute("i", i.toString());
|
||||
this.heldNodes.set(code, node);
|
||||
node.style.transform = `${this.cursorNode.style.transform} translateY(calc(${fontSize} * ${
|
||||
i + 1.5
|
||||
}))`;
|
||||
node.style.fontSize = `calc(${fontSize} * ${this.heldKeySize})`;
|
||||
this.svg.appendChild(node);
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{
|
||||
transform: `translateY(calc(-${fontSize} * ${this.heldNodes.size})) scale(0)`,
|
||||
},
|
||||
{ transform: "translateY(0px) scale(1)" },
|
||||
],
|
||||
{ duration: 200, composite: "add", easing: "ease-out" },
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}
|
||||
|
||||
for (const code of prev) {
|
||||
const node = this.heldNodes.get(code);
|
||||
if (!node) continue;
|
||||
this.heldNodes.delete(code);
|
||||
|
||||
this.occupiedHeld[Number(node.getAttribute("i"))] = undefined;
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ transform: "translateX(0px)" },
|
||||
{ transform: "translateX(-10px)" },
|
||||
],
|
||||
{
|
||||
duration: 500,
|
||||
composite: "accumulate",
|
||||
easing: "ease-in",
|
||||
},
|
||||
)
|
||||
.play();
|
||||
const animation = node.animate([{ opacity: 1 }, { opacity: 0 }], {
|
||||
duration: 500,
|
||||
easing: "ease-in",
|
||||
});
|
||||
animation.onfinish = () => {
|
||||
node.remove();
|
||||
};
|
||||
animation.play();
|
||||
}
|
||||
}
|
||||
|
||||
get animated(): boolean {
|
||||
return this.cursorNode.classList.contains("animated");
|
||||
}
|
||||
|
||||
set animated(value: boolean) {
|
||||
if (value) {
|
||||
this.cursorNode.classList.add("animated");
|
||||
} else {
|
||||
this.cursorNode.classList.remove("animated");
|
||||
}
|
||||
}
|
||||
|
||||
set cursor(cursor: number) {
|
||||
const bounds = this.node.getBoundingClientRect();
|
||||
const style = getComputedStyle(this.node);
|
||||
|
||||
const pos = this.getAtRange(cursor);
|
||||
const x = pos[0] - bounds.x;
|
||||
const y = pos[1] - bounds.y;
|
||||
|
||||
this.cursorNode.setAttribute("height", style.fontSize);
|
||||
this.cursorNode.setAttribute("width", "1");
|
||||
|
||||
this.cursorNode.style.transform = `translate(${x}px, calc(${y}px - ${style.fontSize} / 2))`;
|
||||
}
|
||||
|
||||
set text(text: TextToken[]) {
|
||||
const prev = new Set(this.nodes.keys());
|
||||
|
||||
const bounds = this.node.getBoundingClientRect();
|
||||
|
||||
this.svg.setAttribute("width", bounds.width.toFixed(2));
|
||||
this.svg.setAttribute("height", bounds.height.toFixed(2));
|
||||
this.svg.setAttribute(
|
||||
"viewBox",
|
||||
`0 0 ${bounds.width.toFixed(2)} ${bounds.height.toFixed(2)}`,
|
||||
);
|
||||
|
||||
text.forEach((token, i) => {
|
||||
prev.delete(token);
|
||||
let node = this.nodes.get(token);
|
||||
|
||||
const pos = this.getAtRange(i);
|
||||
const x = pos[0] - bounds.x;
|
||||
const y = pos[1] - bounds.y;
|
||||
const xStr = x.toFixed(2);
|
||||
const yStr = y.toFixed(2);
|
||||
|
||||
if (!node) {
|
||||
node = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||
this.nodes.set(token, node);
|
||||
this.svg.appendChild(node);
|
||||
node.setAttribute("x", xStr);
|
||||
node.setAttribute("y", yStr);
|
||||
node.setAttribute("i", i.toString());
|
||||
if (token.source === "ghost") {
|
||||
node.setAttribute("opacity", "0.5");
|
||||
}
|
||||
this.occupied[i] ??= 0;
|
||||
if (this.animated) {
|
||||
if (this.occupied[i] > 0) {
|
||||
node
|
||||
.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
...this.animationOptions,
|
||||
easing: "ease-out",
|
||||
})
|
||||
.play();
|
||||
} else {
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ opacity: 0, transform: "translateY(10px)" },
|
||||
{ opacity: 1, transform: "translateY(0px)" },
|
||||
],
|
||||
{ ...this.animationOptions, easing: "ease-out" },
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}
|
||||
this.occupied[i]++;
|
||||
}
|
||||
|
||||
if (!token.correct) {
|
||||
node.setAttribute("incorrect", "");
|
||||
} else {
|
||||
node.removeAttribute("incorrect");
|
||||
}
|
||||
|
||||
const prevX = node.getAttribute("x");
|
||||
if (prevX && prevX !== xStr) {
|
||||
const prev = parseFloat(prevX);
|
||||
node.setAttribute("x", xStr);
|
||||
/*if (this.animated) {
|
||||
node.animate(
|
||||
[{ transform: `translateX(${prev - x}px)` }, { transform: `translateX(0px)` }],
|
||||
this.animationOptions
|
||||
);
|
||||
}*/
|
||||
}
|
||||
const prevY = node.getAttribute("y");
|
||||
if (prevY && prevY !== yStr) {
|
||||
const prev = parseFloat(prevY);
|
||||
node.setAttribute("y", yStr);
|
||||
/*if (this.animated) {
|
||||
node.animate(
|
||||
[{ transform: `translateY(${prev - y}px)` }, { transform: `translateY(0px)` }],
|
||||
this.animationOptions
|
||||
);
|
||||
}*/
|
||||
}
|
||||
if (node.textContent !== token.text) {
|
||||
node.textContent = token.text;
|
||||
}
|
||||
});
|
||||
|
||||
for (const token of prev) {
|
||||
const node = this.nodes.get(token)!;
|
||||
const i = parseInt(node.getAttribute("i")!);
|
||||
this.nodes.delete(token);
|
||||
if (this.animated) {
|
||||
const animation = node.animate(
|
||||
[{ opacity: 1 }, { opacity: 0 }],
|
||||
this.animationOptions,
|
||||
);
|
||||
setTimeout(() => {
|
||||
if (this.occupied[i] === 1) {
|
||||
node
|
||||
.animate(
|
||||
[
|
||||
{ transform: "translateY(0px)" },
|
||||
{ transform: "translateY(10px)" },
|
||||
],
|
||||
this.animationOptions,
|
||||
)
|
||||
.play();
|
||||
}
|
||||
}, 10);
|
||||
animation.onfinish = () => {
|
||||
node.remove();
|
||||
this.occupied[i]!--;
|
||||
};
|
||||
animation.play();
|
||||
} else {
|
||||
node.remove();
|
||||
this.occupied[i]!--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private isShiny(char: TextToken, index: number) {
|
||||
return (
|
||||
this.shiny?.includes(index) ||
|
||||
(this.shinyChords && char.source === "robot")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,47 +3,53 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { action as title } from "$lib/title";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
|
||||
export let action: number | KeyInfo;
|
||||
export let display: "inline-keys" | "keys" = "inline-keys";
|
||||
let {
|
||||
action,
|
||||
display,
|
||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" } = $props();
|
||||
|
||||
$: info =
|
||||
let info = $derived(
|
||||
typeof action === "number"
|
||||
? KEYMAP_CODES.get(action) ?? { code: action }
|
||||
: action;
|
||||
$: dynamicMapping = info.keyCode && $osLayout.get(info.keyCode);
|
||||
? (KEYMAP_CODES.get(action) ?? { code: action })
|
||||
: action,
|
||||
);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
|
||||
$: tooltip =
|
||||
let tooltip = $derived(
|
||||
`<${info.id ?? `0x${info.code.toString(16)}`}> ` +
|
||||
(info.title ?? "") +
|
||||
(info.variant === "left"
|
||||
? " (left)"
|
||||
: info.variant === "right"
|
||||
? " (right)"
|
||||
: "");
|
||||
(info.title ?? "") +
|
||||
(info.variant === "left"
|
||||
? " (left)"
|
||||
: info.variant === "right"
|
||||
? " (right)"
|
||||
: ""),
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if dynamicMapping}
|
||||
<span
|
||||
use:title={{ title: $LL.actionSearch.LIVE_LAYOUT_INFO() }}
|
||||
class="dynamic"
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:inline={display === "inline-keys"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if display === "keys"}
|
||||
{#if display === "keys"}
|
||||
<kbd
|
||||
class:icon={!!info.icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
use:title={{ title: tooltip }}
|
||||
>
|
||||
{info.icon ?? info.display ?? info.id ?? `0x${info.code.toString(16)}`}
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}
|
||||
</kbd>
|
||||
{:else if display === "inline-keys"}
|
||||
{#if !info.icon && info.id?.length === 1}
|
||||
{#if !info.icon && dynamicMapping?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
<span
|
||||
use:title={{ title: tooltip }}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{info.id}</span
|
||||
>
|
||||
@@ -55,7 +61,8 @@
|
||||
class:icon={!!info.icon}
|
||||
use:title={{ title: tooltip }}
|
||||
>
|
||||
{info.icon ??
|
||||
{dynamicMapping ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}</kbd
|
||||
|
||||
@@ -3,15 +3,22 @@
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { MouseEventHandler } from "svelte/elements";
|
||||
|
||||
export let id: number | KeyInfo;
|
||||
let {
|
||||
id,
|
||||
onclick,
|
||||
}: { id: number | KeyInfo; onclick?: MouseEventHandler<HTMLButtonElement> } =
|
||||
$props();
|
||||
|
||||
$: key = (typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
||||
| number
|
||||
| KeyInfo;
|
||||
let key = $derived(
|
||||
(typeof id === "number" ? KEYMAP_CODES.get(id) ?? id : id) as
|
||||
| number
|
||||
| KeyInfo,
|
||||
);
|
||||
</script>
|
||||
|
||||
<button on:click>
|
||||
<button {onclick}>
|
||||
{#if typeof key === "object"}
|
||||
<div class="title">
|
||||
<b>
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
export let actions: Array<number | KeyInfo>;
|
||||
export let display: "keys" | "inline-keys" = "inline-keys";
|
||||
let {
|
||||
actions,
|
||||
display = "inline-keys",
|
||||
}: { actions: Array<number | KeyInfo>; display?: "keys" | "inline-keys" } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
{#each actions as action, i (`${typeof action === "number" ? action : action.code}:${i}`)}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</script>
|
||||
|
||||
{#if $needRefresh}
|
||||
<button title="Update ready" on:click={() => updateServiceWorker(true)}
|
||||
<button title="Update ready" onclick={() => updateServiceWorker(true)}
|
||||
>Update <span class="icon">update</span></button
|
||||
>
|
||||
{:else if $offlineReady}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
io.scrollTo({ top: io.scrollHeight });
|
||||
}
|
||||
|
||||
let value: string;
|
||||
let value: string = $state("");
|
||||
let io: HTMLDivElement;
|
||||
</script>
|
||||
|
||||
<form on:submit={submit}>
|
||||
<form onsubmit={submit}>
|
||||
<div bind:this={io} class="io">
|
||||
{#each $serialLog as { type, value }}
|
||||
{#if type === "input"}
|
||||
@@ -24,10 +24,10 @@
|
||||
<p transition:slide>{value}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="anchor" />
|
||||
<div class="anchor"></div>
|
||||
</div>
|
||||
<fieldset>
|
||||
<input on:submit={submit} bind:value />
|
||||
<input onsubmit={submit} bind:value />
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
export let title: string | undefined;
|
||||
export let shortcut: string | undefined;
|
||||
let { title, shortcut }: { title?: string; shortcut?: string } = $props();
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
|
||||
@@ -5,13 +5,22 @@
|
||||
KEYMAP_IDS,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import { createEventDispatcher, onMount } from "svelte";
|
||||
import { onMount } from "svelte";
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
|
||||
export let currentAction: number | undefined = undefined;
|
||||
export let nextAction: number | undefined = undefined;
|
||||
let {
|
||||
currentAction = undefined,
|
||||
nextAction = undefined,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
currentAction?: number;
|
||||
nextAction?: number;
|
||||
onselect: (id: number) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
onMount(() => {
|
||||
searchBox.focus();
|
||||
@@ -39,13 +48,13 @@
|
||||
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
dispatch("select", id);
|
||||
onselect(id);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter") {
|
||||
dispatch("select", exact);
|
||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||
onselect(exact);
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.nextSibling ??
|
||||
@@ -67,40 +76,45 @@
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
let results: number[] = [];
|
||||
let exact: number | undefined = undefined;
|
||||
let code: number = Number.NaN;
|
||||
let results: number[] = $state([]);
|
||||
let exact: number | undefined = $state(undefined);
|
||||
let code: number = $state(Number.NaN);
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
let filter: Set<number>;
|
||||
let filter = $state(new Set<number>());
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={keyboardNavigation} />
|
||||
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-noninteractive-element-interactions -->
|
||||
<dialog open on:click|self={() => dispatch("close")}>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<dialog
|
||||
open
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchBox}
|
||||
on:input={search}
|
||||
on:keypress={(event) => {
|
||||
oninput={search}
|
||||
onkeypress={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
select(exact);
|
||||
}
|
||||
}}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
<button on:click={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||
>{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||
class="icon"
|
||||
on:click={() => dispatch("close")}>close</button
|
||||
onclick={onclose}>close</button
|
||||
>
|
||||
</div>
|
||||
<fieldset class="filters">
|
||||
@@ -140,12 +154,12 @@
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} on:click={() => select(exact)} />
|
||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
@@ -156,7 +170,7 @@
|
||||
? Array.from(KEYMAP_CODES, ([it]) => it)
|
||||
: results}
|
||||
{#each filter ? resultValue.filter( (it) => filter.has(it), ) : resultValue as id (id)}
|
||||
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
||||
<li><ActionListItem {id} onclick={() => select(id)} /></li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import { get } from "svelte/store";
|
||||
import type { Writable } from "svelte/store";
|
||||
import KeyboardKey from "$lib/components/layout/KeyboardKey.svelte";
|
||||
import { getContext } from "svelte";
|
||||
import { getContext, mount, unmount } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import { changes, ChangeType, layout } from "$lib/undo-redo";
|
||||
import { fly } from "svelte/transition";
|
||||
@@ -30,8 +30,8 @@
|
||||
console.assert(iconFontSize % 1 === 0, "Icon font size must be an integer");
|
||||
}
|
||||
|
||||
export let visualLayout: VisualLayout;
|
||||
$: layoutInfo = compileLayout(visualLayout);
|
||||
let { visualLayout }: { visualLayout: VisualLayout } = $props();
|
||||
let layoutInfo = $state(compileLayout(visualLayout));
|
||||
|
||||
function getCenter(key: CompiledLayoutKey): [x: number, y: number] {
|
||||
return [key.pos[0] + key.size[0] / 2, key.pos[1] + key.size[1] / 2];
|
||||
@@ -127,11 +127,26 @@
|
||||
const clickedGroup = groupParent.children.item(index) as SVGGElement;
|
||||
const nextAction = get(layout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const currentAction = get(deviceLayout)[get(activeLayer)]?.[keyInfo.id];
|
||||
const component = new ActionSelector({
|
||||
const component = mount(ActionSelector, {
|
||||
target: document.body,
|
||||
props: {
|
||||
currentAction,
|
||||
nextAction: nextAction?.isApplied ? undefined : nextAction?.action,
|
||||
onclose() {
|
||||
closed();
|
||||
},
|
||||
onselect(action) {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
action,
|
||||
});
|
||||
return changes;
|
||||
});
|
||||
closed();
|
||||
},
|
||||
},
|
||||
});
|
||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||
@@ -167,22 +182,8 @@
|
||||
|
||||
await dialogAnimation.finished;
|
||||
|
||||
component.$destroy();
|
||||
unmount(component);
|
||||
}
|
||||
|
||||
component.$on("close", closed);
|
||||
component.$on("select", ({ detail }) => {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Layout,
|
||||
id: keyInfo.id,
|
||||
layer: get(activeLayer),
|
||||
action: detail,
|
||||
});
|
||||
return changes;
|
||||
});
|
||||
closed();
|
||||
});
|
||||
}
|
||||
|
||||
let focusKey: CompiledLayoutKey;
|
||||
@@ -201,9 +202,9 @@
|
||||
<KeyboardKey
|
||||
{i}
|
||||
{key}
|
||||
on:focusin={() => (focusKey = key)}
|
||||
on:click={() => edit(i)}
|
||||
on:keypress={({ key }) => {
|
||||
onfocusin={() => (focusKey = key)}
|
||||
onclick={() => edit(i)}
|
||||
onkeypress={({ key }) => {
|
||||
if (key === "Enter") {
|
||||
edit(i);
|
||||
}
|
||||
|
||||
@@ -12,14 +12,21 @@
|
||||
getContext<VisualLayoutConfig>("visual-layout-config");
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
export let key: CompiledLayoutKey;
|
||||
export let fontSizeMultiplier = 1;
|
||||
|
||||
export let middle: [number, number];
|
||||
export let pos: [number, number];
|
||||
export let rotate: number;
|
||||
|
||||
export let positions: [[number, number], [number, number], [number, number]];
|
||||
let {
|
||||
key,
|
||||
fontSizeMultiplier = 1,
|
||||
middle,
|
||||
pos,
|
||||
rotate,
|
||||
positions,
|
||||
}: {
|
||||
key: CompiledLayoutKey;
|
||||
fontSizeMultiplier?: number;
|
||||
middle: [number, number];
|
||||
pos: [number, number];
|
||||
rotate: number;
|
||||
positions: [[number, number], [number, number], [number, number]];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#each positions as position, layer}
|
||||
|
||||
@@ -3,24 +3,41 @@
|
||||
import { getContext } from "svelte";
|
||||
import type { VisualLayoutConfig } from "./visual-layout.js";
|
||||
import KeyText from "$lib/components/layout/KeyText.svelte";
|
||||
import type {
|
||||
FocusEventHandler,
|
||||
KeyboardEventHandler,
|
||||
MouseEventHandler,
|
||||
} from "svelte/elements";
|
||||
|
||||
const { scale, margin, strokeWidth } = getContext<VisualLayoutConfig>(
|
||||
"visual-layout-config",
|
||||
);
|
||||
export let i: number;
|
||||
export let key: CompiledLayoutKey;
|
||||
|
||||
$: posX = key.pos[0] * scale;
|
||||
$: posY = key.pos[1] * scale;
|
||||
$: sizeX = key.size[0] * scale;
|
||||
$: sizeY = key.size[1] * scale;
|
||||
let {
|
||||
i,
|
||||
key,
|
||||
onclick,
|
||||
onkeypress,
|
||||
onfocusin,
|
||||
}: {
|
||||
i: number;
|
||||
key: CompiledLayoutKey;
|
||||
onclick: MouseEventHandler<SVGGElement>;
|
||||
onkeypress: KeyboardEventHandler<SVGGElement>;
|
||||
onfocusin: FocusEventHandler<SVGGElement>;
|
||||
} = $props();
|
||||
|
||||
let posX = $derived(key.pos[0] * scale);
|
||||
let posY = $derived(key.pos[1] * scale);
|
||||
let sizeX = $derived(key.size[0] * scale);
|
||||
let sizeY = $derived(key.size[1] * scale);
|
||||
</script>
|
||||
|
||||
<g
|
||||
class="key-group"
|
||||
on:click
|
||||
on:keypress
|
||||
on:focusin
|
||||
{onclick}
|
||||
{onkeypress}
|
||||
{onfocusin}
|
||||
role="button"
|
||||
tabindex={i + 1}
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import type { VisualLayout } from "$lib/serialization/visual-layout";
|
||||
import { fade } from "svelte/transition";
|
||||
|
||||
$: device = $serialPort?.device;
|
||||
let device = $derived($serialPort?.device);
|
||||
const activeLayer = getContext<Writable<number>>("active-layer");
|
||||
|
||||
const layers = [
|
||||
@@ -48,7 +48,7 @@
|
||||
<button
|
||||
class="icon"
|
||||
use:action={{ title, shortcut: `alt+${value + 1}` }}
|
||||
on:click={() => ($activeLayer = value)}
|
||||
onclick={() => ($activeLayer = value)}
|
||||
class:active={$activeLayer === value}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import Dialog from "$lib/dialogs/Dialog.svelte";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let message: string | undefined;
|
||||
export let abortTitle: string;
|
||||
export let confirmTitle: string;
|
||||
|
||||
export let actions: number[] = [];
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let {
|
||||
title,
|
||||
message,
|
||||
abortTitle,
|
||||
confirmTitle,
|
||||
actions = [],
|
||||
onabort,
|
||||
onconfirm,
|
||||
}: {
|
||||
title: string;
|
||||
message?: string;
|
||||
abortTitle: string;
|
||||
confirmTitle: string;
|
||||
actions: number[];
|
||||
onabort: () => void;
|
||||
onconfirm: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Dialog>
|
||||
@@ -20,10 +28,8 @@
|
||||
{/if}
|
||||
<p><ActionString {actions} /></p>
|
||||
<div class="buttons">
|
||||
<button on:click={() => dispatch("abort")}>{abortTitle}</button>
|
||||
<button class="primary" on:click={() => dispatch("confirm")}
|
||||
>{confirmTitle}</button
|
||||
>
|
||||
<button onclick={onabort}>{abortTitle}</button>
|
||||
<button class="primary" onclick={onconfirm}>{confirmTitle}</button>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { onMount, type Snippet } from "svelte";
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
onMount(() => {
|
||||
modal.showModal();
|
||||
@@ -9,7 +11,7 @@
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modal}>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import tippy from "tippy.js";
|
||||
import type { Action } from "svelte/action";
|
||||
import type { ComponentType, SvelteComponent } from "svelte";
|
||||
import { unmount, mount, type Component } from "svelte";
|
||||
|
||||
export const popup: Action<HTMLButtonElement, ComponentType> = (
|
||||
export const popup: Action<HTMLButtonElement, Component> = (
|
||||
node,
|
||||
Component,
|
||||
) => {
|
||||
let component: SvelteComponent | undefined;
|
||||
let component: {} | undefined;
|
||||
let target: HTMLElement | undefined;
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
@@ -14,12 +14,14 @@ export const popup: Action<HTMLButtonElement, ComponentType> = (
|
||||
onShow(instance) {
|
||||
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
|
||||
target.classList.add("active");
|
||||
component ??= new Component({ target });
|
||||
component ??= mount(Component, { target });
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy();
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = undefined;
|
||||
}
|
||||
target?.classList.remove("active");
|
||||
component = undefined;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let ports: SerialPort[];
|
||||
const dispatch = createEventDispatcher<{ confirm: SerialPort | undefined }>();
|
||||
let selected = ports[0]?.getInfo().name;
|
||||
let {
|
||||
ports,
|
||||
onconfirm,
|
||||
}: {
|
||||
ports: SerialPort[];
|
||||
onconfirm: (port: SerialPort | undefined) => void;
|
||||
} = $props();
|
||||
let selected = $state(ports[0]?.getInfo().name);
|
||||
</script>
|
||||
|
||||
<dialog>
|
||||
@@ -19,12 +22,9 @@
|
||||
>
|
||||
{/each}
|
||||
|
||||
<button on:click={() => dispatch("confirm", undefined)}>Cancel</button>
|
||||
<button onclick={() => onconfirm(undefined)}>Cancel</button>
|
||||
<button
|
||||
on:click={() =>
|
||||
dispatch(
|
||||
"confirm",
|
||||
ports.find((it) => it.getInfo().name === selected),
|
||||
)}>Ok</button
|
||||
onclick={() =>
|
||||
onconfirm(ports.find((it) => it.getInfo().name === selected))}>Ok</button
|
||||
>
|
||||
</dialog>
|
||||
|
||||
4
src/lib/style/_reset.scss
Normal file
4
src/lib/style/_reset.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
}
|
||||
6
src/lib/style/elements/_h1.scss
Normal file
6
src/lib/style/elements/_h1.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
h1 {
|
||||
margin-block-start: 0;
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
@@ -1,10 +1,35 @@
|
||||
@import "./reset";
|
||||
|
||||
@import "./form/button";
|
||||
@import "./form/toggle";
|
||||
@import "./form/checkbox";
|
||||
|
||||
@import "./kbd";
|
||||
@import "./print";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
appearance: none;
|
||||
@import "./elements/h1";
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
main {
|
||||
contain: strict;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Action } from "svelte/action";
|
||||
import tippy from "tippy.js";
|
||||
import type { SvelteComponent } from "svelte";
|
||||
import { mount, unmount, type SvelteComponent } from "svelte";
|
||||
import Tooltip from "$lib/components/Tooltip.svelte";
|
||||
|
||||
export const hotkeys = new Map<string, HTMLElement>();
|
||||
@@ -9,20 +9,22 @@ export const action: Action<Element, { title?: string; shortcut?: string }> = (
|
||||
node: Element,
|
||||
{ title, shortcut },
|
||||
) => {
|
||||
let component: SvelteComponent | undefined;
|
||||
let component: {} | undefined;
|
||||
const tooltip = tippy(node, {
|
||||
arrow: false,
|
||||
theme: "tooltip",
|
||||
animation: "fade",
|
||||
onShow(instance) {
|
||||
component ??= new Tooltip({
|
||||
component ??= mount(Tooltip, {
|
||||
target: instance.popper.querySelector(".tippy-content") as Element,
|
||||
props: { title, shortcut },
|
||||
});
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy();
|
||||
component = undefined;
|
||||
if (component) {
|
||||
unmount(component);
|
||||
component = undefined;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import "$lib/style/scrollbar.scss";
|
||||
import "$lib/style/tippy.scss";
|
||||
import "$lib/style/theme.scss";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { onDestroy, onMount, type Snippet } from "svelte";
|
||||
import {
|
||||
applyTheme,
|
||||
argbFromHex,
|
||||
@@ -49,7 +49,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
export let data: LayoutData;
|
||||
let { data, children }: { data: LayoutData; children: Snippet } = $props();
|
||||
|
||||
onMount(async () => {
|
||||
theme.subscribe((it) => {
|
||||
@@ -79,7 +79,7 @@
|
||||
stopLayoutDetection?.();
|
||||
});
|
||||
|
||||
let webManifestLink = "";
|
||||
let webManifestLink = $state("");
|
||||
|
||||
function handleHotkey(event: KeyboardEvent) {
|
||||
let key = $osLayout.get(event.code);
|
||||
@@ -121,7 +121,9 @@
|
||||
<!-- <PickChangesDialog /> -->
|
||||
|
||||
<PageTransition>
|
||||
<slot />
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</PageTransition>
|
||||
|
||||
<Footer />
|
||||
@@ -131,34 +133,4 @@
|
||||
{/if}
|
||||
|
||||
<style lang="scss" global>
|
||||
body {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
}
|
||||
|
||||
main {
|
||||
contain: strict;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
align-items: center;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-block-start: 0;
|
||||
font-size: 4rem;
|
||||
font-weight: 700;
|
||||
color: var(--md-sys-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
0
src/routes/(app)/+page.svelte
Normal file
0
src/routes/(app)/+page.svelte
Normal file
@@ -1,6 +0,0 @@
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load = (() => {
|
||||
redirect(302, "/config/");
|
||||
}) satisfies PageLoad;
|
||||
@@ -25,25 +25,25 @@
|
||||
</p>
|
||||
<fieldset>
|
||||
<legend>{$LL.backup.INDIVIDUAL()}</legend>
|
||||
<button on:click={() => downloadFile(createChordBackup())}>
|
||||
<button onclick={() => downloadFile(createChordBackup())}>
|
||||
<span class="icon">piano</span>
|
||||
{$LL.configure.chords.TITLE()}
|
||||
</button>
|
||||
<button on:click={() => downloadFile(createLayoutBackup())}>
|
||||
<button onclick={() => downloadFile(createLayoutBackup())}>
|
||||
<span class="icon">keyboard</span>
|
||||
{$LL.configure.layout.TITLE()}
|
||||
</button>
|
||||
<button on:click={() => downloadFile(createSettingsBackup())}>
|
||||
<button onclick={() => downloadFile(createSettingsBackup())}>
|
||||
<span class="icon">settings</span>
|
||||
{$LL.configure.settings.TITLE()}
|
||||
</button>
|
||||
</fieldset>
|
||||
<div class="save">
|
||||
<button class="primary" on:click={downloadBackup}
|
||||
<button class="primary" onclick={downloadBackup}
|
||||
><span class="icon">download</span>{$LL.backup.DOWNLOAD()}</button
|
||||
>
|
||||
<label class="button"
|
||||
><input on:input={restoreBackup} type="file" /><span class="icon"
|
||||
><input oninput={restoreBackup} type="file" /><span class="icon"
|
||||
>settings_backup_restore</span
|
||||
>{$LL.backup.RESTORE()}</label
|
||||
>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
$: paths = [
|
||||
let { children }: { children?: Snippet } = $props();
|
||||
|
||||
let paths = $derived([
|
||||
{
|
||||
href: "/config/chords/",
|
||||
title: $LL.configure.chords.TITLE(),
|
||||
@@ -18,7 +21,7 @@
|
||||
title: $LL.configure.settings.TITLE(),
|
||||
icon: "settings",
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
@@ -30,7 +33,9 @@
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<slot />
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
|
||||
@@ -34,13 +34,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
let rebootInfo = false;
|
||||
let terminal = false;
|
||||
let powerDialog = false;
|
||||
|
||||
$: if ($serialPort) {
|
||||
rebootInfo = false;
|
||||
}
|
||||
let rebootInfo = $derived($serialPort !== undefined);
|
||||
let terminal = $state(false);
|
||||
let powerDialog = $state(false);
|
||||
</script>
|
||||
|
||||
<section>
|
||||
@@ -117,7 +113,7 @@
|
||||
{#if $serialPort}
|
||||
<button
|
||||
class="secondary"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
$serialPort?.forget();
|
||||
$serialPort = undefined;
|
||||
}}
|
||||
@@ -125,7 +121,7 @@
|
||||
>{$LL.deviceManager.DISCONNECT()}</button
|
||||
>
|
||||
{:else}
|
||||
<button class="error" on:click={connect}
|
||||
<button class="error" onclick={connect}
|
||||
><span class="icon">usb</span>{$LL.deviceManager.CONNECT()}</button
|
||||
>
|
||||
{/if}
|
||||
@@ -135,13 +131,13 @@
|
||||
title={$LL.deviceManager.TERMINAL()}
|
||||
class="icon"
|
||||
class:disabled={$serialPort === undefined}
|
||||
on:click={() => (terminal = !terminal)}>terminal</a
|
||||
onclick={() => (terminal = !terminal)}>terminal</a
|
||||
>
|
||||
<button
|
||||
class="icon"
|
||||
title={$LL.deviceManager.bootMenu.TITLE()}
|
||||
disabled={$serialPort === undefined}
|
||||
on:click={() => (powerDialog = !powerDialog)}>settings_power</button
|
||||
onclick={() => (powerDialog = !powerDialog)}>settings_power</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -151,18 +147,18 @@
|
||||
role="button"
|
||||
tabindex="-1"
|
||||
transition:fade={{ duration: 250 }}
|
||||
on:click={() => (powerDialog = !powerDialog)}
|
||||
on:keypress={(event) => {
|
||||
onclick={() => (powerDialog = !powerDialog)}
|
||||
onkeypress={(event) => {
|
||||
if (event.key === "Enter") powerDialog = !powerDialog;
|
||||
}}
|
||||
/>
|
||||
></div>
|
||||
<dialog open transition:slide={{ duration: 250 }}>
|
||||
<h3>{$LL.deviceManager.bootMenu.TITLE()}</h3>
|
||||
<button on:click={reboot}
|
||||
<button onclick={reboot}
|
||||
><span class="icon">restart_alt</span
|
||||
>{$LL.deviceManager.bootMenu.REBOOT()}</button
|
||||
>
|
||||
<button on:click={bootloader}
|
||||
<button onclick={bootloader}
|
||||
><span class="icon">rule_settings</span
|
||||
>{$LL.deviceManager.bootMenu.BOOTLOADER()}</button
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
if (event.shiftKey) {
|
||||
changes.set([]);
|
||||
} else {
|
||||
redoQueue = [$changes.pop()!, ...redoQueue];
|
||||
redoQueue.unshift($changes.pop()!);
|
||||
changes.update((it) => it);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
let redoQueue: Change[] = [];
|
||||
let redoQueue: Change[] = $state([]);
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
@@ -138,19 +138,19 @@
|
||||
use:action={{ title: $LL.saveActions.UNDO(), shortcut: "ctrl+z" }}
|
||||
class="icon"
|
||||
disabled={$changes.length === 0}
|
||||
on:click={undo}>undo</button
|
||||
onclick={undo}>undo</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.saveActions.REDO(), shortcut: "ctrl+y" }}
|
||||
class="icon"
|
||||
disabled={redoQueue.length === 0}
|
||||
on:click={redo}>redo</button
|
||||
onclick={redo}>redo</button
|
||||
>
|
||||
{#if $changes.length !== 0}
|
||||
<button
|
||||
transition:fly={{ x: 10 }}
|
||||
use:action={{ title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s" }}
|
||||
on:click={save}
|
||||
onclick={save}
|
||||
class="click-me"
|
||||
><span class="icon">save</span>{$LL.saveActions.SAVE()}</button
|
||||
>
|
||||
|
||||
@@ -10,14 +10,16 @@
|
||||
import SyncOverlay from "./SyncOverlay.svelte";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
|
||||
let locale =
|
||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale();
|
||||
$: if (browser)
|
||||
(async () => {
|
||||
localStorage.setItem("locale", locale);
|
||||
await loadLocaleAsync(locale);
|
||||
let locale = $state(
|
||||
(browser && (localStorage.getItem("locale") as Locales)) || detectLocale(),
|
||||
);
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
localStorage.setItem("locale", locale);
|
||||
loadLocaleAsync(locale).then(() => {
|
||||
setLocale(locale);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
function switchTheme() {
|
||||
const mode = $theme.mode === "light" ? "dark" : "light";
|
||||
@@ -37,7 +39,6 @@
|
||||
<footer>
|
||||
<ul>
|
||||
<li>
|
||||
<!-- svelte-ignore not-defined -->
|
||||
<a
|
||||
href={import.meta.env.VITE_HOMEPAGE_URL}
|
||||
rel="noreferrer"
|
||||
@@ -86,7 +87,7 @@
|
||||
<button
|
||||
use:action={{ title: $LL.profile.theme.DARK_MODE() }}
|
||||
class="icon"
|
||||
on:click={switchTheme}
|
||||
onclick={switchTheme}
|
||||
>
|
||||
dark_mode
|
||||
</button>
|
||||
@@ -94,25 +95,27 @@
|
||||
<button
|
||||
use:action={{ title: $LL.profile.theme.LIGHT_MODE() }}
|
||||
class="icon"
|
||||
on:click={switchTheme}
|
||||
onclick={switchTheme}
|
||||
>
|
||||
light_mode
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
class="icon"
|
||||
use:action={{ title: $LL.profile.LANGUAGE() }}
|
||||
on:click={() => languageSelect.click()}
|
||||
>translate
|
||||
onclick={() => languageSelect.click()}
|
||||
>
|
||||
translate
|
||||
|
||||
<select bind:value={locale} bind:this={languageSelect}>
|
||||
{#each locales as code}
|
||||
<option value={code}>{code}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
|
||||
@@ -35,15 +35,15 @@
|
||||
use:action={{ title: $LL.share.TITLE() }}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon"
|
||||
on:click={triggerShare}>share</button
|
||||
onclick={triggerShare}>share</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.print.TITLE() }}
|
||||
transition:fly={{ x: -8 }}
|
||||
class="icon"
|
||||
on:click={() => print()}>print</button
|
||||
onclick={() => print()}>print</button
|
||||
>
|
||||
<div transition:slide class="separator" />
|
||||
<div transition:slide class="separator"></div>
|
||||
{/if}
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined}
|
||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
import { fly } from "svelte/transition";
|
||||
import { afterNavigate, beforeNavigate } from "$app/navigation";
|
||||
import { expoIn, expoOut } from "svelte/easing";
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let inDirection = 0;
|
||||
let outDirection = 0;
|
||||
let outroEnd: undefined | (() => void) = undefined;
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
let inDirection = $state(0);
|
||||
let outDirection = $state(0);
|
||||
let outroEnd: undefined | (() => void) = $state(undefined);
|
||||
let animationDone: Promise<void>;
|
||||
|
||||
let isNavigating = false;
|
||||
let isNavigating = $state(false);
|
||||
|
||||
const routeOrder = [
|
||||
"/config/chords/",
|
||||
@@ -48,8 +51,8 @@
|
||||
<main
|
||||
in:fly={{ x: inDirection * 24, duration: 150, easing: expoOut }}
|
||||
out:fly={{ x: outDirection * 24, duration: 150, easing: expoIn }}
|
||||
on:outroend={outroEnd}
|
||||
onoutroend={outroEnd}
|
||||
>
|
||||
<slot />
|
||||
{@render children()}
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{:else if $serialPort}
|
||||
<button transition:slide on:click={sync}
|
||||
<button transition:slide onclick={sync}
|
||||
><span class="icon">refresh</span>{$LL.sync.RELOAD()}</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
let resizeObserver: ResizeObserver;
|
||||
|
||||
let abortIndexing: (() => void) | undefined;
|
||||
let progress = 0;
|
||||
let progress = $state(0);
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
@@ -37,11 +37,11 @@
|
||||
|
||||
let index = new FlexSearch.Index();
|
||||
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
|
||||
$: {
|
||||
$effect(() => {
|
||||
abortIndexing?.();
|
||||
progress = 0;
|
||||
buildIndex($chords, $osLayout).then(searchIndex.set);
|
||||
}
|
||||
});
|
||||
|
||||
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
|
||||
const plainPhrase: string[] = [""];
|
||||
@@ -210,7 +210,7 @@
|
||||
|
||||
setContext("cursor-crossfade", crossfade({}));
|
||||
|
||||
let page = 0;
|
||||
let page = $state(0);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -222,7 +222,7 @@
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
|
||||
on:input={(event) => $searchIndex && search($searchIndex, event)}
|
||||
oninput={(event) => $searchIndex && search($searchIndex, event)}
|
||||
class:loading={progress !== $chords.length - 1}
|
||||
/>
|
||||
<div class="paginator">
|
||||
@@ -234,12 +234,12 @@
|
||||
</div>
|
||||
<button
|
||||
class="icon"
|
||||
on:click={() => (page = Math.max(page - 1, 0))}
|
||||
onclick={() => (page = Math.max(page - 1, 0))}
|
||||
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
|
||||
>
|
||||
<button
|
||||
class="icon"
|
||||
on:click={() => (page = Math.min(page + 1, $lastPage))}
|
||||
onclick={() => (page = Math.min(page + 1, $lastPage))}
|
||||
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
|
||||
>
|
||||
</div>
|
||||
@@ -250,22 +250,24 @@
|
||||
<div class="results">
|
||||
<table transition:fly={{ y: 48, easing: expoOut }}>
|
||||
{#if $lastPage !== -1}
|
||||
{#if page === 0}
|
||||
<tr
|
||||
><th class="new-chord"
|
||||
><ChordActionEdit
|
||||
on:submit={({ detail }) => insertChord(detail)}
|
||||
/></th
|
||||
><td /><td /></tr
|
||||
>
|
||||
{/if}
|
||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
||||
{#if chord}
|
||||
<tr>
|
||||
<ChordEdit {chord} on:duplicate={() => (page = 0)} />
|
||||
</tr>
|
||||
<tbody>
|
||||
{#if page === 0}
|
||||
<tr
|
||||
><th class="new-chord"
|
||||
><ChordActionEdit
|
||||
onsubmit={(action) => insertChord(action)}
|
||||
/></th
|
||||
><td></td><td></td></tr
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
|
||||
{#if chord}
|
||||
<tr>
|
||||
<ChordEdit {chord} onduplicate={() => (page = 0)} />
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}</tbody
|
||||
>
|
||||
{:else}
|
||||
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
|
||||
{/if}
|
||||
@@ -277,7 +279,7 @@
|
||||
"\n\nDid you know? " +
|
||||
randomTips[Math.floor(randomTips.length * Math.random())]}
|
||||
></textarea>
|
||||
<button on:click={downloadVocabulary}
|
||||
<button onclick={downloadVocabulary}
|
||||
><span class="icon">download</span>
|
||||
{$LL.configure.chords.VOCABULARY()}</button
|
||||
>
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { ChordInfo } from "$lib/undo-redo";
|
||||
import { SvelteSet } from "svelte/reactivity";
|
||||
import { changes, chordHashes, ChangeType } from "$lib/undo-redo";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import ActionString from "$lib/components/ActionString.svelte";
|
||||
import { selectAction } from "./action-selector";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { get } from "svelte/store";
|
||||
import { inputToAction } from "./input-converter";
|
||||
import { hashChord } from "$lib/serial/chord";
|
||||
import { hashChord, type Chord } from "$lib/serial/chord";
|
||||
|
||||
export let chord: ChordInfo | undefined = undefined;
|
||||
let {
|
||||
chord = undefined,
|
||||
onsubmit,
|
||||
}: { chord?: ChordInfo; onsubmit: (actions: number[]) => void } = $props();
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let pressedKeys = new Set<number>();
|
||||
let editing = false;
|
||||
let pressedKeys = new SvelteSet<number>();
|
||||
let editing = $state(false);
|
||||
|
||||
function compare(a: number, b: number) {
|
||||
return a - b;
|
||||
@@ -37,7 +38,7 @@
|
||||
}
|
||||
|
||||
function edit() {
|
||||
pressedKeys = new Set();
|
||||
pressedKeys.clear();
|
||||
editing = true;
|
||||
}
|
||||
|
||||
@@ -52,14 +53,13 @@
|
||||
return;
|
||||
}
|
||||
pressedKeys.add(input);
|
||||
pressedKeys = pressedKeys;
|
||||
}
|
||||
|
||||
function keyup() {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
if (pressedKeys.size < 1) return;
|
||||
if (!chord) return dispatch("submit", makeChordInput(...pressedKeys));
|
||||
if (!chord) return onsubmit(makeChordInput(...pressedKeys));
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
type: ChangeType.Chord,
|
||||
@@ -73,6 +73,7 @@
|
||||
}
|
||||
|
||||
function addSpecial(event: MouseEvent) {
|
||||
event.stopPropagation();
|
||||
selectAction(event, (action) => {
|
||||
changes.update((changes) => {
|
||||
changes.push({
|
||||
@@ -88,7 +89,7 @@
|
||||
|
||||
function* resolveCompound(chord?: ChordInfo) {
|
||||
if (!chord) return;
|
||||
let current = chord;
|
||||
let current: Chord = chord;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
if (current.actions[3] !== 0) return;
|
||||
const compound = current.actions
|
||||
@@ -106,10 +107,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
$: chordActions = chord?.actions
|
||||
.slice(chord.actions.lastIndexOf(0) + 1)
|
||||
.toSorted(compare);
|
||||
$: compoundInputs = [...resolveCompound(chord)].reverse();
|
||||
let chordActions = $derived(
|
||||
chord?.actions.slice(chord.actions.lastIndexOf(0) + 1).toSorted(compare),
|
||||
);
|
||||
let compoundInputs = $derived([...resolveCompound(chord)].reverse());
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -120,10 +121,10 @@
|
||||
(chordActions.length < 2 ||
|
||||
chordActions.some((it, i) => chordActions[i] !== it))}
|
||||
class="chord"
|
||||
on:click={edit}
|
||||
on:keydown={keydown}
|
||||
on:keyup={keyup}
|
||||
on:blur={keyup}
|
||||
onclick={edit}
|
||||
onkeydown={keydown}
|
||||
onkeyup={keyup}
|
||||
onblur={keyup}
|
||||
>
|
||||
{#if editing && pressedKeys.size === 0}
|
||||
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
|
||||
@@ -143,12 +144,10 @@
|
||||
{/if}
|
||||
<ActionString
|
||||
display="keys"
|
||||
actions={editing ? [...pressedKeys].sort(compare) : chordActions ?? []}
|
||||
actions={editing ? [...pressedKeys].sort(compare) : (chordActions ?? [])}
|
||||
/>
|
||||
<sup>•</sup>
|
||||
<button class="icon add" on:click|stopPropagation={addSpecial}
|
||||
>add_circle</button
|
||||
>
|
||||
<div role="button" class="icon add" onclick={addSpecial}>add_circle</div>
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -8,11 +8,10 @@
|
||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||
import SharePopup from "../SharePopup.svelte";
|
||||
import tippy from "tippy.js";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { mount, unmount } from "svelte";
|
||||
|
||||
export let chord: ChordInfo;
|
||||
|
||||
const dispatch = createEventDispatcher<{ duplicate: void }>();
|
||||
let { chord, onduplicate }: { chord: ChordInfo; onduplicate: () => void } =
|
||||
$props();
|
||||
|
||||
function remove() {
|
||||
changes.update((changes) => {
|
||||
@@ -47,7 +46,7 @@
|
||||
id.splice(id.indexOf(0), 1);
|
||||
id.push(0);
|
||||
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
|
||||
id[id.length - 1]++;
|
||||
id[id.length - 1]!++;
|
||||
}
|
||||
|
||||
changes.update((changes) => {
|
||||
@@ -60,7 +59,7 @@
|
||||
return changes;
|
||||
});
|
||||
|
||||
dispatch("duplicate");
|
||||
onduplicate();
|
||||
}
|
||||
|
||||
async function share(event: Event) {
|
||||
@@ -74,48 +73,48 @@
|
||||
}),
|
||||
);
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
let shareComponent: SharePopup;
|
||||
let shareComponent = {};
|
||||
tippy(event.target as HTMLElement, {
|
||||
onCreate(instance) {
|
||||
const target = instance.popper.querySelector(".tippy-content")!;
|
||||
shareComponent = new SharePopup({ target });
|
||||
shareComponent = mount(SharePopup, { target });
|
||||
},
|
||||
onHidden(instance) {
|
||||
instance.destroy();
|
||||
},
|
||||
onDestroy(_instance) {
|
||||
shareComponent.$destroy();
|
||||
unmount(shareComponent);
|
||||
},
|
||||
}).show();
|
||||
}
|
||||
</script>
|
||||
|
||||
<th>
|
||||
<ChordActionEdit {chord} />
|
||||
<ChordActionEdit {chord} onsubmit={() => {}} />
|
||||
</th>
|
||||
<td>
|
||||
<ChordPhraseEdit {chord} />
|
||||
</td>
|
||||
<td class="table-buttons">
|
||||
{#if !chord.deleted}
|
||||
<button transition:slide class="icon compact" on:click={remove}
|
||||
<button transition:slide class="icon compact" onclick={remove}
|
||||
>delete</button
|
||||
>
|
||||
{:else}
|
||||
<button transition:slide class="icon compact" on:click={restore}
|
||||
<button transition:slide class="icon compact" onclick={restore}
|
||||
>restore_from_trash</button
|
||||
>
|
||||
{/if}
|
||||
<button disabled={chord.deleted} class="icon compact" on:click={duplicate}
|
||||
<button disabled={chord.deleted} class="icon compact" onclick={duplicate}
|
||||
>content_copy</button
|
||||
>
|
||||
<button
|
||||
class="icon compact"
|
||||
class:disabled={chord.isApplied}
|
||||
on:click={restore}>undo</button
|
||||
onclick={restore}>undo</button
|
||||
>
|
||||
<div class="separator" />
|
||||
<button class="icon compact" on:click={share}>share</button>
|
||||
<div class="separator"></div>
|
||||
<button class="icon compact" onclick={share}>share</button>
|
||||
</td>
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export let chord: ChordInfo;
|
||||
let { chord }: { chord: ChordInfo } = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (chord.phrase.length === 0) {
|
||||
box.focus();
|
||||
box?.focus();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
}
|
||||
|
||||
function moveCursor(to: number) {
|
||||
if (!box) return;
|
||||
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
|
||||
const item = box.children.item(cursorPosition) as HTMLElement;
|
||||
cursorOffset = item.offsetLeft + item.offsetWidth;
|
||||
@@ -71,7 +72,7 @@
|
||||
}
|
||||
|
||||
function clickCursor(event: MouseEvent) {
|
||||
if (event.target === button) return;
|
||||
if (box === undefined || event.target === button) return;
|
||||
const distance = (event as unknown as { layerX: number }).layerX;
|
||||
|
||||
let i = 0;
|
||||
@@ -93,37 +94,36 @@
|
||||
insertAction(cursorPosition, action);
|
||||
tick().then(() => moveCursor(cursorPosition + 1));
|
||||
},
|
||||
() => box.focus(),
|
||||
() => box?.focus(),
|
||||
);
|
||||
}
|
||||
|
||||
let button: HTMLButtonElement;
|
||||
let box: HTMLDivElement;
|
||||
let button: HTMLButtonElement | undefined = $state();
|
||||
let box: HTMLDivElement | undefined = $state();
|
||||
let cursorPosition = 0;
|
||||
let cursorOffset = 0;
|
||||
let cursorOffset = $state(0);
|
||||
|
||||
let hasFocus = false;
|
||||
let hasFocus = $state(false);
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<div
|
||||
on:keydown={keypress}
|
||||
on:mousedown={clickCursor}
|
||||
onkeydown={keypress}
|
||||
onmousedown={clickCursor}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
bind:this={box}
|
||||
class:edited={!chord.deleted && chord.phraseChanged}
|
||||
on:focusin={() => (hasFocus = true)}
|
||||
on:focusout={(event) => {
|
||||
onfocusin={() => (hasFocus = true)}
|
||||
onfocusout={(event) => {
|
||||
if (event.relatedTarget !== button) hasFocus = false;
|
||||
}}
|
||||
>
|
||||
{#if hasFocus}
|
||||
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
|
||||
<button class="icon" bind:this={button} on:click={addSpecial}>add</button>
|
||||
<button class="icon" bind:this={button} onclick={addSpecial}>add</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div />
|
||||
<div></div>
|
||||
<!-- placeholder for cursor placement -->
|
||||
{/if}
|
||||
<ActionString actions={chord.phrase} />
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
|
||||
import { tick } from "svelte";
|
||||
import { mount, unmount, tick } from "svelte";
|
||||
|
||||
export function selectAction(
|
||||
event: MouseEvent | KeyboardEvent,
|
||||
select: (action: number) => void,
|
||||
dismissed?: () => void,
|
||||
) {
|
||||
const component = new ActionSelector({ target: document.body });
|
||||
const component = mount(ActionSelector, {
|
||||
target: document.body,
|
||||
props: {
|
||||
onclose: () => closed(),
|
||||
onselect: (action: number) => {
|
||||
select(action);
|
||||
closed();
|
||||
},
|
||||
},
|
||||
});
|
||||
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
|
||||
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
|
||||
const dialogRect = dialog.getBoundingClientRect();
|
||||
@@ -40,14 +49,8 @@ export function selectAction(
|
||||
|
||||
await dialogAnimation.finished;
|
||||
|
||||
component.$destroy();
|
||||
unmount(component);
|
||||
await tick();
|
||||
dismissed?.();
|
||||
}
|
||||
|
||||
component.$on("close", closed);
|
||||
component.$on("select", ({ detail }) => {
|
||||
select(detail);
|
||||
closed();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { share } from "$lib/share";
|
||||
import tippy from "tippy.js";
|
||||
import { setContext } from "svelte";
|
||||
import { mount, setContext, unmount } from "svelte";
|
||||
import Layout from "$lib/components/layout/Layout.svelte";
|
||||
import { charaFileToUriComponent } from "$lib/share/share-url";
|
||||
import SharePopup from "../SharePopup.svelte";
|
||||
@@ -25,17 +25,17 @@
|
||||
}),
|
||||
);
|
||||
await navigator.clipboard.writeText(url.toString());
|
||||
let shareComponent: SharePopup;
|
||||
let shareComponent: {};
|
||||
tippy(event.target as HTMLElement, {
|
||||
onCreate(instance) {
|
||||
const target = instance.popper.querySelector(".tippy-content")!;
|
||||
shareComponent = new SharePopup({ target });
|
||||
shareComponent = mount(SharePopup, { target });
|
||||
},
|
||||
onHidden(instance) {
|
||||
instance.destroy();
|
||||
},
|
||||
onDestroy() {
|
||||
shareComponent.$destroy();
|
||||
unmount(shareComponent);
|
||||
},
|
||||
}).show();
|
||||
}
|
||||
|
||||
@@ -229,12 +229,6 @@
|
||||
/>ms</span
|
||||
></label
|
||||
>
|
||||
<label
|
||||
>Compound Chording<input
|
||||
type="checkbox"
|
||||
use:setting={{ id: 0x61 }}
|
||||
/></label
|
||||
>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
export let challenge: string;
|
||||
let { challenge, onconfirm }: { challenge: string; onconfirm: () => void } =
|
||||
$props();
|
||||
|
||||
let challengeInput = "";
|
||||
$: challengeString = `${challenge} ${$serialPort!.device}`;
|
||||
$: isValid = challengeInput === challengeString;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
let challengeInput = $state("");
|
||||
let challengeString = $derived(`${challenge} ${$serialPort!.device}`);
|
||||
let isValid = $derived(challengeInput === challengeString);
|
||||
</script>
|
||||
|
||||
<h3>Type the following to confirm the action</h3>
|
||||
|
||||
<p>{challengeString}</p>
|
||||
<!-- svelte-ignore a11y-autofocus -->
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
@@ -22,9 +20,7 @@
|
||||
placeholder={challengeString}
|
||||
/>
|
||||
|
||||
<button disabled={!isValid} on:click={() => dispatch("confirm")}
|
||||
>Confirm {challenge}</button
|
||||
>
|
||||
<button disabled={!isValid} onclick={onconfirm}>Confirm {challenge}</button>
|
||||
|
||||
<style lang="scss">
|
||||
input[type="text"] {
|
||||
|
||||
@@ -155,28 +155,30 @@
|
||||
doc: examplePlugin,
|
||||
});
|
||||
});
|
||||
$: channels = $serialPort
|
||||
? ({
|
||||
getVersion: async (..._args: unknown[]) => $serialPort.version,
|
||||
getDevice: async (..._args: unknown[]) => $serialPort.device,
|
||||
commit: async (..._args: unknown[]) => {
|
||||
if (
|
||||
confirm(
|
||||
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
||||
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
|
||||
"Click OK to perform the commit anyways.",
|
||||
)
|
||||
) {
|
||||
return $serialPort.commit();
|
||||
}
|
||||
},
|
||||
...Object.fromEntries(
|
||||
charaMethods.map(
|
||||
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
||||
let channels = $derived(
|
||||
$serialPort
|
||||
? ({
|
||||
getVersion: async (..._args: unknown[]) => $serialPort.version,
|
||||
getDevice: async (..._args: unknown[]) => $serialPort.device,
|
||||
commit: async (..._args: unknown[]) => {
|
||||
if (
|
||||
confirm(
|
||||
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
||||
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
|
||||
"Click OK to perform the commit anyways.",
|
||||
)
|
||||
) {
|
||||
return $serialPort.commit();
|
||||
}
|
||||
},
|
||||
...Object.fromEntries(
|
||||
charaMethods.map(
|
||||
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
||||
),
|
||||
),
|
||||
),
|
||||
} satisfies Record<string, Function>)
|
||||
: ({} as any);
|
||||
} satisfies Record<string, Function>)
|
||||
: ({} as any),
|
||||
);
|
||||
|
||||
async function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
|
||||
@@ -205,12 +207,12 @@
|
||||
let editorView: EditorView;
|
||||
</script>
|
||||
|
||||
<svelte:window on:message={onMessage} />
|
||||
<svelte:window onmessage={onMessage} />
|
||||
<section>
|
||||
<button on:click={runPlugin}
|
||||
<button onclick={runPlugin}
|
||||
><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button
|
||||
>
|
||||
<div class="editor-root" bind:this={editor} />
|
||||
<div class="editor-root" bind:this={editor}></div>
|
||||
</section>
|
||||
|
||||
<iframe
|
||||
@@ -219,7 +221,7 @@
|
||||
bind:this={frame}
|
||||
src="/sandbox/"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
></iframe>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
|
||||
Reference in New Issue
Block a user