mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-05-03 19:48:59 +00:00
Compare commits
1 Commits
v2.7.0
...
4bc84b5399
| Author | SHA1 | Date | |
|---|---|---|---|
|
4bc84b5399
|
@@ -57,7 +57,6 @@ const config = {
|
|||||||
"graphic_eq",
|
"graphic_eq",
|
||||||
"mail",
|
"mail",
|
||||||
"calculate",
|
"calculate",
|
||||||
"playground_2",
|
|
||||||
"open_in_browser",
|
"open_in_browser",
|
||||||
"chevron_backward",
|
"chevron_backward",
|
||||||
"chevron_forward",
|
"chevron_forward",
|
||||||
|
|||||||
37
package.json
37
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "charachorder-device-manager",
|
"name": "charachorder-device-manager",
|
||||||
"version": "2.7.0",
|
"version": "2.6.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -35,25 +35,27 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
|
"@codemirror/collab": "^6.1.1",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/language": "^6.11.3",
|
"@codemirror/language": "^6.12.1",
|
||||||
|
"@codemirror/lint": "^6.9.2",
|
||||||
"@codemirror/merge": "^6.11.2",
|
"@codemirror/merge": "^6.11.2",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.3",
|
||||||
"@codemirror/view": "^6.39.4",
|
"@codemirror/view": "^6.39.9",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
"@fontsource-variable/material-symbols-rounded": "^5.2.30",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
"@fontsource-variable/noto-sans-mono": "^5.2.10",
|
||||||
"@lezer/common": "^1.4.0",
|
"@lezer/common": "^1.5.0",
|
||||||
"@lezer/generator": "^1.8.0",
|
"@lezer/generator": "^1.8.0",
|
||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@lezer/lr": "^1.4.5",
|
"@lezer/lr": "^1.4.7",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@melt-ui/pp": "^0.3.2",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@melt-ui/svelte": "^0.86.6",
|
"@melt-ui/svelte": "^0.86.6",
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||||
"@sveltejs/adapter-static": "^3.0.10",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.49.2",
|
"@sveltejs/kit": "^2.49.3",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
||||||
"@tauri-apps/api": "^1.6.0",
|
"@tauri-apps/api": "^1.6.0",
|
||||||
"@tauri-apps/cli": "^1.6.0",
|
"@tauri-apps/cli": "^1.6.0",
|
||||||
"@types/dom-view-transitions": "^1.0.6",
|
"@types/dom-view-transitions": "^1.0.6",
|
||||||
@@ -73,31 +75,32 @@
|
|||||||
"glob": "^11.0.3",
|
"glob": "^11.0.3",
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^26.1.0",
|
||||||
|
"matrix-js-sdk": "^37.12.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-css-order": "^2.1.2",
|
"prettier-plugin-css-order": "^2.2.0",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.97.0",
|
"sass": "^1.97.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.1",
|
"socket.io-client": "^4.8.3",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
"stylelint-config-html": "^1.1.0",
|
"stylelint-config-html": "^1.1.0",
|
||||||
"stylelint-config-prettier-scss": "^1.0.0",
|
"stylelint-config-prettier-scss": "^1.0.0",
|
||||||
"stylelint-config-recommended-scss": "^16.0.2",
|
"stylelint-config-recommended-scss": "^16.0.2",
|
||||||
"stylelint-config-standard-scss": "^16.0.0",
|
"stylelint-config-standard-scss": "^16.0.0",
|
||||||
"svelte": "5.37.1",
|
"svelte": "5.46.1",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.5",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.3",
|
||||||
"tippy.js": "^6.3.7",
|
"tippy.js": "^6.3.7",
|
||||||
"typesafe-i18n": "^5.26.2",
|
"typesafe-i18n": "^5.26.2",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^7.0.6",
|
"vite": "^7.3.1",
|
||||||
"vite-plugin-mkcert": "^1.17.9",
|
"vite-plugin-mkcert": "^1.17.9",
|
||||||
"vite-plugin-pwa": "^1.0.2",
|
"vite-plugin-pwa": "^1.2.0",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"web-serial-polyfill": "^1.0.15",
|
"web-serial-polyfill": "^1.0.15",
|
||||||
"workbox-window": "^7.3.0"
|
"workbox-window": "^7.4.0"
|
||||||
},
|
},
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
955
pnpm-lock.yaml
generated
955
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "2.7.0"
|
version = "2.6.0"
|
||||||
description = "A Tauri App"
|
description = "A Tauri App"
|
||||||
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
authors = ["Thea Schöbl <dev@theaninova.de>"]
|
||||||
license = "AGPL-3"
|
license = "AGPL-3"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"devPath": "http://localhost:5173",
|
"devPath": "http://localhost:5173",
|
||||||
"distDir": "../build"
|
"distDir": "../build"
|
||||||
},
|
},
|
||||||
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
"package": { "productName": "amacc1ng", "version": "2.6.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,35 +1,26 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
import type { Attachment } from "svelte/attachments";
|
||||||
import type { CharaDevice } from "$lib/serial/device";
|
import { browser } from "$app/environment";
|
||||||
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
import { persistentWritable } from "$lib/storage";
|
||||||
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
|
|
||||||
export function ccosKeyInterceptor(
|
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
|
||||||
port: CharaDevice | undefined,
|
|
||||||
recorder: ReplayRecorder,
|
export function ccosKeyInterceptor() {
|
||||||
) {
|
return ((element: Window) => {
|
||||||
return ((element: HTMLElement) => {
|
const ccos = browser
|
||||||
const ccos =
|
? import("./ccos").then((module) => module.fetchCCOS(".test"))
|
||||||
port?.port && "handleKeyEvent" in port?.port
|
: Promise.resolve(undefined);
|
||||||
? (port.port as CCOS)
|
|
||||||
: undefined;
|
|
||||||
console.log("Attaching CCOS key interceptor", ccos);
|
|
||||||
|
|
||||||
function onEvent(event: KeyboardEvent) {
|
function onEvent(event: KeyboardEvent) {
|
||||||
ccos?.handleKeyEvent(event);
|
ccos.then((it) => it?.handleKeyEvent(event));
|
||||||
if (!event.defaultPrevented) {
|
|
||||||
recorder.next(event);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ccos) {
|
element.addEventListener("keydown", onEvent, true);
|
||||||
element.addEventListener("keydown", onEvent, true);
|
element.addEventListener("keyup", onEvent, true);
|
||||||
element.addEventListener("keyup", onEvent, true);
|
|
||||||
element.add;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
ccos.then((it) => it?.destroy());
|
||||||
element.removeEventListener("keydown", onEvent, true);
|
element.removeEventListener("keydown", onEvent, true);
|
||||||
element.removeEventListener("keyup", onEvent, true);
|
element.removeEventListener("keyup", onEvent, true);
|
||||||
};
|
};
|
||||||
}) satisfies Attachment<HTMLElement>;
|
}) satisfies Attachment<Window>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { getMeta } from "$lib/meta/meta-storage";
|
import { getMeta } from "$lib/meta/meta-storage";
|
||||||
import type { SerialPortLike } from "$lib/serial/device";
|
import type { SerialPortLike } from "$lib/serial/device";
|
||||||
import type {
|
import type {
|
||||||
|
CCOSInEvent,
|
||||||
CCOSInitEvent,
|
CCOSInitEvent,
|
||||||
CCOSKeyPressEvent,
|
CCOSKeyPressEvent,
|
||||||
CCOSKeyReleaseEvent,
|
CCOSKeyReleaseEvent,
|
||||||
@@ -10,7 +11,7 @@ import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
|||||||
|
|
||||||
const device = "zero_wasm";
|
const device = "zero_wasm";
|
||||||
|
|
||||||
export class CCOSKeyboardEvent extends KeyboardEvent {
|
class CCOSKeyboardEvent extends KeyboardEvent {
|
||||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||||
super(...params);
|
super(...params);
|
||||||
}
|
}
|
||||||
@@ -25,46 +26,7 @@ const MASK_GUI = 0b1000_1000;
|
|||||||
export class CCOS implements SerialPortLike {
|
export class CCOS implements SerialPortLike {
|
||||||
private readonly currKeys = new Set<number>();
|
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" });
|
private readonly worker = new Worker("/ccos-worker.js", { type: "module" });
|
||||||
|
|
||||||
@@ -164,6 +126,7 @@ export class CCOS implements SerialPortLike {
|
|||||||
this.controller?.enqueue(event.data);
|
this.controller?.enqueue(event.data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
console.log("CCOS worker message", event.data);
|
||||||
switch (event.data.type) {
|
switch (event.data.type) {
|
||||||
case "ready": {
|
case "ready": {
|
||||||
this.resolveReady();
|
this.resolveReady();
|
||||||
@@ -257,7 +220,7 @@ export class CCOS implements SerialPortLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCCOS(
|
export async function fetchCCOS(
|
||||||
version = "3.0.0-rc.0",
|
version = ".2.2.0-beta.12+266bdda",
|
||||||
fetch: typeof window.fetch = window.fetch,
|
fetch: typeof window.fetch = window.fetch,
|
||||||
): Promise<CCOS | undefined> {
|
): Promise<CCOS | undefined> {
|
||||||
const meta = await getMeta(device, version, fetch);
|
const meta = await getMeta(device, version, fetch);
|
||||||
|
|||||||
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
71
src/lib/chat/MatrixRoomMembers.svelte
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<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>
|
||||||
73
src/lib/chat/MatrixRooms.svelte
Normal file
73
src/lib/chat/MatrixRooms.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<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>
|
||||||
0
src/lib/chat/MatrixSpace.svelte
Normal file
0
src/lib/chat/MatrixSpace.svelte
Normal file
231
src/lib/chat/MatrixTimeline.svelte
Normal file
231
src/lib/chat/MatrixTimeline.svelte
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<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>
|
||||||
109
src/lib/chat/chat-rx.ts
Normal file
109
src/lib/chat/chat-rx.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
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")!;
|
||||||
|
}
|
||||||
35
src/lib/chat/chat.ts
Normal file
35
src/lib/chat/chat.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
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")!;
|
||||||
|
}
|
||||||
381
src/lib/chat/events/MatrixEvent.svelte
Normal file
381
src/lib/chat/events/MatrixEvent.svelte
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
<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>
|
||||||
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
56
src/lib/chat/events/MatrixMessageEvent.svelte
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<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>
|
||||||
71
src/lib/chat/matrix-rx/client.ts
Normal file
71
src/lib/chat/matrix-rx/client.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
11
src/lib/chat/matrix-rx/events.ts
Normal file
11
src/lib/chat/matrix-rx/events.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
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]>
|
||||||
|
>;
|
||||||
|
}
|
||||||
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
85
src/lib/chat/matrix-rx/rooms.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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]>
|
||||||
|
>;
|
||||||
|
}
|
||||||
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
19
src/lib/chat/matrix-rx/timeline.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
196
src/lib/chord-editor/action-linter.ts
Normal file
196
src/lib/chord-editor/action-linter.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import {
|
||||||
|
KEYMAP_CODES,
|
||||||
|
KEYMAP_IDS,
|
||||||
|
type KeyInfo,
|
||||||
|
} from "$lib/serial/keymap-codes";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
|
import { linter, type Diagnostic } from "@codemirror/lint";
|
||||||
|
import { derived, get } from "svelte/store";
|
||||||
|
import { parseCharaChords } from "./action-serializer";
|
||||||
|
import { deviceChords } from "$lib/serial/connection";
|
||||||
|
|
||||||
|
export const actionLinterDependencies = derived(
|
||||||
|
[KEYMAP_IDS, KEYMAP_CODES, deviceChords],
|
||||||
|
(it) => it,
|
||||||
|
);
|
||||||
|
|
||||||
|
export const actionLinter = linter(
|
||||||
|
(view) => {
|
||||||
|
const diagnostics: Diagnostic[] = [];
|
||||||
|
const [ids, codes, deviceChords] = get(actionLinterDependencies);
|
||||||
|
|
||||||
|
const { meta, compoundInputs } = parseCharaChords(view.state, ids);
|
||||||
|
|
||||||
|
syntaxTree(view.state)
|
||||||
|
.cursor()
|
||||||
|
.iterate((node) => {
|
||||||
|
let action: KeyInfo | undefined = undefined;
|
||||||
|
switch (node.name) {
|
||||||
|
case "SingleLetter": {
|
||||||
|
action = ids.get(view.state.doc.sliceString(node.from, node.to));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "ActionId": {
|
||||||
|
action = ids.get(view.state.doc.sliceString(node.from, node.to));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "HexNumber": {
|
||||||
|
const hexString = view.state.doc.sliceString(node.from, node.to);
|
||||||
|
const code = Number.parseInt(hexString, 16);
|
||||||
|
if (hexString.length === 10) {
|
||||||
|
if (compoundInputs.has(code)) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
severity: "info",
|
||||||
|
message: "Compound hash literal can be expanded",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: "Expand",
|
||||||
|
apply(view, from, to) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: from - 1,
|
||||||
|
to: to + 1,
|
||||||
|
insert: compoundInputs.get(code)! + "|",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(code >= 0 && code <= 1023)) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
severity: "error",
|
||||||
|
message: "Hex code invalid (out of range)",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
name: "Remove",
|
||||||
|
apply(view, from, to) {
|
||||||
|
view.dispatch({ changes: { from, to } });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
action = codes.get(code);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!action) {
|
||||||
|
const action = view.state.doc.sliceString(node.from, node.to);
|
||||||
|
diagnostics.push({
|
||||||
|
from: node.from,
|
||||||
|
to: node.to,
|
||||||
|
severity: node.name === "HexNumber" ? "warning" : "error",
|
||||||
|
message: `Unknown action: ${action}`,
|
||||||
|
actions: [
|
||||||
|
...(node.name === "SingleLetter"
|
||||||
|
? ([
|
||||||
|
{
|
||||||
|
name: "Generate Windows Hex Numpad Code",
|
||||||
|
apply(view, from, to) {
|
||||||
|
view.dispatch({
|
||||||
|
changes: {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
insert:
|
||||||
|
"<PRESS_NEXT><LEFT_ALT><KP_PLUS>" +
|
||||||
|
action
|
||||||
|
.codePointAt(0)!
|
||||||
|
.toString(16)
|
||||||
|
.split("")
|
||||||
|
.map((c) =>
|
||||||
|
/^\d$/.test(c)
|
||||||
|
? `<KP_${c}>`
|
||||||
|
: c.toLowerCase(),
|
||||||
|
)
|
||||||
|
.join("") +
|
||||||
|
"<RELEASE_NEXT><LEFT_ALT>",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] satisfies Diagnostic["actions"])
|
||||||
|
: []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const m of meta) {
|
||||||
|
if (m.invalidActions) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "error",
|
||||||
|
markClass: "chord-invalid",
|
||||||
|
message: `Chord contains invalid actions`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m.invalidInput) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "error",
|
||||||
|
markClass: "chord-invalid",
|
||||||
|
message: `Chord input is invalid`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m.emptyPhrase) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "warning",
|
||||||
|
message: `Chord phrase is empty`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m.overriddenBy) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "warning",
|
||||||
|
message: `Chord overridden by previous chord`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m.orphan) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "warning",
|
||||||
|
message: `Orphan compound chord`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (m.disabled) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "info",
|
||||||
|
markClass: "chord-ignored",
|
||||||
|
message: `Chord disabled`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((m.overrides?.length ?? 0) > 0) {
|
||||||
|
diagnostics.push({
|
||||||
|
from: m.from,
|
||||||
|
to: m.to,
|
||||||
|
severity: "info",
|
||||||
|
message: `Chord overrides other chords`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return diagnostics;
|
||||||
|
},
|
||||||
|
{ delay: 100 },
|
||||||
|
);
|
||||||
@@ -56,18 +56,16 @@ function actionWidgets(view: EditorView) {
|
|||||||
enter: (node) => {
|
enter: (node) => {
|
||||||
if (node.name !== "ExplicitAction") return;
|
if (node.name !== "ExplicitAction") return;
|
||||||
const value =
|
const value =
|
||||||
node.node.getChild("ActionId") ??
|
node.node.getChild("ActionId") ?? node.node.getChild("HexNumber");
|
||||||
node.node.getChild("HexNumber") ??
|
|
||||||
node.node.getChild("DecimalNumber");
|
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
if (!node.node.getChild("ExplicitDelimEnd")) {
|
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = view.state.doc.sliceString(value.from, value.to);
|
const id = view.state.doc.sliceString(value.from, value.to);
|
||||||
|
if (value.name === "HexNumber" && id.length === 10) return;
|
||||||
let deco = Decoration.replace({
|
let deco = Decoration.replace({
|
||||||
widget: new ActionWidget(
|
widget: new ActionWidget(
|
||||||
value.name === "ActionId" ? id : parseInt(id),
|
value.name === "ActionId" ? id : Number.parseInt(id, 16),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
widgets.push(deco.range(node.from, node.to));
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
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 { get } from "svelte/store";
|
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 {
|
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||||
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||||
@@ -9,8 +24,270 @@ export function actionToValue(action: number | KeyInfo) {
|
|||||||
const info =
|
const info =
|
||||||
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
||||||
if (info && info.id?.length === 1)
|
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))
|
if (!info || !canUseIdAsString(info))
|
||||||
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
||||||
return `<${info.id}>`;
|
return `<${info.id}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ParseMeta {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
invalidActions?: true;
|
||||||
|
invalidInput?: true;
|
||||||
|
emptyPhrase?: true;
|
||||||
|
orphan?: true;
|
||||||
|
disabled?: true;
|
||||||
|
overrides?: number[];
|
||||||
|
overriddenBy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParseResult {
|
||||||
|
result: CharaChordFile["chords"];
|
||||||
|
meta: ParseMeta[];
|
||||||
|
compoundInputs: Map<number, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCharaChords(
|
||||||
|
data: EditorState,
|
||||||
|
ids: Map<string, KeyInfo>,
|
||||||
|
): ParseResult {
|
||||||
|
const chords: CharaChordFile["chords"] = [];
|
||||||
|
const metas: ParseMeta[] = [];
|
||||||
|
const keys = new Map<string, number>();
|
||||||
|
const compoundInputs = new Map<number, string>();
|
||||||
|
|
||||||
|
let currentChord: CharaChordFile["chords"][number] | undefined = undefined;
|
||||||
|
let compound: number | undefined = undefined;
|
||||||
|
let currentActions: number[] = [];
|
||||||
|
let invalidActions = false;
|
||||||
|
let invalidInput = false;
|
||||||
|
let chordFrom = 0;
|
||||||
|
|
||||||
|
function makeChordInput(node: SyntaxNodeRef): number[] {
|
||||||
|
invalidInput ||= !willBeValidChordInput(currentActions.length, !!compound);
|
||||||
|
const input = composeChordInput(currentActions, compound);
|
||||||
|
compound = hashChord(input);
|
||||||
|
if (!compoundInputs.has(compound)) {
|
||||||
|
compoundInputs.set(compound, data.doc.sliceString(chordFrom, node.from));
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
syntaxTree(data)
|
||||||
|
.cursor()
|
||||||
|
.iterate(
|
||||||
|
(node) => {
|
||||||
|
if (node.name === "Chord") {
|
||||||
|
currentChord = undefined;
|
||||||
|
compound = undefined;
|
||||||
|
invalidActions = false;
|
||||||
|
invalidInput = false;
|
||||||
|
chordFrom = node.from;
|
||||||
|
} else if (node.name === "ActionString") {
|
||||||
|
currentActions = [];
|
||||||
|
} else if (node.name === "HexNumber") {
|
||||||
|
const hexString = data.doc.sliceString(node.from, node.to);
|
||||||
|
const code = Number.parseInt(hexString, 16);
|
||||||
|
if (hexString.length === 10) {
|
||||||
|
if (compound !== undefined) {
|
||||||
|
invalidInput = true;
|
||||||
|
}
|
||||||
|
compound = code;
|
||||||
|
} else {
|
||||||
|
if (Number.isNaN(code) || code < 0 || code > 1023) {
|
||||||
|
invalidActions = true;
|
||||||
|
}
|
||||||
|
currentActions.push(code);
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
node.name === "ActionId" ||
|
||||||
|
node.name === "SingleLetter" ||
|
||||||
|
node.name === "EscapedChar"
|
||||||
|
) {
|
||||||
|
const id = data.doc.sliceString(node.from, node.to);
|
||||||
|
const code = ids.get(id)?.code;
|
||||||
|
if (code === undefined) {
|
||||||
|
invalidActions = true;
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(id);
|
||||||
|
for (let byte of bytes) {
|
||||||
|
currentActions.push(-byte);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentActions.push(code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(node) => {
|
||||||
|
if (node.name === "Chord" && currentChord !== undefined) {
|
||||||
|
if (currentChord !== undefined) {
|
||||||
|
currentChord[1] = currentActions;
|
||||||
|
const index = chords.length;
|
||||||
|
chords.push(currentChord);
|
||||||
|
const meta: ParseMeta = { from: node.from, to: node.to };
|
||||||
|
if (invalidActions) {
|
||||||
|
meta.invalidActions = true;
|
||||||
|
}
|
||||||
|
if (invalidInput) {
|
||||||
|
meta.invalidInput = true;
|
||||||
|
}
|
||||||
|
metas.push(meta);
|
||||||
|
if (currentChord[1].length === 0) {
|
||||||
|
meta.emptyPhrase = true;
|
||||||
|
}
|
||||||
|
const key = JSON.stringify(currentChord[0]);
|
||||||
|
if (!meta.invalidInput) {
|
||||||
|
if (keys.has(key)) {
|
||||||
|
const targetIndex = keys.get(key)!;
|
||||||
|
const targetMeta = metas[targetIndex]!;
|
||||||
|
if (!targetMeta.overrides) targetMeta.overrides = [];
|
||||||
|
targetMeta.overrides.push(index);
|
||||||
|
meta.overriddenBy = targetIndex;
|
||||||
|
} else {
|
||||||
|
keys.set(key, index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
meta.emptyPhrase ||
|
||||||
|
meta.invalidInput ||
|
||||||
|
meta.invalidActions ||
|
||||||
|
meta.overriddenBy !== undefined
|
||||||
|
) {
|
||||||
|
meta.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.name === "CompoundDelim") {
|
||||||
|
makeChordInput(node);
|
||||||
|
} else if (node.name === "PhraseDelim") {
|
||||||
|
const input = makeChordInput(node);
|
||||||
|
currentChord = [composeChordInput(input, compound), []];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < metas.length; i++) {
|
||||||
|
const [, compound] = splitCompound(chords[i]![0]);
|
||||||
|
if (compound !== undefined && !compoundInputs.has(compound)) {
|
||||||
|
metas[i]!.orphan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { result: chords, meta: metas, compoundInputs };
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChordRecord {
|
||||||
|
private chords = new Map<string, Set<string>>();
|
||||||
|
|
||||||
|
constructor(chords: CharaChordFile["chords"]) {
|
||||||
|
for (let chord of chords) {
|
||||||
|
const key = JSON.stringify(chord[0]);
|
||||||
|
if (!this.chords.has(key)) {
|
||||||
|
this.chords.set(key, new Set());
|
||||||
|
}
|
||||||
|
this.chords.get(key)!.add(JSON.stringify(chord));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static createDiff(
|
||||||
|
previous: CharaChordFile["chords"],
|
||||||
|
updated: CharaChordFile["chords"],
|
||||||
|
) {
|
||||||
|
const deleted = new ChordRecord(previous);
|
||||||
|
const added = new ChordRecord(updated);
|
||||||
|
const dupA = deleted.duplicates(added);
|
||||||
|
const dupB = added.duplicates(deleted);
|
||||||
|
for (let chord of dupA) {
|
||||||
|
deleted.remove(chord);
|
||||||
|
added.remove(chord);
|
||||||
|
}
|
||||||
|
for (let chord of dupB) {
|
||||||
|
deleted.remove(chord);
|
||||||
|
added.remove(chord);
|
||||||
|
}
|
||||||
|
return { deleted, added };
|
||||||
|
}
|
||||||
|
|
||||||
|
duplicates(
|
||||||
|
other: ChordRecord,
|
||||||
|
): IteratorObject<CharaChordFile["chords"][number]> {
|
||||||
|
const duplicates = new Set<string>();
|
||||||
|
for (let [key, chordSet] of this.chords) {
|
||||||
|
for (let chord of chordSet) {
|
||||||
|
if (other.hasInternal(key, chord)) {
|
||||||
|
duplicates.add(chord);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return duplicates
|
||||||
|
.values()
|
||||||
|
.map((it) => JSON.parse(it) as CharaChordFile["chords"][number]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasInternal(key: string, chord: string): boolean {
|
||||||
|
return this.chords.get(key)?.has(chord) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(chord: CharaChordFile["chords"][number]): boolean {
|
||||||
|
return this.hasInternal(JSON.stringify(chord[0]), JSON.stringify(chord));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(chord: CharaChordFile["chords"][number]) {
|
||||||
|
const key = JSON.stringify(chord[0]);
|
||||||
|
const set = this.chords.get(key);
|
||||||
|
if (set) {
|
||||||
|
set.delete(JSON.stringify(chord));
|
||||||
|
if (set.size === 0) {
|
||||||
|
this.chords.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncChords(
|
||||||
|
previous: CharaChordFile["chords"],
|
||||||
|
updated: CharaChordFile["chords"],
|
||||||
|
state: EditorState,
|
||||||
|
) {
|
||||||
|
const deviceDiff = ChordRecord.createDiff(previous, updated);
|
||||||
|
const current = parseCharaChords(state, get(KEYMAP_IDS));
|
||||||
|
// save initial device chords
|
||||||
|
// compare new device chords with initial device chords
|
||||||
|
// take changed/new/removed chords
|
||||||
|
// compare current editor chords with initial device chords
|
||||||
|
// compare two change sets
|
||||||
|
// apply removals if the chord didn't change on either end
|
||||||
|
// apply
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rebaseUpdates(
|
||||||
|
updates: readonly Update[],
|
||||||
|
over: readonly { changes: ChangeDesc; clientID: string }[],
|
||||||
|
) {
|
||||||
|
if (!over.length || !updates.length) return updates;
|
||||||
|
let changes: ChangeDesc | null = null,
|
||||||
|
skip = 0;
|
||||||
|
for (let update of over) {
|
||||||
|
let other = skip < updates.length ? updates[skip] : null;
|
||||||
|
if (other && other.clientID == update.clientID) {
|
||||||
|
if (changes) changes = changes.mapDesc(other.changes, true);
|
||||||
|
skip++;
|
||||||
|
} else {
|
||||||
|
changes = changes ? changes.composeDesc(update.changes) : update.changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skip) updates = updates.slice(skip);
|
||||||
|
return !changes
|
||||||
|
? updates
|
||||||
|
: updates.map((update) => {
|
||||||
|
let updateChanges = update.changes.map(changes!);
|
||||||
|
changes = changes!.mapDesc(update.changes, true);
|
||||||
|
return {
|
||||||
|
changes: updateChanges,
|
||||||
|
effects:
|
||||||
|
update.effects && StateEffect.mapEffects(update.effects, changes!),
|
||||||
|
clientID: update.clientID,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,72 +1,39 @@
|
|||||||
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
import {
|
||||||
import type {
|
EditorView,
|
||||||
Completion,
|
ViewPlugin,
|
||||||
CompletionSection,
|
ViewUpdate,
|
||||||
CompletionSource,
|
type PluginValue,
|
||||||
} from "@codemirror/autocomplete";
|
} from "@codemirror/view";
|
||||||
import { derived, get } from "svelte/store";
|
import { syntaxTree } from "@codemirror/language";
|
||||||
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
import type { EditorState } from "@codemirror/state";
|
||||||
|
|
||||||
const completionSections = derived(
|
export function actionAutocompletePlugin(
|
||||||
KEYMAP_CATEGORIES,
|
query: (query: string | undefined) => void,
|
||||||
(categories) =>
|
) {
|
||||||
new Map(
|
return ViewPlugin.fromClass(
|
||||||
categories.map(
|
class implements PluginValue {
|
||||||
(category) =>
|
constructor(readonly view: EditorView) {}
|
||||||
[
|
|
||||||
category,
|
|
||||||
{
|
|
||||||
name: category.name,
|
|
||||||
} satisfies CompletionSection,
|
|
||||||
] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const actionAutocompleteItems = derived(
|
update(update: ViewUpdate) {
|
||||||
[KEYMAP_CODES, completionSections],
|
query(this.resolveAutocomplete(update.state));
|
||||||
([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(),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const actionAutocomplete = ((context) => {
|
resolveAutocomplete(state: EditorState): string | undefined {
|
||||||
let word = context.tokenBefore([
|
if (state.selection.ranges.length !== 1) return;
|
||||||
"ExplicitDelimStart",
|
const from = state.selection.ranges[0]!.from;
|
||||||
"ActionId",
|
const to = state.selection.ranges[0]!.to;
|
||||||
"HexNumber",
|
if (from !== to) return;
|
||||||
"DecimalNumber",
|
const tree = syntaxTree(state);
|
||||||
]);
|
const node = tree.resolveInner(from, -1).parent;
|
||||||
if (!word) return null;
|
if (node?.name !== "ExplicitAction") return;
|
||||||
console.log(get(actionAutocompleteItems));
|
if (node.getChild("ExplicitDelimEnd")) return;
|
||||||
return {
|
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
|
||||||
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
return (
|
||||||
validFor: /^<?[a-zA-Z0-9_]*$/,
|
(queryNode
|
||||||
options: get(actionAutocompleteItems),
|
? state.doc.sliceString(queryNode.from, queryNode.to)
|
||||||
};
|
: undefined) || undefined
|
||||||
}) satisfies CompletionSource;
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class DelimWidget extends WidgetType {
|
|||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
if (!this.element) {
|
if (!this.element) {
|
||||||
this.element = document.createElement("span");
|
/*this.element = document.createElement("span");
|
||||||
this.element.innerHTML =
|
this.element.innerHTML =
|
||||||
" ⇛" + (this.hasConcatenator ? "" : " ");
|
" ⇛" + (this.hasConcatenator ? "" : " ");
|
||||||
this.element.style.scale = "1.8";
|
this.element.style.scale = "1.8";
|
||||||
@@ -41,7 +41,9 @@ export class DelimWidget extends WidgetType {
|
|||||||
props: { action: 574, display: "keys", inText: true, ghost: true },
|
props: { action: 574, display: "keys", inText: true, ghost: true },
|
||||||
});
|
});
|
||||||
this.element.appendChild(button);
|
this.element.appendChild(button);
|
||||||
}
|
}*/
|
||||||
|
this.element = document.createElement("div");
|
||||||
|
this.element.style.breakAfter = "column";
|
||||||
}
|
}
|
||||||
return this.element;
|
return this.element;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
HighlightStyle,
|
HighlightStyle,
|
||||||
} from "@codemirror/language";
|
} from "@codemirror/language";
|
||||||
import { styleTags, tags } from "@lezer/highlight";
|
import { styleTags, tags } from "@lezer/highlight";
|
||||||
import { actionAutocomplete } from "./autocomplete";
|
|
||||||
|
|
||||||
export const chordHighlightStyle = HighlightStyle.define([
|
export const chordHighlightStyle = HighlightStyle.define([
|
||||||
{
|
{
|
||||||
@@ -51,7 +50,5 @@ export const chordLanguage = LRLanguage.define({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function chordLanguageSupport() {
|
export function chordLanguageSupport() {
|
||||||
return new LanguageSupport(chordLanguage, [
|
return new LanguageSupport(chordLanguage, [chordLanguage.data.of({})]);
|
||||||
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@top Program { Chord* }
|
@top Program { Chord* }
|
||||||
|
|
||||||
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
|
||||||
EscapedSingleAction { Escape EscapedLetter }
|
EscapedSingleAction { Escape EscapedLetter }
|
||||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||||
ActionString { Action* }
|
ActionString { Action* }
|
||||||
@@ -9,16 +9,15 @@ ChordPhrase { ActionString }
|
|||||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||||
|
|
||||||
@tokens {
|
@tokens {
|
||||||
@precedence {HexNumber, DecimalNumber}
|
@precedence {HexNumber}
|
||||||
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||||
@precedence {EscapedLetter}
|
@precedence {EscapedLetter}
|
||||||
ExplicitDelimStart {"<"}
|
ExplicitDelimStart {"<"}
|
||||||
ExplicitDelimEnd {">"}
|
ExplicitDelimEnd {">"}
|
||||||
CompoundDelim {"+>"}
|
CompoundDelim {"|"}
|
||||||
PhraseDelim {"=>"}
|
PhraseDelim {"=>"}
|
||||||
Escape { "\\" }
|
Escape { "\\" }
|
||||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||||
DecimalNumber { $[0-9]+ }
|
|
||||||
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||||
SingleLetter { ![\\] }
|
SingleLetter { ![\\] }
|
||||||
EscapedLetter { ![] }
|
EscapedLetter { ![] }
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
.=<LEFT_SHIFT> => =>
|
a|.=<LEFT_SHIFT>=>t=t
|
||||||
;ims => <0x219><IMPULSE>
|
;ims=<0x219><IMPULSE>
|
||||||
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
|
-;<KSC_2C><LEFT_SHIFT>=><0x23e>_<0x23e>
|
||||||
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
|
.;g=><0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||||
'dg => <0x23e>'<0x23e>
|
'dg=><0x23e>'<0x23e>
|
||||||
'gl => <0x23e>'ll<0x23e>
|
'gl=><0x23e>'ll<0x23e>
|
||||||
'ar => <0x23e>'re<0x23e>
|
'ar=><0x23e>'re<0x23e>
|
||||||
'gs => <0x23e>'s<0x23e>
|
'gs=><0x23e>'s<0x23e>
|
||||||
'ev => <0x23e>'ve<0x23e>
|
'ev=><0x23e>'ve<0x23e>
|
||||||
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
|
<SPACE>-;=><0x23e><0x223>-<0x223><KSC_00>
|
||||||
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
|
<SPACE>;<LEFT_SHIFT>=><0x23e><0x223><0x23d><0x223><KSC_00>
|
||||||
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
|
<SPACE>;g=><0x23e><0x223><SPACE><0x223><KSC_00>
|
||||||
deg => <0x23e>ed<0x23e>
|
deg=><0x23e>ed<0x23e>
|
||||||
;gr => <0x23e>er<0x23e>
|
;gr=><0x23e>er<0x23e>
|
||||||
;es => <0x23e>es<0x23e>
|
;es=><0x23e>es<0x23e>
|
||||||
;est => <0x23e>est<0x23e>
|
;est=><0x23e>est<0x23e>
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
let {
|
let {
|
||||||
action,
|
action,
|
||||||
display,
|
display,
|
||||||
|
ignoreIcon = false,
|
||||||
inText = false,
|
inText = false,
|
||||||
}: {
|
}: {
|
||||||
action: string | number | KeyInfo;
|
action: string | number | KeyInfo;
|
||||||
display: "inline-keys" | "keys" | "verbose";
|
display: "inline-keys" | "keys" | "verbose";
|
||||||
|
ignoreIcon?: boolean;
|
||||||
inText?: boolean;
|
inText?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
? ({ code: 1024, id: action } satisfies KeyInfo)
|
? ({ code: 1024, id: action } satisfies KeyInfo)
|
||||||
: action),
|
: action),
|
||||||
);
|
);
|
||||||
|
let icon = $derived(ignoreIcon ? undefined : info.icon);
|
||||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||||
let hasPopover = $derived(
|
let hasPopover = $derived(
|
||||||
!retrievedInfo || !info.id || info.title || info.description,
|
!retrievedInfo || !info.id || info.title || info.description,
|
||||||
@@ -53,12 +56,6 @@
|
|||||||
<br />
|
<br />
|
||||||
<small>{info.description}</small>
|
<small>{info.description}</small>
|
||||||
{/if}
|
{/if}
|
||||||
{#if info.breaking}
|
|
||||||
<br /> <i>Prevents prepended autospaces</i>
|
|
||||||
{/if}
|
|
||||||
{#if info.separator || info.breaking}
|
|
||||||
<br /> <i>Stops autocorrect</i>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
{:else}
|
||||||
<b>Unknown Action</b><br />
|
<b>Unknown Action</b><br />
|
||||||
{#if info.code > 1023}
|
{#if info.code > 1023}
|
||||||
@@ -69,7 +66,7 @@
|
|||||||
|
|
||||||
{#snippet kbdText()}
|
{#snippet kbdText()}
|
||||||
{dynamicMapping ??
|
{dynamicMapping ??
|
||||||
info.icon ??
|
icon ??
|
||||||
info.display ??
|
info.display ??
|
||||||
info.id ??
|
info.id ??
|
||||||
`0x${info.code.toString(16)}`}
|
`0x${info.code.toString(16)}`}
|
||||||
@@ -77,7 +74,7 @@
|
|||||||
{#snippet kbdSnippet(withPopover = true)}
|
{#snippet kbdSnippet(withPopover = true)}
|
||||||
<kbd
|
<kbd
|
||||||
class:in-text={inText}
|
class:in-text={inText}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!icon}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
class:error={info.code > 1023}
|
class:error={info.code > 1023}
|
||||||
@@ -97,7 +94,7 @@
|
|||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||||
>
|
>
|
||||||
{:else if !info.icon && info.id?.length === 1}
|
{:else if !icon && info.id?.length === 1}
|
||||||
<span
|
<span
|
||||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
class:in-text={inText}
|
class:in-text={inText}
|
||||||
@@ -112,7 +109,7 @@
|
|||||||
class:in-text={inText}
|
class:in-text={inText}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!icon}
|
||||||
class:warn={!retrievedInfo}
|
class:warn={!retrievedInfo}
|
||||||
class:error={info.code > 1023}
|
class:error={info.code > 1023}
|
||||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
@@ -161,21 +158,50 @@
|
|||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
$variant-offset: 12px;
|
|
||||||
$variant-padding: calc(2px + $variant-offset);
|
|
||||||
$variant-color: color-mix(
|
$variant-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--md-sys-color-on-surface) 50%,
|
var(--md-sys-color-on-surface) 50%,
|
||||||
transparent
|
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 {
|
.left {
|
||||||
padding-inline-end: $variant-padding;
|
background-image: linear-gradient(
|
||||||
text-shadow: $variant-offset 0 2px $variant-color;
|
to right,
|
||||||
|
var(--bg-color) $cutoff,
|
||||||
|
transparent $cutoff
|
||||||
|
);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
clip-path: inset(0 0 0 $cutoff);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.right {
|
.right {
|
||||||
padding-inline-start: $variant-padding;
|
background-image: linear-gradient(
|
||||||
text-shadow: -$variant-offset 0 2px $variant-color;
|
to left,
|
||||||
|
var(--bg-color) $cutoff,
|
||||||
|
transparent $cutoff
|
||||||
|
);
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
clip-path: inset(0 $cutoff 0 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
@@ -206,13 +232,13 @@
|
|||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
max-width: 15ch;
|
max-width: 15ch;
|
||||||
-webkit-line-clamp: 2; /* number of lines to show */
|
|
||||||
line-clamp: 2;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
-webkit-line-clamp: 2; /* number of lines to show */
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,17 @@
|
|||||||
let {
|
let {
|
||||||
currentAction = undefined,
|
currentAction = undefined,
|
||||||
nextAction = undefined,
|
nextAction = undefined,
|
||||||
|
queryFilter = undefined,
|
||||||
|
ignoreIcon,
|
||||||
autofocus = false,
|
autofocus = false,
|
||||||
onselect,
|
onselect,
|
||||||
onclose,
|
onclose,
|
||||||
}: {
|
}: {
|
||||||
currentAction?: number;
|
currentAction?: number;
|
||||||
|
queryFilter?: string;
|
||||||
nextAction?: number;
|
nextAction?: number;
|
||||||
autofocus?: boolean;
|
autofocus?: boolean;
|
||||||
|
ignoreIcon?: boolean;
|
||||||
onselect?: (id: number) => void;
|
onselect?: (id: number) => void;
|
||||||
onclose?: () => void;
|
onclose?: () => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -43,6 +47,14 @@
|
|||||||
createIndex($KEYMAP_CODES);
|
createIndex($KEYMAP_CODES);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let didClear = true;
|
||||||
|
$effect(() => {
|
||||||
|
if (queryFilter !== undefined || !didClear) {
|
||||||
|
searchBox.value = queryFilter ?? "";
|
||||||
|
search();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||||
for (const [, action] of codes) {
|
for (const [, action] of codes) {
|
||||||
await index?.addAsync(
|
await index?.addAsync(
|
||||||
@@ -60,6 +72,7 @@
|
|||||||
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
didClear = searchBox.value === "";
|
||||||
const result =
|
const result =
|
||||||
searchBox.value === ""
|
searchBox.value === ""
|
||||||
? Array.from($KEYMAP_CODES.keys())
|
? Array.from($KEYMAP_CODES.keys())
|
||||||
@@ -167,7 +180,7 @@
|
|||||||
<li>Action code is out of range</li>
|
<li>Action code is out of range</li>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{#each results as [category, actions] (category)}
|
{#each results as [category, actions] (actions)}
|
||||||
{#if actions.length > 0}
|
{#if actions.length > 0}
|
||||||
<div class="category">
|
<div class="category">
|
||||||
<h3>{category.name}</h3>
|
<h3>{category.name}</h3>
|
||||||
@@ -191,7 +204,7 @@
|
|||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
<Action {action} display="verbose"></Action>
|
<Action {action} display="verbose" {ignoreIcon}></Action>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
101
src/lib/learn/chords.ts
Normal file
101
src/lib/learn/chords.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
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;
|
||||||
|
},
|
||||||
|
);
|
||||||
11
src/lib/learn/stats.ts
Normal file
11
src/lib/learn/stats.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { persistentWritable } from "$lib/storage";
|
||||||
|
|
||||||
|
interface ChordStats {
|
||||||
|
level: number;
|
||||||
|
lastUprank: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
||||||
|
"chord-stats",
|
||||||
|
{},
|
||||||
|
);
|
||||||
@@ -56,6 +56,84 @@ export function deserializeActions(native: bigint): number[] {
|
|||||||
return actions;
|
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
|
* Hashes a chord input the same way as CCOS
|
||||||
*/
|
*/
|
||||||
@@ -72,5 +150,6 @@ export function hashChord(actions: number[]) {
|
|||||||
if ((hash & 0xff) === 0xff) {
|
if ((hash & 0xff) === 0xff) {
|
||||||
hash ^= 0xff;
|
hash ^= 0xff;
|
||||||
}
|
}
|
||||||
return hash & 0x3fff_ffff;
|
hash &= 0x3fff_ffff;
|
||||||
|
return hash === 0 ? 1 : hash;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ export const syncStatus: Writable<
|
|||||||
"done" | "error" | "downloading" | "uploading"
|
"done" | "error" | "downloading" | "uploading"
|
||||||
> = writable("done");
|
> = writable("done");
|
||||||
|
|
||||||
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
export const deviceMeta = persistentWritable<VersionMeta | undefined>(
|
||||||
|
"current-meta",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export interface ProgressInfo {
|
export interface ProgressInfo {
|
||||||
max: number;
|
max: number;
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export class CharaDevice {
|
|||||||
version!: string;
|
version!: string;
|
||||||
company!: "CHARACHORDER" | "FORGE";
|
company!: "CHARACHORDER" | "FORGE";
|
||||||
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
device!: "ONE" | "TWO" | "LITE" | "X" | "M4G" | "ENGINE" | "ZERO";
|
||||||
chipset!: "M0" | "S2" | "S3" | "WASM";
|
chipset!: "M0" | "S2" | "S3";
|
||||||
keyCount!: 90 | 67 | 256;
|
keyCount!: 90 | 67 | 256;
|
||||||
layerCount = 3;
|
layerCount = 3;
|
||||||
profileCount = 1;
|
profileCount = 1;
|
||||||
@@ -157,7 +157,7 @@ export class CharaDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly port: SerialPortLike,
|
private readonly port: SerialPortLike,
|
||||||
public baudRate = 115200,
|
public baudRate = 115200,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -183,11 +183,11 @@ export class CharaDevice {
|
|||||||
this.company = company as typeof this.company;
|
this.company = company as typeof this.company;
|
||||||
this.device = device as typeof this.device;
|
this.device = device as typeof this.device;
|
||||||
this.chipset = chipset as typeof this.chipset;
|
this.chipset = chipset as typeof this.chipset;
|
||||||
if (semverGte(this.version, "2.2.0-beta.4") && this.chipset !== "M0") {
|
if (semverGte(this.version, "2.2.0-beta.4")) {
|
||||||
this.profileCount = 3;
|
this.profileCount = this.chipset === "M0" ? 2 : 3;
|
||||||
}
|
}
|
||||||
if (semverGte(this.version, "2.2.0-beta.20") && this.chipset !== "M0") {
|
if (semverGte(this.version, "2.2.0-beta.20")) {
|
||||||
this.layerCount = 4;
|
this.layerCount = this.chipset === "M0" ? 3 : 4;
|
||||||
}
|
}
|
||||||
this.keyCount = KEY_COUNTS[this.device];
|
this.keyCount = KEY_COUNTS[this.device];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
kbd {
|
kbd {
|
||||||
display: inline-flex;
|
--bg-color: color-mix(
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin-block: 6px;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
//border: 1px solid currentcolor;
|
|
||||||
background: color-mix(
|
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--md-sys-color-surface-variant) 50%,
|
var(--md-sys-color-surface-variant) 50%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
padding: 4px;
|
--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;
|
height: 20px;
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&.icon {
|
&.icon {
|
||||||
|
|||||||
@@ -46,6 +46,15 @@
|
|||||||
element?.closest<HTMLElement>("[popover]")?.hidePopover();
|
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) {
|
async function connectDevice(event: MouseEvent) {
|
||||||
const port = await navigator.serial.requestPort({
|
const port = await navigator.serial.requestPort({
|
||||||
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
||||||
@@ -79,10 +88,9 @@
|
|||||||
{#if ports.length !== 0}
|
{#if ports.length !== 0}
|
||||||
<h4>Recent Devices</h4>
|
<h4>Recent Devices</h4>
|
||||||
<div class="devices">
|
<div class="devices">
|
||||||
<!--
|
|
||||||
<div class="device">
|
<div class="device">
|
||||||
<button onclick={connectCC0}> CC0</button>
|
<button onclick={connectCC0}> CC0</button>
|
||||||
</div>-->
|
</div>
|
||||||
{#each ports as port}
|
{#each ports as port}
|
||||||
<div class="device">
|
<div class="device">
|
||||||
<button
|
<button
|
||||||
@@ -107,7 +115,7 @@
|
|||||||
<button onclick={connectDevice} class="primary"
|
<button onclick={connectDevice} class="primary"
|
||||||
><span class="icon">add</span>Connect</button
|
><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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -116,25 +116,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<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>
|
<li>
|
||||||
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
<a href={import.meta.env.VITE_BUGS_URL} rel="noreferrer" target="_blank"
|
||||||
><span class="icon">bug_report</span> Bugs</a
|
><span class="icon">bug_report</span> Bugs</a
|
||||||
@@ -187,11 +168,6 @@
|
|||||||
|
|
||||||
$sync-border-radius: 16px;
|
$sync-border-radius: 16px;
|
||||||
|
|
||||||
.discord-icon {
|
|
||||||
margin: 5px;
|
|
||||||
inline-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sync-box {
|
.sync-box {
|
||||||
display: flex;
|
display: flex;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -17,11 +17,6 @@
|
|||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
|
||||||
href: "/editor/",
|
|
||||||
icon: "playground_2",
|
|
||||||
title: "Emulator",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: import.meta.env.VITE_LEARN_URL,
|
href: import.meta.env.VITE_LEARN_URL,
|
||||||
icon: "school",
|
icon: "school",
|
||||||
@@ -41,6 +36,14 @@
|
|||||||
external: true,
|
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 {
|
] satisfies {
|
||||||
href: string;
|
href: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const prerender = false;
|
|
||||||
92
src/routes/(app)/chat-rx/+page.svelte
Normal file
92
src/routes/(app)/chat-rx/+page.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<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>
|
||||||
33
src/routes/(app)/chat-rx/Login.svelte
Normal file
33
src/routes/(app)/chat-rx/Login.svelte
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<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}
|
||||||
0
src/routes/(app)/chat-rx/Space.svelte
Normal file
0
src/routes/(app)/chat-rx/Space.svelte
Normal file
180
src/routes/(app)/chat/+page.svelte
Normal file
180
src/routes/(app)/chat/+page.svelte
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
import { onDestroy, onMount, setContext } from "svelte";
|
||||||
|
import type {
|
||||||
|
IndexedDBStore,
|
||||||
|
IndexedDBCryptoStore,
|
||||||
|
LoginResponse,
|
||||||
|
} from "matrix-js-sdk";
|
||||||
|
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
|
||||||
|
import { matrixClient, currentRoomId } from "$lib/chat/chat";
|
||||||
|
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
|
||||||
|
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
|
||||||
|
|
||||||
|
let loggedIn = $state(false);
|
||||||
|
let ready = $state(false);
|
||||||
|
|
||||||
|
let store: IndexedDBStore;
|
||||||
|
let cryptoStore: IndexedDBCryptoStore;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
if (!browser) return;
|
||||||
|
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
||||||
|
"matrix-js-sdk"
|
||||||
|
);
|
||||||
|
|
||||||
|
const storedLogin = getStoredLogin();
|
||||||
|
|
||||||
|
store = new IndexedDBStore({
|
||||||
|
dbName: "matrix",
|
||||||
|
indexedDB: window.indexedDB,
|
||||||
|
});
|
||||||
|
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
|
||||||
|
|
||||||
|
$matrixClient = createClient({
|
||||||
|
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
||||||
|
userId: storedLogin?.user_id,
|
||||||
|
accessToken: storedLogin?.access_token,
|
||||||
|
timelineSupport: true,
|
||||||
|
store,
|
||||||
|
cryptoStore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginToken = new URLSearchParams(window.location.search).get(
|
||||||
|
"loginToken",
|
||||||
|
);
|
||||||
|
if (loginToken) {
|
||||||
|
await handleLogin(await $matrixClient.loginWithToken(loginToken));
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
await postLogin();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function passwordLogin(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.target as HTMLFormElement;
|
||||||
|
const username = (form.elements.namedItem("username") as HTMLInputElement)
|
||||||
|
.value;
|
||||||
|
const password = (form.elements.namedItem("password") as HTMLInputElement)
|
||||||
|
.value;
|
||||||
|
|
||||||
|
await handleLogin(
|
||||||
|
await $matrixClient.loginWithPassword(username, password),
|
||||||
|
);
|
||||||
|
await postLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin(response: LoginResponse) {
|
||||||
|
localStorage.setItem("matrix-login", JSON.stringify(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postLogin() {
|
||||||
|
loggedIn = $matrixClient.isLoggedIn();
|
||||||
|
|
||||||
|
if (loggedIn) {
|
||||||
|
await store.startup();
|
||||||
|
await cryptoStore.startup();
|
||||||
|
await $matrixClient.startClient();
|
||||||
|
$matrixClient.once("sync", function (state, prevState, res) {
|
||||||
|
ready = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredLogin(): LoginResponse | undefined {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem("matrix-login")!);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if ($matrixClient) {
|
||||||
|
$matrixClient.stopClient();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $matrixClient && loggedIn}
|
||||||
|
{#if ready}
|
||||||
|
<div class="chat">
|
||||||
|
<div class="rooms">
|
||||||
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
$matrixClient.logout(true);
|
||||||
|
$matrixClient.clearStores();
|
||||||
|
localStorage.removeItem("matrix-login");
|
||||||
|
window.location.reload();
|
||||||
|
}}>logout</button
|
||||||
|
>
|
||||||
|
<MatrixRooms rooms={$matrixClient.getRooms()} />
|
||||||
|
</div>
|
||||||
|
{#if $currentRoomId}
|
||||||
|
{@const room = $matrixClient.getRoom($currentRoomId)}
|
||||||
|
{#key room}
|
||||||
|
{#if room}
|
||||||
|
<div class="timeline">
|
||||||
|
<MatrixTimeline timeline={room.getLiveTimeline()} />
|
||||||
|
</div>
|
||||||
|
<div class="members">
|
||||||
|
<MatrixRoomMembers members={room.getJoinedMembers()} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else if $matrixClient}
|
||||||
|
{#await $matrixClient.loginFlows() then flows}
|
||||||
|
{#each flows.flows as flow}
|
||||||
|
{#if flow.type === "m.login.sso"}
|
||||||
|
<a
|
||||||
|
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
||||||
|
>
|
||||||
|
{#each flow.identity_providers as idp}
|
||||||
|
{#if idp.icon}
|
||||||
|
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
||||||
|
{:else}
|
||||||
|
{idp.name}
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</a>
|
||||||
|
{:else if flow.type === "m.login.password"}
|
||||||
|
<!-- TODO: unambigous sso
|
||||||
|
<form onsubmit={passwordLogin}>
|
||||||
|
<input name="username" type="text" placeholder="Username" />
|
||||||
|
<input name="password" type="password" placeholder="Password" />
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
</form>
|
||||||
|
-->
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
{/await}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
> *:not(:last-child) {
|
||||||
|
border-right: 1px solid var(--md-sys-color-outline);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rooms {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.members {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -187,13 +187,8 @@
|
|||||||
let supportsAutospace = $derived(
|
let supportsAutospace = $derived(
|
||||||
semverGte($deviceMeta?.version ?? "0.0.0", "2.1.0"),
|
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(
|
let hasAutospace = $derived(
|
||||||
supportsAutospaceV2
|
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||||
? chord.phrase.at(-1) !== NO_CONCATENATOR_ACTION
|
|
||||||
: isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function isHidden(action: number, index: number, array: number[]) {
|
function isHidden(action: number, index: number, array: number[]) {
|
||||||
@@ -233,10 +228,8 @@
|
|||||||
moveCursor(cursorPosition + 1, true);
|
moveCursor(cursorPosition + 1, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!supportsAutospaceV2) {
|
await tick();
|
||||||
await tick();
|
resolveAutospace(autospace);
|
||||||
resolveAutospace(autospace);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -272,24 +265,8 @@
|
|||||||
<AutospaceSelector
|
<AutospaceSelector
|
||||||
variant="end"
|
variant="end"
|
||||||
value={!hasAutospace}
|
value={!hasAutospace}
|
||||||
onchange={async (event) => {
|
onchange={(event) =>
|
||||||
if (supportsAutospaceV2) {
|
resolveAutospace((event.target as HTMLInputElement).checked)}
|
||||||
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}
|
{/if}
|
||||||
<sup>•</sup>
|
<sup>•</sup>
|
||||||
|
|||||||
@@ -47,6 +47,19 @@
|
|||||||
font-weight: bold;
|
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 {
|
p {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|||||||
252
src/routes/(app)/config/cv2/+page.svelte
Normal file
252
src/routes/(app)/config/cv2/+page.svelte
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
<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>
|
||||||
10
src/routes/(app)/config/x2/+page.svelte
Normal file
10
src/routes/(app)/config/x2/+page.svelte
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<script>
|
||||||
|
import { parseCharaChords } from "$lib/chord-editor/action-serializer";
|
||||||
|
import text from "$lib/chord-editor/test.txt?raw";
|
||||||
|
import { KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<pre>{text}</pre>
|
||||||
|
{#each parseCharaChords(text, $KEYMAP_IDS) as chord}
|
||||||
|
<pre>{JSON.stringify(chord)} ({text.slice(chord.from, chord.to)})</pre>
|
||||||
|
{/each}
|
||||||
@@ -5,17 +5,18 @@
|
|||||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
||||||
import TrackRollingWpm from "$lib/charrecorder/TrackRollingWpm.svelte";
|
import TrackRollingWpm from "$lib/charrecorder/TrackRollingWpm.svelte";
|
||||||
import { fade } from "svelte/transition";
|
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 recorder: ReplayRecorder = $state(new ReplayRecorder());
|
||||||
let replay: Replay | undefined = $state();
|
let replay: Replay | undefined = $state();
|
||||||
|
|
||||||
let wpm = $state(0);
|
let wpm = $state(0);
|
||||||
let cc0Loading = $state(false);
|
|
||||||
let chords: InferredChord[] = $state([]);
|
let chords: InferredChord[] = $state([]);
|
||||||
|
|
||||||
|
function handleRawKey(event: KeyboardEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
keyEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
function keyEvent(event: KeyboardEvent) {
|
function keyEvent(event: KeyboardEvent) {
|
||||||
if (event.key === "Tab") {
|
if (event.key === "Tab") {
|
||||||
clear();
|
clear();
|
||||||
@@ -46,60 +47,15 @@
|
|||||||
a.download = "replay.json";
|
a.download = "replay.json";
|
||||||
a.click();
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Editor</title>
|
<title>Editor</title>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
<svelte:window onkeydown={handleRawKey} onkeyup={handleRawKey} />
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2>
|
<h2>Editor</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}
|
{#if replay}
|
||||||
<div class="replay" transition:fade={{ duration: 100 }}>
|
<div class="replay" transition:fade={{ duration: 100 }}>
|
||||||
@@ -110,9 +66,7 @@
|
|||||||
{#key recorder}
|
{#key recorder}
|
||||||
<div
|
<div
|
||||||
class="editor"
|
class="editor"
|
||||||
tabindex="-1"
|
|
||||||
out:fade={{ duration: 100 }}
|
out:fade={{ duration: 100 }}
|
||||||
{@attach ccosKeyInterceptor($serialPort, recorder)}
|
|
||||||
style:opacity={replay ? 0 : undefined}
|
style:opacity={replay ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
||||||
@@ -141,38 +95,15 @@
|
|||||||
width: 100%;
|
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,
|
.replay,
|
||||||
.editor {
|
.editor {
|
||||||
|
position: absolute;
|
||||||
|
top: 3em;
|
||||||
|
left: 0;
|
||||||
transition: opacity 0.1s;
|
transition: opacity 0.1s;
|
||||||
margin: 4px;
|
|
||||||
outline: 1px solid var(--md-sys-color-outline);
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
padding-bottom: 5em;
|
padding-bottom: 5em;
|
||||||
|
padding-left: 0;
|
||||||
&:focus-within {
|
|
||||||
outline: 2px solid var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
24
src/routes/(app)/learn/+page.svelte
Normal file
24
src/routes/(app)/learn/+page.svelte
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<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>
|
||||||
232
src/routes/(app)/learn/chords/+page.svelte
Normal file
232
src/routes/(app)/learn/chords/+page.svelte
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<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>
|
||||||
124
src/routes/(app)/learn/layout/+page.svelte
Normal file
124
src/routes/(app)/learn/layout/+page.svelte
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<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>
|
||||||
652
src/routes/(app)/learn/sentence/+page.svelte
Normal file
652
src/routes/(app)/learn/sentence/+page.svelte
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
<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>
|
||||||
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
32
src/routes/(app)/learn/sentence/configuration.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
8
src/routes/(app)/learn/sentence/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 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";
|
||||||
0
src/routes/(app)/learn/sentence/types.ts
Normal file
0
src/routes/(app)/learn/sentence/types.ts
Normal file
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
69
src/routes/(app)/learn/sentence/word-selector.spec.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal file
25
src/routes/(app)/learn/sentence/word-selector.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
263
src/routes/(app)/plugin/+page.svelte
Normal file
263
src/routes/(app)/plugin/+page.svelte
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<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>
|
||||||
32
src/routes/(app)/plugin/example-plugin.js
Normal file
32
src/routes/(app)/plugin/example-plugin.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
// @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!
|
||||||
30
src/routes/(app)/plugin/plugin-types.ts
Normal file
30
src/routes/(app)/plugin/plugin-types.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
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;
|
||||||
0
src/routes/(app)/stats/+page.svelte
Normal file
0
src/routes/(app)/stats/+page.svelte
Normal file
20
src/routes/(app)/test/+page.svelte
Normal file
20
src/routes/(app)/test/+page.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<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>
|
||||||
60
static/sandbox/index.html
Normal file
60
static/sandbox/index.html
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<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>
|
||||||
10
static/sentences/Modelfile
Normal file
10
static/sentences/Modelfile
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
FROM llama3.1
|
||||||
|
|
||||||
|
TEMPLATE """
|
||||||
|
<|system|>
|
||||||
|
Only output the sentence
|
||||||
|
<|user|>
|
||||||
|
Create a typical sentence with around 10 words that includes the word "{{ .Prompt }}".
|
||||||
|
<|assistant|>
|
||||||
|
Create a sentence that includes "{{ .Prompt }}".
|
||||||
|
"""
|
||||||
@@ -26,7 +26,6 @@ process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
|||||||
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
|
process.env["VITE_STORE_URL"] = "https://www.charachorder.com/";
|
||||||
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
|
process.env["VITE_MATRIX_URL"] = "https://charachorder.io/";
|
||||||
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
|
process.env["VITE_FIRMWARE_URL"] = "https://charachorder.io/firmware";
|
||||||
process.env["VITE_DISCORD_URL"] = "https://discord.gg/CharaChorder";
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
build: {
|
build: {
|
||||||
@@ -63,6 +62,7 @@ export default defineConfig({
|
|||||||
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
"client/**/*.{js,css,ico,woff2,csv,png,webp,svg,webmanifest}",
|
||||||
"prerendered/**/*.html",
|
"prerendered/**/*.html",
|
||||||
],
|
],
|
||||||
|
globIgnores: ["prerendered/pages/ccos/**/*"],
|
||||||
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
ignoreURLParametersMatching: [/^import|redirectUrl|loginToken$/],
|
||||||
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user