6 Commits

Author SHA1 Message Date
b9c6c05819 2.7.0 2026-01-28 18:19:03 +01:00
16bf766de9 feat: ccos emulator 2026-01-28 18:08:11 +01:00
ee8d400ad7 feat: hide cc0 2026-01-28 16:39:08 +01:00
9a1c2b5bf6 refactor: cleanup 2026-01-28 16:37:47 +01:00
1d1fcb72e3 fix: m0 should not have profiles
refactor: remove old editor/chat/learn links
2026-01-28 16:14:52 +01:00
ee3f84645d feat: support autospace v2 2026-01-20 17:17:55 +01:00
65 changed files with 812 additions and 4699 deletions

View File

@@ -57,6 +57,7 @@ const config = {
"graphic_eq",
"mail",
"calculate",
"playground_2",
"open_in_browser",
"chevron_backward",
"chevron_forward",

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 =
"&emsp;⇛" + (this.hasConcatenator ? "" : "&emsp;");
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;
}

View File

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

View File

@@ -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 { ![] }

View File

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

View File

@@ -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 />&nbsp;<i>Prevents prepended autospaces</i>
{/if}
{#if info.separator || info.breaking}
<br />&nbsp;<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;
}
}

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
import { persistentWritable } from "$lib/storage";
interface ChordStats {
level: number;
lastUprank: number;
}
export const chordStats = persistentWritable<Record<string, ChordStats>>(
"chord-stats",
{},
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
export const prerender = false;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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