mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-16 06:02:41 +00:00
Compare commits
6 Commits
4bc84b5399
...
v2.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
b9c6c05819
|
|||
|
16bf766de9
|
|||
|
ee8d400ad7
|
|||
|
9a1c2b5bf6
|
|||
|
1d1fcb72e3
|
|||
|
ee3f84645d
|
@@ -57,6 +57,7 @@ const config = {
|
||||
"graphic_eq",
|
||||
"mail",
|
||||
"calculate",
|
||||
"playground_2",
|
||||
"open_in_browser",
|
||||
"chevron_backward",
|
||||
"chevron_forward",
|
||||
|
||||
37
package.json
37
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "charachorder-device-manager",
|
||||
"version": "2.6.0",
|
||||
"version": "2.7.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"private": true,
|
||||
"engines": {
|
||||
@@ -35,27 +35,25 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/collab": "^6.1.1",
|
||||
"@codemirror/commands": "^6.10.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/lint": "^6.9.2",
|
||||
"@codemirror/language": "^6.11.3",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/state": "^6.5.3",
|
||||
"@codemirror/view": "^6.39.9",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.39.4",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/common": "^1.4.0",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@lezer/lr": "^1.4.7",
|
||||
"@lezer/lr": "^1.4.5",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.49.3",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||
"@sveltejs/kit": "^2.49.2",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@tauri-apps/api": "^1.6.0",
|
||||
"@tauri-apps/cli": "^1.6.0",
|
||||
"@types/dom-view-transitions": "^1.0.6",
|
||||
@@ -75,32 +73,31 @@
|
||||
"glob": "^11.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsdom": "^26.1.0",
|
||||
"matrix-js-sdk": "^37.12.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-css-order": "^2.2.0",
|
||||
"prettier-plugin-css-order": "^2.1.2",
|
||||
"prettier-plugin-svelte": "^3.4.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"sass": "^1.97.2",
|
||||
"sass": "^1.97.0",
|
||||
"semver": "^7.7.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"stylelint": "^16.26.1",
|
||||
"stylelint-config-html": "^1.1.0",
|
||||
"stylelint-config-prettier-scss": "^1.0.0",
|
||||
"stylelint-config-recommended-scss": "^16.0.2",
|
||||
"stylelint-config-standard-scss": "^16.0.0",
|
||||
"svelte": "5.46.1",
|
||||
"svelte-check": "^4.3.5",
|
||||
"svelte": "5.37.1",
|
||||
"svelte-check": "^4.3.4",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tippy.js": "^6.3.7",
|
||||
"typesafe-i18n": "^5.26.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-mkcert": "^1.17.9",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-plugin-pwa": "^1.0.2",
|
||||
"vitest": "^4.0.16",
|
||||
"web-serial-polyfill": "^1.0.15",
|
||||
"workbox-window": "^7.4.0"
|
||||
"workbox-window": "^7.3.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
955
pnpm-lock.yaml
generated
955
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "2.6.0"
|
||||
version = "2.7.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||
license = "AGPL-3"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"devPath": "http://localhost:5173",
|
||||
"distDir": "../build"
|
||||
},
|
||||
"package": { "productName": "amacc1ng", "version": "2.6.0" },
|
||||
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
||||
"tauri": {
|
||||
"allowlist": { "all": false },
|
||||
"bundle": {
|
||||
|
||||
@@ -1,26 +1,35 @@
|
||||
import type { Attachment } from "svelte/attachments";
|
||||
import { browser } from "$app/environment";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import type { CharaDevice } from "$lib/serial/device";
|
||||
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
||||
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
|
||||
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
|
||||
|
||||
export function ccosKeyInterceptor() {
|
||||
return ((element: Window) => {
|
||||
const ccos = browser
|
||||
? import("./ccos").then((module) => module.fetchCCOS(".test"))
|
||||
: Promise.resolve(undefined);
|
||||
export function ccosKeyInterceptor(
|
||||
port: CharaDevice | undefined,
|
||||
recorder: ReplayRecorder,
|
||||
) {
|
||||
return ((element: HTMLElement) => {
|
||||
const ccos =
|
||||
port?.port && "handleKeyEvent" in port?.port
|
||||
? (port.port as CCOS)
|
||||
: undefined;
|
||||
console.log("Attaching CCOS key interceptor", ccos);
|
||||
|
||||
function onEvent(event: KeyboardEvent) {
|
||||
ccos.then((it) => it?.handleKeyEvent(event));
|
||||
ccos?.handleKeyEvent(event);
|
||||
if (!event.defaultPrevented) {
|
||||
recorder.next(event);
|
||||
}
|
||||
}
|
||||
|
||||
element.addEventListener("keydown", onEvent, true);
|
||||
element.addEventListener("keyup", onEvent, true);
|
||||
if (ccos) {
|
||||
element.addEventListener("keydown", onEvent, true);
|
||||
element.addEventListener("keyup", onEvent, true);
|
||||
element.add;
|
||||
}
|
||||
|
||||
return () => {
|
||||
ccos.then((it) => it?.destroy());
|
||||
element.removeEventListener("keydown", onEvent, true);
|
||||
element.removeEventListener("keyup", onEvent, true);
|
||||
};
|
||||
}) satisfies Attachment<Window>;
|
||||
}) satisfies Attachment<HTMLElement>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getMeta } from "$lib/meta/meta-storage";
|
||||
import type { SerialPortLike } from "$lib/serial/device";
|
||||
import type {
|
||||
CCOSInEvent,
|
||||
CCOSInitEvent,
|
||||
CCOSKeyPressEvent,
|
||||
CCOSKeyReleaseEvent,
|
||||
@@ -11,7 +10,7 @@ import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
||||
|
||||
const device = "zero_wasm";
|
||||
|
||||
class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
export class CCOSKeyboardEvent extends KeyboardEvent {
|
||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||
super(...params);
|
||||
}
|
||||
@@ -26,7 +25,46 @@ const MASK_GUI = 0b1000_1000;
|
||||
export class CCOS implements SerialPortLike {
|
||||
private readonly currKeys = new Set<number>();
|
||||
|
||||
private readonly layout = new Map<string, string>();
|
||||
private readonly layout = new Map<string, string>([
|
||||
...Array.from(
|
||||
{ length: 26 },
|
||||
(_, i) =>
|
||||
[
|
||||
JSON.stringify([`Key${String.fromCharCode(65 + i)}`, "Shift"]),
|
||||
String.fromCharCode(65 + i),
|
||||
] as const,
|
||||
),
|
||||
...Array.from(
|
||||
{ length: 10 },
|
||||
(_, i) => [JSON.stringify([`Key${i}`]), i.toString()] as const,
|
||||
),
|
||||
|
||||
[JSON.stringify(["Space"]), " "],
|
||||
[JSON.stringify(["Backquote"]), "`"],
|
||||
[JSON.stringify(["Minus"]), "-"],
|
||||
[JSON.stringify(["Comma"]), ","],
|
||||
[JSON.stringify(["Period"]), "."],
|
||||
[JSON.stringify(["Semicolon"]), ";"],
|
||||
[JSON.stringify(["Equal"]), "="],
|
||||
|
||||
[JSON.stringify(["Backquote", "Shift"]), "~"],
|
||||
[JSON.stringify(["Minus", "Shift"]), "_"],
|
||||
[JSON.stringify(["Comma", "Shift"]), "<"],
|
||||
[JSON.stringify(["Period", "Shift"]), ">"],
|
||||
[JSON.stringify(["Semicolon", "Shift"]), ":"],
|
||||
[JSON.stringify(["Equal", "Shift"]), "+"],
|
||||
|
||||
[JSON.stringify(["Digit0", "Shift"]), ")"],
|
||||
[JSON.stringify(["Digit1", "Shift"]), "!"],
|
||||
[JSON.stringify(["Digit2", "Shift"]), "@"],
|
||||
[JSON.stringify(["Digit3", "Shift"]), "#"],
|
||||
[JSON.stringify(["Digit4", "Shift"]), "$"],
|
||||
[JSON.stringify(["Digit5", "Shift"]), "%"],
|
||||
[JSON.stringify(["Digit6", "Shift"]), "^"],
|
||||
[JSON.stringify(["Digit7", "Shift"]), "&"],
|
||||
[JSON.stringify(["Digit8", "Shift"]), "*"],
|
||||
[JSON.stringify(["Digit9", "Shift"]), "("],
|
||||
]);
|
||||
|
||||
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||
|
||||
@@ -126,7 +164,6 @@ export class CCOS implements SerialPortLike {
|
||||
this.controller?.enqueue(event.data);
|
||||
return;
|
||||
}
|
||||
console.log("CCOS worker message", event.data);
|
||||
switch (event.data.type) {
|
||||
case "ready": {
|
||||
this.resolveReady();
|
||||
@@ -220,7 +257,7 @@ export class CCOS implements SerialPortLike {
|
||||
}
|
||||
|
||||
export async function fetchCCOS(
|
||||
version = ".2.2.0-beta.12+266bdda",
|
||||
version = "3.0.0-rc.0",
|
||||
fetch: typeof window.fetch = window.fetch,
|
||||
): Promise<CCOS | undefined> {
|
||||
const meta = await getMeta(device, version, fetch);
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { RoomMember } from "matrix-js-sdk";
|
||||
import { matrixClient, memberColor } from "./chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
|
||||
let { members }: { members: RoomMember[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="member-list">
|
||||
{#each members as member (member.userId)}
|
||||
{@const avatar = member.getMxcAvatarUrl()}
|
||||
<div class="member">
|
||||
{#if avatar}
|
||||
<img
|
||||
class="avatar"
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
|
||||
alt={member.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
{@const color = memberColor(member, $theme)}
|
||||
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
|
||||
<div
|
||||
style:background={hexFromArgb(modeColor.color)}
|
||||
style:color={hexFromArgb(modeColor.onColor)}
|
||||
class="avatar avatar-placeholder icon"
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
<span>{member.name}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.avatar {
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 8px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
span {
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Room } from "matrix-js-sdk";
|
||||
import { matrixClient, currentRoomId } from "./chat";
|
||||
|
||||
let { rooms }: { rooms: Room[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="rooms">
|
||||
{#each $matrixClient.getRooms() as room}
|
||||
{@const avatar = room.getMxcAvatarUrl()}
|
||||
<button
|
||||
class:active={$currentRoomId === room.roomId}
|
||||
class="room"
|
||||
onclick={() => ($currentRoomId = room.roomId)}
|
||||
>
|
||||
{#if avatar}
|
||||
<img
|
||||
alt={room.name}
|
||||
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
{:else}
|
||||
<div>#</div>
|
||||
{/if}
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#await $matrixClient.publicRooms()}
|
||||
<div>Loading...</div>
|
||||
{:then rooms}
|
||||
{#each rooms.chunk as room}
|
||||
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
||||
<div>#</div>
|
||||
<div>{room.name}</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:catch error}
|
||||
<div>{error.message}</div>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rooms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
padding-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
padding-inline: 16px;
|
||||
padding-block: 2px;
|
||||
padding-block: 4px;
|
||||
width: 100%;
|
||||
height: unset;
|
||||
min-height: 0;
|
||||
|
||||
&.active {
|
||||
background: var(--md-sys-color-primary-container);
|
||||
color: var(--md-sys-color-on-primary-container);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,231 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomMember,
|
||||
RoomMemberEvent,
|
||||
} from "matrix-js-sdk";
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { matrixClient } from "./chat";
|
||||
import MatrixEventComponent from "./events/MatrixEvent.svelte";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { type Socket, io } from "socket.io-client";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
let { timeline }: { timeline: EventTimeline } = $props();
|
||||
|
||||
const excludeEvents = ["m.reaction", "m.room.redaction"];
|
||||
|
||||
let events = $state(
|
||||
timeline
|
||||
.getEvents()
|
||||
.filter((it) => !excludeEvents.includes(it.getType()))
|
||||
.reverse(),
|
||||
);
|
||||
|
||||
let recorder = $state(new ReplayRecorder());
|
||||
let showCursor = $state(false);
|
||||
|
||||
let timelineElement: HTMLElement = $state()!;
|
||||
|
||||
async function onTimeline(
|
||||
event: MatrixEvent,
|
||||
room?: Room,
|
||||
toStartOfTimeline?: boolean,
|
||||
) {
|
||||
if (room?.roomId !== timeline.getRoomId()) return;
|
||||
const sender = event.getSender();
|
||||
if (sender) {
|
||||
live.delete(sender);
|
||||
}
|
||||
if (excludeEvents.includes(event.getType())) return;
|
||||
if (toStartOfTimeline) {
|
||||
events.push(event);
|
||||
} else {
|
||||
const needScroll = timelineElement.scrollTop < 20;
|
||||
events.unshift(event);
|
||||
if (needScroll) {
|
||||
await tick();
|
||||
timelineElement.scroll({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let typing = $state<string[]>([]);
|
||||
|
||||
function onTyping(event: MatrixEvent, member: RoomMember) {
|
||||
typing = event.event.content?.["user_ids"] ?? [];
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const roomId = timeline.getRoomId();
|
||||
if (!roomId) return;
|
||||
const finalText = recorder.player.stepper.text
|
||||
.map((token) => token.text)
|
||||
.join("");
|
||||
const finalRecording = recorder.finish();
|
||||
if (!finalText) return;
|
||||
recorder = new ReplayRecorder();
|
||||
await $matrixClient.sendMessage(roomId, {
|
||||
msgtype: "m.text" as MsgType.Text,
|
||||
body: finalText,
|
||||
// @ts-expect-error
|
||||
"m.replay": finalRecording,
|
||||
});
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
|
||||
send();
|
||||
return;
|
||||
} else {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
|
||||
recorder = new ReplayRecorder();
|
||||
} else {
|
||||
socket.emit("message", {
|
||||
timeStamp: event.timeStamp,
|
||||
type: event.type,
|
||||
key: event.key,
|
||||
code: event.code,
|
||||
username: $matrixClient.getUserId(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let socket: Socket = $state()!;
|
||||
let live = new SvelteMap<string, ReplayRecorder>();
|
||||
|
||||
onMount(() => {
|
||||
socket = io("https://srv.charachorder.io");
|
||||
socket.emit("join", timeline.getRoomId());
|
||||
|
||||
socket.on("message", async ({ message }) => {
|
||||
let userRecorder = live.get(message.username);
|
||||
if (!userRecorder) {
|
||||
userRecorder = new ReplayRecorder();
|
||||
live.set(message.username, userRecorder);
|
||||
}
|
||||
|
||||
await tick();
|
||||
|
||||
userRecorder.next(message);
|
||||
|
||||
if (userRecorder.player.stepper.text.length === 0) {
|
||||
live.delete(message.username);
|
||||
}
|
||||
});
|
||||
|
||||
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
socket?.disconnect();
|
||||
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
||||
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div bind:this={timelineElement} class="timeline">
|
||||
{#each live.entries() as [userId, recorder] (userId)}
|
||||
{@const roomId = timeline.getRoomId()}
|
||||
{#if roomId}
|
||||
{@const room = $matrixClient.getRoom(roomId)}
|
||||
{@const member = room?.getMember(userId)}
|
||||
{#if member}
|
||||
<MatrixEventComponent sender={member} replay={recorder.player} />
|
||||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
{#each events as event, i (event.event["event_id"])}
|
||||
{@const prev = events[i + 1]}
|
||||
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="static-elements">
|
||||
<div class="indicators"></div>
|
||||
<div class="input-box">
|
||||
<button class="icon">add</button>
|
||||
<div
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
class="input"
|
||||
onkeydown={onKey}
|
||||
onkeyup={onKey}
|
||||
onfocusin={() => (showCursor = true)}
|
||||
onfocusout={() => (showCursor = false)}
|
||||
>
|
||||
<CharRecorder replay={recorder.player} cursor={showCursor} />
|
||||
</div>
|
||||
<button class="icon" onclick={send}>send</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
$border-radius: 16px;
|
||||
|
||||
.input {
|
||||
flex-grow: 1;
|
||||
cursor: text;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: $border-radius;
|
||||
padding: 0.5em;
|
||||
font-size: 1rem;
|
||||
|
||||
text-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.input-box {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
padding-block: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.static-elements {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
flex-direction: column-reverse;
|
||||
contain: content;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,109 +0,0 @@
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type {
|
||||
ClientEvent,
|
||||
LoginResponse,
|
||||
MatrixClient,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
import { MatrixRx } from "./matrix-rx/client";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
||||
|
||||
export const matrix = derived(
|
||||
[matrixClient, isLoggedIn],
|
||||
([matrixClient, isLoggedIn]) =>
|
||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
||||
);
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function storeLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
export async function initMatrixClient() {
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
const store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
const cryptoStore = new IndexedDBCryptoStore(
|
||||
window.indexedDB,
|
||||
"matrix-crypto",
|
||||
);
|
||||
|
||||
const client = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
console.log("store");
|
||||
await store.startup();
|
||||
console.log("cryptoStore");
|
||||
await cryptoStore.startup();
|
||||
console.log("client");
|
||||
await client.startClient();
|
||||
client.once("sync" as ClientEvent.Sync, () => {
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
storeLogin(await client.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
isLoggedIn.set(client.isLoggedIn());
|
||||
}
|
||||
|
||||
matrixClient.set(client);
|
||||
console.log("done");
|
||||
}
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import {
|
||||
themeFromSourceColor,
|
||||
argbFromHex,
|
||||
type CustomColorGroup,
|
||||
} from "@material/material-color-utilities";
|
||||
import type { UserTheme } from "$lib/preferences";
|
||||
|
||||
export const matrixClient: Writable<MatrixClient> = writable();
|
||||
|
||||
export const currentRoomId = persistentWritable<string | null>(
|
||||
"currentRoomId",
|
||||
null,
|
||||
);
|
||||
|
||||
export function memberColor(
|
||||
member: RoomMember,
|
||||
theme: UserTheme,
|
||||
): CustomColorGroup {
|
||||
let hash = 0;
|
||||
member.userId.split("").forEach((char) => {
|
||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
||||
});
|
||||
let color = "#";
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const value = (hash >> (i * 8)) & 0xff;
|
||||
color += value.toString(16).padStart(2, "0");
|
||||
}
|
||||
|
||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
||||
{ value: argbFromHex(color), name: "member", blend: true },
|
||||
]).customColors.find((c) => c.color.name === "member")!;
|
||||
}
|
||||
@@ -1,381 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
EventTimeline,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
Relations,
|
||||
RelationsEvent,
|
||||
RoomMember,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
|
||||
import { matrixClient, memberColor } from "../chat";
|
||||
import { theme } from "$lib/preferences";
|
||||
import { hexFromArgb } from "@material/material-color-utilities";
|
||||
import { fade } from "svelte/transition";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
let {
|
||||
event,
|
||||
prev,
|
||||
sender,
|
||||
replay: replayPlayer,
|
||||
timeline,
|
||||
}: {
|
||||
event?: MatrixEvent;
|
||||
prev?: MatrixEvent;
|
||||
sender?: RoomMember | null;
|
||||
replay?: Replay | ReplayPlayer;
|
||||
timeline?: EventTimeline;
|
||||
} = $props();
|
||||
|
||||
let toolbarHover = $state(false);
|
||||
let mainHover = $state(false);
|
||||
|
||||
let hover = $derived(toolbarHover || mainHover);
|
||||
|
||||
let replay: Replay | undefined = $state();
|
||||
|
||||
let reactions: Relations | undefined = $state(
|
||||
timeline && event?.event.event_id
|
||||
? timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
|
||||
|
||||
function createRelations() {
|
||||
if (!timeline || !event?.event.event_id) return;
|
||||
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions = timeline
|
||||
.getTimelineSet()
|
||||
.relations.getChildEventsForEvent(
|
||||
event.event.event_id,
|
||||
"m.annotation",
|
||||
"m.reaction",
|
||||
);
|
||||
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
|
||||
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.on(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
annotations.set(
|
||||
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
|
||||
);
|
||||
console.log("create");
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
createRelations();
|
||||
event?.on(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
event?.off(
|
||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
|
||||
reactions?.off(
|
||||
"Relations.remove" as RelationsEvent.Remove,
|
||||
createRelations,
|
||||
);
|
||||
reactions?.off(
|
||||
"Relations.redaction" as RelationsEvent.Redaction,
|
||||
createRelations,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="event"
|
||||
role="log"
|
||||
onmouseover={() => (mainHover = true)}
|
||||
onfocus={() => (mainHover = true)}
|
||||
onmouseout={() => (mainHover = false)}
|
||||
onblur={() => (mainHover = false)}
|
||||
>
|
||||
{#if event && hover}
|
||||
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
|
||||
{/if}
|
||||
|
||||
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
|
||||
{@const color = memberColor(sender, $theme)}
|
||||
{@const avatarMxc = sender.getMxcAvatarUrl()}
|
||||
{#if avatarMxc}
|
||||
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
|
||||
<img
|
||||
class="avatar"
|
||||
src={avatar}
|
||||
alt={sender.name}
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
{:else}
|
||||
<div
|
||||
class="avatar avatar-placeholder icon"
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
|
||||
)}
|
||||
>
|
||||
person
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="sender"
|
||||
style:color={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
>
|
||||
<strong>{sender.name}</strong>
|
||||
{#if replay || replayPlayer}
|
||||
<div class="dots">
|
||||
{#each new Array(3) as _, i}
|
||||
<div
|
||||
style:animation-delay={i * 0.2 + "s"}
|
||||
style:background={hexFromArgb(
|
||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
||||
)}
|
||||
class="dot"
|
||||
></div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="content">
|
||||
{#if event}
|
||||
{#if event.getType() === "m.room.message"}
|
||||
<MatrixMessageEvent {event} bind:replay />
|
||||
{:else}
|
||||
<details>
|
||||
<summary>{event.getType()}</summary>
|
||||
<pre>{JSON.stringify(event.event, null, 2)}</pre>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if replayPlayer}
|
||||
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if event && hover}
|
||||
<div
|
||||
role="toolbar"
|
||||
tabindex="0"
|
||||
class="toolbar"
|
||||
transition:fade={{ duration: 100 }}
|
||||
onmouseover={() => (toolbarHover = true)}
|
||||
onfocus={() => (toolbarHover = true)}
|
||||
onmouseout={() => (toolbarHover = false)}
|
||||
onblur={() => (toolbarHover = false)}
|
||||
>
|
||||
{#if event.getType() === "m.room.message"}
|
||||
{@const message = event.event.content?.["body"]}
|
||||
<a
|
||||
class="icon rocket"
|
||||
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
|
||||
>rocket_launch</a
|
||||
>
|
||||
{/if}
|
||||
<button class="icon">add_reaction</button>
|
||||
<button class="icon">reply</button>
|
||||
{#if event.event.content?.["m.replay"]}
|
||||
{#if replay}
|
||||
<button class="icon" onclick={() => (replay = undefined)}>stop</button
|
||||
>
|
||||
{:else}
|
||||
<button
|
||||
class="icon"
|
||||
onclick={() => (replay = event.event.content?.["m.replay"])}
|
||||
>replay</button
|
||||
>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="icon">more_horiz</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $annotations && $annotations.length > 0}
|
||||
<div class="reactions">
|
||||
{#each $annotations as [reaction, events]}
|
||||
<button class="reaction"
|
||||
>{reaction} <span class="count">{events.size}</span></button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
details {
|
||||
opacity: 0.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
pre {
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
@keyframes rocket {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
90% {
|
||||
transform: translate(4px, -4px);
|
||||
}
|
||||
100% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
.icon.rocket {
|
||||
animation: rocket 2s;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: -26px;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
border-radius: 4px;
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
padding: 4px;
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
|
||||
a,
|
||||
button {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
animation: bounce 1s infinite;
|
||||
border-radius: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sender,
|
||||
.avatar {
|
||||
margin-block: 2px 4px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
grid-area: avatar;
|
||||
translate: 0 2px;
|
||||
border-radius: 50%;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
div.avatar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sender {
|
||||
display: flex;
|
||||
grid-area: sender;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.reactions {
|
||||
display: flex;
|
||||
grid-area: reactions;
|
||||
gap: 4px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.reaction {
|
||||
display: flex;
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 6px;
|
||||
padding: 6px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
|
||||
> .count {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.event {
|
||||
display: grid;
|
||||
position: relative;
|
||||
grid-template-columns: 32px 1fr auto;
|
||||
|
||||
grid-template-areas:
|
||||
"avatar sender date"
|
||||
"avatar content content"
|
||||
"none reactions reactions";
|
||||
margin-inline: 0.5em;
|
||||
border-radius: 4px;
|
||||
padding-inline: 0.5em;
|
||||
padding-block: 0.25em;
|
||||
}
|
||||
|
||||
.content {
|
||||
grid-area: content;
|
||||
text-wrap: wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.reactions,
|
||||
.content,
|
||||
.sender {
|
||||
margin-inline: 8px;
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
opacity: 0.25;
|
||||
z-index: -1;
|
||||
inset: 0;
|
||||
|
||||
border-radius: 8px;
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import type { Replay } from "$lib/charrecorder/core/types";
|
||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { fade } from "svelte/transition";
|
||||
import { matrixClient } from "../chat";
|
||||
|
||||
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
|
||||
$props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if event.event.content?.msgtype === "m.image"}
|
||||
<img
|
||||
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
|
||||
alt={event.event.content["body"]}
|
||||
/>
|
||||
{:else}
|
||||
<span class="content" style:opacity={replay && 0}
|
||||
>{event.event.content?.["body"]}</span
|
||||
>
|
||||
{/if}
|
||||
{#if replay}
|
||||
<div class="replay" out:fade>
|
||||
<CharRecorder
|
||||
{replay}
|
||||
cursor={true}
|
||||
keys={true}
|
||||
ondone={() => (replay = undefined)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
min-height: 1.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
border-radius: 8px;
|
||||
max-width: 100%;
|
||||
max-height: 16em;
|
||||
}
|
||||
|
||||
.content {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.replay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,71 +0,0 @@
|
||||
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
|
||||
import {
|
||||
filter,
|
||||
map,
|
||||
type Observable,
|
||||
of,
|
||||
distinctUntilChanged,
|
||||
merge,
|
||||
} from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
function roomListDistinct(prev: Room[], curr: Room[]) {
|
||||
if (prev.length !== curr.length) return false;
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export class MatrixRx {
|
||||
topLevelRooms$: Observable<Room[]>;
|
||||
|
||||
topLevelSpaces$: Observable<Room[]>;
|
||||
|
||||
topLevelChats$: Observable<Room[]>;
|
||||
|
||||
constructor(private client: MatrixClient) {
|
||||
this.topLevelRooms$ = merge(
|
||||
of([]),
|
||||
fromMatrixClientEvent(client, "Room"),
|
||||
fromMatrixClientEvent(client, "deleteRoom"),
|
||||
fromMatrixClientEvent(client, "Room.myMembership"),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(
|
||||
([_room, prev, curr]) =>
|
||||
prev.getStateEvents("m.space.parent").length !==
|
||||
curr.getStateEvents("m.space.parent").length,
|
||||
),
|
||||
),
|
||||
).pipe(
|
||||
map(() =>
|
||||
this.client.getVisibleRooms().filter(
|
||||
(room) =>
|
||||
room.getMyMembership() !== "leave" &&
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents("m.space.parent").length === 0,
|
||||
),
|
||||
),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
|
||||
this.topLevelChats$ = this.topLevelRooms$.pipe(
|
||||
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
|
||||
distinctUntilChanged(roomListDistinct),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class SpaceRx {
|
||||
constructor(
|
||||
private client: MatrixClient,
|
||||
private space: Room,
|
||||
) {}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
|
||||
import { fromEvent, type Observable } from "rxjs";
|
||||
|
||||
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
|
||||
client: MatrixClient,
|
||||
eventName: `${T}`, // hack so we can use strings instead of enums
|
||||
): Observable<Parameters<ClientEventHandlerMap[T]>> {
|
||||
return fromEvent(client, eventName) as Observable<
|
||||
Parameters<ClientEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import type {
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
Direction,
|
||||
RoomState,
|
||||
RoomStateEventHandlerMap,
|
||||
EventType,
|
||||
} from "matrix-js-sdk";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
import {
|
||||
map,
|
||||
filter,
|
||||
merge,
|
||||
startWith,
|
||||
Observable,
|
||||
of,
|
||||
fromEvent,
|
||||
concat,
|
||||
defer,
|
||||
} from "rxjs";
|
||||
|
||||
export function matrixRoom$(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<Room | undefined> {
|
||||
return merge([
|
||||
fromMatrixClientEvent(client, "Room").pipe(
|
||||
filter(([room]) => room.roomId === roomId),
|
||||
),
|
||||
fromMatrixClientEvent(client, "deleteRoom").pipe(
|
||||
filter(([id]) => id === roomId),
|
||||
),
|
||||
]).pipe(
|
||||
startWith([]),
|
||||
map(() => client.getRoom(roomId) ?? undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomTimeline$(
|
||||
client: MatrixClient,
|
||||
room: Room | undefined,
|
||||
): Observable<MatrixEvent[] | undefined> {
|
||||
if (!room) return of(undefined);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(
|
||||
([, eventRoom]) =>
|
||||
eventRoom !== undefined && eventRoom.roomId === room.roomId,
|
||||
),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
|
||||
export function roomCurrentStateEvents$(
|
||||
client: MatrixClient,
|
||||
room: Room,
|
||||
eventType: EventType | string,
|
||||
): Observable<MatrixEvent[]> {
|
||||
return concat(
|
||||
defer(() =>
|
||||
of(
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState("f" as Direction.Forward)
|
||||
?.getStateEvents(eventType) ?? [],
|
||||
),
|
||||
),
|
||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
||||
filter(([room]) => room.roomId === room.roomId),
|
||||
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
|
||||
state: RoomState,
|
||||
eventName: `${T}`,
|
||||
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
|
||||
return fromEvent(state, eventName) as Observable<
|
||||
Parameters<RoomStateEventHandlerMap[T]>
|
||||
>;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
||||
import { filter, map, of, startWith, type Observable } from "rxjs";
|
||||
import { fromMatrixClientEvent } from "./events";
|
||||
|
||||
export function roomTimeline(
|
||||
client: MatrixClient,
|
||||
roomId: string | undefined,
|
||||
): Observable<MatrixEvent[]> {
|
||||
if (!roomId) return of([]);
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) return of([]);
|
||||
const eventTimeline = room.getLiveTimeline();
|
||||
|
||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
||||
filter(([, room]) => room?.roomId === roomId),
|
||||
startWith([]),
|
||||
map(() => eventTimeline.getEvents()),
|
||||
);
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
import {
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { linter, type Diagnostic } from "@codemirror/lint";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { parseCharaChords } from "./action-serializer";
|
||||
import { deviceChords } from "$lib/serial/connection";
|
||||
|
||||
export const actionLinterDependencies = derived(
|
||||
[KEYMAP_IDS, KEYMAP_CODES, deviceChords],
|
||||
(it) => it,
|
||||
);
|
||||
|
||||
export const actionLinter = linter(
|
||||
(view) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const [ids, codes, deviceChords] = get(actionLinterDependencies);
|
||||
|
||||
const { meta, compoundInputs } = parseCharaChords(view.state, ids);
|
||||
|
||||
syntaxTree(view.state)
|
||||
.cursor()
|
||||
.iterate((node) => {
|
||||
let action: KeyInfo | undefined = undefined;
|
||||
switch (node.name) {
|
||||
case "SingleLetter": {
|
||||
action = ids.get(view.state.doc.sliceString(node.from, node.to));
|
||||
break;
|
||||
}
|
||||
case "ActionId": {
|
||||
action = ids.get(view.state.doc.sliceString(node.from, node.to));
|
||||
break;
|
||||
}
|
||||
case "HexNumber": {
|
||||
const hexString = view.state.doc.sliceString(node.from, node.to);
|
||||
const code = Number.parseInt(hexString, 16);
|
||||
if (hexString.length === 10) {
|
||||
if (compoundInputs.has(code)) {
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
severity: "info",
|
||||
message: "Compound hash literal can be expanded",
|
||||
actions: [
|
||||
{
|
||||
name: "Expand",
|
||||
apply(view, from, to) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: from - 1,
|
||||
to: to + 1,
|
||||
insert: compoundInputs.get(code)! + "|",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(code >= 0 && code <= 1023)) {
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
severity: "error",
|
||||
message: "Hex code invalid (out of range)",
|
||||
actions: [
|
||||
{
|
||||
name: "Remove",
|
||||
apply(view, from, to) {
|
||||
view.dispatch({ changes: { from, to } });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
action = codes.get(code);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
if (!action) {
|
||||
const action = view.state.doc.sliceString(node.from, node.to);
|
||||
diagnostics.push({
|
||||
from: node.from,
|
||||
to: node.to,
|
||||
severity: node.name === "HexNumber" ? "warning" : "error",
|
||||
message: `Unknown action: ${action}`,
|
||||
actions: [
|
||||
...(node.name === "SingleLetter"
|
||||
? ([
|
||||
{
|
||||
name: "Generate Windows Hex Numpad Code",
|
||||
apply(view, from, to) {
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from,
|
||||
to,
|
||||
insert:
|
||||
"<PRESS_NEXT><LEFT_ALT><KP_PLUS>" +
|
||||
action
|
||||
.codePointAt(0)!
|
||||
.toString(16)
|
||||
.split("")
|
||||
.map((c) =>
|
||||
/^\d$/.test(c)
|
||||
? `<KP_${c}>`
|
||||
: c.toLowerCase(),
|
||||
)
|
||||
.join("") +
|
||||
"<RELEASE_NEXT><LEFT_ALT>",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
] satisfies Diagnostic["actions"])
|
||||
: []),
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (const m of meta) {
|
||||
if (m.invalidActions) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "error",
|
||||
markClass: "chord-invalid",
|
||||
message: `Chord contains invalid actions`,
|
||||
});
|
||||
}
|
||||
if (m.invalidInput) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "error",
|
||||
markClass: "chord-invalid",
|
||||
message: `Chord input is invalid`,
|
||||
});
|
||||
}
|
||||
if (m.emptyPhrase) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "warning",
|
||||
message: `Chord phrase is empty`,
|
||||
});
|
||||
}
|
||||
if (m.overriddenBy) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "warning",
|
||||
message: `Chord overridden by previous chord`,
|
||||
});
|
||||
}
|
||||
if (m.orphan) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "warning",
|
||||
message: `Orphan compound chord`,
|
||||
});
|
||||
}
|
||||
if (m.disabled) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "info",
|
||||
markClass: "chord-ignored",
|
||||
message: `Chord disabled`,
|
||||
});
|
||||
}
|
||||
if ((m.overrides?.length ?? 0) > 0) {
|
||||
diagnostics.push({
|
||||
from: m.from,
|
||||
to: m.to,
|
||||
severity: "info",
|
||||
message: `Chord overrides other chords`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
},
|
||||
{ delay: 100 },
|
||||
);
|
||||
@@ -56,16 +56,18 @@ function actionWidgets(view: EditorView) {
|
||||
enter: (node) => {
|
||||
if (node.name !== "ExplicitAction") return;
|
||||
const value =
|
||||
node.node.getChild("ActionId") ?? node.node.getChild("HexNumber");
|
||||
node.node.getChild("ActionId") ??
|
||||
node.node.getChild("HexNumber") ??
|
||||
node.node.getChild("DecimalNumber");
|
||||
if (!value) return;
|
||||
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = view.state.doc.sliceString(value.from, value.to);
|
||||
if (value.name === "HexNumber" && id.length === 10) return;
|
||||
let deco = Decoration.replace({
|
||||
widget: new ActionWidget(
|
||||
value.name === "ActionId" ? id : Number.parseInt(id, 16),
|
||||
value.name === "ActionId" ? id : parseInt(id),
|
||||
),
|
||||
});
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import {
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { StateEffect, ChangeDesc, type EditorState } from "@codemirror/state";
|
||||
import type { Update } from "@codemirror/collab";
|
||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { get } from "svelte/store";
|
||||
import {
|
||||
composeChordInput,
|
||||
hashChord,
|
||||
splitCompound,
|
||||
willBeValidChordInput,
|
||||
} from "$lib/serial/chord";
|
||||
import type { SyntaxNodeRef } from "@lezer/common";
|
||||
|
||||
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||
@@ -24,270 +9,8 @@ export function actionToValue(action: number | KeyInfo) {
|
||||
const info =
|
||||
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
||||
if (info && info.id?.length === 1)
|
||||
return /^[<>|\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||
return /^[<>\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||
if (!info || !canUseIdAsString(info))
|
||||
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
||||
return `<${info.id}>`;
|
||||
}
|
||||
|
||||
export interface ParseMeta {
|
||||
from: number;
|
||||
to: number;
|
||||
invalidActions?: true;
|
||||
invalidInput?: true;
|
||||
emptyPhrase?: true;
|
||||
orphan?: true;
|
||||
disabled?: true;
|
||||
overrides?: number[];
|
||||
overriddenBy?: number;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
result: CharaChordFile["chords"];
|
||||
meta: ParseMeta[];
|
||||
compoundInputs: Map<number, string>;
|
||||
}
|
||||
|
||||
export function parseCharaChords(
|
||||
data: EditorState,
|
||||
ids: Map<string, KeyInfo>,
|
||||
): ParseResult {
|
||||
const chords: CharaChordFile["chords"] = [];
|
||||
const metas: ParseMeta[] = [];
|
||||
const keys = new Map<string, number>();
|
||||
const compoundInputs = new Map<number, string>();
|
||||
|
||||
let currentChord: CharaChordFile["chords"][number] | undefined = undefined;
|
||||
let compound: number | undefined = undefined;
|
||||
let currentActions: number[] = [];
|
||||
let invalidActions = false;
|
||||
let invalidInput = false;
|
||||
let chordFrom = 0;
|
||||
|
||||
function makeChordInput(node: SyntaxNodeRef): number[] {
|
||||
invalidInput ||= !willBeValidChordInput(currentActions.length, !!compound);
|
||||
const input = composeChordInput(currentActions, compound);
|
||||
compound = hashChord(input);
|
||||
if (!compoundInputs.has(compound)) {
|
||||
compoundInputs.set(compound, data.doc.sliceString(chordFrom, node.from));
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
syntaxTree(data)
|
||||
.cursor()
|
||||
.iterate(
|
||||
(node) => {
|
||||
if (node.name === "Chord") {
|
||||
currentChord = undefined;
|
||||
compound = undefined;
|
||||
invalidActions = false;
|
||||
invalidInput = false;
|
||||
chordFrom = node.from;
|
||||
} else if (node.name === "ActionString") {
|
||||
currentActions = [];
|
||||
} else if (node.name === "HexNumber") {
|
||||
const hexString = data.doc.sliceString(node.from, node.to);
|
||||
const code = Number.parseInt(hexString, 16);
|
||||
if (hexString.length === 10) {
|
||||
if (compound !== undefined) {
|
||||
invalidInput = true;
|
||||
}
|
||||
compound = code;
|
||||
} else {
|
||||
if (Number.isNaN(code) || code < 0 || code > 1023) {
|
||||
invalidActions = true;
|
||||
}
|
||||
currentActions.push(code);
|
||||
}
|
||||
} else if (
|
||||
node.name === "ActionId" ||
|
||||
node.name === "SingleLetter" ||
|
||||
node.name === "EscapedChar"
|
||||
) {
|
||||
const id = data.doc.sliceString(node.from, node.to);
|
||||
const code = ids.get(id)?.code;
|
||||
if (code === undefined) {
|
||||
invalidActions = true;
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(id);
|
||||
for (let byte of bytes) {
|
||||
currentActions.push(-byte);
|
||||
}
|
||||
} else {
|
||||
currentActions.push(code);
|
||||
}
|
||||
}
|
||||
},
|
||||
(node) => {
|
||||
if (node.name === "Chord" && currentChord !== undefined) {
|
||||
if (currentChord !== undefined) {
|
||||
currentChord[1] = currentActions;
|
||||
const index = chords.length;
|
||||
chords.push(currentChord);
|
||||
const meta: ParseMeta = { from: node.from, to: node.to };
|
||||
if (invalidActions) {
|
||||
meta.invalidActions = true;
|
||||
}
|
||||
if (invalidInput) {
|
||||
meta.invalidInput = true;
|
||||
}
|
||||
metas.push(meta);
|
||||
if (currentChord[1].length === 0) {
|
||||
meta.emptyPhrase = true;
|
||||
}
|
||||
const key = JSON.stringify(currentChord[0]);
|
||||
if (!meta.invalidInput) {
|
||||
if (keys.has(key)) {
|
||||
const targetIndex = keys.get(key)!;
|
||||
const targetMeta = metas[targetIndex]!;
|
||||
if (!targetMeta.overrides) targetMeta.overrides = [];
|
||||
targetMeta.overrides.push(index);
|
||||
meta.overriddenBy = targetIndex;
|
||||
} else {
|
||||
keys.set(key, index);
|
||||
}
|
||||
}
|
||||
if (
|
||||
meta.emptyPhrase ||
|
||||
meta.invalidInput ||
|
||||
meta.invalidActions ||
|
||||
meta.overriddenBy !== undefined
|
||||
) {
|
||||
meta.disabled = true;
|
||||
}
|
||||
}
|
||||
} else if (node.name === "CompoundDelim") {
|
||||
makeChordInput(node);
|
||||
} else if (node.name === "PhraseDelim") {
|
||||
const input = makeChordInput(node);
|
||||
currentChord = [composeChordInput(input, compound), []];
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
for (let i = 0; i < metas.length; i++) {
|
||||
const [, compound] = splitCompound(chords[i]![0]);
|
||||
if (compound !== undefined && !compoundInputs.has(compound)) {
|
||||
metas[i]!.orphan = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { result: chords, meta: metas, compoundInputs };
|
||||
}
|
||||
|
||||
class ChordRecord {
|
||||
private chords = new Map<string, Set<string>>();
|
||||
|
||||
constructor(chords: CharaChordFile["chords"]) {
|
||||
for (let chord of chords) {
|
||||
const key = JSON.stringify(chord[0]);
|
||||
if (!this.chords.has(key)) {
|
||||
this.chords.set(key, new Set());
|
||||
}
|
||||
this.chords.get(key)!.add(JSON.stringify(chord));
|
||||
}
|
||||
}
|
||||
|
||||
static createDiff(
|
||||
previous: CharaChordFile["chords"],
|
||||
updated: CharaChordFile["chords"],
|
||||
) {
|
||||
const deleted = new ChordRecord(previous);
|
||||
const added = new ChordRecord(updated);
|
||||
const dupA = deleted.duplicates(added);
|
||||
const dupB = added.duplicates(deleted);
|
||||
for (let chord of dupA) {
|
||||
deleted.remove(chord);
|
||||
added.remove(chord);
|
||||
}
|
||||
for (let chord of dupB) {
|
||||
deleted.remove(chord);
|
||||
added.remove(chord);
|
||||
}
|
||||
return { deleted, added };
|
||||
}
|
||||
|
||||
duplicates(
|
||||
other: ChordRecord,
|
||||
): IteratorObject<CharaChordFile["chords"][number]> {
|
||||
const duplicates = new Set<string>();
|
||||
for (let [key, chordSet] of this.chords) {
|
||||
for (let chord of chordSet) {
|
||||
if (other.hasInternal(key, chord)) {
|
||||
duplicates.add(chord);
|
||||
}
|
||||
}
|
||||
}
|
||||
return duplicates
|
||||
.values()
|
||||
.map((it) => JSON.parse(it) as CharaChordFile["chords"][number]);
|
||||
}
|
||||
|
||||
private hasInternal(key: string, chord: string): boolean {
|
||||
return this.chords.get(key)?.has(chord) ?? false;
|
||||
}
|
||||
|
||||
has(chord: CharaChordFile["chords"][number]): boolean {
|
||||
return this.hasInternal(JSON.stringify(chord[0]), JSON.stringify(chord));
|
||||
}
|
||||
|
||||
remove(chord: CharaChordFile["chords"][number]) {
|
||||
const key = JSON.stringify(chord[0]);
|
||||
const set = this.chords.get(key);
|
||||
if (set) {
|
||||
set.delete(JSON.stringify(chord));
|
||||
if (set.size === 0) {
|
||||
this.chords.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncChords(
|
||||
previous: CharaChordFile["chords"],
|
||||
updated: CharaChordFile["chords"],
|
||||
state: EditorState,
|
||||
) {
|
||||
const deviceDiff = ChordRecord.createDiff(previous, updated);
|
||||
const current = parseCharaChords(state, get(KEYMAP_IDS));
|
||||
// save initial device chords
|
||||
// compare new device chords with initial device chords
|
||||
// take changed/new/removed chords
|
||||
// compare current editor chords with initial device chords
|
||||
// compare two change sets
|
||||
// apply removals if the chord didn't change on either end
|
||||
// apply
|
||||
}
|
||||
|
||||
export function rebaseUpdates(
|
||||
updates: readonly Update[],
|
||||
over: readonly { changes: ChangeDesc; clientID: string }[],
|
||||
) {
|
||||
if (!over.length || !updates.length) return updates;
|
||||
let changes: ChangeDesc | null = null,
|
||||
skip = 0;
|
||||
for (let update of over) {
|
||||
let other = skip < updates.length ? updates[skip] : null;
|
||||
if (other && other.clientID == update.clientID) {
|
||||
if (changes) changes = changes.mapDesc(other.changes, true);
|
||||
skip++;
|
||||
} else {
|
||||
changes = changes ? changes.composeDesc(update.changes) : update.changes;
|
||||
}
|
||||
}
|
||||
|
||||
if (skip) updates = updates.slice(skip);
|
||||
return !changes
|
||||
? updates
|
||||
: updates.map((update) => {
|
||||
let updateChanges = update.changes.map(changes!);
|
||||
changes = changes!.mapDesc(update.changes, true);
|
||||
return {
|
||||
changes: updateChanges,
|
||||
effects:
|
||||
update.effects && StateEffect.mapEffects(update.effects, changes!),
|
||||
clientID: update.clientID,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,39 +1,72 @@
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
type PluginValue,
|
||||
} from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import type {
|
||||
Completion,
|
||||
CompletionSection,
|
||||
CompletionSource,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
||||
|
||||
export function actionAutocompletePlugin(
|
||||
query: (query: string | undefined) => void,
|
||||
) {
|
||||
return ViewPlugin.fromClass(
|
||||
class implements PluginValue {
|
||||
constructor(readonly view: EditorView) {}
|
||||
const completionSections = derived(
|
||||
KEYMAP_CATEGORIES,
|
||||
(categories) =>
|
||||
new Map(
|
||||
categories.map(
|
||||
(category) =>
|
||||
[
|
||||
category,
|
||||
{
|
||||
name: category.name,
|
||||
} satisfies CompletionSection,
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
query(this.resolveAutocomplete(update.state));
|
||||
}
|
||||
export const actionAutocompleteItems = derived(
|
||||
[KEYMAP_CODES, completionSections],
|
||||
([codes, sections]) =>
|
||||
codes
|
||||
.values()
|
||||
.map((info) => {
|
||||
const canUseId = canUseIdAsString(info);
|
||||
const completionValue =
|
||||
(canUseId && info.id) ||
|
||||
`0x${info.code.toString(16).padStart(2, "0")}`;
|
||||
return {
|
||||
label:
|
||||
[
|
||||
canUseId || !info.id ? undefined : `"${info.id}"`,
|
||||
info.title,
|
||||
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || completionValue,
|
||||
detail: actionToValue(info),
|
||||
section: info.category ? sections.get(info.category) : undefined,
|
||||
info: info.description,
|
||||
type: "keyword",
|
||||
apply: completionValue + ">",
|
||||
} satisfies Completion;
|
||||
})
|
||||
.filter(
|
||||
(item) => typeof item.label === "string" && item.apply !== undefined,
|
||||
)
|
||||
.toArray(),
|
||||
);
|
||||
|
||||
resolveAutocomplete(state: EditorState): string | undefined {
|
||||
if (state.selection.ranges.length !== 1) return;
|
||||
const from = state.selection.ranges[0]!.from;
|
||||
const to = state.selection.ranges[0]!.to;
|
||||
if (from !== to) return;
|
||||
const tree = syntaxTree(state);
|
||||
const node = tree.resolveInner(from, -1).parent;
|
||||
if (node?.name !== "ExplicitAction") return;
|
||||
if (node.getChild("ExplicitDelimEnd")) return;
|
||||
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
|
||||
return (
|
||||
(queryNode
|
||||
? state.doc.sliceString(queryNode.from, queryNode.to)
|
||||
: undefined) || undefined
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
export const actionAutocomplete = ((context) => {
|
||||
let word = context.tokenBefore([
|
||||
"ExplicitDelimStart",
|
||||
"ActionId",
|
||||
"HexNumber",
|
||||
"DecimalNumber",
|
||||
]);
|
||||
if (!word) return null;
|
||||
console.log(get(actionAutocompleteItems));
|
||||
return {
|
||||
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
||||
validFor: /^<?[a-zA-Z0-9_]*$/,
|
||||
options: get(actionAutocompleteItems),
|
||||
};
|
||||
}) satisfies CompletionSource;
|
||||
|
||||
@@ -26,7 +26,7 @@ export class DelimWidget extends WidgetType {
|
||||
|
||||
toDOM() {
|
||||
if (!this.element) {
|
||||
/*this.element = document.createElement("span");
|
||||
this.element = document.createElement("span");
|
||||
this.element.innerHTML =
|
||||
" ⇛" + (this.hasConcatenator ? "" : " ");
|
||||
this.element.style.scale = "1.8";
|
||||
@@ -41,9 +41,7 @@ export class DelimWidget extends WidgetType {
|
||||
props: { action: 574, display: "keys", inText: true, ghost: true },
|
||||
});
|
||||
this.element.appendChild(button);
|
||||
}*/
|
||||
this.element = document.createElement("div");
|
||||
this.element.style.breakAfter = "column";
|
||||
}
|
||||
}
|
||||
return this.element;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HighlightStyle,
|
||||
} from "@codemirror/language";
|
||||
import { styleTags, tags } from "@lezer/highlight";
|
||||
import { actionAutocomplete } from "./autocomplete";
|
||||
|
||||
export const chordHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
@@ -50,5 +51,7 @@ export const chordLanguage = LRLanguage.define({
|
||||
});
|
||||
|
||||
export function chordLanguageSupport() {
|
||||
return new LanguageSupport(chordLanguage, [chordLanguage.data.of({})]);
|
||||
return new LanguageSupport(chordLanguage, [
|
||||
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@top Program { Chord* }
|
||||
|
||||
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
|
||||
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
||||
EscapedSingleAction { Escape EscapedLetter }
|
||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||
ActionString { Action* }
|
||||
@@ -9,15 +9,16 @@ ChordPhrase { ActionString }
|
||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||
|
||||
@tokens {
|
||||
@precedence {HexNumber}
|
||||
@precedence {HexNumber, DecimalNumber}
|
||||
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||
@precedence {EscapedLetter}
|
||||
ExplicitDelimStart {"<"}
|
||||
ExplicitDelimEnd {">"}
|
||||
CompoundDelim {"|"}
|
||||
CompoundDelim {"+>"}
|
||||
PhraseDelim {"=>"}
|
||||
Escape { "\\" }
|
||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||
DecimalNumber { $[0-9]+ }
|
||||
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||
SingleLetter { ![\\] }
|
||||
EscapedLetter { ![] }
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
a|.=<LEFT_SHIFT>=>t=t
|
||||
;ims=<0x219><IMPULSE>
|
||||
-;<KSC_2C><LEFT_SHIFT>=><0x23e>_<0x23e>
|
||||
.;g=><0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||
'dg=><0x23e>'<0x23e>
|
||||
'gl=><0x23e>'ll<0x23e>
|
||||
'ar=><0x23e>'re<0x23e>
|
||||
'gs=><0x23e>'s<0x23e>
|
||||
'ev=><0x23e>'ve<0x23e>
|
||||
<SPACE>-;=><0x23e><0x223>-<0x223><KSC_00>
|
||||
<SPACE>;<LEFT_SHIFT>=><0x23e><0x223><0x23d><0x223><KSC_00>
|
||||
<SPACE>;g=><0x23e><0x223><SPACE><0x223><KSC_00>
|
||||
deg=><0x23e>ed<0x23e>
|
||||
;gr=><0x23e>er<0x23e>
|
||||
;es=><0x23e>es<0x23e>
|
||||
;est=><0x23e>est<0x23e>
|
||||
.=<LEFT_SHIFT> => =>
|
||||
;ims => <0x219><IMPULSE>
|
||||
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
|
||||
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||
'dg => <0x23e>'<0x23e>
|
||||
'gl => <0x23e>'ll<0x23e>
|
||||
'ar => <0x23e>'re<0x23e>
|
||||
'gs => <0x23e>'s<0x23e>
|
||||
'ev => <0x23e>'ve<0x23e>
|
||||
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
|
||||
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
|
||||
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
|
||||
deg => <0x23e>ed<0x23e>
|
||||
;gr => <0x23e>er<0x23e>
|
||||
;es => <0x23e>es<0x23e>
|
||||
;est => <0x23e>est<0x23e>
|
||||
|
||||
@@ -8,12 +8,10 @@
|
||||
let {
|
||||
action,
|
||||
display,
|
||||
ignoreIcon = false,
|
||||
inText = false,
|
||||
}: {
|
||||
action: string | number | KeyInfo;
|
||||
display: "inline-keys" | "keys" | "verbose";
|
||||
ignoreIcon?: boolean;
|
||||
inText?: boolean;
|
||||
} = $props();
|
||||
|
||||
@@ -32,7 +30,6 @@
|
||||
? ({ code: 1024, id: action } satisfies KeyInfo)
|
||||
: action),
|
||||
);
|
||||
let icon = $derived(ignoreIcon ? undefined : info.icon);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
let hasPopover = $derived(
|
||||
!retrievedInfo || !info.id || info.title || info.description,
|
||||
@@ -56,6 +53,12 @@
|
||||
<br />
|
||||
<small>{info.description}</small>
|
||||
{/if}
|
||||
{#if info.breaking}
|
||||
<br /> <i>Prevents prepended autospaces</i>
|
||||
{/if}
|
||||
{#if info.separator || info.breaking}
|
||||
<br /> <i>Stops autocorrect</i>
|
||||
{/if}
|
||||
{:else}
|
||||
<b>Unknown Action</b><br />
|
||||
{#if info.code > 1023}
|
||||
@@ -66,7 +69,7 @@
|
||||
|
||||
{#snippet kbdText()}
|
||||
{dynamicMapping ??
|
||||
icon ??
|
||||
info.icon ??
|
||||
info.display ??
|
||||
info.id ??
|
||||
`0x${info.code.toString(16)}`}
|
||||
@@ -74,7 +77,7 @@
|
||||
{#snippet kbdSnippet(withPopover = true)}
|
||||
<kbd
|
||||
class:in-text={inText}
|
||||
class:icon={!!icon}
|
||||
class:icon={!!info.icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:error={info.code > 1023}
|
||||
@@ -94,7 +97,7 @@
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if !icon && info.id?.length === 1}
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
<span
|
||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||
class:in-text={inText}
|
||||
@@ -109,7 +112,7 @@
|
||||
class:in-text={inText}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:icon={!!icon}
|
||||
class:icon={!!info.icon}
|
||||
class:warn={!retrievedInfo}
|
||||
class:error={info.code > 1023}
|
||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||
@@ -158,50 +161,21 @@
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
$variant-offset: 12px;
|
||||
$variant-padding: calc(2px + $variant-offset);
|
||||
$variant-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-on-surface) 50%,
|
||||
transparent
|
||||
);
|
||||
|
||||
.left,
|
||||
.right {
|
||||
background-color: transparent;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
outline: 2px dashed
|
||||
color-mix(in srgb, var(--bg-color), var(--md-sys-color-outline) 40%);
|
||||
outline-offset: -2px;
|
||||
border-radius: var(--border-radius);
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
|
||||
$cutoff: 60%;
|
||||
|
||||
.left {
|
||||
background-image: linear-gradient(
|
||||
to right,
|
||||
var(--bg-color) $cutoff,
|
||||
transparent $cutoff
|
||||
);
|
||||
|
||||
&::before {
|
||||
clip-path: inset(0 0 0 $cutoff);
|
||||
}
|
||||
padding-inline-end: $variant-padding;
|
||||
text-shadow: $variant-offset 0 2px $variant-color;
|
||||
}
|
||||
.right {
|
||||
background-image: linear-gradient(
|
||||
to left,
|
||||
var(--bg-color) $cutoff,
|
||||
transparent $cutoff
|
||||
);
|
||||
|
||||
&::before {
|
||||
clip-path: inset(0 $cutoff 0 0);
|
||||
}
|
||||
padding-inline-start: $variant-padding;
|
||||
text-shadow: -$variant-offset 0 2px $variant-color;
|
||||
}
|
||||
|
||||
.inline-kbd {
|
||||
@@ -232,13 +206,13 @@
|
||||
display: -webkit-box;
|
||||
opacity: 0.9;
|
||||
max-width: 15ch;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
font-style: italic;
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
-webkit-line-clamp: 2; /* number of lines to show */
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,17 +19,13 @@
|
||||
let {
|
||||
currentAction = undefined,
|
||||
nextAction = undefined,
|
||||
queryFilter = undefined,
|
||||
ignoreIcon,
|
||||
autofocus = false,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
currentAction?: number;
|
||||
queryFilter?: string;
|
||||
nextAction?: number;
|
||||
autofocus?: boolean;
|
||||
ignoreIcon?: boolean;
|
||||
onselect?: (id: number) => void;
|
||||
onclose?: () => void;
|
||||
} = $props();
|
||||
@@ -47,14 +43,6 @@
|
||||
createIndex($KEYMAP_CODES);
|
||||
});
|
||||
|
||||
let didClear = true;
|
||||
$effect(() => {
|
||||
if (queryFilter !== undefined || !didClear) {
|
||||
searchBox.value = queryFilter ?? "";
|
||||
search();
|
||||
}
|
||||
});
|
||||
|
||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||
for (const [, action] of codes) {
|
||||
await index?.addAsync(
|
||||
@@ -72,7 +60,6 @@
|
||||
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||
),
|
||||
);
|
||||
didClear = searchBox.value === "";
|
||||
const result =
|
||||
searchBox.value === ""
|
||||
? Array.from($KEYMAP_CODES.keys())
|
||||
@@ -180,7 +167,7 @@
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as [category, actions] (actions)}
|
||||
{#each results as [category, actions] (category)}
|
||||
{#if actions.length > 0}
|
||||
<div class="category">
|
||||
<h3>{category.name}</h3>
|
||||
@@ -204,7 +191,7 @@
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
<Action {action} display="verbose" {ignoreIcon}></Action>
|
||||
<Action {action} display="verbose"></Action>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
||||
import { derived } from "svelte/store";
|
||||
|
||||
export const words = derived(
|
||||
[chords, osLayout, KEYMAP_CODES],
|
||||
([chords, layout, KEYMAP_CODES]) =>
|
||||
new Map<string, ChordInfo>(
|
||||
chords
|
||||
.map((chord) => ({
|
||||
chord,
|
||||
output: chord.phrase.map((action) =>
|
||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
||||
),
|
||||
}))
|
||||
.filter(({ output }) => output.every((it) => !!it))
|
||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
||||
),
|
||||
);
|
||||
|
||||
interface Score {
|
||||
lastTyped: number;
|
||||
score: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
||||
|
||||
export const learnConfigDefault = {
|
||||
maxScore: 3,
|
||||
minScore: -3,
|
||||
scoreBlend: 0.5,
|
||||
weakRate: 0.8,
|
||||
weakBoost: 0.5,
|
||||
maxWeak: 3,
|
||||
newRate: 0.3,
|
||||
initialNewRate: 0.9,
|
||||
initialCount: 10,
|
||||
};
|
||||
export const learnConfigStored = persistentWritable<
|
||||
Partial<typeof learnConfigDefault>
|
||||
>("learn-config", {});
|
||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
||||
...learnConfigDefault,
|
||||
...config,
|
||||
}));
|
||||
|
||||
let lastWord: string | undefined;
|
||||
|
||||
function shuffle<T>(array: T[]): T[] {
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
||||
return array[
|
||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
||||
];
|
||||
}
|
||||
|
||||
export const nextWord = derived(
|
||||
[words, scores, learnConfig],
|
||||
([words, scores, config]) => {
|
||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
||||
|
||||
values.sort(([, a], [, b]) => a.score - b.score);
|
||||
const weakCount =
|
||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
||||
values.length + 1) - 1;
|
||||
const weak = randomLog2(values, weakCount);
|
||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
||||
lastWord = weak[0];
|
||||
return weak[0];
|
||||
}
|
||||
|
||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
||||
const recent = randomLog2(values);
|
||||
const newRate =
|
||||
values.length < config.initialCount
|
||||
? config.initialNewRate
|
||||
: config.newRate;
|
||||
if (
|
||||
recent &&
|
||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
||||
Math.random() > newRate)
|
||||
) {
|
||||
lastWord = recent[0];
|
||||
return recent[0];
|
||||
}
|
||||
|
||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
||||
const word = newWord || recent?.[0] || weak?.[0];
|
||||
lastWord = word;
|
||||
return word;
|
||||
},
|
||||
);
|
||||
@@ -1,11 +0,0 @@
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
|
||||
interface ChordStats {
|
||||
level: number;
|
||||
lastUprank: number;
|
||||
}
|
||||
|
||||
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
||||
"chord-stats",
|
||||
{},
|
||||
);
|
||||
@@ -56,84 +56,6 @@ export function deserializeActions(native: bigint): number[] {
|
||||
return actions;
|
||||
}
|
||||
|
||||
const compoundHashItems = 3;
|
||||
const maxChordInputItems = 12;
|
||||
const actionBits = 10;
|
||||
const actionMask = (1 << actionBits) - 1;
|
||||
|
||||
/**
|
||||
* Applies the compound value to a **valid** chord input
|
||||
*/
|
||||
export function applyCompound(actions: number[], compound: number): number[] {
|
||||
const result = [...actions];
|
||||
for (let i = 0; i < compoundHashItems; i++) {
|
||||
result[i] = (compound >>> (i * actionBits)) & actionMask;
|
||||
}
|
||||
result[compoundHashItems] = 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the compound value from a chord input, if present
|
||||
*/
|
||||
export function splitCompound(
|
||||
actions: number[],
|
||||
): [inputs: number[], compound: number | undefined] {
|
||||
if (actions[compoundHashItems] != 0) {
|
||||
return [
|
||||
actions.slice(
|
||||
Math.max(
|
||||
0,
|
||||
actions.findIndex((it) => it !== 0),
|
||||
),
|
||||
),
|
||||
undefined,
|
||||
];
|
||||
}
|
||||
|
||||
let compound = 0;
|
||||
for (let i = 0; i < compoundHashItems; i++) {
|
||||
compound |= (actions[i] ?? 0) << (i * actionBits);
|
||||
}
|
||||
|
||||
return [
|
||||
actions.slice(
|
||||
actions.findIndex((it, i) => i > compoundHashItems && it !== 0),
|
||||
),
|
||||
compound === 0 ? undefined : compound,
|
||||
];
|
||||
}
|
||||
|
||||
export function willBeValidChordInput(
|
||||
inputCount: number,
|
||||
hasCompound: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
inputCount > 0 &&
|
||||
inputCount <= maxChordInputItems - (hasCompound ? compoundHashItems + 1 : 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composes a chord input from a list of actions and an optional compound value
|
||||
* to a valid chord input
|
||||
*/
|
||||
export function composeChordInput(
|
||||
actions: number[],
|
||||
compound?: number,
|
||||
): number[] {
|
||||
const result = [
|
||||
...Array.from(
|
||||
{
|
||||
length: Math.max(0, maxChordInputItems - actions.length),
|
||||
},
|
||||
() => 0,
|
||||
),
|
||||
...actions.slice(0, maxChordInputItems).sort((a, b) => a - b),
|
||||
];
|
||||
return compound !== undefined ? applyCompound(result, compound) : result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hashes a chord input the same way as CCOS
|
||||
*/
|
||||
@@ -150,6 +72,5 @@ export function hashChord(actions: number[]) {
|
||||
if ((hash & 0xff) === 0xff) {
|
||||
hash ^= 0xff;
|
||||
}
|
||||
hash &= 0x3fff_ffff;
|
||||
return hash === 0 ? 1 : hash;
|
||||
return hash & 0x3fff_ffff;
|
||||
}
|
||||
|
||||
@@ -55,10 +55,7 @@ export const syncStatus: Writable<
|
||||
"done" | "error" | "downloading" | "uploading"
|
||||
> = writable("done");
|
||||
|
||||
export const deviceMeta = persistentWritable<VersionMeta | undefined>(
|
||||
"current-meta",
|
||||
undefined,
|
||||
);
|
||||
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
||||
|
||||
export interface ProgressInfo {
|
||||
max: number;
|
||||
|
||||
@@ -147,7 +147,7 @@ export class CharaDevice {
|
||||
version!: string;
|
||||
company!: "CHARACHORDER" | "FORGE";
|
||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
||||
chipset!: "M0" | "S2" | "S3";
|
||||
chipset!: "M0" | "S2" | "S3" | "WASM";
|
||||
keyCount!: 90 | 67 | 256;
|
||||
layerCount = 3;
|
||||
profileCount = 1;
|
||||
@@ -157,7 +157,7 @@ export class CharaDevice {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly port: SerialPortLike,
|
||||
readonly port: SerialPortLike,
|
||||
public baudRate = 115200,
|
||||
) {}
|
||||
|
||||
@@ -183,11 +183,11 @@ export class CharaDevice {
|
||||
this.company = company as typeof this.company;
|
||||
this.device = device as typeof this.device;
|
||||
this.chipset = chipset as typeof this.chipset;
|
||||
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||
this.profileCount = this.chipset === "M0" ? 2 : 3;
|
||||
if (semverGte(this.version, "2.2.0-beta.4") && this.chipset !== "M0") {
|
||||
this.profileCount = 3;
|
||||
}
|
||||
if (semverGte(this.version, "2.2.0-beta.20")) {
|
||||
this.layerCount = this.chipset === "M0" ? 3 : 4;
|
||||
if (semverGte(this.version, "2.2.0-beta.20") && this.chipset !== "M0") {
|
||||
this.layerCount = 4;
|
||||
}
|
||||
this.keyCount = KEY_COUNTS[this.device];
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
kbd {
|
||||
--bg-color: color-mix(
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block: 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
//border: 1px solid currentcolor;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-surface-variant) 50%,
|
||||
transparent
|
||||
);
|
||||
--border-radius: 4px;
|
||||
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block: 6px;
|
||||
border-radius: var(--border-radius);
|
||||
background: var(--bg-color);
|
||||
padding: 4px;
|
||||
|
||||
height: 20px;
|
||||
color: currentcolor;
|
||||
font-weight: normal;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&.icon {
|
||||
|
||||
@@ -46,15 +46,6 @@
|
||||
element?.closest<HTMLElement>("[popover]")?.hidePopover();
|
||||
}
|
||||
|
||||
async function connectCC0(event: MouseEvent) {
|
||||
const { fetchCCOS } = await import("$lib/ccos/ccos");
|
||||
closePopover();
|
||||
const ccos = await fetchCCOS();
|
||||
if (ccos) {
|
||||
connect(ccos, !event.shiftKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function connectDevice(event: MouseEvent) {
|
||||
const port = await navigator.serial.requestPort({
|
||||
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
||||
@@ -88,9 +79,10 @@
|
||||
{#if ports.length !== 0}
|
||||
<h4>Recent Devices</h4>
|
||||
<div class="devices">
|
||||
<!--
|
||||
<div class="device">
|
||||
<button onclick={connectCC0}> CC0</button>
|
||||
</div>
|
||||
</div>-->
|
||||
{#each ports as port}
|
||||
<div class="device">
|
||||
<button
|
||||
@@ -115,7 +107,7 @@
|
||||
<button onclick={connectDevice} class="primary"
|
||||
><span class="icon">add</span>Connect</button
|
||||
>
|
||||
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
|
||||
<!--<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -116,6 +116,25 @@
|
||||
{/if}
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href={import.meta.env.VITE_DISCORD_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<svg
|
||||
class="discord-icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 126.64 96"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m81 0-3 7Q63 4 49 7l-4-7-26 8Q-4 45 1 80q14 10 32 16l6-11-10-5 2-2q33 13 64 0l3 2-11 5 7 11q17-5 32-16 4-40-19-72-12-5-26-8M42 65q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12m42 0q-10-1-11-12 0-15 11-13c11 2 12 6 12 13q-1 11-12 12"
|
||||
/></svg
|
||||
>
|
||||
Discord</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
||||
><span class="icon">bug_report</span> Bugs</a
|
||||
@@ -168,6 +187,11 @@
|
||||
|
||||
$sync-border-radius: 16px;
|
||||
|
||||
.discord-icon {
|
||||
margin: 5px;
|
||||
inline-size: 14px;
|
||||
}
|
||||
|
||||
.sync-box {
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
@@ -17,6 +17,11 @@
|
||||
: []),
|
||||
],
|
||||
[
|
||||
{
|
||||
href: "/editor/",
|
||||
icon: "playground_2",
|
||||
title: "Emulator",
|
||||
},
|
||||
{
|
||||
href: import.meta.env.VITE_LEARN_URL,
|
||||
icon: "school",
|
||||
@@ -36,14 +41,6 @@
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
[
|
||||
{ href: "/editor", icon: "edit_document", title: "Editor", wip: true },
|
||||
{ href: "/chat", icon: "chat", title: "Chat", wip: true },
|
||||
{ href: "/learn", icon: "school", title: "Learn", wip: true },
|
||||
],
|
||||
/*[
|
||||
{ href: "/plugin", icon: "code", title: "Plugin", wip: true },
|
||||
],*/
|
||||
] satisfies {
|
||||
href: string;
|
||||
icon: string;
|
||||
|
||||
1
src/routes/(app)/ccos/+layout.server.ts
Normal file
1
src/routes/(app)/ccos/+layout.server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const prerender = false;
|
||||
@@ -1,92 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat-rx";
|
||||
import { flip } from "svelte/animate";
|
||||
import { slide } from "svelte/transition";
|
||||
import Login from "./Login.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { browser } from "$app/environment";
|
||||
|
||||
onMount(async () => {
|
||||
if (browser) {
|
||||
await initMatrixClient();
|
||||
}
|
||||
});
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let spaces = $derived($matrix?.topLevelSpaces$);
|
||||
|
||||
function spaceShort(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((it) => it[0])
|
||||
.join("");
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $isLoggedIn}
|
||||
<div class="layout">
|
||||
<nav class="spaces">
|
||||
<a href="/chat/chats" class="icon chats">chat</a>
|
||||
<hr />
|
||||
{#if $spaces}
|
||||
<ul>
|
||||
{#each $spaces as space (space.roomId)}
|
||||
<li animate:flip transition:slide>
|
||||
<a class="space" href="/chat/space/{space.roomId}">
|
||||
{spaceShort(space.name)}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<button class="icon">add</button>
|
||||
</nav>
|
||||
</div>
|
||||
{:else}
|
||||
<Login />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 60%;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chats {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.space {
|
||||
margin-bottom: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { matrixClient } from "$lib/chat/chat";
|
||||
|
||||
function passwordLogin() {
|
||||
// TODO
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $matrixClient}
|
||||
{#await $matrixClient.loginFlows() then flows}
|
||||
{#each flows.flows as flow}
|
||||
{#if flow.type === "m.login.sso"}
|
||||
<a
|
||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||
>
|
||||
{#each flow.identity_providers as idp}
|
||||
{#if idp.icon}
|
||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||
{:else}
|
||||
{idp.name}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else if flow.type === "m.login.password"}
|
||||
<form onsubmit={passwordLogin}>
|
||||
<input name="username" type="text" placeholder="Username" />
|
||||
<input name="password" type="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { browser } from "$app/environment";
|
||||
import { onDestroy, onMount, setContext } from "svelte";
|
||||
import type {
|
||||
IndexedDBStore,
|
||||
IndexedDBCryptoStore,
|
||||
LoginResponse,
|
||||
} from "matrix-js-sdk";
|
||||
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
|
||||
import { matrixClient, currentRoomId } from "$lib/chat/chat";
|
||||
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
|
||||
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
|
||||
|
||||
let loggedIn = $state(false);
|
||||
let ready = $state(false);
|
||||
|
||||
let store: IndexedDBStore;
|
||||
let cryptoStore: IndexedDBCryptoStore;
|
||||
|
||||
onMount(async () => {
|
||||
if (!browser) return;
|
||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||
"matrix-js-sdk"
|
||||
);
|
||||
|
||||
const storedLogin = getStoredLogin();
|
||||
|
||||
store = new IndexedDBStore({
|
||||
dbName: "matrix",
|
||||
indexedDB: window.indexedDB,
|
||||
});
|
||||
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
|
||||
|
||||
$matrixClient = createClient({
|
||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||
userId: storedLogin?.user_id,
|
||||
accessToken: storedLogin?.access_token,
|
||||
timelineSupport: true,
|
||||
store,
|
||||
cryptoStore,
|
||||
});
|
||||
|
||||
const loginToken = new URLSearchParams(window.location.search).get(
|
||||
"loginToken",
|
||||
);
|
||||
if (loginToken) {
|
||||
await handleLogin(await $matrixClient.loginWithToken(loginToken));
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
|
||||
await postLogin();
|
||||
});
|
||||
|
||||
async function passwordLogin(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const form = event.target as HTMLFormElement;
|
||||
const username = (form.elements.namedItem("username") as HTMLInputElement)
|
||||
.value;
|
||||
const password = (form.elements.namedItem("password") as HTMLInputElement)
|
||||
.value;
|
||||
|
||||
await handleLogin(
|
||||
await $matrixClient.loginWithPassword(username, password),
|
||||
);
|
||||
await postLogin();
|
||||
}
|
||||
|
||||
async function handleLogin(response: LoginResponse) {
|
||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||
}
|
||||
|
||||
async function postLogin() {
|
||||
loggedIn = $matrixClient.isLoggedIn();
|
||||
|
||||
if (loggedIn) {
|
||||
await store.startup();
|
||||
await cryptoStore.startup();
|
||||
await $matrixClient.startClient();
|
||||
$matrixClient.once("sync", function (state, prevState, res) {
|
||||
ready = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getStoredLogin(): LoginResponse | undefined {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if ($matrixClient) {
|
||||
$matrixClient.stopClient();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if $matrixClient && loggedIn}
|
||||
{#if ready}
|
||||
<div class="chat">
|
||||
<div class="rooms">
|
||||
<button
|
||||
onclick={() => {
|
||||
$matrixClient.logout(true);
|
||||
$matrixClient.clearStores();
|
||||
localStorage.removeItem("matrix-login");
|
||||
window.location.reload();
|
||||
}}>logout</button
|
||||
>
|
||||
<MatrixRooms rooms={$matrixClient.getRooms()} />
|
||||
</div>
|
||||
{#if $currentRoomId}
|
||||
{@const room = $matrixClient.getRoom($currentRoomId)}
|
||||
{#key room}
|
||||
{#if room}
|
||||
<div class="timeline">
|
||||
<MatrixTimeline timeline={room.getLiveTimeline()} />
|
||||
</div>
|
||||
<div class="members">
|
||||
<MatrixRoomMembers members={room.getJoinedMembers()} />
|
||||
</div>
|
||||
{/if}
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if $matrixClient}
|
||||
{#await $matrixClient.loginFlows() then flows}
|
||||
{#each flows.flows as flow}
|
||||
{#if flow.type === "m.login.sso"}
|
||||
<a
|
||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||
>
|
||||
{#each flow.identity_providers as idp}
|
||||
{#if idp.icon}
|
||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||
{:else}
|
||||
{idp.name}
|
||||
{/if}
|
||||
{/each}
|
||||
</a>
|
||||
{:else if flow.type === "m.login.password"}
|
||||
<!-- TODO: unambigous sso
|
||||
<form onsubmit={passwordLogin}>
|
||||
<input name="username" type="text" placeholder="Username" />
|
||||
<input name="password" type="password" placeholder="Password" />
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
-->
|
||||
{/if}
|
||||
{/each}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.chat {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
> *:not(:last-child) {
|
||||
border-right: 1px solid var(--md-sys-color-outline);
|
||||
}
|
||||
}
|
||||
|
||||
.timeline {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.rooms {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.members {
|
||||
flex-shrink: 0;
|
||||
width: 200px;
|
||||
}
|
||||
</style>
|
||||
@@ -187,8 +187,13 @@
|
||||
let supportsAutospace = $derived(
|
||||
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
|
||||
);
|
||||
let supportsAutospaceV2 = $derived(
|
||||
semverGte($deviceMeta?.version ?? "0.0.0", "3.0.0-gamma.5"),
|
||||
);
|
||||
let hasAutospace = $derived(
|
||||
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||
supportsAutospaceV2
|
||||
? chord.phrase.at(-1) !== NO_CONCATENATOR_ACTION
|
||||
: isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||
);
|
||||
|
||||
function isHidden(action: number, index: number, array: number[]) {
|
||||
@@ -228,8 +233,10 @@
|
||||
moveCursor(cursorPosition + 1, true);
|
||||
}
|
||||
}
|
||||
await tick();
|
||||
resolveAutospace(autospace);
|
||||
if (!supportsAutospaceV2) {
|
||||
await tick();
|
||||
resolveAutospace(autospace);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
@@ -265,8 +272,24 @@
|
||||
<AutospaceSelector
|
||||
variant="end"
|
||||
value={!hasAutospace}
|
||||
onchange={(event) =>
|
||||
resolveAutospace((event.target as HTMLInputElement).checked)}
|
||||
onchange={async (event) => {
|
||||
if (supportsAutospaceV2) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||
deleteAction(chord.phrase.length - 1);
|
||||
await tick();
|
||||
moveCursor(cursorPosition, true);
|
||||
}
|
||||
} else {
|
||||
if (chord.phrase.at(-1) !== NO_CONCATENATOR_ACTION) {
|
||||
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
|
||||
moveCursor(cursorPosition, true);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
resolveAutospace((event.target as HTMLInputElement).checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
<sup>•</sup>
|
||||
|
||||
@@ -47,19 +47,6 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.chord {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.compound {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { chords } from "$lib/undo-redo";
|
||||
import { EditorView } from "codemirror";
|
||||
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
||||
import { actionPlugin } from "$lib/chord-editor/action-plugin";
|
||||
import { delimPlugin } from "$lib/chord-editor/chord-delim-plugin";
|
||||
import { highlightActiveLine, keymap } from "@codemirror/view";
|
||||
import { history, standardKeymap } from "@codemirror/commands";
|
||||
import "$lib/chord-editor/chords.grammar";
|
||||
import {
|
||||
chordHighlightStyle,
|
||||
chordLanguageSupport,
|
||||
} from "$lib/chord-editor/chords-grammar-plugin";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import ActionList from "$lib/components/layout/ActionList.svelte";
|
||||
import { actionAutocompletePlugin } from "$lib/chord-editor/autocomplete";
|
||||
import {
|
||||
actionLinter,
|
||||
actionLinterDependencies,
|
||||
} from "$lib/chord-editor/action-linter";
|
||||
import { forceLinting } from "@codemirror/lint";
|
||||
import { untrack } from "svelte";
|
||||
import { splitCompound } from "$lib/serial/chord";
|
||||
|
||||
let queryFilter: string | undefined = $state(undefined);
|
||||
|
||||
const rawCode = persistentWritable("chord-editor-raw-code", false);
|
||||
const showEdits = persistentWritable("chord-editor-show-edits", true);
|
||||
const denseSpacing = persistentWritable("chord-editor-spacing", false);
|
||||
|
||||
let originalDoc = $derived(
|
||||
$chords
|
||||
.map((chord) => {
|
||||
const [actions, compound] = splitCompound(chord.actions);
|
||||
return (
|
||||
(compound
|
||||
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
|
||||
: "") +
|
||||
actions.map((it) => actionToValue(it)).join("") +
|
||||
"=>" +
|
||||
chord.phrase.map((it) => actionToValue(it)).join("")
|
||||
);
|
||||
})
|
||||
.join("\n"),
|
||||
);
|
||||
let editor: HTMLDivElement | undefined = $state(undefined);
|
||||
let view: EditorView;
|
||||
|
||||
$effect(() => {
|
||||
if (!editor) return;
|
||||
view = new EditorView({
|
||||
parent: editor,
|
||||
doc: originalDoc,
|
||||
extensions: [
|
||||
...($rawCode ? [] : [delimPlugin, actionPlugin]),
|
||||
chordLanguageSupport(),
|
||||
actionLinter,
|
||||
// lineNumbers(),
|
||||
actionAutocompletePlugin((query) => {
|
||||
queryFilter = query;
|
||||
}),
|
||||
history(),
|
||||
syntaxHighlighting(chordHighlightStyle),
|
||||
highlightActiveLine(),
|
||||
// drawSelection(),
|
||||
EditorView.theme({
|
||||
".cm-line": {
|
||||
borderBottom: "1px solid transparent",
|
||||
caretColor: "var(--md-sys-color-on-surface)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
width: "100%",
|
||||
fontFamily: "inherit !important",
|
||||
gap: "8px",
|
||||
},
|
||||
".cm-content": {
|
||||
width: "100%",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderColor: "var(--md-sys-color-on-surface)",
|
||||
},
|
||||
}),
|
||||
keymap.of(standardKeymap),
|
||||
],
|
||||
});
|
||||
return () => view.destroy();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$actionLinterDependencies;
|
||||
untrack(() => view && forceLinting(view));
|
||||
});
|
||||
</script>
|
||||
|
||||
<label><input type="checkbox" bind:checked={$rawCode} />Edit as code</label>
|
||||
<!--<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>-->
|
||||
<label
|
||||
><input type="checkbox" bind:checked={$denseSpacing} />Dense Spacing</label
|
||||
>
|
||||
|
||||
<div class="split">
|
||||
<div
|
||||
class="editor"
|
||||
class:hide-edits={!$showEdits}
|
||||
class:raw={$rawCode}
|
||||
class:dense-spacing={$denseSpacing}
|
||||
bind:this={editor}
|
||||
></div>
|
||||
<ActionList {queryFilter} ignoreIcon={$rawCode} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.split {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
|
||||
> :global(:last-child) {
|
||||
width: min(600px, 30vw);
|
||||
}
|
||||
}
|
||||
|
||||
.editor :global(.cm-deletedChunk) {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: min(600px, 30vw);
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
|
||||
:global(.cm-tooltip) {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
:global(ul) {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
:global(li[role="option"][aria-selected="true"]) {
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
:global(completion-section) {
|
||||
margin-block: 8px;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.raw) :global(.cm-line) {
|
||||
columns: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&.dense-spacing :global(.cm-line) {
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
:global(.cm-line) {
|
||||
padding-block: 8px;
|
||||
width: 100%;
|
||||
text-wrap: wrap;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
> :global(*) {
|
||||
break-before: avoid;
|
||||
break-after: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.chord-ignored) {
|
||||
opacity: 0.5;
|
||||
background-image: none;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
:global(.chord-invalid) {
|
||||
color: var(--md-sys-color-error);
|
||||
text-decoration-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
:global(.change-button) {
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(.cm-deletedLineGutter) {
|
||||
background-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
:global(.cm-changedLineGutter) {
|
||||
background-color: var(--md-sys-color-success);
|
||||
}
|
||||
|
||||
:global(.cm-changedText) {
|
||||
background: linear-gradient(
|
||||
var(--md-sys-color-primary),
|
||||
var(--md-sys-color-primary)
|
||||
)
|
||||
bottom / 100% 1px no-repeat;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
&.raw :global(.cm-gutters) {
|
||||
border-color: var(--md-sys-color-surface-variant);
|
||||
background-color: var(--md-sys-color-surface);
|
||||
}
|
||||
|
||||
:global(.cm-editor) {
|
||||
outline: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:global(.cm-changedLine) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-primary) 5%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
:global(.cm-activeLine),
|
||||
:global(.cm-line:hover) {
|
||||
--auto-space-show: 1;
|
||||
}
|
||||
|
||||
:global(.cm-activeLine) {
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
&:not(.cm-changedLine) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(::selection),
|
||||
:global(.cm-selectionBackground) {
|
||||
background-color: var(--md-sys-color-surface-variant) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +0,0 @@
|
||||
<script>
|
||||
import { parseCharaChords } from "$lib/chord-editor/action-serializer";
|
||||
import text from "$lib/chord-editor/test.txt?raw";
|
||||
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
</script>
|
||||
|
||||
<pre>{text}</pre>
|
||||
{#each parseCharaChords(text, $KEYMAP_IDS) as chord}
|
||||
<pre>{JSON.stringify(chord)} ({text.slice(chord.from, chord.to)})</pre>
|
||||
{/each}
|
||||
@@ -5,18 +5,17 @@
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import TrackRollingWpm from "$lib/charrecorder/TrackRollingWpm.svelte";
|
||||
import { fade } from "svelte/transition";
|
||||
import { initSerial, serialPort } from "$lib/serial/connection";
|
||||
import { tick } from "svelte";
|
||||
import { ccosKeyInterceptor } from "$lib/ccos/attachment";
|
||||
|
||||
let recorder: ReplayRecorder = $state(new ReplayRecorder());
|
||||
let replay: Replay | undefined = $state();
|
||||
|
||||
let wpm = $state(0);
|
||||
let cc0Loading = $state(false);
|
||||
let chords: InferredChord[] = $state([]);
|
||||
|
||||
function handleRawKey(event: KeyboardEvent) {
|
||||
event.preventDefault();
|
||||
keyEvent(event);
|
||||
}
|
||||
|
||||
function keyEvent(event: KeyboardEvent) {
|
||||
if (event.key === "Tab") {
|
||||
clear();
|
||||
@@ -47,15 +46,60 @@
|
||||
a.download = "replay.json";
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function connectCC0(event: MouseEvent) {
|
||||
cc0Loading = true;
|
||||
try {
|
||||
await tick();
|
||||
if ($serialPort) {
|
||||
$serialPort?.close();
|
||||
$serialPort = undefined;
|
||||
}
|
||||
const { fetchCCOS } = await import("$lib/ccos/ccos");
|
||||
const ccos = await fetchCCOS();
|
||||
if (ccos) {
|
||||
try {
|
||||
await initSerial(ccos, !event.shiftKey);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cc0Loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Editor</title>
|
||||
</svelte:head>
|
||||
<svelte:window onkeydown={handleRawKey} onkeyup={handleRawKey} />
|
||||
|
||||
<section>
|
||||
<h2>Editor</h2>
|
||||
<h2>
|
||||
CCOS Emulator
|
||||
{#if $serialPort?.chipset === "WASM"}
|
||||
<small>(Emulator Active)</small>
|
||||
{:else}
|
||||
<button class="primary" disabled={cc0Loading} onclick={connectCC0}>
|
||||
<span class="icon">play_arrow</span>
|
||||
Boot CCOS Emulator</button
|
||||
>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
<p style:max-width="600px">
|
||||
Try a (limited) demo of CCOS running directly in your browser.<br /><span
|
||||
style:color="var(--md-sys-color-primary)"
|
||||
>Chording requires an <b>NKRO Keyboard</b> to work properly.</span
|
||||
>
|
||||
<br />Browsers usually report key timings with limited accuracy to revent
|
||||
fingerprinting, which can impact chording.
|
||||
<br /><i>Results may vary.</i>
|
||||
<br />
|
||||
Use sidebar tabs to configure <a href="/config/chords/">Chords</a>,
|
||||
<a href="/config/layout/">Layout</a>
|
||||
and <a href="/config/settings/">Settings</a>.
|
||||
</p>
|
||||
|
||||
{#if replay}
|
||||
<div class="replay" transition:fade={{ duration: 100 }}>
|
||||
@@ -66,7 +110,9 @@
|
||||
{#key recorder}
|
||||
<div
|
||||
class="editor"
|
||||
tabindex="-1"
|
||||
out:fade={{ duration: 100 }}
|
||||
{@attach ccosKeyInterceptor($serialPort, recorder)}
|
||||
style:opacity={replay ? 0 : undefined}
|
||||
>
|
||||
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||
@@ -95,15 +141,38 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
a {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
small {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--md-sys-color-primary);
|
||||
font-weight: 500;
|
||||
font-size: 0.6em;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
display: inline-flex;
|
||||
background: none;
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
.replay,
|
||||
.editor {
|
||||
position: absolute;
|
||||
top: 3em;
|
||||
left: 0;
|
||||
transition: opacity 0.1s;
|
||||
margin: 4px;
|
||||
outline: 1px solid var(--md-sys-color-outline);
|
||||
padding: 16px;
|
||||
padding-bottom: 5em;
|
||||
padding-left: 0;
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
<li><a href="/learn/layout/">Layout</a></li>
|
||||
<li><a href="/learn/chords/">Chords</a></li>
|
||||
<li><a href="/learn/sentence/">Sentences</a></li>
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
ul {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin: 16px;
|
||||
padding: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
a {
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,232 +0,0 @@
|
||||
<script lang="ts">
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import {
|
||||
words,
|
||||
nextWord,
|
||||
scores,
|
||||
learnConfigDefault,
|
||||
learnConfig,
|
||||
learnConfigStored,
|
||||
} from "$lib/learn/chords";
|
||||
import { fade } from "svelte/transition";
|
||||
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||
|
||||
let recorder = $derived(new ReplayRecorder($nextWord));
|
||||
let start = performance.now();
|
||||
$effect(() => {
|
||||
start = recorder && performance.now();
|
||||
});
|
||||
|
||||
let chords: InferredChord[] = $state([]);
|
||||
|
||||
function onkeyboard(event: KeyboardEvent) {
|
||||
recorder.next(event);
|
||||
}
|
||||
|
||||
function lerp(a: number, b: number, t: number) {
|
||||
return a + (b - a) * t;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const [chord] = chords;
|
||||
if (!chord) return;
|
||||
|
||||
console.log(chord);
|
||||
|
||||
if (chord.output.trim() === $nextWord) {
|
||||
scores.update((scores) => {
|
||||
const score = Math.max(
|
||||
$learnConfig.minScore,
|
||||
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
||||
);
|
||||
|
||||
if (!scores[$nextWord]) {
|
||||
scores[$nextWord] = {
|
||||
score,
|
||||
lastTyped: performance.now(),
|
||||
total: 1,
|
||||
};
|
||||
return scores;
|
||||
}
|
||||
|
||||
const oldScore = scores[$nextWord].score;
|
||||
scores[$nextWord].score = lerp(
|
||||
score,
|
||||
oldScore,
|
||||
$learnConfig.scoreBlend,
|
||||
);
|
||||
scores[$nextWord].lastTyped = performance.now();
|
||||
scores[$nextWord].total += 1;
|
||||
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function skip() {
|
||||
button?.blur();
|
||||
scores.update((scores) => {
|
||||
return scores;
|
||||
});
|
||||
}
|
||||
|
||||
let button = $state<HTMLButtonElement>();
|
||||
</script>
|
||||
|
||||
<h2>WIP</h2>
|
||||
|
||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
||||
|
||||
{#key $nextWord}
|
||||
<h3>
|
||||
{$nextWord}
|
||||
{#if $scores[$nextWord!] === undefined}
|
||||
<sup class="new-word">new</sup>
|
||||
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
||||
<sup class="weak">weak</sup>
|
||||
{/if}
|
||||
</h3>
|
||||
|
||||
<div class="chord" in:fade>
|
||||
<CharRecorder replay={recorder.player} cursor={true}>
|
||||
<TrackChords bind:chords />
|
||||
</CharRecorder>
|
||||
</div>
|
||||
{/key}
|
||||
|
||||
{#key $nextWord}
|
||||
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
||||
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
||||
</div>
|
||||
{/key}
|
||||
<button onclick={skip} bind:this={button}>skip</button>
|
||||
|
||||
<section class="stats">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Weak</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => a.score - b.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Strong</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.score - a.score)
|
||||
.splice(0, 10) as [word, score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
<td><i>{score.score.toFixed(2)}</i></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Rehearse</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries($scores)
|
||||
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
||||
.splice(0, 10) as [word, _score]}
|
||||
<tr class="decay">
|
||||
<td>{word}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<details>
|
||||
<summary>Settings</summary>
|
||||
<button onclick={() => ($scores = {})}>Reset</button>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each Object.entries(learnConfigDefault) as [key, value]}
|
||||
<tr>
|
||||
<th>{key}</th>
|
||||
<td
|
||||
><input
|
||||
type="number"
|
||||
value={$learnConfig[key as keyof typeof $learnConfig] ?? value}
|
||||
step="0.1"
|
||||
oninput={(event) =>
|
||||
($learnConfigStored[key as keyof typeof $learnConfig] = (
|
||||
event.target as HTMLInputElement
|
||||
).value as any)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
disabled={!$learnConfigStored[key as keyof typeof $learnConfig]}
|
||||
onclick={() =>
|
||||
($learnConfigStored[key as keyof typeof $learnConfigStored] =
|
||||
undefined)}>⟲</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
|
||||
<style lang="scss">
|
||||
@use "sass:math";
|
||||
|
||||
input {
|
||||
border: none;
|
||||
background: none;
|
||||
width: 5ch;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1ch;
|
||||
min-width: 20ch;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 3em;
|
||||
}
|
||||
|
||||
sup {
|
||||
font-weight: normal;
|
||||
font-size: 0.8em;
|
||||
|
||||
&.new-word {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
&.weak {
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
}
|
||||
|
||||
@for $i from 1 through 10 {
|
||||
tr.decay:nth-child(#{$i}) {
|
||||
opacity: 1 - math.div($i, 10);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,124 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { setContext } from "svelte";
|
||||
import Layout from "$lib/components/layout/Layout.svelte";
|
||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { layout } from "$lib/undo-redo";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
|
||||
let hasStarted = $state(false);
|
||||
|
||||
setContext<VisualLayoutConfig>("visual-layout-config", {
|
||||
scale: 50,
|
||||
inactiveScale: 0.5,
|
||||
inactiveOpacity: 0.4,
|
||||
strokeWidth: 1,
|
||||
margin: 5,
|
||||
fontSize: 9,
|
||||
iconFontSize: 14,
|
||||
});
|
||||
|
||||
const actions = derived(layout, (layout) => {
|
||||
const result = new Set<number>();
|
||||
for (const layer of layout) {
|
||||
for (const key of layer) {
|
||||
result.add(key[0].action);
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
});
|
||||
|
||||
const currentAction = writable(0);
|
||||
|
||||
const expected = derived(
|
||||
[layout, currentAction],
|
||||
([layout, currentAction]) => {
|
||||
const result: Array<{ layer: number; key: number }> = [];
|
||||
for (let layer = 0; layer <= layout.length; layer++) {
|
||||
const layerArr = layout[layer];
|
||||
if (layerArr === undefined) {
|
||||
continue;
|
||||
}
|
||||
for (let key = 0; key <= layerArr.length; key++) {
|
||||
if (layerArr[key]?.[0].action === currentAction) {
|
||||
result.push({ layer, key });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
const highlight = derived(
|
||||
expected,
|
||||
(expected) => new Set(expected.map(({ key }) => key)),
|
||||
);
|
||||
|
||||
const highlightAction = derived(
|
||||
currentAction,
|
||||
(currentAction) => new Set([currentAction]),
|
||||
);
|
||||
|
||||
const currentLayer = writable(0);
|
||||
|
||||
setContext("highlight", highlight);
|
||||
|
||||
setContext("highlight-action", highlightAction);
|
||||
|
||||
setContext("active-layer", currentLayer);
|
||||
|
||||
async function next() {
|
||||
console.log("Next");
|
||||
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
|
||||
if (nextAction !== undefined) {
|
||||
currentAction.set(nextAction);
|
||||
currentLayer.set($expected[0]?.layer ?? 0);
|
||||
const key = await $serialPort?.queryKey();
|
||||
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
|
||||
console.log("Correct", key);
|
||||
} else {
|
||||
console.log("Incorrect", key);
|
||||
}
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
|
||||
hasStarted = true;
|
||||
next();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="challenge">
|
||||
<Action display="inline-keys" action={$currentAction}></Action>
|
||||
</div>
|
||||
|
||||
<Layout />
|
||||
</section>
|
||||
|
||||
<style lang="scss">
|
||||
.challenge {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,652 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/stores";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
||||
import debounce from "$lib/util/debounce";
|
||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||
import { shuffleInPlace } from "$lib/util/shuffle";
|
||||
import { fade, fly, slide } from "svelte/transition";
|
||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
|
||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
||||
import TrackText from "$lib/charrecorder/TrackText.svelte";
|
||||
import { browser } from "$app/environment";
|
||||
import { expoOut } from "svelte/easing";
|
||||
import { goto } from "$app/navigation";
|
||||
import { untrack } from "svelte";
|
||||
import {
|
||||
type PageParam,
|
||||
SENTENCE_TRAINER_PAGE_PARAMS,
|
||||
} from "./configuration";
|
||||
import {
|
||||
AVG_WORD_LENGTH,
|
||||
MILLIS_IN_SECOND,
|
||||
SECONDS_IN_MINUTE,
|
||||
} from "./constants";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
|
||||
/**
|
||||
* Resolves parameter from search URL or returns default
|
||||
* @param param {@link PageParam} generic parameter that can be provided
|
||||
* in search url
|
||||
* @return Value of the parameter converted to its type or default value
|
||||
* if parameter is not present in the URL.
|
||||
*/
|
||||
function getParamOrDefault<T>(param: PageParam<T>): T {
|
||||
if (browser) {
|
||||
const value = $page.url.searchParams.get(param.key);
|
||||
if (null !== value) {
|
||||
return param.parse ? param.parse(value) : (value as unknown as T);
|
||||
}
|
||||
}
|
||||
return param.default;
|
||||
}
|
||||
|
||||
function viaLocalStorage<T>(key: string, initial: T) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(key) ?? "");
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
// Delay to ensure cursor is visible after focus is set.
|
||||
// it is a workaround for conflict between goto call on sentence update
|
||||
// and cursor focus when next word is selected.
|
||||
const CURSOR_FOCUS_DELAY_MS = 10;
|
||||
|
||||
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
||||
viaLocalStorage("mastery-thresholds", [
|
||||
[1500, 1050, "Words"],
|
||||
[3000, 2500, "Pairs"],
|
||||
[5000, 3500, "Trios"],
|
||||
]),
|
||||
);
|
||||
|
||||
function reset() {
|
||||
localStorage.removeItem("mastery-thresholds");
|
||||
localStorage.removeItem("idle-timeout");
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
const inputSentence = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
|
||||
);
|
||||
|
||||
const wpmTarget = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
|
||||
);
|
||||
|
||||
const devTools = $derived(
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
|
||||
);
|
||||
|
||||
let chordInputContainer: HTMLDivElement | null = null;
|
||||
|
||||
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
|
||||
|
||||
let inputSentenceLength = $derived(inputSentence.length);
|
||||
let msPerChar = $derived(
|
||||
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
|
||||
MILLIS_IN_SECOND,
|
||||
);
|
||||
let totalMs = $derived(inputSentenceLength * msPerChar);
|
||||
let msPerWord = $derived(
|
||||
(inputSentenceLength * msPerChar) / sentenceWords.length,
|
||||
);
|
||||
let currentWord = $state("");
|
||||
let wordStats = new SvelteMap<string, number[]>();
|
||||
let wordMastery = new SvelteMap<string, number>();
|
||||
let text = $state("");
|
||||
let level = $state(0);
|
||||
let bestWPM = $state(0);
|
||||
let wpm = $state(0);
|
||||
let chords: InferredChord[] = $state([]);
|
||||
let recorder = $state(new ReplayRecorder());
|
||||
let idle = $state(true);
|
||||
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
|
||||
|
||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
$effect(() => {
|
||||
if (wpm > bestWPM) {
|
||||
bestWPM = wpm;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (browser && $page.url.searchParams) {
|
||||
selectNextWord();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
localStorage.setItem("idle-timeout", idleTime.toString());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
localStorage.setItem(
|
||||
"mastery-thresholds",
|
||||
JSON.stringify(masteryThresholds),
|
||||
);
|
||||
});
|
||||
|
||||
let words = $derived.by(() => {
|
||||
const words = sentenceWords;
|
||||
switch (level) {
|
||||
case 0: {
|
||||
shuffleInPlace(words);
|
||||
return words;
|
||||
}
|
||||
case 1: {
|
||||
const pairs = [];
|
||||
for (let i = 0; i < words.length - 1; i++) {
|
||||
pairs.push(`${words[i]} ${words[i + 1]}`);
|
||||
}
|
||||
shuffleInPlace(pairs);
|
||||
return pairs;
|
||||
}
|
||||
case 2: {
|
||||
const trios = [];
|
||||
for (let i = 0; i < words.length - 2; i++) {
|
||||
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
||||
}
|
||||
shuffleInPlace(trios);
|
||||
return trios;
|
||||
}
|
||||
default: {
|
||||
return [inputSentence];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
for (const [word, speeds] of wordStats.entries()) {
|
||||
const level = word.split(" ").length - 1;
|
||||
const masteryThreshold = masteryThresholds[level];
|
||||
if (masteryThreshold === undefined) continue;
|
||||
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
|
||||
wordMastery.set(
|
||||
word,
|
||||
1 -
|
||||
Math.min(
|
||||
1,
|
||||
Math.max(
|
||||
0,
|
||||
(averageSpeed - masteryThreshold[1]) /
|
||||
(masteryThreshold[0] - masteryThreshold[1]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let progress = $derived(
|
||||
level === masteryThresholds.length
|
||||
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
|
||||
: words.length > 0
|
||||
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
|
||||
words.length
|
||||
: 0,
|
||||
);
|
||||
let mastered = $derived(
|
||||
words.length > 0
|
||||
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
|
||||
: 0,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (progress === 1 && level < masteryThresholds.length) {
|
||||
level++;
|
||||
}
|
||||
});
|
||||
|
||||
function selectNextWord() {
|
||||
const nextWord = pickNextWord(
|
||||
words,
|
||||
wordMastery,
|
||||
untrack(() => currentWord),
|
||||
);
|
||||
currentWord = nextWord;
|
||||
recorder = new ReplayRecorder(nextWord);
|
||||
setTimeout(() => {
|
||||
chordInputContainer?.focus();
|
||||
}, CURSOR_FOCUS_DELAY_MS);
|
||||
}
|
||||
|
||||
function checkInput() {
|
||||
if (recorder.player.stepper.challenge.length === 0) return;
|
||||
const replay = recorder.finish(false);
|
||||
const elapsed = replay.finish - replay.start! - idleTime;
|
||||
if (elapsed < masteryThresholds[level]![0]) {
|
||||
const prevStats = wordStats.get(currentWord) ?? [];
|
||||
prevStats.push(elapsed);
|
||||
wordStats.set(currentWord, prevStats.slice(-10));
|
||||
}
|
||||
|
||||
text = "";
|
||||
setTimeout(() => {
|
||||
selectNextWord();
|
||||
});
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!idle || !text) return;
|
||||
if (text.trim() !== currentWord.trim()) return;
|
||||
if (level === masteryThresholds.length) {
|
||||
const replay = recorder.finish();
|
||||
const elapsed = replay.finish - replay.start!;
|
||||
text = "";
|
||||
recorder = new ReplayRecorder(currentWord);
|
||||
console.log(elapsed, totalMs);
|
||||
wpm = (totalMs / elapsed) * wpmTarget;
|
||||
} else {
|
||||
checkInput();
|
||||
}
|
||||
});
|
||||
|
||||
function onkey(event: KeyboardEvent) {
|
||||
if (idleTimeout) {
|
||||
clearTimeout(idleTimeout);
|
||||
}
|
||||
idle = false;
|
||||
recorder.next(event);
|
||||
idleTimeout = setTimeout(() => {
|
||||
idle = true;
|
||||
}, idleTime);
|
||||
}
|
||||
|
||||
function updateSentence(event: Event) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set(
|
||||
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
|
||||
(event.target as HTMLInputElement).value,
|
||||
);
|
||||
goto(`?${params.toString()}`);
|
||||
}
|
||||
|
||||
const debouncedUpdateSentence = debounce(
|
||||
updateSentence,
|
||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
|
||||
);
|
||||
|
||||
function handleInputAreaKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault(); // Prevent new line.
|
||||
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
|
||||
updateSentence(event); // Update immediately
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Sentence Trainer</h1>
|
||||
<textarea
|
||||
rows="7"
|
||||
cols="80"
|
||||
oninput={debouncedUpdateSentence}
|
||||
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
|
||||
>
|
||||
|
||||
<div class="levels">
|
||||
{#each masteryThresholds as [, , title], i}
|
||||
<button
|
||||
class:active={level === i}
|
||||
class:mastered={i < level || progress === 1}
|
||||
class="threshold"
|
||||
onclick={() => {
|
||||
level = i;
|
||||
selectNextWord();
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
{/each}
|
||||
<button
|
||||
class:active={level === masteryThresholds.length}
|
||||
class:mastered={masteryThresholds.length < level || progress === 1}
|
||||
class="threshold"
|
||||
onclick={() => {
|
||||
level = masteryThresholds.length;
|
||||
selectNextWord();
|
||||
}}
|
||||
>
|
||||
{wpmTarget} WPM
|
||||
</button>
|
||||
{#each masteryThresholds as _, i}
|
||||
<div
|
||||
class="progress"
|
||||
style:--progress="{-100 *
|
||||
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
|
||||
style:--mastered="{-100 *
|
||||
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
|
||||
class:active={level === i}
|
||||
></div>
|
||||
{/each}
|
||||
<div
|
||||
class="progress"
|
||||
style:--progress="-100%"
|
||||
style:--mastered="{-100 *
|
||||
(1 -
|
||||
(level === masteryThresholds.length
|
||||
? progress
|
||||
: masteryThresholds.length < level
|
||||
? 1
|
||||
: 0))}%"
|
||||
class:active={level === masteryThresholds.length}
|
||||
></div>
|
||||
</div>
|
||||
<div class="sentence">
|
||||
{#each sentenceWords as _, i}
|
||||
{#if i !== sentenceWords.length - 1}
|
||||
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<div
|
||||
class="arch"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery}
|
||||
style:grid-row={(i % 2) + 1}
|
||||
style:grid-column="{i + 1} / span 2"
|
||||
style:border-bottom="none"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each sentenceWords as word, i}
|
||||
{@const mastery = wordMastery.get(word)}
|
||||
<div
|
||||
class="word"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery ?? 0}
|
||||
style:grid-row={3}
|
||||
style:grid-column={i + 1}
|
||||
>
|
||||
{word}
|
||||
</div>
|
||||
{/each}
|
||||
{#each sentenceWords as _, i}
|
||||
{#if i < sentenceWords.length - 2}
|
||||
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<div
|
||||
class="arch"
|
||||
class:mastered={mastery === 1}
|
||||
style:opacity={mastery}
|
||||
style:grid-row={(i % 3) + 4}
|
||||
style:grid-column="{i + 1} / span 3"
|
||||
style:border-top="none"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if level === masteryThresholds.length}
|
||||
{@const maxDigits = 4}
|
||||
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
|
||||
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
|
||||
<div class="finish" transition:slide>
|
||||
<div
|
||||
class="wpm"
|
||||
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
|
||||
style:opacity={progress}
|
||||
style:font-size="3rem"
|
||||
style:color="var(--md-sys-color-{progress === 1
|
||||
? 'primary'
|
||||
: 'on-background'})"
|
||||
style:scale={(progress + 0.5) / 2}
|
||||
>
|
||||
{#each indices as i}
|
||||
{@const char = wpmString[i]}
|
||||
{#key char}
|
||||
<div
|
||||
style:grid-column={i + 1}
|
||||
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
|
||||
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
|
||||
>
|
||||
{char}
|
||||
</div>
|
||||
{/key}
|
||||
{/each}
|
||||
<div style:grid-column={maxDigits + 3} style:justify-self="start">
|
||||
WPM
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="wpm"
|
||||
style:grid-template-columns="4ch 1ch auto"
|
||||
style:font-size="1.5rem"
|
||||
>
|
||||
{#key wpm}
|
||||
<div
|
||||
style:grid-column={1}
|
||||
style:justify-self="end"
|
||||
transition:fade={{ duration: 200 }}
|
||||
>
|
||||
{Math.floor(wpm)}
|
||||
</div>
|
||||
{/key}
|
||||
<div style:grid-column={3} style:justify-self="start">WPM</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<ChordHud {chords} />
|
||||
<div class="container">
|
||||
<div
|
||||
bind:this={chordInputContainer}
|
||||
class="input-section"
|
||||
onkeydown={onkey}
|
||||
onkeyup={onkey}
|
||||
tabindex="0"
|
||||
role="textbox"
|
||||
>
|
||||
{#key recorder}
|
||||
<div class="input" transition:fade={{ duration: 200 }}>
|
||||
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||
<TrackText bind:text />
|
||||
<TrackChords bind:chords />
|
||||
</CharRecorder>
|
||||
</div>
|
||||
{/key}
|
||||
</div>
|
||||
</div>
|
||||
{#if devTools}
|
||||
<div>Dev Tools</div>
|
||||
<button onclick={reset}>Reset</button>
|
||||
<label>Idle Time <input bind:value={idleTime} /></label>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Total</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(totalMs)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Char</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerChar)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Word</th>
|
||||
<td
|
||||
><span style:color="var(--md-sys-color-tertiary)"
|
||||
>{Math.round(msPerWord)}</span
|
||||
>ms
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each masteryThresholds as _, i}
|
||||
<tr>
|
||||
<th>L{i + 1}</th>
|
||||
<td><input bind:value={masteryThresholds[i]![0]} /></td>
|
||||
<td><input bind:value={masteryThresholds[i]![1]} /></td>
|
||||
<td><input bind:value={masteryThresholds[i]![2]} /></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<table>
|
||||
<tbody>
|
||||
{#each wordStats.entries() as [word, stats]}
|
||||
{@const mastery = wordMastery.get(word) ?? 0}
|
||||
<tr>
|
||||
<th>{word}</th>
|
||||
<td
|
||||
style:color="var(--md-sys-color-{mastery === 1
|
||||
? 'primary'
|
||||
: 'tertiary'})"
|
||||
>{Math.round(mastery * 100)}%
|
||||
</td>
|
||||
{#each stats as stat}
|
||||
<td>{stat}</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.levels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2px;
|
||||
|
||||
button {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.wpm {
|
||||
display: grid;
|
||||
transition: scale 0.2s ease;
|
||||
width: min-content;
|
||||
|
||||
* {
|
||||
grid-row: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.finish {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(2, 1fr);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sentence {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(4, auto);
|
||||
gap: 4px 1ch;
|
||||
margin-block: 1rem;
|
||||
width: min-content;
|
||||
|
||||
.word,
|
||||
.arch {
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&.mastered {
|
||||
border-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.arch {
|
||||
border: 2px solid var(--md-sys-color-outline);
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.progress {
|
||||
position: relative;
|
||||
grid-row: 2;
|
||||
border: none;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
width: auto;
|
||||
height: 1rem;
|
||||
overflow: hidden;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
display: block;
|
||||
position: absolute;
|
||||
transition: transform 0.2s;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
content: "";
|
||||
}
|
||||
|
||||
&::before {
|
||||
transform: translateX(var(--progress));
|
||||
background: var(--md-sys-color-outline);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: translateX(var(--mastered));
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.threshold {
|
||||
grid-row: 1;
|
||||
justify-self: center;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
width: auto;
|
||||
|
||||
&.mastered,
|
||||
&.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.mastered {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.input-section {
|
||||
display: grid;
|
||||
cursor: text;
|
||||
|
||||
:global(.cursor) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.input {
|
||||
display: flex;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
transition:
|
||||
outline 0.2s ease,
|
||||
border-radius 0.2s ease;
|
||||
margin-block: 1rem;
|
||||
outline: 2px dashed transparent;
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
max-width: 16cm;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.input-section:focus-within {
|
||||
outline: none;
|
||||
|
||||
.input {
|
||||
outline-color: var(--md-sys-color-primary);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
:global(.cursor) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
export interface PageParam<T> {
|
||||
key: string;
|
||||
default: T;
|
||||
parse?: (value: string) => T;
|
||||
}
|
||||
|
||||
export const SENTENCE_TRAINER_PAGE_PARAMS: {
|
||||
sentence: PageParam<string>;
|
||||
wpm: PageParam<number>;
|
||||
showDevTools: PageParam<boolean>;
|
||||
textAreaDebounceInMillis: PageParam<number>;
|
||||
} = {
|
||||
sentence: {
|
||||
key: "sentence",
|
||||
default: "This text has been typed at the speed of thought",
|
||||
},
|
||||
wpm: {
|
||||
key: "wpm",
|
||||
default: 250,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
showDevTools: {
|
||||
key: "dev",
|
||||
default: false,
|
||||
parse: (value) => value === "true",
|
||||
},
|
||||
textAreaDebounceInMillis: {
|
||||
key: "debounceMillis",
|
||||
default: 5000,
|
||||
parse: (value) => Number(value),
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
// Domain constants
|
||||
export const AVG_WORD_LENGTH = 5;
|
||||
export const SECONDS_IN_MINUTE = 60;
|
||||
export const MILLIS_IN_SECOND = 1000;
|
||||
|
||||
// Error messages.
|
||||
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
|
||||
"The sentence is too short to make N-Grams, please enter longer sentence";
|
||||
@@ -1,69 +0,0 @@
|
||||
import { describe, it, beforeEach, expect, vi } from "vitest";
|
||||
import { pickNextWord } from "./word-selector";
|
||||
import { untrack } from "svelte";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
||||
|
||||
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
|
||||
vi.mock("svelte", () => ({
|
||||
untrack: vi.fn((fn: any) => fn()),
|
||||
}));
|
||||
|
||||
describe("pickNextWord", () => {
|
||||
let words: string[];
|
||||
let wordMastery: SvelteMap<string, number>;
|
||||
let currentWord: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Set up sample words and mastery values.
|
||||
words = ["alpha", "beta", "gamma"];
|
||||
wordMastery = new SvelteMap<string, number>();
|
||||
// For this test, assume none of the words are mastered.
|
||||
words.forEach((word) => wordMastery.set(word, 0));
|
||||
currentWord = "alpha";
|
||||
});
|
||||
|
||||
it("should return a word different from current", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped, we expect next word.
|
||||
expect(nextWord).toBe("beta");
|
||||
});
|
||||
|
||||
it("should randomly skip words", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since currentWord ("alpha") should be skipped as current
|
||||
// and "beta" should be randomly skipped we expect "gamma".
|
||||
expect(nextWord).toBe("gamma");
|
||||
});
|
||||
|
||||
it("should return current word if all other words were randomly skipped", () => {
|
||||
// Force Math.random() to return a predictable value.
|
||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
|
||||
|
||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
||||
|
||||
// Since all other words have been randomly skipped, we expect
|
||||
// current word to be returned.
|
||||
expect(nextWord).toBe("alpha");
|
||||
});
|
||||
|
||||
it("current word should be passed untracked", () => {
|
||||
pickNextWord(words, wordMastery, currentWord);
|
||||
expect(untrack).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
|
||||
const result = pickNextWord([], wordMastery, currentWord);
|
||||
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +0,0 @@
|
||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
||||
import { SvelteMap } from "svelte/reactivity";
|
||||
|
||||
export function pickNextWord(
|
||||
words: string[],
|
||||
wordMastery: SvelteMap<string, number>,
|
||||
untrackedCurrentWord: string,
|
||||
) {
|
||||
const unmasteredWords = words
|
||||
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
|
||||
.filter(([, it]) => it !== 1);
|
||||
unmasteredWords.sort(([, a], [, b]) => a - b);
|
||||
let nextWord =
|
||||
unmasteredWords[0]?.[0] ??
|
||||
words[0] ??
|
||||
TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE;
|
||||
// This is important to break infinite loop created by
|
||||
// reading and writing `currentWord` inside $effect rune
|
||||
for (const [word] of unmasteredWords) {
|
||||
if (word === untrackedCurrentWord || Math.random() > 0.5) continue;
|
||||
nextWord = word;
|
||||
break;
|
||||
}
|
||||
return nextWord;
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { onMount } from "svelte";
|
||||
import { basicSetup, EditorView } from "codemirror";
|
||||
import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
|
||||
import { defaultKeymap } from "@codemirror/commands";
|
||||
import { keymap } from "@codemirror/view";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import type { CompletionContext, Completion } from "@codemirror/autocomplete";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import { serialPort } from "$lib/serial/connection";
|
||||
import examplePlugin from "./example-plugin.js?raw";
|
||||
import {
|
||||
charaMethods,
|
||||
type ChannelCharaEventData,
|
||||
type ChannelResponseEventData,
|
||||
} from "./plugin-types";
|
||||
|
||||
let theme = EditorView.baseTheme({
|
||||
".cm-editor .cm-content": {
|
||||
fontFamily: '"Noto Sans Mono", monospace',
|
||||
},
|
||||
".cm-FoldPlaceholder": {
|
||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface-variant)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
||||
color: "var(--md-sys-color-on-surface-variant)",
|
||||
borderColor: "var(--md-sys-color-outline)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--md-sys-color-tertiary)",
|
||||
color: "var(--md-sys-color-on-tertiary)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "transparent",
|
||||
},
|
||||
".cm-cursor": {
|
||||
borderColor: "var(--md-sys-color-on-background)",
|
||||
},
|
||||
".cm-selectionBackground": {
|
||||
background: "transparent !important",
|
||||
backdropFilter: "invert(0.3)",
|
||||
},
|
||||
".cm-tooltip": {
|
||||
backgroundColor: "var(--md-sys-color-background) !important",
|
||||
color: "var(--md-sys-color-on-background)",
|
||||
borderColor: "var(--md-sys-color-outline)",
|
||||
},
|
||||
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
||||
backgroundColor: "var(--md-sys-color-primary) !important",
|
||||
color: "var(--md-sys-color-on-primary) !important",
|
||||
},
|
||||
".cm-completionIcon.cm-completionIcon-keyword::after": {
|
||||
content: "'🗝'",
|
||||
},
|
||||
});
|
||||
const highlightStyle = HighlightStyle.define(
|
||||
[
|
||||
{ tag: tags.keyword, color: "var(--md-sys-color-primary)" },
|
||||
{ tag: tags.number, color: "var(--md-sys-color-secondary)" },
|
||||
{ tag: tags.string, color: "var(--md-sys-color-tertiary)" },
|
||||
{
|
||||
tag: tags.comment,
|
||||
color: "var(--md-sys-color-on-background)",
|
||||
opacity: 0.6,
|
||||
},
|
||||
],
|
||||
{
|
||||
all: { fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px" },
|
||||
},
|
||||
);
|
||||
|
||||
const globalsCompletion: Completion[] = [
|
||||
{ label: "Chara", type: "class", boost: 90 },
|
||||
{ label: "Actions", type: "class", boost: 90 },
|
||||
];
|
||||
|
||||
const actionsCompletion: Completion[] = Array.from(
|
||||
$KEYMAP_CODES,
|
||||
([id, info]) => {
|
||||
const isValidIdentifier =
|
||||
info.id && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(info.id);
|
||||
return {
|
||||
label: info.id
|
||||
? isValidIdentifier
|
||||
? info.id
|
||||
: `["${info.id}"]`
|
||||
: info.id!,
|
||||
displayLabel: info.id,
|
||||
detail: [info.title, `(0x${id.toString(16)})`, info.description]
|
||||
.filter((it) => !!it)
|
||||
.join(" "),
|
||||
section: info.category,
|
||||
boost: isValidIdentifier ? Math.min(info.id?.length ?? 0, 10) + 50 : 40,
|
||||
type: "property",
|
||||
};
|
||||
},
|
||||
).filter((it) => it.label !== undefined);
|
||||
|
||||
const completion = javascriptLanguage.data.of({
|
||||
autocomplete: function completeGlobals(context: CompletionContext) {
|
||||
let nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
|
||||
if (nodeBefore.name === "VariableName") {
|
||||
return {
|
||||
from: nodeBefore.from,
|
||||
options: globalsCompletion,
|
||||
};
|
||||
} else if (nodeBefore.name === "Script") {
|
||||
return {
|
||||
from: context.pos,
|
||||
options: globalsCompletion,
|
||||
};
|
||||
} else if (
|
||||
(nodeBefore.name === "PropertyName" || nodeBefore.name === ".") &&
|
||||
nodeBefore.parent?.name === "MemberExpression" &&
|
||||
nodeBefore.parent.firstChild
|
||||
) {
|
||||
const variable = nodeBefore.parent.firstChild;
|
||||
const variableName = context.state.sliceDoc(variable.from, variable.to);
|
||||
if (variableName === "Actions") {
|
||||
return {
|
||||
from:
|
||||
nodeBefore.name === "PropertyName"
|
||||
? nodeBefore.from
|
||||
: nodeBefore.to,
|
||||
options: actionsCompletion,
|
||||
};
|
||||
}
|
||||
let parent = nodeBefore.prevSibling;
|
||||
while (parent !== null && parent?.name !== "VariableName") {
|
||||
parent = parent.prevSibling;
|
||||
}
|
||||
if (parent) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
editorView = new EditorView({
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
keymap.of(defaultKeymap),
|
||||
theme,
|
||||
syntaxHighlighting(highlightStyle),
|
||||
completion,
|
||||
],
|
||||
parent: editor,
|
||||
doc: examplePlugin,
|
||||
});
|
||||
});
|
||||
|
||||
let channels = $derived.by(() => {
|
||||
if (!$serialPort) return {} as any;
|
||||
return {
|
||||
getVersion: (..._args: unknown[]) => Promise.resolve($serialPort.version),
|
||||
getDevice: (..._args: unknown[]) => Promise.resolve($serialPort.device),
|
||||
commit: (..._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 Promise.resolve($serialPort.commit());
|
||||
}
|
||||
return Promise.resolve();
|
||||
},
|
||||
...Object.fromEntries(
|
||||
charaMethods.map(
|
||||
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
||||
),
|
||||
),
|
||||
} satisfies Record<string, Function>;
|
||||
});
|
||||
|
||||
async function onMessage(event: MessageEvent) {
|
||||
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
|
||||
|
||||
const [channel, params] = event.data;
|
||||
const response = channels[channel as keyof typeof channels](...params);
|
||||
frame.contentWindow!.postMessage(
|
||||
{ response: await response } satisfies ChannelResponseEventData,
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
function runPlugin() {
|
||||
frame.contentWindow?.postMessage(
|
||||
{
|
||||
actionCodes: $KEYMAP_CODES,
|
||||
script: editorView.state.doc.toString(),
|
||||
charaChannels: Object.keys(channels),
|
||||
} satisfies ChannelCharaEventData,
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
let frame: HTMLIFrameElement;
|
||||
let editor: HTMLDivElement;
|
||||
let editorView: EditorView;
|
||||
</script>
|
||||
|
||||
<svelte:window onmessage={onMessage} />
|
||||
<section>
|
||||
<h3>Plugin</h3>
|
||||
<button onclick={runPlugin}
|
||||
><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button
|
||||
>
|
||||
<div class="editor-root" bind:this={editor}></div>
|
||||
</section>
|
||||
|
||||
<iframe
|
||||
aria-hidden="true"
|
||||
title="code sandbox"
|
||||
bind:this={frame}
|
||||
src="/sandbox/"
|
||||
sandbox="allow-scripts"
|
||||
></iframe>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
padding-inline-start: 0;
|
||||
padding-inline-end: 8px;
|
||||
|
||||
width: min-content;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
font-weight: bold;
|
||||
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.editor-root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +0,0 @@
|
||||
// @ts-nocheck
|
||||
/*******************************
|
||||
* HOLD UP AND READ THIS FIRST *
|
||||
*******************************
|
||||
*
|
||||
* Chara devices have a LIMITED number of commits.
|
||||
* calling `Chara.commit()` can be a dangerous operation, which is why a confirmation dialog will be shown.
|
||||
* Devices are only rated for 10,000-25,000 commits, exceeding that limit may result in premature breakdowns.
|
||||
* `Chara.setSetting` or `Chara.setLayoutKey` is not affected by this, they last however only until the next boot.
|
||||
*
|
||||
* Chord writing is more forgiving, but keep in mind that excessive large-scale writing can still damage the device.
|
||||
*
|
||||
*/
|
||||
|
||||
const count = await Chara.getChordCount(); // => 499
|
||||
const chord = await Chara.getChord(2); // => {actions: [1, 2, 3], phrase: [4, 5, 6]}
|
||||
|
||||
const setting = await Chara.getSetting(5); // => 0
|
||||
|
||||
// This, for example, would return all chords
|
||||
const chords = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
chords.push(await Chara.getChord(i));
|
||||
}
|
||||
|
||||
// You can also print values to the browser console (F12)
|
||||
console.log("Chords:", chords);
|
||||
|
||||
// You can access the actions by ID!
|
||||
Actions.SPACE; // => {id: "SPACE", code: 32, icon: "space_bar", description: ...}
|
||||
Actions[32]; // This also works
|
||||
Actions[0x20]; // Or this!
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { CharaDevice } from "$lib/serial/device";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
|
||||
export const charaMethods = [
|
||||
"reboot",
|
||||
"bootloader",
|
||||
"getRamBytesAvailable",
|
||||
"getSetting",
|
||||
"setSetting",
|
||||
"getLayoutKey",
|
||||
"setLayoutKey",
|
||||
"deleteChord",
|
||||
"setChord",
|
||||
"getChordPhrase",
|
||||
"getChordCount",
|
||||
"getChord",
|
||||
"send",
|
||||
] as const satisfies Array<keyof CharaDevice>;
|
||||
|
||||
export interface ChannelResponseEventData {
|
||||
response: unknown;
|
||||
}
|
||||
|
||||
export interface ChannelCharaEventData {
|
||||
charaChannels: string[];
|
||||
script: string;
|
||||
actionCodes: Map<number, KeyInfo>;
|
||||
}
|
||||
|
||||
export type ChannelEventData = ChannelResponseEventData | ChannelCharaEventData;
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import AnimatedNumber from "$lib/components/AnimatedNumber.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
let interval: ReturnType<typeof setInterval>;
|
||||
|
||||
let value = $state(Math.round(Math.random() * 100));
|
||||
|
||||
onMount(() => {
|
||||
interval = setInterval(() => {
|
||||
value = Math.round(Math.random() * 100);
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
</script>
|
||||
|
||||
<p>The number is <AnimatedNumber {value} /></p>
|
||||
@@ -1,60 +0,0 @@
|
||||
<script>
|
||||
/** @type {Promise<unknown> | undefined} */
|
||||
let ongoingRequest = undefined;
|
||||
/** @type {(data: unknown) => void | undefined} */
|
||||
let resolveRequest = undefined;
|
||||
/** @type {MessageEventSource | undefined} */
|
||||
let source = undefined;
|
||||
|
||||
/**
|
||||
* @param {string} channel
|
||||
* @param {unknown} args
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async function post(channel, args) {
|
||||
while (ongoingRequest) {
|
||||
await ongoingRequest;
|
||||
}
|
||||
ongoingRequest = new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
source?.postMessage([channel, args], { targetOrigin: "*" });
|
||||
});
|
||||
ongoingRequest.then(() => {
|
||||
ongoingRequest = undefined;
|
||||
});
|
||||
return ongoingRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MessageEvent<import('../../src/routes/plugin/plugin-types').ChannelEventData>} event
|
||||
*/
|
||||
function onMessage(event) {
|
||||
if ("response" in event.data) {
|
||||
resolveRequest?.(event.data.response);
|
||||
} else {
|
||||
source = event.source ?? undefined;
|
||||
|
||||
const Action = event.data.actionCodes;
|
||||
Object.assign(
|
||||
Action,
|
||||
Object.fromEntries(
|
||||
Object.values(event.data.actionCodes)
|
||||
.filter((it) => !!it.id)
|
||||
.map((it) => [it.id, it]),
|
||||
),
|
||||
);
|
||||
|
||||
new Function("Action", "Chara", event.data.script)(
|
||||
Action,
|
||||
Object.fromEntries(
|
||||
event.data.charaChannels.map((name) => [
|
||||
name,
|
||||
(...args) => post(name, args),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
</script>
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM llama3.1
|
||||
|
||||
TEMPLATE """
|
||||
<|system|>
|
||||
Only output the sentence
|
||||
<|user|>
|
||||
Create a typical sentence with around 10 words that includes the word "{{ .Prompt }}".
|
||||
<|assistant|>
|
||||
Create a sentence that includes "{{ .Prompt }}".
|
||||
"""
|
||||
@@ -26,6 +26,7 @@ process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
||||
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
|
||||
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
|
||||
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
|
||||
process.env["VITE_DISCORD_URL"] = "https://discord.gg/CharaChorder";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
@@ -62,7 +63,6 @@ export default defineConfig({
|
||||
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
||||
"prerendered/**/*.html",
|
||||
],
|
||||
globIgnores: ["prerendered/pages/ccos/**/*"],
|
||||
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user