refactor: update to Svelte 5 preview

feat: add charrecorder
feat: dynamic os layouts for CC1
This commit is contained in:
2024-08-01 00:28:38 +02:00
parent 6201cf5b0c
commit b8b903c5e1
61 changed files with 6765 additions and 4572 deletions

View File

@@ -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

View File

@@ -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

View 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>

View 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>

View 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>

View 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>

View 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);
}
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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);
}
}

View 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]),
};
}
}

View 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;
}
}

View 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;
}

View 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"],
]);

View 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")
);
}
}

View File

@@ -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(
`&lt;${info.id ?? `0x${info.code.toString(16)}`}&gt; ` +
(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

View File

@@ -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>

View File

@@ -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}`)}

View File

@@ -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}

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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}

View File

@@ -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}
>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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;
},
});

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
* {
box-sizing: border-box;
appearance: none;
}

View File

@@ -0,0 +1,6 @@
h1 {
margin-block-start: 0;
font-size: 4rem;
font-weight: 700;
color: var(--md-sys-color-secondary);
}

View File

@@ -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;
}

View File

@@ -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;
}
},
});

View File

@@ -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>

View File

View File

@@ -1,6 +0,0 @@
import { redirect } from "@sveltejs/kit";
import type { PageLoad } from "./$types";
export const load = (() => {
redirect(302, "/config/");
}) satisfies PageLoad;

View File

@@ -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
>

View File

@@ -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 {

View File

@@ -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
>

View File

@@ -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
>

View File

@@ -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>

View File

@@ -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 }}

View File

@@ -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}

View File

@@ -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}

View File

@@ -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
>

View File

@@ -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">

View File

@@ -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">

View File

@@ -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} />

View File

@@ -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();
});
}

View File

@@ -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();
}

View File

@@ -229,12 +229,6 @@
/>ms</span
></label
>
<label
>Compound Chording<input
type="checkbox"
use:setting={{ id: 0x61 }}
/></label
>
</fieldset>
<fieldset>

View File

@@ -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"] {

View File

@@ -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 {