mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-02-25 02:22:02 +00:00
Compare commits
8 Commits
7d7c432bb2
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
dee754c015
|
|||
|
5e4283a462
|
|||
|
b9c6c05819
|
|||
|
16bf766de9
|
|||
|
ee8d400ad7
|
|||
|
9a1c2b5bf6
|
|||
|
1d1fcb72e3
|
|||
|
ee3f84645d
|
@@ -57,6 +57,7 @@ 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.6.0",
|
"version": "2.7.0",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"private": true,
|
"private": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -35,27 +35,25 @@
|
|||||||
},
|
},
|
||||||
"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.12.1",
|
"@codemirror/language": "^6.11.3",
|
||||||
"@codemirror/lint": "^6.9.2",
|
|
||||||
"@codemirror/merge": "^6.11.2",
|
"@codemirror/merge": "^6.11.2",
|
||||||
"@codemirror/state": "^6.5.3",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.39.9",
|
"@codemirror/view": "^6.39.4",
|
||||||
"@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.5.0",
|
"@lezer/common": "^1.4.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.7",
|
"@lezer/lr": "^1.4.5",
|
||||||
"@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.3",
|
"@sveltejs/kit": "^2.49.2",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.3",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@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",
|
||||||
@@ -75,32 +73,31 @@
|
|||||||
"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.2.0",
|
"prettier-plugin-css-order": "^2.1.2",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-svelte": "^3.4.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"sass": "^1.97.2",
|
"sass": "^1.97.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.1",
|
||||||
"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.46.1",
|
"svelte": "5.37.1",
|
||||||
"svelte-check": "^4.3.5",
|
"svelte-check": "^4.3.4",
|
||||||
"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.9.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.0.6",
|
||||||
"vite-plugin-mkcert": "^1.17.9",
|
"vite-plugin-mkcert": "^1.17.9",
|
||||||
"vite-plugin-pwa": "^1.2.0",
|
"vite-plugin-pwa": "^1.0.2",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16",
|
||||||
"web-serial-polyfill": "^1.0.15",
|
"web-serial-polyfill": "^1.0.15",
|
||||||
"workbox-window": "^7.4.0"
|
"workbox-window": "^7.3.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.6.0"
|
version = "2.7.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.6.0" },
|
"package": { "productName": "amacc1ng", "version": "2.7.0" },
|
||||||
"tauri": {
|
"tauri": {
|
||||||
"allowlist": { "all": false },
|
"allowlist": { "all": false },
|
||||||
"bundle": {
|
"bundle": {
|
||||||
|
|||||||
@@ -1,26 +1,35 @@
|
|||||||
import type { Attachment } from "svelte/attachments";
|
import type { Attachment } from "svelte/attachments";
|
||||||
import { browser } from "$app/environment";
|
import type { CharaDevice } from "$lib/serial/device";
|
||||||
import { persistentWritable } from "$lib/storage";
|
import type { CCOS, CCOSKeyboardEvent } from "./ccos";
|
||||||
|
import type { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
||||||
|
|
||||||
export const emulatedCCOS = persistentWritable("emulatedCCOS", false);
|
export function ccosKeyInterceptor(
|
||||||
|
port: CharaDevice | undefined,
|
||||||
export function ccosKeyInterceptor() {
|
recorder: ReplayRecorder,
|
||||||
return ((element: Window) => {
|
) {
|
||||||
const ccos = browser
|
return ((element: HTMLElement) => {
|
||||||
? import("./ccos").then((module) => module.fetchCCOS(".test"))
|
const ccos =
|
||||||
: Promise.resolve(undefined);
|
port?.port && "handleKeyEvent" in port?.port
|
||||||
|
? (port.port as CCOS)
|
||||||
|
: undefined;
|
||||||
|
console.log("Attaching CCOS key interceptor", ccos);
|
||||||
|
|
||||||
function onEvent(event: KeyboardEvent) {
|
function onEvent(event: KeyboardEvent) {
|
||||||
ccos.then((it) => it?.handleKeyEvent(event));
|
ccos?.handleKeyEvent(event);
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
recorder.next(event);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
element.addEventListener("keydown", onEvent, true);
|
if (ccos) {
|
||||||
element.addEventListener("keyup", onEvent, true);
|
element.addEventListener("keydown", 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<Window>;
|
}) satisfies Attachment<HTMLElement>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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,
|
||||||
@@ -11,7 +10,7 @@ import { KEYCODE_TO_SCANCODE, SCANCODE_TO_KEYCODE } from "./ccos-interop";
|
|||||||
|
|
||||||
const device = "zero_wasm";
|
const device = "zero_wasm";
|
||||||
|
|
||||||
class CCOSKeyboardEvent extends KeyboardEvent {
|
export class CCOSKeyboardEvent extends KeyboardEvent {
|
||||||
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
constructor(...params: ConstructorParameters<typeof KeyboardEvent>) {
|
||||||
super(...params);
|
super(...params);
|
||||||
}
|
}
|
||||||
@@ -26,7 +25,46 @@ 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" });
|
||||||
|
|
||||||
@@ -126,7 +164,6 @@ 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();
|
||||||
@@ -220,7 +257,7 @@ export class CCOS implements SerialPortLike {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCCOS(
|
export async function fetchCCOS(
|
||||||
version = ".2.2.0-beta.12+266bdda",
|
version = "3.0.0-rc.0",
|
||||||
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);
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { RoomMember } from "matrix-js-sdk";
|
|
||||||
import { matrixClient, memberColor } from "./chat";
|
|
||||||
import { theme } from "$lib/preferences";
|
|
||||||
import { hexFromArgb } from "@material/material-color-utilities";
|
|
||||||
|
|
||||||
let { members }: { members: RoomMember[] } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="member-list">
|
|
||||||
{#each members as member (member.userId)}
|
|
||||||
{@const avatar = member.getMxcAvatarUrl()}
|
|
||||||
<div class="member">
|
|
||||||
{#if avatar}
|
|
||||||
<img
|
|
||||||
class="avatar"
|
|
||||||
src={$matrixClient.mxcUrlToHttp(avatar, 32, 32)}
|
|
||||||
alt={member.name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
{@const color = memberColor(member, $theme)}
|
|
||||||
{@const modeColor = $theme.mode === "dark" ? color.dark : color.light}
|
|
||||||
<div
|
|
||||||
style:background={hexFromArgb(modeColor.color)}
|
|
||||||
style:color={hexFromArgb(modeColor.onColor)}
|
|
||||||
class="avatar avatar-placeholder icon"
|
|
||||||
>
|
|
||||||
person
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span>{member.name}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.avatar {
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar-placeholder {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 8px;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type { Room } from "matrix-js-sdk";
|
|
||||||
import { matrixClient, currentRoomId } from "./chat";
|
|
||||||
|
|
||||||
let { rooms }: { rooms: Room[] } = $props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="rooms">
|
|
||||||
{#each $matrixClient.getRooms() as room}
|
|
||||||
{@const avatar = room.getMxcAvatarUrl()}
|
|
||||||
<button
|
|
||||||
class:active={$currentRoomId === room.roomId}
|
|
||||||
class="room"
|
|
||||||
onclick={() => ($currentRoomId = room.roomId)}
|
|
||||||
>
|
|
||||||
{#if avatar}
|
|
||||||
<img
|
|
||||||
alt={room.name}
|
|
||||||
src={$matrixClient.mxcUrlToHttp(avatar, 16, 16)}
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div>#</div>
|
|
||||||
{/if}
|
|
||||||
<div>{room.name}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
{#await $matrixClient.publicRooms()}
|
|
||||||
<div>Loading...</div>
|
|
||||||
{:then rooms}
|
|
||||||
{#each rooms.chunk as room}
|
|
||||||
<button class="room" onclick={() => ($currentRoomId = room.roomId)}>
|
|
||||||
<div>#</div>
|
|
||||||
<div>{room.name}</div>
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
{:catch error}
|
|
||||||
<div>{error.message}</div>
|
|
||||||
{/await}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.rooms {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 8px;
|
|
||||||
padding-left: 0;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.room {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding-inline: 16px;
|
|
||||||
padding-block: 2px;
|
|
||||||
padding-block: 4px;
|
|
||||||
width: 100%;
|
|
||||||
height: unset;
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
&.active {
|
|
||||||
background: var(--md-sys-color-primary-container);
|
|
||||||
color: var(--md-sys-color-on-primary-container);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {
|
|
||||||
EventTimeline,
|
|
||||||
MatrixEvent,
|
|
||||||
MsgType,
|
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomMember,
|
|
||||||
RoomMemberEvent,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { onDestroy, onMount, tick } from "svelte";
|
|
||||||
import { matrixClient } from "./chat";
|
|
||||||
import MatrixEventComponent from "./events/MatrixEvent.svelte";
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
import { type Socket, io } from "socket.io-client";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
|
|
||||||
let { timeline }: { timeline: EventTimeline } = $props();
|
|
||||||
|
|
||||||
const excludeEvents = ["m.reaction", "m.room.redaction"];
|
|
||||||
|
|
||||||
let events = $state(
|
|
||||||
timeline
|
|
||||||
.getEvents()
|
|
||||||
.filter((it) => !excludeEvents.includes(it.getType()))
|
|
||||||
.reverse(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let recorder = $state(new ReplayRecorder());
|
|
||||||
let showCursor = $state(false);
|
|
||||||
|
|
||||||
let timelineElement: HTMLElement = $state()!;
|
|
||||||
|
|
||||||
async function onTimeline(
|
|
||||||
event: MatrixEvent,
|
|
||||||
room?: Room,
|
|
||||||
toStartOfTimeline?: boolean,
|
|
||||||
) {
|
|
||||||
if (room?.roomId !== timeline.getRoomId()) return;
|
|
||||||
const sender = event.getSender();
|
|
||||||
if (sender) {
|
|
||||||
live.delete(sender);
|
|
||||||
}
|
|
||||||
if (excludeEvents.includes(event.getType())) return;
|
|
||||||
if (toStartOfTimeline) {
|
|
||||||
events.push(event);
|
|
||||||
} else {
|
|
||||||
const needScroll = timelineElement.scrollTop < 20;
|
|
||||||
events.unshift(event);
|
|
||||||
if (needScroll) {
|
|
||||||
await tick();
|
|
||||||
timelineElement.scroll({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let typing = $state<string[]>([]);
|
|
||||||
|
|
||||||
function onTyping(event: MatrixEvent, member: RoomMember) {
|
|
||||||
typing = event.event.content?.["user_ids"] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function send() {
|
|
||||||
const roomId = timeline.getRoomId();
|
|
||||||
if (!roomId) return;
|
|
||||||
const finalText = recorder.player.stepper.text
|
|
||||||
.map((token) => token.text)
|
|
||||||
.join("");
|
|
||||||
const finalRecording = recorder.finish();
|
|
||||||
if (!finalText) return;
|
|
||||||
recorder = new ReplayRecorder();
|
|
||||||
await $matrixClient.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text" as MsgType.Text,
|
|
||||||
body: finalText,
|
|
||||||
// @ts-expect-error
|
|
||||||
"m.replay": finalRecording,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKey(event: KeyboardEvent) {
|
|
||||||
if (event.type === "keyup" && event.key === "Enter" && !event.shiftKey) {
|
|
||||||
send();
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
recorder.next(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "keyup" && recorder.player.stepper.text.length === 0) {
|
|
||||||
recorder = new ReplayRecorder();
|
|
||||||
} else {
|
|
||||||
socket.emit("message", {
|
|
||||||
timeStamp: event.timeStamp,
|
|
||||||
type: event.type,
|
|
||||||
key: event.key,
|
|
||||||
code: event.code,
|
|
||||||
username: $matrixClient.getUserId(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let socket: Socket = $state()!;
|
|
||||||
let live = new SvelteMap<string, ReplayRecorder>();
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
socket = io("https://srv.charachorder.io");
|
|
||||||
socket.emit("join", timeline.getRoomId());
|
|
||||||
|
|
||||||
socket.on("message", async ({ message }) => {
|
|
||||||
let userRecorder = live.get(message.username);
|
|
||||||
if (!userRecorder) {
|
|
||||||
userRecorder = new ReplayRecorder();
|
|
||||||
live.set(message.username, userRecorder);
|
|
||||||
}
|
|
||||||
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
userRecorder.next(message);
|
|
||||||
|
|
||||||
if (userRecorder.player.stepper.text.length === 0) {
|
|
||||||
live.delete(message.username);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$matrixClient.on("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
|
||||||
$matrixClient.on("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
socket?.disconnect();
|
|
||||||
$matrixClient.off("Room.timeline" as RoomEvent.Timeline, onTimeline);
|
|
||||||
$matrixClient.off("RoomMember.typing" as RoomMemberEvent.Typing, onTyping);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div bind:this={timelineElement} class="timeline">
|
|
||||||
{#each live.entries() as [userId, recorder] (userId)}
|
|
||||||
{@const roomId = timeline.getRoomId()}
|
|
||||||
{#if roomId}
|
|
||||||
{@const room = $matrixClient.getRoom(roomId)}
|
|
||||||
{@const member = room?.getMember(userId)}
|
|
||||||
{#if member}
|
|
||||||
<MatrixEventComponent sender={member} replay={recorder.player} />
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#each events as event, i (event.event["event_id"])}
|
|
||||||
{@const prev = events[i + 1]}
|
|
||||||
<MatrixEventComponent {event} sender={event.sender} {prev} {timeline} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="static-elements">
|
|
||||||
<div class="indicators"></div>
|
|
||||||
<div class="input-box">
|
|
||||||
<button class="icon">add</button>
|
|
||||||
<div
|
|
||||||
role="textbox"
|
|
||||||
tabindex="0"
|
|
||||||
class="input"
|
|
||||||
onkeydown={onKey}
|
|
||||||
onkeyup={onKey}
|
|
||||||
onfocusin={() => (showCursor = true)}
|
|
||||||
onfocusout={() => (showCursor = false)}
|
|
||||||
>
|
|
||||||
<CharRecorder replay={recorder.player} cursor={showCursor} />
|
|
||||||
</div>
|
|
||||||
<button class="icon" onclick={send}>send</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
$border-radius: 16px;
|
|
||||||
|
|
||||||
.input {
|
|
||||||
flex-grow: 1;
|
|
||||||
cursor: text;
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
|
||||||
border-radius: $border-radius;
|
|
||||||
padding: 0.5em;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
text-wrap: wrap;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
|
||||||
|
|
||||||
&:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-box {
|
|
||||||
display: flex;
|
|
||||||
flex-shrink: 0;
|
|
||||||
gap: 4px;
|
|
||||||
padding-block: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.static-elements {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
contain: content;
|
|
||||||
width: 100%;
|
|
||||||
height: auto;
|
|
||||||
overflow-x: hidden;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: flex-end;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { derived, writable, type Writable } from "svelte/store";
|
|
||||||
import type {
|
|
||||||
ClientEvent,
|
|
||||||
LoginResponse,
|
|
||||||
MatrixClient,
|
|
||||||
RoomMember,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import {
|
|
||||||
themeFromSourceColor,
|
|
||||||
argbFromHex,
|
|
||||||
type CustomColorGroup,
|
|
||||||
} from "@material/material-color-utilities";
|
|
||||||
import type { UserTheme } from "$lib/preferences";
|
|
||||||
import { MatrixRx } from "./matrix-rx/client";
|
|
||||||
|
|
||||||
export const matrixClient: Writable<MatrixClient> = writable();
|
|
||||||
|
|
||||||
export const isLoggedIn: Writable<boolean> = writable(false);
|
|
||||||
|
|
||||||
export const matrix = derived(
|
|
||||||
[matrixClient, isLoggedIn],
|
|
||||||
([matrixClient, isLoggedIn]) =>
|
|
||||||
isLoggedIn ? new MatrixRx(matrixClient) : undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const currentRoomId = persistentWritable<string | null>(
|
|
||||||
"currentRoomId",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
function getStoredLogin(): LoginResponse | undefined {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function storeLogin(response: LoginResponse) {
|
|
||||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function initMatrixClient() {
|
|
||||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
|
||||||
"matrix-js-sdk"
|
|
||||||
);
|
|
||||||
|
|
||||||
const storedLogin = getStoredLogin();
|
|
||||||
|
|
||||||
const store = new IndexedDBStore({
|
|
||||||
dbName: "matrix",
|
|
||||||
indexedDB: window.indexedDB,
|
|
||||||
});
|
|
||||||
const cryptoStore = new IndexedDBCryptoStore(
|
|
||||||
window.indexedDB,
|
|
||||||
"matrix-crypto",
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = createClient({
|
|
||||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
|
||||||
userId: storedLogin?.user_id,
|
|
||||||
accessToken: storedLogin?.access_token,
|
|
||||||
timelineSupport: true,
|
|
||||||
store,
|
|
||||||
cryptoStore,
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("store");
|
|
||||||
await store.startup();
|
|
||||||
console.log("cryptoStore");
|
|
||||||
await cryptoStore.startup();
|
|
||||||
console.log("client");
|
|
||||||
await client.startClient();
|
|
||||||
client.once("sync" as ClientEvent.Sync, () => {
|
|
||||||
isLoggedIn.set(client.isLoggedIn());
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginToken = new URLSearchParams(window.location.search).get(
|
|
||||||
"loginToken",
|
|
||||||
);
|
|
||||||
if (loginToken) {
|
|
||||||
storeLogin(await client.loginWithToken(loginToken));
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
isLoggedIn.set(client.isLoggedIn());
|
|
||||||
}
|
|
||||||
|
|
||||||
matrixClient.set(client);
|
|
||||||
console.log("done");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function memberColor(
|
|
||||||
member: RoomMember,
|
|
||||||
theme: UserTheme,
|
|
||||||
): CustomColorGroup {
|
|
||||||
let hash = 0;
|
|
||||||
member.userId.split("").forEach((char) => {
|
|
||||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
|
||||||
});
|
|
||||||
let color = "#";
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += value.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
|
||||||
{ value: argbFromHex(color), name: "member", blend: true },
|
|
||||||
]).customColors.find((c) => c.color.name === "member")!;
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { writable, type Writable } from "svelte/store";
|
|
||||||
import type { MatrixClient, RoomMember } from "matrix-js-sdk";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import {
|
|
||||||
themeFromSourceColor,
|
|
||||||
argbFromHex,
|
|
||||||
type CustomColorGroup,
|
|
||||||
} from "@material/material-color-utilities";
|
|
||||||
import type { UserTheme } from "$lib/preferences";
|
|
||||||
|
|
||||||
export const matrixClient: Writable<MatrixClient> = writable();
|
|
||||||
|
|
||||||
export const currentRoomId = persistentWritable<string | null>(
|
|
||||||
"currentRoomId",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
export function memberColor(
|
|
||||||
member: RoomMember,
|
|
||||||
theme: UserTheme,
|
|
||||||
): CustomColorGroup {
|
|
||||||
let hash = 0;
|
|
||||||
member.userId.split("").forEach((char) => {
|
|
||||||
hash = char.charCodeAt(0) + ((hash << 5) - hash);
|
|
||||||
});
|
|
||||||
let color = "#";
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += value.toString(16).padStart(2, "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
return themeFromSourceColor(argbFromHex(theme.color), [
|
|
||||||
{ value: argbFromHex(color), name: "member", blend: true },
|
|
||||||
]).customColors.find((c) => c.color.name === "member")!;
|
|
||||||
}
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import type {
|
|
||||||
EventTimeline,
|
|
||||||
MatrixEvent,
|
|
||||||
MatrixEventEvent,
|
|
||||||
Relations,
|
|
||||||
RelationsEvent,
|
|
||||||
RoomMember,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import MatrixMessageEvent from "./MatrixMessageEvent.svelte";
|
|
||||||
import { matrixClient, memberColor } from "../chat";
|
|
||||||
import { theme } from "$lib/preferences";
|
|
||||||
import { hexFromArgb } from "@material/material-color-utilities";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import type { Replay } from "$lib/charrecorder/core/types";
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import type { ReplayPlayer } from "$lib/charrecorder/core/player";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
let {
|
|
||||||
event,
|
|
||||||
prev,
|
|
||||||
sender,
|
|
||||||
replay: replayPlayer,
|
|
||||||
timeline,
|
|
||||||
}: {
|
|
||||||
event?: MatrixEvent;
|
|
||||||
prev?: MatrixEvent;
|
|
||||||
sender?: RoomMember | null;
|
|
||||||
replay?: Replay | ReplayPlayer;
|
|
||||||
timeline?: EventTimeline;
|
|
||||||
} = $props();
|
|
||||||
|
|
||||||
let toolbarHover = $state(false);
|
|
||||||
let mainHover = $state(false);
|
|
||||||
|
|
||||||
let hover = $derived(toolbarHover || mainHover);
|
|
||||||
|
|
||||||
let replay: Replay | undefined = $state();
|
|
||||||
|
|
||||||
let reactions: Relations | undefined = $state(
|
|
||||||
timeline && event?.event.event_id
|
|
||||||
? timeline
|
|
||||||
.getTimelineSet()
|
|
||||||
.relations.getChildEventsForEvent(
|
|
||||||
event.event.event_id,
|
|
||||||
"m.annotation",
|
|
||||||
"m.reaction",
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
);
|
|
||||||
let annotations = writable<[string, Set<MatrixEvent>][] | null | undefined>();
|
|
||||||
|
|
||||||
function createRelations() {
|
|
||||||
if (!timeline || !event?.event.event_id) return;
|
|
||||||
reactions?.off("Relations.add" as RelationsEvent.Add, createRelations);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.remove" as RelationsEvent.Remove,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions = timeline
|
|
||||||
.getTimelineSet()
|
|
||||||
.relations.getChildEventsForEvent(
|
|
||||||
event.event.event_id,
|
|
||||||
"m.annotation",
|
|
||||||
"m.reaction",
|
|
||||||
);
|
|
||||||
reactions?.on("Relations.add" as RelationsEvent.Add, createRelations);
|
|
||||||
reactions?.on("Relations.remove" as RelationsEvent.Remove, createRelations);
|
|
||||||
reactions?.on(
|
|
||||||
"Relations.redaction" as RelationsEvent.Redaction,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
annotations.set(
|
|
||||||
reactions?.getSortedAnnotationsByKey()?.filter(([, it]) => it.size > 0),
|
|
||||||
);
|
|
||||||
console.log("create");
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
createRelations();
|
|
||||||
event?.on(
|
|
||||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
event?.off(
|
|
||||||
"Event.relationsCreated" as MatrixEventEvent.RelationsCreated,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions?.off("Relations.add" as RelationsEvent.Remove, createRelations);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.remove" as RelationsEvent.Remove,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
reactions?.off(
|
|
||||||
"Relations.redaction" as RelationsEvent.Redaction,
|
|
||||||
createRelations,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="event"
|
|
||||||
role="log"
|
|
||||||
onmouseover={() => (mainHover = true)}
|
|
||||||
onfocus={() => (mainHover = true)}
|
|
||||||
onmouseout={() => (mainHover = false)}
|
|
||||||
onblur={() => (mainHover = false)}
|
|
||||||
>
|
|
||||||
{#if event && hover}
|
|
||||||
<div class="backdrop" transition:fade={{ duration: 100 }}></div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if sender && !(prev && prev?.getType() === event?.getType() && prev.sender?.userId === event.sender?.userId)}
|
|
||||||
{@const color = memberColor(sender, $theme)}
|
|
||||||
{@const avatarMxc = sender.getMxcAvatarUrl()}
|
|
||||||
{#if avatarMxc}
|
|
||||||
{@const avatar = $matrixClient.mxcUrlToHttp(avatarMxc, 32, 32)}
|
|
||||||
<img
|
|
||||||
class="avatar"
|
|
||||||
src={avatar}
|
|
||||||
alt={sender.name}
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<div
|
|
||||||
class="avatar avatar-placeholder icon"
|
|
||||||
style:background={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
style:color={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.onColor : color.light.onColor,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
person
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="sender"
|
|
||||||
style:color={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<strong>{sender.name}</strong>
|
|
||||||
{#if replay || replayPlayer}
|
|
||||||
<div class="dots">
|
|
||||||
{#each new Array(3) as _, i}
|
|
||||||
<div
|
|
||||||
style:animation-delay={i * 0.2 + "s"}
|
|
||||||
style:background={hexFromArgb(
|
|
||||||
$theme.mode === "dark" ? color.dark.color : color.light.color,
|
|
||||||
)}
|
|
||||||
class="dot"
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="content">
|
|
||||||
{#if event}
|
|
||||||
{#if event.getType() === "m.room.message"}
|
|
||||||
<MatrixMessageEvent {event} bind:replay />
|
|
||||||
{:else}
|
|
||||||
<details>
|
|
||||||
<summary>{event.getType()}</summary>
|
|
||||||
<pre>{JSON.stringify(event.event, null, 2)}</pre>
|
|
||||||
</details>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
{#if replayPlayer}
|
|
||||||
<CharRecorder replay={replayPlayer} cursor={true} keys={true} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if event && hover}
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
tabindex="0"
|
|
||||||
class="toolbar"
|
|
||||||
transition:fade={{ duration: 100 }}
|
|
||||||
onmouseover={() => (toolbarHover = true)}
|
|
||||||
onfocus={() => (toolbarHover = true)}
|
|
||||||
onmouseout={() => (toolbarHover = false)}
|
|
||||||
onblur={() => (toolbarHover = false)}
|
|
||||||
>
|
|
||||||
{#if event.getType() === "m.room.message"}
|
|
||||||
{@const message = event.event.content?.["body"]}
|
|
||||||
<a
|
|
||||||
class="icon rocket"
|
|
||||||
href="/learn/sentence/?sentence={encodeURIComponent(message)}"
|
|
||||||
>rocket_launch</a
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
<button class="icon">add_reaction</button>
|
|
||||||
<button class="icon">reply</button>
|
|
||||||
{#if event.event.content?.["m.replay"]}
|
|
||||||
{#if replay}
|
|
||||||
<button class="icon" onclick={() => (replay = undefined)}>stop</button
|
|
||||||
>
|
|
||||||
{:else}
|
|
||||||
<button
|
|
||||||
class="icon"
|
|
||||||
onclick={() => (replay = event.event.content?.["m.replay"])}
|
|
||||||
>replay</button
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
<button class="icon">more_horiz</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $annotations && $annotations.length > 0}
|
|
||||||
<div class="reactions">
|
|
||||||
{#each $annotations as [reaction, events]}
|
|
||||||
<button class="reaction"
|
|
||||||
>{reaction} <span class="count">{events.size}</span></button
|
|
||||||
>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
details {
|
|
||||||
opacity: 0.5;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
text-wrap: wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes rocket {
|
|
||||||
0% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
90% {
|
|
||||||
transform: translate(4px, -4px);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.icon.rocket {
|
|
||||||
animation: rocket 2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toolbar {
|
|
||||||
display: flex;
|
|
||||||
position: absolute;
|
|
||||||
top: -26px;
|
|
||||||
right: 0;
|
|
||||||
z-index: 100;
|
|
||||||
border-radius: 4px;
|
|
||||||
background: var(--md-sys-color-secondary-container);
|
|
||||||
padding: 4px;
|
|
||||||
color: var(--md-sys-color-on-secondary-container);
|
|
||||||
|
|
||||||
a,
|
|
||||||
button {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dots {
|
|
||||||
display: flex;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes bounce {
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
animation: bounce 1s infinite;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender,
|
|
||||||
.avatar {
|
|
||||||
margin-block: 2px 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
grid-area: avatar;
|
|
||||||
translate: 0 2px;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.avatar {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sender {
|
|
||||||
display: flex;
|
|
||||||
grid-area: sender;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions {
|
|
||||||
display: flex;
|
|
||||||
grid-area: reactions;
|
|
||||||
gap: 4px;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reaction {
|
|
||||||
display: flex;
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 6px;
|
|
||||||
height: 24px;
|
|
||||||
font-size: 12px;
|
|
||||||
|
|
||||||
> .count {
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.event {
|
|
||||||
display: grid;
|
|
||||||
position: relative;
|
|
||||||
grid-template-columns: 32px 1fr auto;
|
|
||||||
|
|
||||||
grid-template-areas:
|
|
||||||
"avatar sender date"
|
|
||||||
"avatar content content"
|
|
||||||
"none reactions reactions";
|
|
||||||
margin-inline: 0.5em;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding-inline: 0.5em;
|
|
||||||
padding-block: 0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
grid-area: content;
|
|
||||||
text-wrap: wrap;
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.reactions,
|
|
||||||
.content,
|
|
||||||
.sender {
|
|
||||||
margin-inline: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.backdrop {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0.25;
|
|
||||||
z-index: -1;
|
|
||||||
inset: 0;
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import type { Replay } from "$lib/charrecorder/core/types";
|
|
||||||
import type { MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import { matrixClient } from "../chat";
|
|
||||||
|
|
||||||
let { event, replay = $bindable() }: { event: MatrixEvent; replay?: Replay } =
|
|
||||||
$props();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{#if event.event.content?.msgtype === "m.image"}
|
|
||||||
<img
|
|
||||||
src={$matrixClient.mxcUrlToHttp(event.event.content["url"])}
|
|
||||||
alt={event.event.content["body"]}
|
|
||||||
/>
|
|
||||||
{:else}
|
|
||||||
<span class="content" style:opacity={replay && 0}
|
|
||||||
>{event.event.content?.["body"]}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
{#if replay}
|
|
||||||
<div class="replay" out:fade>
|
|
||||||
<CharRecorder
|
|
||||||
{replay}
|
|
||||||
cursor={true}
|
|
||||||
keys={true}
|
|
||||||
ondone={() => (replay = undefined)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
div {
|
|
||||||
position: relative;
|
|
||||||
min-height: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
border-radius: 8px;
|
|
||||||
max-width: 100%;
|
|
||||||
max-height: 16em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { Direction, MatrixClient, Room } from "matrix-js-sdk";
|
|
||||||
import {
|
|
||||||
filter,
|
|
||||||
map,
|
|
||||||
type Observable,
|
|
||||||
of,
|
|
||||||
distinctUntilChanged,
|
|
||||||
merge,
|
|
||||||
} from "rxjs";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
|
|
||||||
function roomListDistinct(prev: Room[], curr: Room[]) {
|
|
||||||
if (prev.length !== curr.length) return false;
|
|
||||||
for (let i = 0; i < prev.length; i++) {
|
|
||||||
if (prev[i]!.roomId !== curr[i]!.roomId) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MatrixRx {
|
|
||||||
topLevelRooms$: Observable<Room[]>;
|
|
||||||
|
|
||||||
topLevelSpaces$: Observable<Room[]>;
|
|
||||||
|
|
||||||
topLevelChats$: Observable<Room[]>;
|
|
||||||
|
|
||||||
constructor(private client: MatrixClient) {
|
|
||||||
this.topLevelRooms$ = merge(
|
|
||||||
of([]),
|
|
||||||
fromMatrixClientEvent(client, "Room"),
|
|
||||||
fromMatrixClientEvent(client, "deleteRoom"),
|
|
||||||
fromMatrixClientEvent(client, "Room.myMembership"),
|
|
||||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
|
||||||
filter(
|
|
||||||
([_room, prev, curr]) =>
|
|
||||||
prev.getStateEvents("m.space.parent").length !==
|
|
||||||
curr.getStateEvents("m.space.parent").length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).pipe(
|
|
||||||
map(() =>
|
|
||||||
this.client.getVisibleRooms().filter(
|
|
||||||
(room) =>
|
|
||||||
room.getMyMembership() !== "leave" &&
|
|
||||||
room
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getState("f" as Direction.Forward)
|
|
||||||
?.getStateEvents("m.space.parent").length === 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.topLevelSpaces$ = this.topLevelRooms$.pipe(
|
|
||||||
map((rooms) => rooms.filter((room) => room.isSpaceRoom())),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.topLevelChats$ = this.topLevelRooms$.pipe(
|
|
||||||
map((rooms) => rooms.filter((room) => !room.isSpaceRoom())),
|
|
||||||
distinctUntilChanged(roomListDistinct),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SpaceRx {
|
|
||||||
constructor(
|
|
||||||
private client: MatrixClient,
|
|
||||||
private space: Room,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { ClientEventHandlerMap, MatrixClient } from "matrix-js-sdk";
|
|
||||||
import { fromEvent, type Observable } from "rxjs";
|
|
||||||
|
|
||||||
export function fromMatrixClientEvent<T extends keyof ClientEventHandlerMap>(
|
|
||||||
client: MatrixClient,
|
|
||||||
eventName: `${T}`, // hack so we can use strings instead of enums
|
|
||||||
): Observable<Parameters<ClientEventHandlerMap[T]>> {
|
|
||||||
return fromEvent(client, eventName) as Observable<
|
|
||||||
Parameters<ClientEventHandlerMap[T]>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import type {
|
|
||||||
MatrixClient,
|
|
||||||
MatrixEvent,
|
|
||||||
Room,
|
|
||||||
Direction,
|
|
||||||
RoomState,
|
|
||||||
RoomStateEventHandlerMap,
|
|
||||||
EventType,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
import {
|
|
||||||
map,
|
|
||||||
filter,
|
|
||||||
merge,
|
|
||||||
startWith,
|
|
||||||
Observable,
|
|
||||||
of,
|
|
||||||
fromEvent,
|
|
||||||
concat,
|
|
||||||
defer,
|
|
||||||
} from "rxjs";
|
|
||||||
|
|
||||||
export function matrixRoom$(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string | undefined,
|
|
||||||
): Observable<Room | undefined> {
|
|
||||||
return merge([
|
|
||||||
fromMatrixClientEvent(client, "Room").pipe(
|
|
||||||
filter(([room]) => room.roomId === roomId),
|
|
||||||
),
|
|
||||||
fromMatrixClientEvent(client, "deleteRoom").pipe(
|
|
||||||
filter(([id]) => id === roomId),
|
|
||||||
),
|
|
||||||
]).pipe(
|
|
||||||
startWith([]),
|
|
||||||
map(() => client.getRoom(roomId) ?? undefined),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomTimeline$(
|
|
||||||
client: MatrixClient,
|
|
||||||
room: Room | undefined,
|
|
||||||
): Observable<MatrixEvent[] | undefined> {
|
|
||||||
if (!room) return of(undefined);
|
|
||||||
const eventTimeline = room.getLiveTimeline();
|
|
||||||
|
|
||||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
|
||||||
filter(
|
|
||||||
([, eventRoom]) =>
|
|
||||||
eventRoom !== undefined && eventRoom.roomId === room.roomId,
|
|
||||||
),
|
|
||||||
startWith([]),
|
|
||||||
map(() => eventTimeline.getEvents()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function roomCurrentStateEvents$(
|
|
||||||
client: MatrixClient,
|
|
||||||
room: Room,
|
|
||||||
eventType: EventType | string,
|
|
||||||
): Observable<MatrixEvent[]> {
|
|
||||||
return concat(
|
|
||||||
defer(() =>
|
|
||||||
of(
|
|
||||||
room
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getState("f" as Direction.Forward)
|
|
||||||
?.getStateEvents(eventType) ?? [],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
fromMatrixClientEvent(client, "Room.CurrentStateUpdated").pipe(
|
|
||||||
filter(([room]) => room.roomId === room.roomId),
|
|
||||||
map(([_room, _prev, curr]) => curr.getStateEvents(eventType)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fromRoomStateEvent<T extends keyof RoomStateEventHandlerMap>(
|
|
||||||
state: RoomState,
|
|
||||||
eventName: `${T}`,
|
|
||||||
): Observable<Parameters<RoomStateEventHandlerMap[T]>> {
|
|
||||||
return fromEvent(state, eventName) as Observable<
|
|
||||||
Parameters<RoomStateEventHandlerMap[T]>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import type { EventTimeline, MatrixClient, MatrixEvent } from "matrix-js-sdk";
|
|
||||||
import { filter, map, of, startWith, type Observable } from "rxjs";
|
|
||||||
import { fromMatrixClientEvent } from "./events";
|
|
||||||
|
|
||||||
export function roomTimeline(
|
|
||||||
client: MatrixClient,
|
|
||||||
roomId: string | undefined,
|
|
||||||
): Observable<MatrixEvent[]> {
|
|
||||||
if (!roomId) return of([]);
|
|
||||||
const room = client.getRoom(roomId);
|
|
||||||
if (!room) return of([]);
|
|
||||||
const eventTimeline = room.getLiveTimeline();
|
|
||||||
|
|
||||||
return fromMatrixClientEvent(client, "Room.timeline").pipe(
|
|
||||||
filter(([, room]) => room?.roomId === roomId),
|
|
||||||
startWith([]),
|
|
||||||
map(() => eventTimeline.getEvents()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { linter, type Diagnostic } from "@codemirror/lint";
|
|
||||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
|
||||||
|
|
||||||
export function actionLinter(config?: Parameters<typeof linter>[1]) {
|
|
||||||
const finalConfig: Parameters<typeof linter>[1] = {
|
|
||||||
...config,
|
|
||||||
needsRefresh(update) {
|
|
||||||
return (
|
|
||||||
update.startState.field(parsedChordsField) !==
|
|
||||||
update.state.field(parsedChordsField)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return linter((view) => {
|
|
||||||
console.log("lint");
|
|
||||||
const diagnostics: Diagnostic[] = [];
|
|
||||||
const parsed = view.state.field(parsedChordsField);
|
|
||||||
|
|
||||||
for (const chord of parsed.chords) {
|
|
||||||
if (chord.disabled) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: chord.range[0],
|
|
||||||
to: chord.range[1],
|
|
||||||
severity: "info",
|
|
||||||
markClass: "chord-ignored",
|
|
||||||
message: `Chord disabled`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (chord.compounds) {
|
|
||||||
for (const compound of chord.compounds) {
|
|
||||||
if (compound.actions.length === 0 && compound.parent) {
|
|
||||||
const replacement = view.state.doc.sliceString(
|
|
||||||
compound.parent.range[0],
|
|
||||||
compound.parent.input!.range[1],
|
|
||||||
);
|
|
||||||
diagnostics.push({
|
|
||||||
from: compound.range[0],
|
|
||||||
to: compound.range[1],
|
|
||||||
severity: "warning",
|
|
||||||
message: `Compound literal can be replaced with "${replacement}"`,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
name: "Replace",
|
|
||||||
apply(view, from, to) {
|
|
||||||
view.dispatch({
|
|
||||||
changes: {
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
insert: replacement + "|",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lastCompound = chord.compounds.at(-1);
|
|
||||||
if (lastCompound) {
|
|
||||||
const from = chord.range[0];
|
|
||||||
const to = lastCompound.range[1];
|
|
||||||
if (lastCompound.parent) {
|
|
||||||
diagnostics.push({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
severity: "info",
|
|
||||||
markClass: "chord-child",
|
|
||||||
message: `Child of ${view.state.doc.sliceString(lastCompound.parent.range[0], lastCompound.parent.range[1])}`,
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
name: "Select Parent",
|
|
||||||
apply(view) {
|
|
||||||
view.dispatch({
|
|
||||||
selection: {
|
|
||||||
anchor: lastCompound.parent!.range[0],
|
|
||||||
},
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
diagnostics.push({
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
severity: "warning",
|
|
||||||
message: `Orphan compound`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chord.children) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: chord.range[0],
|
|
||||||
to: chord.range[1],
|
|
||||||
severity: "info",
|
|
||||||
markClass: "chord-parent",
|
|
||||||
message: `Parent of ${chord.children.length} compound(s)`,
|
|
||||||
actions: chord.children.map((child) => ({
|
|
||||||
name: `Go to ${view.state.doc.sliceString(child.range[0], child.range[1])}`,
|
|
||||||
apply(view) {
|
|
||||||
view.dispatch({
|
|
||||||
selection: {
|
|
||||||
anchor: child.range[0],
|
|
||||||
},
|
|
||||||
scrollIntoView: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (chord.phrase) {
|
|
||||||
if (!chord.phrase.originalValue) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: chord.range[0],
|
|
||||||
to: chord.range[1],
|
|
||||||
severity: "info",
|
|
||||||
markClass: "chord-new",
|
|
||||||
message: `New Chord`,
|
|
||||||
});
|
|
||||||
} else if (chord.phrase.originalValue !== chord.phrase.value) {
|
|
||||||
diagnostics.push({
|
|
||||||
from: chord.range[0],
|
|
||||||
to: chord.range[1],
|
|
||||||
severity: "info",
|
|
||||||
markClass: "chord-unchanged",
|
|
||||||
message: `Phrase changed`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return diagnostics;
|
|
||||||
}, finalConfig);
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
|
||||||
import { derived } from "svelte/store";
|
|
||||||
import { reactiveStateField } from "./store-state-field";
|
|
||||||
|
|
||||||
const actionMeta = derived([KEYMAP_IDS, KEYMAP_CODES], ([ids, codes]) => ({
|
|
||||||
ids,
|
|
||||||
codes,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const actionMetaPlugin = reactiveStateField(actionMeta);
|
|
||||||
@@ -7,34 +7,33 @@ import {
|
|||||||
} from "@codemirror/view";
|
} from "@codemirror/view";
|
||||||
import { mount, unmount } from "svelte";
|
import { mount, unmount } from "svelte";
|
||||||
import Action from "$lib/components/Action.svelte";
|
import Action from "$lib/components/Action.svelte";
|
||||||
|
import { syntaxTree } from "@codemirror/language";
|
||||||
import type { Range } from "@codemirror/state";
|
import type { Range } from "@codemirror/state";
|
||||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
|
||||||
import { iterActions } from "./parse-meta";
|
|
||||||
|
|
||||||
export class ActionWidget extends WidgetType {
|
export class ActionWidget extends WidgetType {
|
||||||
component?: {};
|
component?: {};
|
||||||
|
element?: HTMLElement;
|
||||||
|
|
||||||
constructor(readonly id: string | number) {
|
constructor(readonly id: string | number) {
|
||||||
super();
|
super();
|
||||||
this.id = id;
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*override eq(other: ActionWidget) {
|
override eq(other: ActionWidget) {
|
||||||
return this.id == other.id;
|
return this.id == other.id;
|
||||||
}*/
|
}
|
||||||
|
|
||||||
toDOM() {
|
toDOM() {
|
||||||
if (this.component) {
|
if (!this.element) {
|
||||||
unmount(this.component);
|
this.element = document.createElement("span");
|
||||||
}
|
this.element.style.paddingInline = "2px";
|
||||||
const element = document.createElement("span");
|
|
||||||
element.style.paddingInline = "2px";
|
|
||||||
|
|
||||||
this.component = mount(Action, {
|
this.component = mount(Action, {
|
||||||
target: element,
|
target: this.element,
|
||||||
props: { action: this.id, display: "keys", inText: true },
|
props: { action: this.id, display: "keys", inText: true },
|
||||||
});
|
});
|
||||||
return element;
|
}
|
||||||
|
return this.element;
|
||||||
}
|
}
|
||||||
|
|
||||||
override ignoreEvent() {
|
override ignoreEvent() {
|
||||||
@@ -51,24 +50,29 @@ export class ActionWidget extends WidgetType {
|
|||||||
function actionWidgets(view: EditorView) {
|
function actionWidgets(view: EditorView) {
|
||||||
const widgets: Range<Decoration>[] = [];
|
const widgets: Range<Decoration>[] = [];
|
||||||
for (const { from, to } of view.visibleRanges) {
|
for (const { from, to } of view.visibleRanges) {
|
||||||
for (const chord of view.state.field(parsedChordsField).chords) {
|
syntaxTree(view.state).iterate({
|
||||||
if (chord.range[1] < from || chord.range[0] > to) continue;
|
from,
|
||||||
iterActions(chord, (action) => {
|
to,
|
||||||
if (
|
enter: (node) => {
|
||||||
view.state.selection.ranges.some(
|
if (node.name !== "ExplicitAction") return;
|
||||||
(r) => r.from <= action.range[1] && r.to > action.range[0],
|
const value =
|
||||||
)
|
node.node.getChild("ActionId") ??
|
||||||
) {
|
node.node.getChild("HexNumber") ??
|
||||||
|
node.node.getChild("DecimalNumber");
|
||||||
|
if (!value) return;
|
||||||
|
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action.info && action.explicit) {
|
|
||||||
const deco = Decoration.replace({
|
const id = view.state.doc.sliceString(value.from, value.to);
|
||||||
widget: new ActionWidget(action.code),
|
let deco = Decoration.replace({
|
||||||
});
|
widget: new ActionWidget(
|
||||||
widgets.push(deco.range(action.range[0], action.range[1]));
|
value.name === "ActionId" ? id : parseInt(id),
|
||||||
}
|
),
|
||||||
});
|
});
|
||||||
}
|
widgets.push(deco.range(node.from, node.to));
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Decoration.set(widgets);
|
return Decoration.set(widgets);
|
||||||
}
|
}
|
||||||
@@ -85,9 +89,7 @@ export const actionPlugin = ViewPlugin.fromClass(
|
|||||||
if (
|
if (
|
||||||
update.docChanged ||
|
update.docChanged ||
|
||||||
update.viewportChanged ||
|
update.viewportChanged ||
|
||||||
update.selectionSet ||
|
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||||
update.startState.field(parsedChordsField) !=
|
|
||||||
update.state.field(parsedChordsField)
|
|
||||||
)
|
)
|
||||||
this.decorations = actionWidgets(update.view);
|
this.decorations = actionWidgets(update.view);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,308 +1,16 @@
|
|||||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
|
||||||
import { syntaxTree } from "@codemirror/language";
|
|
||||||
import type { EditorState } from "@codemirror/state";
|
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import {
|
|
||||||
composeChordInput,
|
|
||||||
hasConcatenator,
|
|
||||||
hashChord,
|
|
||||||
willBeValidChordInput,
|
|
||||||
} from "$lib/serial/chord";
|
|
||||||
import type {
|
|
||||||
ActionMeta,
|
|
||||||
ChordMeta,
|
|
||||||
MetaRange,
|
|
||||||
ParseResult,
|
|
||||||
} from "./parse-meta";
|
|
||||||
|
|
||||||
export function canUseIdAsString(info: KeyInfo): boolean {
|
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||||
return !!info.id && /^[^>\n]+$/.test(info.id);
|
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function actionToValue(action: number | KeyInfo) {
|
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 function parseChordMeta(
|
|
||||||
data: EditorState,
|
|
||||||
ids: Map<string, KeyInfo>,
|
|
||||||
codes: Map<number, KeyInfo>,
|
|
||||||
): ChordMeta[] {
|
|
||||||
console.time("parseChordTree");
|
|
||||||
const result: ChordMeta[] = [];
|
|
||||||
|
|
||||||
let current: ChordMeta = { range: [0, 0], valid: false };
|
|
||||||
let actions: ActionMeta[] = [];
|
|
||||||
let actionRange: MetaRange | undefined = undefined;
|
|
||||||
|
|
||||||
syntaxTree(data)
|
|
||||||
.cursor()
|
|
||||||
.iterate(
|
|
||||||
(node) => {
|
|
||||||
if (node.name === "Action") {
|
|
||||||
actionRange = [node.from, node.to];
|
|
||||||
} else if (node.name === "ChordPhrase") {
|
|
||||||
current.phrase = {
|
|
||||||
range: [node.from, node.to],
|
|
||||||
value: [],
|
|
||||||
valid: true,
|
|
||||||
actions: [],
|
|
||||||
hasConcatenator: false,
|
|
||||||
};
|
|
||||||
} else if (node.name === "Chord") {
|
|
||||||
current = { range: [node.from, node.to], valid: false };
|
|
||||||
} else if (node.name === "ActionString") {
|
|
||||||
actions = [];
|
|
||||||
} else if (node.name === "HexNumber") {
|
|
||||||
const hexString = data.doc.sliceString(node.from, node.to);
|
|
||||||
const code = Number.parseInt(hexString, 16);
|
|
||||||
const parentNode = node.node.parent;
|
|
||||||
if (parentNode?.type.name === "CompoundLiteral") {
|
|
||||||
current.compounds ??= [];
|
|
||||||
current.compounds.push({
|
|
||||||
range: [parentNode.from, parentNode.to],
|
|
||||||
value: code,
|
|
||||||
actions: [],
|
|
||||||
valid: true, // TODO: validate compound literal
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const valid = !(Number.isNaN(code) || code < 0 || code > 1023);
|
|
||||||
actions.push({
|
|
||||||
code,
|
|
||||||
info: codes.get(code),
|
|
||||||
explicit: true,
|
|
||||||
valid,
|
|
||||||
range: actionRange!,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (
|
|
||||||
node.name === "ActionId" ||
|
|
||||||
node.name === "SingleLetter" ||
|
|
||||||
node.name === "EscapedLetter"
|
|
||||||
) {
|
|
||||||
const id = data.doc.sliceString(node.from, node.to);
|
|
||||||
const info = ids.get(id);
|
|
||||||
const value: ActionMeta = {
|
|
||||||
code: info?.code ?? Number.NaN,
|
|
||||||
info,
|
|
||||||
valid: info !== undefined,
|
|
||||||
range: actionRange!,
|
|
||||||
};
|
|
||||||
if (node.name === "ActionId") {
|
|
||||||
value.explicit = true;
|
|
||||||
}
|
|
||||||
actions.push(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
(node) => {
|
|
||||||
if (node.name === "Chord") {
|
|
||||||
result.push(current);
|
|
||||||
if (current.phrase) {
|
|
||||||
current.phrase.actions = actions;
|
|
||||||
current.phrase.value = actions.map(({ code }) => code);
|
|
||||||
current.phrase.valid = actions.every(({ valid }) => valid);
|
|
||||||
current.phrase.hasConcatenator = hasConcatenator(
|
|
||||||
current.phrase.value,
|
|
||||||
codes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
current.valid =
|
|
||||||
(current.phrase?.valid ?? false) && (current.input?.valid ?? false);
|
|
||||||
if (!current.valid) {
|
|
||||||
current.disabled = true;
|
|
||||||
}
|
|
||||||
} else if (node.name === "CompoundInput") {
|
|
||||||
const lastCompound = current.compounds?.at(-1);
|
|
||||||
current.compounds ??= [];
|
|
||||||
current.compounds.push({
|
|
||||||
range: [node.from, node.to],
|
|
||||||
value: hashChord(
|
|
||||||
composeChordInput(
|
|
||||||
actions.map(({ code }) => code),
|
|
||||||
lastCompound?.value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
actions,
|
|
||||||
valid:
|
|
||||||
willBeValidChordInput(
|
|
||||||
actions.length,
|
|
||||||
lastCompound !== undefined,
|
|
||||||
) && actions.every(({ valid }) => valid),
|
|
||||||
});
|
|
||||||
} else if (node.name === "ChordInput") {
|
|
||||||
const lastCompound = current.compounds?.at(-1);
|
|
||||||
current.input = {
|
|
||||||
range: [node.from, node.to],
|
|
||||||
value: composeChordInput(
|
|
||||||
actions.map(({ code }) => code),
|
|
||||||
lastCompound?.value,
|
|
||||||
),
|
|
||||||
valid:
|
|
||||||
willBeValidChordInput(
|
|
||||||
actions.length,
|
|
||||||
lastCompound !== undefined,
|
|
||||||
) && actions.every(({ valid }) => valid),
|
|
||||||
actions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.timeEnd("parseChordTree");
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveChordOverrides(chords: ChordMeta[]) {
|
|
||||||
console.time("resolveOverrides");
|
|
||||||
const seen = new Map<string, ChordMeta>();
|
|
||||||
for (const info of chords) {
|
|
||||||
if (!info.input || info.disabled) continue;
|
|
||||||
const key = JSON.stringify(info.input.value);
|
|
||||||
const override = seen.get(key);
|
|
||||||
if (override) {
|
|
||||||
override.overrides ??= [];
|
|
||||||
override.overrides.push(info);
|
|
||||||
info.overriddenBy = override;
|
|
||||||
info.disabled = true;
|
|
||||||
} else {
|
|
||||||
seen.set(key, info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.timeEnd("resolveOverrides");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveChordAliases(chords: ChordMeta[]) {
|
|
||||||
console.time("resolveAliases");
|
|
||||||
const aliases = new Map<string, ChordMeta[]>();
|
|
||||||
for (const info of chords) {
|
|
||||||
if (!info.phrase) continue;
|
|
||||||
const key = JSON.stringify(info.phrase.value);
|
|
||||||
const list = aliases.get(key) ?? [];
|
|
||||||
list.push(info);
|
|
||||||
aliases.set(key, list);
|
|
||||||
}
|
|
||||||
for (const aliasList of aliases.values()) {
|
|
||||||
if (aliasList.length > 1) {
|
|
||||||
for (const info of aliasList) {
|
|
||||||
info.aliases = aliasList.filter((i) => i !== info);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.timeEnd("resolveAliases");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveCompoundParents(chords: ChordMeta[]) {
|
|
||||||
console.time("resolveCompoundParents");
|
|
||||||
const compounds = new Map<number, ChordMeta>();
|
|
||||||
for (const chord of chords) {
|
|
||||||
if (chord.input && !chord.disabled) {
|
|
||||||
compounds.set(hashChord(chord.input.value), chord);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const chord of chords) {
|
|
||||||
if (chord.compounds) {
|
|
||||||
for (const compound of chord.compounds) {
|
|
||||||
const parent = compounds.get(compound.value);
|
|
||||||
if (parent) {
|
|
||||||
compound.parent = parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const lastCompound = chord.compounds?.at(-1);
|
|
||||||
if (lastCompound && lastCompound.parent) {
|
|
||||||
lastCompound.parent.children ??= [];
|
|
||||||
lastCompound.parent.children.push(chord);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
console.timeEnd("resolveCompoundParents");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveChanges(
|
|
||||||
chords: ChordMeta[],
|
|
||||||
deviceChords: CharaChordFile["chords"],
|
|
||||||
): CharaChordFile["chords"] {
|
|
||||||
console.time("resolveChanges");
|
|
||||||
const removed: CharaChordFile["chords"] = [];
|
|
||||||
const info = new Map<string, ChordMeta>();
|
|
||||||
for (const chord of chords) {
|
|
||||||
if (chord.input && chord.phrase && !chord.disabled) {
|
|
||||||
info.set(
|
|
||||||
JSON.stringify([chord.input.value, chord.phrase?.value ?? []]),
|
|
||||||
chord,
|
|
||||||
);
|
|
||||||
info.set(JSON.stringify(chord.input.value), chord);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const deviceChord of deviceChords) {
|
|
||||||
const exact = info.get(JSON.stringify(deviceChord));
|
|
||||||
if (exact) {
|
|
||||||
exact.phrase!.originalValue = exact.phrase!.value;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const byInput = info.get(JSON.stringify(deviceChord[0]));
|
|
||||||
if (byInput) {
|
|
||||||
byInput.phrase!.originalValue = deviceChord[1];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
removed.push(deviceChord);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.timeEnd("resolveChanges");
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseCharaChords(
|
|
||||||
data: EditorState,
|
|
||||||
ids: Map<string, KeyInfo>,
|
|
||||||
codes: Map<number, KeyInfo>,
|
|
||||||
deviceChords: CharaChordFile["chords"],
|
|
||||||
): ParseResult {
|
|
||||||
console.time("parseTotal");
|
|
||||||
|
|
||||||
const chords = parseChordMeta(data, ids, codes);
|
|
||||||
resolveChordOverrides(chords);
|
|
||||||
resolveChordAliases(chords);
|
|
||||||
resolveCompoundParents(chords);
|
|
||||||
const removed = resolveChanges(chords, deviceChords);
|
|
||||||
|
|
||||||
/*for (let i = 0; i < metas.length; i++) {
|
|
||||||
const [, compound] = splitCompound(chords[i]![0]);
|
|
||||||
if (
|
|
||||||
compound !== undefined &&
|
|
||||||
(!compoundInputs.has(compound) || orphanCompounds.has(compound))
|
|
||||||
) {
|
|
||||||
metas[i]!.orphan = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removed: CharaChordFile["chords"] = [];
|
|
||||||
for (let deviceChord of deviceChords) {
|
|
||||||
const key = JSON.stringify(deviceChord[0]);
|
|
||||||
if (!keys.has(key)) {
|
|
||||||
removed.push(deviceChord);
|
|
||||||
} else {
|
|
||||||
const index = keys.get(key)!;
|
|
||||||
const meta = metas[index]!;
|
|
||||||
if (
|
|
||||||
JSON.stringify(deviceChord[1]) !==
|
|
||||||
JSON.stringify(chords[keys.get(key)!]![1])
|
|
||||||
) {
|
|
||||||
meta.originalPhrase = deviceChord[1];
|
|
||||||
} else {
|
|
||||||
meta.unchanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
console.timeEnd("parseTotal");
|
|
||||||
|
|
||||||
console.log(chords);
|
|
||||||
return { chords, removed };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,39 +1,72 @@
|
|||||||
import {
|
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||||
EditorView,
|
import type {
|
||||||
ViewPlugin,
|
Completion,
|
||||||
ViewUpdate,
|
CompletionSection,
|
||||||
type PluginValue,
|
CompletionSource,
|
||||||
} from "@codemirror/view";
|
} from "@codemirror/autocomplete";
|
||||||
import { syntaxTree } from "@codemirror/language";
|
import { derived, get } from "svelte/store";
|
||||||
import type { EditorState } from "@codemirror/state";
|
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
||||||
|
|
||||||
export function actionAutocompletePlugin(
|
const completionSections = derived(
|
||||||
query: (query: string | undefined) => void,
|
KEYMAP_CATEGORIES,
|
||||||
) {
|
(categories) =>
|
||||||
return ViewPlugin.fromClass(
|
new Map(
|
||||||
class implements PluginValue {
|
categories.map(
|
||||||
constructor(readonly view: EditorView) {}
|
(category) =>
|
||||||
|
[
|
||||||
|
category,
|
||||||
|
{
|
||||||
|
name: category.name,
|
||||||
|
} satisfies CompletionSection,
|
||||||
|
] as const,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
export const actionAutocompleteItems = derived(
|
||||||
query(this.resolveAutocomplete(update.state));
|
[KEYMAP_CODES, completionSections],
|
||||||
}
|
([codes, sections]) =>
|
||||||
|
codes
|
||||||
|
.values()
|
||||||
|
.map((info) => {
|
||||||
|
const canUseId = canUseIdAsString(info);
|
||||||
|
const completionValue =
|
||||||
|
(canUseId && info.id) ||
|
||||||
|
`0x${info.code.toString(16).padStart(2, "0")}`;
|
||||||
|
return {
|
||||||
|
label:
|
||||||
|
[
|
||||||
|
canUseId || !info.id ? undefined : `"${info.id}"`,
|
||||||
|
info.title,
|
||||||
|
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") || completionValue,
|
||||||
|
detail: actionToValue(info),
|
||||||
|
section: info.category ? sections.get(info.category) : undefined,
|
||||||
|
info: info.description,
|
||||||
|
type: "keyword",
|
||||||
|
apply: completionValue + ">",
|
||||||
|
} satisfies Completion;
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(item) => typeof item.label === "string" && item.apply !== undefined,
|
||||||
|
)
|
||||||
|
.toArray(),
|
||||||
|
);
|
||||||
|
|
||||||
resolveAutocomplete(state: EditorState): string | undefined {
|
export const actionAutocomplete = ((context) => {
|
||||||
if (state.selection.ranges.length !== 1) return;
|
let word = context.tokenBefore([
|
||||||
const from = state.selection.ranges[0]!.from;
|
"ExplicitDelimStart",
|
||||||
const to = state.selection.ranges[0]!.to;
|
"ActionId",
|
||||||
if (from !== to) return;
|
"HexNumber",
|
||||||
const tree = syntaxTree(state);
|
"DecimalNumber",
|
||||||
const node = tree.resolveInner(from, -1).parent;
|
]);
|
||||||
if (node?.name !== "ExplicitAction") return;
|
if (!word) return null;
|
||||||
if (node.getChild("ExplicitDelimEnd")) return;
|
console.log(get(actionAutocompleteItems));
|
||||||
const queryNode = node.getChild("ExplicitDelimStart")?.nextSibling;
|
return {
|
||||||
return (
|
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
||||||
(queryNode
|
validFor: /^<?[a-zA-Z0-9_]*$/,
|
||||||
? state.doc.sliceString(queryNode.from, queryNode.to)
|
options: get(actionAutocompleteItems),
|
||||||
: undefined) || undefined
|
};
|
||||||
);
|
}) satisfies CompletionSource;
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import { EditorView, Decoration, type DecorationSet } from "@codemirror/view";
|
|
||||||
import { StateField } from "@codemirror/state";
|
|
||||||
import { parsedChordsEffect } from "./parsed-chords-plugin";
|
|
||||||
|
|
||||||
const changedMark = Decoration.mark({ class: "cm-changed" });
|
|
||||||
|
|
||||||
const chordMetaMark = StateField.define<DecorationSet>({
|
|
||||||
create() {
|
|
||||||
return Decoration.none;
|
|
||||||
},
|
|
||||||
update(decorations, tr) {
|
|
||||||
const newChords = tr.effects.findLast((e) => e.is(parsedChordsEffect));
|
|
||||||
if (!newChords) {
|
|
||||||
return decorations.map(tr.changes);
|
|
||||||
}
|
|
||||||
return newChords.value.meta.map(meta => {
|
|
||||||
if (meta.originalPhrase) {
|
|
||||||
return underlineMark.range(meta.from, meta.to);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
provide: (f) => EditorView.decorations.from(f),
|
|
||||||
});
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import type { EditorState } from "@codemirror/state";
|
|
||||||
import { EditorView, showPanel, type Panel } from "@codemirror/view";
|
|
||||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
|
||||||
|
|
||||||
function getChanges(state: EditorState): string {
|
|
||||||
const parsed = state.field(parsedChordsField);
|
|
||||||
const added = parsed.chords.reduce(
|
|
||||||
(acc, chord) =>
|
|
||||||
acc + (chord.phrase && chord.phrase.originalValue === undefined ? 1 : 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const changed = parsed.chords.reduce(
|
|
||||||
(acc, chord) =>
|
|
||||||
acc +
|
|
||||||
(chord.phrase &&
|
|
||||||
chord.phrase.originalValue &&
|
|
||||||
chord.phrase.originalValue !== chord.phrase.value
|
|
||||||
? 1
|
|
||||||
: 0),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
const removed = parsed.removed.length;
|
|
||||||
return `+${added} ~${changed} -${removed} (${parsed.chords.length} total)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function wordCountPanel(view: EditorView): Panel {
|
|
||||||
let dom = document.createElement("div");
|
|
||||||
dom.textContent = getChanges(view.state);
|
|
||||||
return {
|
|
||||||
dom,
|
|
||||||
update(update) {
|
|
||||||
dom.textContent = getChanges(update.state);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function changesPanel() {
|
|
||||||
return showPanel.of(wordCountPanel);
|
|
||||||
}
|
|
||||||
17
src/lib/chord-editor/changes-plugin.ts
Normal file
17
src/lib/chord-editor/changes-plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import {
|
||||||
|
EditorView,
|
||||||
|
ViewPlugin,
|
||||||
|
ViewUpdate,
|
||||||
|
type PluginValue,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
|
||||||
|
export const changesPlugin = ViewPlugin.fromClass(
|
||||||
|
class implements PluginValue {
|
||||||
|
constructor(readonly view: EditorView) {}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
eventHandlers: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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,9 +41,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
|
||||||
import { StateEffect, StateField } from "@codemirror/state";
|
|
||||||
|
|
||||||
export const chordSyncEffect = StateEffect.define<CharaChordFile["chords"]>();
|
|
||||||
|
|
||||||
export const deviceChordField = StateField.define<CharaChordFile["chords"]>({
|
|
||||||
create() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
update(value, transaction) {
|
|
||||||
// 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
|
|
||||||
return (
|
|
||||||
transaction.effects.findLast((it) => it.is(chordSyncEffect))?.value ??
|
|
||||||
value
|
|
||||||
);
|
|
||||||
},
|
|
||||||
compare(a, b) {
|
|
||||||
return JSON.stringify(a) === JSON.stringify(b);
|
|
||||||
},
|
|
||||||
toJSON(value) {
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
fromJSON(value) {
|
|
||||||
return value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -5,6 +5,7 @@ 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([
|
||||||
{
|
{
|
||||||
@@ -50,5 +51,7 @@ export const chordLanguage = LRLanguage.define({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function chordLanguageSupport() {
|
export function chordLanguageSupport() {
|
||||||
return new LanguageSupport(chordLanguage, [chordLanguage.data.of({})]);
|
return new LanguageSupport(chordLanguage, [
|
||||||
|
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,27 @@
|
|||||||
@top Program { Chord* }
|
@top Program { Chord* }
|
||||||
|
|
||||||
ExplicitAction { ExplicitDelimStart (HexNumber | ActionId) ExplicitDelimEnd }
|
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
||||||
EscapedSingleAction { Escape EscapedLetter }
|
EscapedSingleAction { Escape EscapedLetter }
|
||||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||||
|
ActionString { Action* }
|
||||||
ActionString { Action+ }
|
ChordInput { (ActionString CompoundDelim)* ActionString }
|
||||||
|
|
||||||
CompoundLiteral { CompoundDelim HexNumber CompoundDelim }
|
|
||||||
CompoundInput { ActionString CompoundDelim }
|
|
||||||
|
|
||||||
ChordInput { CompoundLiteral? CompoundInput* ActionString }
|
|
||||||
ChordPhrase { ActionString }
|
ChordPhrase { ActionString }
|
||||||
|
|
||||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||||
|
|
||||||
@skip {
|
|
||||||
Space
|
|
||||||
}
|
|
||||||
|
|
||||||
@tokens {
|
@tokens {
|
||||||
@precedence { HexNumber, ActionId }
|
@precedence {HexNumber, DecimalNumber}
|
||||||
@precedence { Space, Escape }
|
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||||
@precedence { Space, SingleLetter }
|
@precedence {EscapedLetter}
|
||||||
@precedence { Escape, SingleLetter }
|
|
||||||
@precedence { CompoundDelim, SingleLetter }
|
|
||||||
@precedence { ActionId, Space }
|
|
||||||
@precedence { EscapedLetter, Space }
|
|
||||||
|
|
||||||
Space {" "}
|
|
||||||
ExplicitDelimStart {"<"}
|
ExplicitDelimStart {"<"}
|
||||||
ExplicitDelimEnd {">"}
|
ExplicitDelimEnd {">"}
|
||||||
CompoundDelim {"|"}
|
CompoundDelim {"+>"}
|
||||||
PhraseDelim {"=>"}
|
PhraseDelim {"=>"}
|
||||||
Escape { "\\" }
|
Escape { "\\" }
|
||||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||||
ActionId { ![\n>]+ }
|
DecimalNumber { $[0-9]+ }
|
||||||
SingleLetter { ![\n<] }
|
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||||
EscapedLetter { ![\n] }
|
SingleLetter { ![\\] }
|
||||||
ChordDelim { ("\n" | @eof) }
|
EscapedLetter { ![] }
|
||||||
|
ChordDelim { ($[\n] | @eof) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@detectDelim
|
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
|
||||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
|
||||||
import type { ChangeDesc } from "@codemirror/state";
|
|
||||||
|
|
||||||
export type MetaRange = [from: number, to: number];
|
|
||||||
|
|
||||||
function mapMetaRange(range: MetaRange, change: ChangeDesc): MetaRange {
|
|
||||||
const newFrom = change.mapPos(range[0]);
|
|
||||||
const newTo = change.mapPos(range[1]);
|
|
||||||
if (newFrom === range[0] && newTo === range[1]) {
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
return [newFrom, newTo];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionMeta {
|
|
||||||
code: number;
|
|
||||||
info?: KeyInfo;
|
|
||||||
explicit?: boolean;
|
|
||||||
range: MetaRange;
|
|
||||||
valid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapActionMeta(action: ActionMeta, change: ChangeDesc): ActionMeta {
|
|
||||||
const newRange = mapMetaRange(action.range, change);
|
|
||||||
if (newRange === action.range) {
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...action,
|
|
||||||
range: newRange,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapArray<T>(
|
|
||||||
array: T[],
|
|
||||||
change: ChangeDesc,
|
|
||||||
mapFn: (action: T, change: ChangeDesc) => T,
|
|
||||||
): T[] {
|
|
||||||
let changed = false;
|
|
||||||
const newArray = array.map((value) => {
|
|
||||||
const newValue = mapFn(value, change);
|
|
||||||
if (newValue !== value) {
|
|
||||||
changed = true;
|
|
||||||
return newValue;
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
if (changed) {
|
|
||||||
return newArray;
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionStringMeta<T> {
|
|
||||||
range: MetaRange;
|
|
||||||
value: T;
|
|
||||||
valid: boolean;
|
|
||||||
actions: ActionMeta[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapActionStringMeta<T extends ActionStringMeta<unknown>>(
|
|
||||||
actionString: T,
|
|
||||||
change: ChangeDesc,
|
|
||||||
) {
|
|
||||||
const newRange = mapMetaRange(actionString.range, change);
|
|
||||||
const newActions = mapArray(actionString.actions, change, mapActionMeta);
|
|
||||||
if (newRange === actionString.range && newActions === actionString.actions) {
|
|
||||||
return actionString;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...actionString,
|
|
||||||
range: newRange,
|
|
||||||
actions: newActions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PhraseMeta extends ActionStringMeta<number[]> {
|
|
||||||
hasConcatenator: boolean;
|
|
||||||
originalValue?: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CompoundMeta extends ActionStringMeta<number> {
|
|
||||||
parent?: ChordMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InputMeta extends ActionStringMeta<number[]> {}
|
|
||||||
|
|
||||||
export interface ChordMeta {
|
|
||||||
range: MetaRange;
|
|
||||||
valid: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
compounds?: CompoundMeta[];
|
|
||||||
input?: InputMeta;
|
|
||||||
phrase?: PhraseMeta;
|
|
||||||
children?: ChordMeta[];
|
|
||||||
overrides?: ChordMeta[];
|
|
||||||
aliases?: ChordMeta[];
|
|
||||||
overriddenBy?: ChordMeta;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapChordMeta(chord: ChordMeta, change: ChangeDesc): ChordMeta {
|
|
||||||
const newRange = mapMetaRange(chord.range, change);
|
|
||||||
const newCompounds = chord.compounds
|
|
||||||
? mapArray(chord.compounds, change, mapActionStringMeta)
|
|
||||||
: undefined;
|
|
||||||
const newInput = chord.input
|
|
||||||
? mapActionStringMeta(chord.input, change)
|
|
||||||
: undefined;
|
|
||||||
const newPhrase = chord.phrase
|
|
||||||
? mapActionStringMeta(chord.phrase, change)
|
|
||||||
: undefined;
|
|
||||||
if (
|
|
||||||
newRange === chord.range &&
|
|
||||||
newCompounds === chord.compounds &&
|
|
||||||
newInput === chord.input &&
|
|
||||||
newPhrase === chord.phrase
|
|
||||||
) {
|
|
||||||
return chord;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newChord: ChordMeta = {
|
|
||||||
...chord,
|
|
||||||
range: newRange,
|
|
||||||
};
|
|
||||||
if (newCompounds) newChord.compounds = newCompounds;
|
|
||||||
if (newInput) newChord.input = newInput;
|
|
||||||
if (newPhrase) newChord.phrase = newPhrase;
|
|
||||||
return newChord;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ParseResult {
|
|
||||||
chords: ChordMeta[];
|
|
||||||
removed: CharaChordFile["chords"];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapParseResult(
|
|
||||||
result: ParseResult,
|
|
||||||
change: ChangeDesc,
|
|
||||||
): ParseResult {
|
|
||||||
const newChords = mapArray(result.chords, change, mapChordMeta);
|
|
||||||
if (newChords === result.chords) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...result,
|
|
||||||
chords: newChords,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function iterActions(
|
|
||||||
chord: ChordMeta,
|
|
||||||
callback: (action: ActionMeta) => void,
|
|
||||||
) {
|
|
||||||
if (chord.input) {
|
|
||||||
for (const action of chord.input.actions) {
|
|
||||||
callback(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chord.compounds) {
|
|
||||||
for (const compound of chord.compounds) {
|
|
||||||
for (const action of compound.actions) {
|
|
||||||
callback(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (chord.phrase) {
|
|
||||||
for (const action of chord.phrase.actions) {
|
|
||||||
callback(action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { StateField } from "@codemirror/state";
|
|
||||||
import { parseCharaChords } from "./action-serializer";
|
|
||||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
|
||||||
import { syntaxTree } from "@codemirror/language";
|
|
||||||
import { deviceChordField } from "./chord-sync-plugin";
|
|
||||||
import { mapParseResult, type ParseResult } from "./parse-meta";
|
|
||||||
|
|
||||||
export const parsedChordsField = StateField.define<ParseResult>({
|
|
||||||
create() {
|
|
||||||
return {
|
|
||||||
chords: [],
|
|
||||||
removed: [],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
update(value, transaction) {
|
|
||||||
const tree = syntaxTree(transaction.state);
|
|
||||||
const ids = transaction.state.field(actionMetaPlugin.field).ids;
|
|
||||||
const codes = transaction.state.field(actionMetaPlugin.field).codes;
|
|
||||||
const deviceChords = transaction.state.field(deviceChordField);
|
|
||||||
if (
|
|
||||||
tree !== syntaxTree(transaction.startState) ||
|
|
||||||
ids !== transaction.startState.field(actionMetaPlugin.field).ids ||
|
|
||||||
codes !== transaction.startState.field(actionMetaPlugin.field).codes ||
|
|
||||||
deviceChords !== transaction.startState.field(deviceChordField)
|
|
||||||
) {
|
|
||||||
return parseCharaChords(transaction.state, ids, codes, deviceChords);
|
|
||||||
}
|
|
||||||
return mapParseResult(value, transaction.changes);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
import {
|
|
||||||
EditorView,
|
|
||||||
highlightActiveLine,
|
|
||||||
keymap,
|
|
||||||
lineNumbers,
|
|
||||||
ViewPlugin,
|
|
||||||
ViewUpdate,
|
|
||||||
} from "@codemirror/view";
|
|
||||||
import {
|
|
||||||
history,
|
|
||||||
historyField,
|
|
||||||
historyKeymap,
|
|
||||||
standardKeymap,
|
|
||||||
} from "@codemirror/commands";
|
|
||||||
import { debounceTime, mergeMap, Subject } from "rxjs";
|
|
||||||
import { EditorState, type EditorStateConfig } from "@codemirror/state";
|
|
||||||
import { lintGutter } from "@codemirror/lint";
|
|
||||||
import {
|
|
||||||
chordHighlightStyle,
|
|
||||||
chordLanguageSupport,
|
|
||||||
} from "./chords-grammar-plugin";
|
|
||||||
import { actionLinter } from "./action-linter";
|
|
||||||
import { actionAutocompletePlugin } from "./autocomplete";
|
|
||||||
import { delimPlugin } from "./chord-delim-plugin";
|
|
||||||
import { actionPlugin } from "./action-plugin";
|
|
||||||
import { syntaxHighlighting } from "@codemirror/language";
|
|
||||||
import { deviceChordField } from "./chord-sync-plugin";
|
|
||||||
import { actionMetaPlugin } from "./action-meta-plugin";
|
|
||||||
import { parsedChordsField } from "./parsed-chords-plugin";
|
|
||||||
import { changesPanel } from "./changes-panel";
|
|
||||||
import {
|
|
||||||
parseCompressed,
|
|
||||||
stringifyCompressed,
|
|
||||||
} from "$lib/serial/serialization";
|
|
||||||
|
|
||||||
const serializedFields = {
|
|
||||||
history: historyField,
|
|
||||||
deviceChords: deviceChordField,
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface EditorConfig {
|
|
||||||
rawCode?: boolean;
|
|
||||||
storeName: string;
|
|
||||||
autocomplete(query: string | undefined): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadPersistentState(
|
|
||||||
params: EditorConfig,
|
|
||||||
): Promise<EditorState> {
|
|
||||||
const stored = localStorage.getItem(params.storeName);
|
|
||||||
const config = {
|
|
||||||
extensions: [
|
|
||||||
actionMetaPlugin.plugin,
|
|
||||||
deviceChordField,
|
|
||||||
parsedChordsField,
|
|
||||||
changesPanel(),
|
|
||||||
lintGutter(),
|
|
||||||
params.rawCode ? [lineNumbers()] : [delimPlugin, actionPlugin],
|
|
||||||
chordLanguageSupport(),
|
|
||||||
actionLinter({
|
|
||||||
delay: 100,
|
|
||||||
markerFilter(diagnostics) {
|
|
||||||
return diagnostics.filter((it) => it.from !== it.to);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
actionAutocompletePlugin(params.autocomplete),
|
|
||||||
persistentStatePlugin(params.storeName),
|
|
||||||
history(),
|
|
||||||
syntaxHighlighting(chordHighlightStyle),
|
|
||||||
highlightActiveLine(),
|
|
||||||
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": {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
".cm-cursor": {
|
|
||||||
borderColor: "var(--md-sys-color-on-surface)",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
keymap.of([...standardKeymap, ...historyKeymap]),
|
|
||||||
],
|
|
||||||
} satisfies EditorStateConfig;
|
|
||||||
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const parsed = await parseCompressed(new Blob([stored]));
|
|
||||||
return EditorState.fromJSON(parsed, config, serializedFields);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to parse persistent state:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return EditorState.create(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function persistentStatePlugin(storeName: string) {
|
|
||||||
return ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
updateSubject = new Subject<void>();
|
|
||||||
subscription = this.updateSubject
|
|
||||||
.pipe(
|
|
||||||
debounceTime(500),
|
|
||||||
mergeMap(() =>
|
|
||||||
stringifyCompressed(this.view.state.toJSON(serializedFields)),
|
|
||||||
),
|
|
||||||
mergeMap((blob) => blob.text()),
|
|
||||||
)
|
|
||||||
.subscribe((value) => {
|
|
||||||
localStorage.setItem(storeName, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
constructor(readonly view: EditorView) {}
|
|
||||||
|
|
||||||
update(update: ViewUpdate) {
|
|
||||||
if (update.state !== update.startState) {
|
|
||||||
this.updateSubject.next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.subscription.unsubscribe();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { StateEffect, StateField } from "@codemirror/state";
|
|
||||||
import { EditorView, ViewPlugin } from "@codemirror/view";
|
|
||||||
import { get, type Readable } from "svelte/store";
|
|
||||||
|
|
||||||
export function reactiveStateField<T>(store: Readable<T>) {
|
|
||||||
const effect = StateEffect.define<T>();
|
|
||||||
const field = StateField.define<T>({
|
|
||||||
create() {
|
|
||||||
return get(store);
|
|
||||||
},
|
|
||||||
update(value, transaction) {
|
|
||||||
return (
|
|
||||||
transaction.effects.findLast((it) => it.is(effect))?.value ?? value
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const plugin = ViewPlugin.fromClass(
|
|
||||||
class {
|
|
||||||
unsubscribe: () => void;
|
|
||||||
|
|
||||||
constructor(readonly view: EditorView) {
|
|
||||||
this.unsubscribe = store.subscribe((value) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
view.dispatch({ effects: effect.of(value) });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this.unsubscribe();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { field, plugin: [field, plugin] };
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
a|.=<LEFT_SHIFT>=>t=t
|
.=<LEFT_SHIFT> => =>
|
||||||
;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,12 +8,10 @@
|
|||||||
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();
|
||||||
|
|
||||||
@@ -32,7 +30,6 @@
|
|||||||
? ({ 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,
|
||||||
@@ -56,6 +53,12 @@
|
|||||||
<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}
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
|
|
||||||
{#snippet kbdText()}
|
{#snippet kbdText()}
|
||||||
{dynamicMapping ??
|
{dynamicMapping ??
|
||||||
icon ??
|
info.icon ??
|
||||||
info.display ??
|
info.display ??
|
||||||
info.id ??
|
info.id ??
|
||||||
`0x${info.code.toString(16)}`}
|
`0x${info.code.toString(16)}`}
|
||||||
@@ -74,7 +77,7 @@
|
|||||||
{#snippet kbdSnippet(withPopover = true)}
|
{#snippet kbdSnippet(withPopover = true)}
|
||||||
<kbd
|
<kbd
|
||||||
class:in-text={inText}
|
class:in-text={inText}
|
||||||
class:icon={!!icon}
|
class:icon={!!info.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}
|
||||||
@@ -94,7 +97,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 !icon && info.id?.length === 1}
|
{:else if !info.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}
|
||||||
@@ -109,7 +112,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={!!icon}
|
class:icon={!!info.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}
|
||||||
@@ -158,50 +161,21 @@
|
|||||||
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 {
|
||||||
background-image: linear-gradient(
|
padding-inline-end: $variant-padding;
|
||||||
to right,
|
text-shadow: $variant-offset 0 2px $variant-color;
|
||||||
var(--bg-color) $cutoff,
|
|
||||||
transparent $cutoff
|
|
||||||
);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
clip-path: inset(0 0 0 $cutoff);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.right {
|
.right {
|
||||||
background-image: linear-gradient(
|
padding-inline-start: $variant-padding;
|
||||||
to left,
|
text-shadow: -$variant-offset 0 2px $variant-color;
|
||||||
var(--bg-color) $cutoff,
|
|
||||||
transparent $cutoff
|
|
||||||
);
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
clip-path: inset(0 $cutoff 0 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
@@ -232,13 +206,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,17 +19,13 @@
|
|||||||
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();
|
||||||
@@ -47,14 +43,6 @@
|
|||||||
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(
|
||||||
@@ -72,7 +60,6 @@
|
|||||||
(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())
|
||||||
@@ -180,7 +167,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] (actions)}
|
{#each results as [category, actions] (category)}
|
||||||
{#if actions.length > 0}
|
{#if actions.length > 0}
|
||||||
<div class="category">
|
<div class="category">
|
||||||
<h3>{category.name}</h3>
|
<h3>{category.name}</h3>
|
||||||
@@ -204,7 +191,7 @@
|
|||||||
}
|
}
|
||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
<Action {action} display="verbose" {ignoreIcon}></Action>
|
<Action {action} display="verbose"></Action>
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { osLayout } from "$lib/os-layout";
|
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import { type ChordInfo, chords } from "$lib/undo-redo";
|
|
||||||
import { derived } from "svelte/store";
|
|
||||||
|
|
||||||
export const words = derived(
|
|
||||||
[chords, osLayout, KEYMAP_CODES],
|
|
||||||
([chords, layout, KEYMAP_CODES]) =>
|
|
||||||
new Map<string, ChordInfo>(
|
|
||||||
chords
|
|
||||||
.map((chord) => ({
|
|
||||||
chord,
|
|
||||||
output: chord.phrase.map((action) =>
|
|
||||||
layout.get(KEYMAP_CODES.get(action)?.keyCode ?? ""),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
.filter(({ output }) => output.every((it) => !!it))
|
|
||||||
.map(({ chord, output }) => [output.join("").trim(), chord] as const),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
interface Score {
|
|
||||||
lastTyped: number;
|
|
||||||
score: number;
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const scores = persistentWritable<Record<string, Score>>("scores", {});
|
|
||||||
|
|
||||||
export const learnConfigDefault = {
|
|
||||||
maxScore: 3,
|
|
||||||
minScore: -3,
|
|
||||||
scoreBlend: 0.5,
|
|
||||||
weakRate: 0.8,
|
|
||||||
weakBoost: 0.5,
|
|
||||||
maxWeak: 3,
|
|
||||||
newRate: 0.3,
|
|
||||||
initialNewRate: 0.9,
|
|
||||||
initialCount: 10,
|
|
||||||
};
|
|
||||||
export const learnConfigStored = persistentWritable<
|
|
||||||
Partial<typeof learnConfigDefault>
|
|
||||||
>("learn-config", {});
|
|
||||||
export const learnConfig = derived(learnConfigStored, (config) => ({
|
|
||||||
...learnConfigDefault,
|
|
||||||
...config,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let lastWord: string | undefined;
|
|
||||||
|
|
||||||
function shuffle<T>(array: T[]): T[] {
|
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[array[i], array[j]] = [array[j]!, array[i]!];
|
|
||||||
}
|
|
||||||
return array;
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomLog2<T>(array: T[], max = array.length): T | undefined {
|
|
||||||
return array[
|
|
||||||
Math.floor(Math.pow(2, Math.log2(Math.random() * Math.log2(max))))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const nextWord = derived(
|
|
||||||
[words, scores, learnConfig],
|
|
||||||
([words, scores, config]) => {
|
|
||||||
const values = Object.entries(scores).filter(([it]) => it !== lastWord);
|
|
||||||
|
|
||||||
values.sort(([, a], [, b]) => a.score - b.score);
|
|
||||||
const weakCount =
|
|
||||||
(values.findIndex(([, { score }]) => score > 0) + 1 ||
|
|
||||||
values.length + 1) - 1;
|
|
||||||
const weak = randomLog2(values, weakCount);
|
|
||||||
if (weak && Math.random() / weakCount < config.weakRate) {
|
|
||||||
lastWord = weak[0];
|
|
||||||
return weak[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
values.sort(([, { lastTyped: a }], [, { lastTyped: b }]) => a - b);
|
|
||||||
const recent = randomLog2(values);
|
|
||||||
const newRate =
|
|
||||||
values.length < config.initialCount
|
|
||||||
? config.initialNewRate
|
|
||||||
: config.newRate;
|
|
||||||
if (
|
|
||||||
recent &&
|
|
||||||
(Math.random() < Math.min(1, Math.max(0, weakCount / config.maxWeak)) ||
|
|
||||||
Math.random() > newRate)
|
|
||||||
) {
|
|
||||||
lastWord = recent[0];
|
|
||||||
return recent[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const newWord = shuffle(Array.from(words.keys())).find((it) => !scores[it]);
|
|
||||||
const word = newWord || recent?.[0] || weak?.[0];
|
|
||||||
lastWord = word;
|
|
||||||
return word;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
|
|
||||||
interface ChordStats {
|
|
||||||
level: number;
|
|
||||||
lastUprank: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const chordStats = persistentWritable<Record<string, ChordStats>>(
|
|
||||||
"chord-stats",
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { compressActions, decompressActions } from "../serialization/actions";
|
import { compressActions, decompressActions } from "../serialization/actions";
|
||||||
import type { KeyInfo } from "./keymap-codes";
|
|
||||||
|
|
||||||
export interface Chord {
|
export interface Chord {
|
||||||
actions: number[];
|
actions: number[];
|
||||||
@@ -57,103 +56,6 @@ 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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACTION_JOIN = 574;
|
|
||||||
const ACTION_KSC_00 = 256;
|
|
||||||
|
|
||||||
export function hasConcatenator(
|
|
||||||
actions: number[],
|
|
||||||
ids: Map<number, KeyInfo>,
|
|
||||||
): boolean {
|
|
||||||
const lastAction = actions.at(-1);
|
|
||||||
for (const action of actions) {
|
|
||||||
if (!ids.get(action)?.printable) {
|
|
||||||
if (actions.length == 0) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return lastAction == ACTION_JOIN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastAction != ACTION_KSC_00;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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
|
||||||
*/
|
*/
|
||||||
@@ -170,6 +72,5 @@ export function hashChord(actions: number[]) {
|
|||||||
if ((hash & 0xff) === 0xff) {
|
if ((hash & 0xff) === 0xff) {
|
||||||
hash ^= 0xff;
|
hash ^= 0xff;
|
||||||
}
|
}
|
||||||
hash &= 0x3fff_ffff;
|
return hash & 0x3fff_ffff;
|
||||||
return hash === 0 ? 1 : hash;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { get, writable } from "svelte/store";
|
import { derived, get, writable } from "svelte/store";
|
||||||
import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
|
import { CharaDevice, type SerialPortLike } from "$lib/serial/device";
|
||||||
import type { Chord } from "$lib/serial/chord";
|
import type { Chord } from "$lib/serial/chord";
|
||||||
import type { Writable } from "svelte/store";
|
import type { Writable } from "svelte/store";
|
||||||
@@ -7,12 +7,27 @@ import { persistentWritable } from "$lib/storage";
|
|||||||
import { userPreferences } from "$lib/preferences";
|
import { userPreferences } from "$lib/preferences";
|
||||||
import { getMeta } from "$lib/meta/meta-storage";
|
import { getMeta } from "$lib/meta/meta-storage";
|
||||||
import type { VersionMeta } from "$lib/meta/types/meta";
|
import type { VersionMeta } from "$lib/meta/types/meta";
|
||||||
|
import { serial as serialPolyfill } from "web-serial-polyfill";
|
||||||
|
|
||||||
export const serialPort = writable<CharaDevice | undefined>();
|
export const serialPort = writable<CharaDevice | undefined>();
|
||||||
|
|
||||||
navigator.serial?.addEventListener("disconnect", async (event) => {
|
export const forceWebUSB = persistentWritable("force-webusb", false);
|
||||||
|
|
||||||
|
async function onSerialDisconnect() {
|
||||||
serialPort.set(undefined);
|
serialPort.set(undefined);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
export const serialObject = derived<typeof forceWebUSB, Serial>(
|
||||||
|
forceWebUSB,
|
||||||
|
(forceWebUSB) =>
|
||||||
|
forceWebUSB || !("serial" in navigator)
|
||||||
|
? (serialPolyfill as any as Serial)
|
||||||
|
: navigator.serial,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ("serial" in navigator) {
|
||||||
|
navigator.serial.addEventListener("disconnect", onSerialDisconnect);
|
||||||
|
}
|
||||||
|
|
||||||
export interface SerialLogEntry {
|
export interface SerialLogEntry {
|
||||||
type: "input" | "output" | "system";
|
type: "input" | "output" | "system";
|
||||||
@@ -55,10 +70,7 @@ export const syncStatus: Writable<
|
|||||||
"done" | "error" | "downloading" | "uploading"
|
"done" | "error" | "downloading" | "uploading"
|
||||||
> = writable("done");
|
> = writable("done");
|
||||||
|
|
||||||
export const deviceMeta = persistentWritable<VersionMeta | undefined>(
|
export const deviceMeta = writable<VersionMeta | undefined>(undefined);
|
||||||
"current-meta",
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ProgressInfo {
|
export interface ProgressInfo {
|
||||||
max: number;
|
max: number;
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
stringifyChordActions,
|
stringifyChordActions,
|
||||||
stringifyPhrase,
|
stringifyPhrase,
|
||||||
} from "$lib/serial/chord";
|
} from "$lib/serial/chord";
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||||
import semverGte from "semver/functions/gte";
|
import semverGte from "semver/functions/gte";
|
||||||
|
|
||||||
@@ -71,23 +70,8 @@ const KEY_COUNTS = {
|
|||||||
ZERO: 256,
|
ZERO: 256,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
if (
|
export async function getViablePorts(serial: Serial): Promise<SerialPort[]> {
|
||||||
browser &&
|
return serial.getPorts().then((ports) =>
|
||||||
navigator.serial === undefined &&
|
|
||||||
import.meta.env.TAURI_FAMILY !== undefined
|
|
||||||
) {
|
|
||||||
await import("./tauri-serial");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browser && navigator.serial === undefined && navigator.usb !== undefined) {
|
|
||||||
// @ts-expect-error polyfill
|
|
||||||
navigator.serial = await import("web-serial-polyfill").then(
|
|
||||||
({ serial }) => serial,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getViablePorts(): Promise<SerialPort[]> {
|
|
||||||
return navigator.serial.getPorts().then((ports) =>
|
|
||||||
ports.filter((it) => {
|
ports.filter((it) => {
|
||||||
const { usbProductId, usbVendorId } = it.getInfo();
|
const { usbProductId, usbVendorId } = it.getInfo();
|
||||||
for (const filter of PORT_FILTERS.values()) {
|
for (const filter of PORT_FILTERS.values()) {
|
||||||
@@ -109,8 +93,8 @@ type LengthArray<T, N extends number, R extends T[] = []> = number extends N
|
|||||||
? R
|
? R
|
||||||
: LengthArray<T, N, [T, ...R]>;
|
: LengthArray<T, N, [T, ...R]>;
|
||||||
|
|
||||||
export async function canAutoConnect() {
|
export async function canAutoConnect(serial: Serial) {
|
||||||
return getViablePorts().then((it) => it.length === 1);
|
return getViablePorts(serial).then((it) => it.length === 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
async function timeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
||||||
@@ -147,7 +131,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";
|
chipset!: "M0" | "S2" | "S3" | "WASM";
|
||||||
keyCount!: 90 | 67 | 256;
|
keyCount!: 90 | 67 | 256;
|
||||||
layerCount = 3;
|
layerCount = 3;
|
||||||
profileCount = 1;
|
profileCount = 1;
|
||||||
@@ -157,7 +141,7 @@ export class CharaDevice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly port: SerialPortLike,
|
readonly port: SerialPortLike,
|
||||||
public baudRate = 115200,
|
public baudRate = 115200,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -183,11 +167,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")) {
|
if (semverGte(this.version, "2.2.0-beta.4") && this.chipset !== "M0") {
|
||||||
this.profileCount = this.chipset === "M0" ? 2 : 3;
|
this.profileCount = 3;
|
||||||
}
|
}
|
||||||
if (semverGte(this.version, "2.2.0-beta.20")) {
|
if (semverGte(this.version, "2.2.0-beta.20") && this.chipset !== "M0") {
|
||||||
this.layerCount = this.chipset === "M0" ? 3 : 4;
|
this.layerCount = 4;
|
||||||
}
|
}
|
||||||
this.keyCount = KEY_COUNTS[this.device];
|
this.keyCount = KEY_COUNTS[this.device];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
kbd {
|
kbd {
|
||||||
--bg-color: color-mix(
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
margin-block: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
//border: 1px solid currentcolor;
|
||||||
|
background: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--md-sys-color-surface-variant) 50%,
|
var(--md-sys-color-surface-variant) 50%,
|
||||||
transparent
|
transparent
|
||||||
);
|
);
|
||||||
--border-radius: 4px;
|
|
||||||
|
|
||||||
display: inline-flex;
|
|
||||||
position: relative;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
margin-block: 6px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background: var(--bg-color);
|
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
||||||
height: 20px;
|
height: 20px;
|
||||||
color: currentcolor;
|
color: currentcolor;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
||||||
&.icon {
|
&.icon {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
themeFromSourceColor,
|
themeFromSourceColor,
|
||||||
} from "@material/material-color-utilities";
|
} from "@material/material-color-utilities";
|
||||||
import { canAutoConnect, getViablePorts } from "$lib/serial/device";
|
import { canAutoConnect, getViablePorts } from "$lib/serial/device";
|
||||||
import { initSerial } from "$lib/serial/connection";
|
import { initSerial, serialObject } from "$lib/serial/connection";
|
||||||
import type { LayoutData } from "./$types";
|
import type { LayoutData } from "./$types";
|
||||||
import { browser } from "$app/environment";
|
import { browser } from "$app/environment";
|
||||||
import "tippy.js/animations/shift-away.css";
|
import "tippy.js/animations/shift-away.css";
|
||||||
@@ -74,8 +74,12 @@
|
|||||||
webManifestLink = await initPwa();
|
webManifestLink = await initPwa();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (browser && $userPreferences.autoConnect && (await canAutoConnect())) {
|
if (
|
||||||
const [port] = await getViablePorts();
|
browser &&
|
||||||
|
$userPreferences.autoConnect &&
|
||||||
|
(await canAutoConnect($serialObject))
|
||||||
|
) {
|
||||||
|
const [port] = await getViablePorts($serialObject);
|
||||||
await initSerial(port!, true);
|
await initSerial(port!, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import LL from "$i18n/i18n-svelte";
|
import LL from "$i18n/i18n-svelte";
|
||||||
import { preference, userPreferences } from "$lib/preferences";
|
import { preference, userPreferences } from "$lib/preferences";
|
||||||
import { initSerial } from "$lib/serial/connection";
|
import {
|
||||||
|
forceWebUSB,
|
||||||
|
initSerial,
|
||||||
|
serialObject,
|
||||||
|
} from "$lib/serial/connection";
|
||||||
import {
|
import {
|
||||||
getPortName,
|
getPortName,
|
||||||
PORT_FILTERS,
|
PORT_FILTERS,
|
||||||
type SerialPortLike,
|
type SerialPortLike,
|
||||||
} from "$lib/serial/device";
|
} from "$lib/serial/device";
|
||||||
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
import { showConnectionFailedDialog } from "$lib/dialogs/connection-failed-dialog";
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
import { persistentWritable } from "$lib/storage";
|
||||||
|
import { browser } from "$app/environment";
|
||||||
|
|
||||||
let ports = $state<SerialPort[]>([]);
|
let ports = $state<SerialPort[]>([]);
|
||||||
let element: HTMLDivElement | undefined = $state();
|
let element: HTMLDivElement | undefined = $state();
|
||||||
|
let supportsWebSerial = browser && "serial" in navigator;
|
||||||
|
let supportsWebUSB = browser && "usb" in navigator;
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
refreshPorts();
|
refreshPorts($serialObject);
|
||||||
});
|
});
|
||||||
|
|
||||||
let hasDiscoveredAutoConnect = persistentWritable(
|
let hasDiscoveredAutoConnect = persistentWritable(
|
||||||
@@ -29,8 +35,8 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function refreshPorts() {
|
async function refreshPorts(serial: Serial) {
|
||||||
ports = await navigator.serial.getPorts();
|
ports = await serial.getPorts();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect(port: SerialPortLike, withSync: boolean) {
|
async function connect(port: SerialPortLike, withSync: boolean) {
|
||||||
@@ -46,22 +52,13 @@
|
|||||||
element?.closest<HTMLElement>("[popover]")?.hidePopover();
|
element?.closest<HTMLElement>("[popover]")?.hidePopover();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectCC0(event: MouseEvent) {
|
async function connectDevice(event: MouseEvent, serial: Serial) {
|
||||||
const { fetchCCOS } = await import("$lib/ccos/ccos");
|
const port = await serial.requestPort({
|
||||||
closePopover();
|
|
||||||
const ccos = await fetchCCOS();
|
|
||||||
if (ccos) {
|
|
||||||
connect(ccos, !event.shiftKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function connectDevice(event: MouseEvent) {
|
|
||||||
const port = await navigator.serial.requestPort({
|
|
||||||
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
filters: event.shiftKey ? [] : [...PORT_FILTERS.values()],
|
||||||
});
|
});
|
||||||
if (!port) return;
|
if (!port) return;
|
||||||
closePopover();
|
closePopover();
|
||||||
refreshPorts();
|
refreshPorts(serial);
|
||||||
connect(port, true);
|
connect(port, true);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -69,54 +66,79 @@
|
|||||||
<div
|
<div
|
||||||
bind:this={element}
|
bind:this={element}
|
||||||
class="device-list"
|
class="device-list"
|
||||||
onmouseenter={() => refreshPorts()}
|
onmouseenter={() => refreshPorts($serialObject)}
|
||||||
role="region"
|
role="region"
|
||||||
>
|
>
|
||||||
{#if ports.length === 1}
|
{#if supportsWebSerial || supportsWebUSB}
|
||||||
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
|
<fieldset class:promote={!$hasDiscoveredAutoConnect}>
|
||||||
<label
|
{#if ports.length === 1}
|
||||||
><input type="checkbox" use:preference={"autoConnect"} />
|
<label
|
||||||
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
|
><input type="checkbox" use:preference={"autoConnect"} />
|
||||||
</label>
|
<div class="title">{$LL.deviceManager.AUTO_CONNECT()}</div>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label
|
<label
|
||||||
><input type="checkbox" use:preference={"backup"} />
|
><input type="checkbox" use:preference={"backup"} />
|
||||||
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
|
<div class="title">{@html $LL.backup.AUTO_BACKUP()}</div>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<label title="You can try this if you have trouble with the connection."
|
||||||
|
><input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!supportsWebSerial}
|
||||||
|
checked={!supportsWebSerial || $forceWebUSB}
|
||||||
|
onchange={(event) => {
|
||||||
|
$forceWebUSB = (event.target as HTMLInputElement).checked;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div class="title">WebUSB Fallback</div>
|
||||||
</label>
|
</label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
{/if}
|
{#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>-->
|
||||||
|
{#each ports as port}
|
||||||
|
<div class="device">
|
||||||
|
<button
|
||||||
|
onclick={(event) => {
|
||||||
|
connect(port, !event.shiftKey);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getPortName(port)}</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="error"
|
||||||
|
onclick={() => {
|
||||||
|
port.forget();
|
||||||
|
refreshPorts($serialObject);
|
||||||
|
}}><span class="icon">visibility_off</span> Hide</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{#each ports as port}
|
{/if}
|
||||||
<div class="device">
|
<div class="pair">
|
||||||
<button
|
<button
|
||||||
onclick={(event) => {
|
onclick={(event) => connectDevice(event, $serialObject)}
|
||||||
connect(port, !event.shiftKey);
|
class="primary"><span class="icon">add</span>Connect</button
|
||||||
}}
|
>
|
||||||
>
|
<!--<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>-->
|
||||||
{getPortName(port)}</button
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="error"
|
|
||||||
onclick={() => {
|
|
||||||
port.forget();
|
|
||||||
refreshPorts();
|
|
||||||
}}><span class="icon">visibility_off</span> Hide</button
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
{#if !supportsWebSerial}
|
||||||
|
<p>Browser with limited support detected.</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p><b>Your browser is missing support for critical features.</b></p>
|
||||||
|
<p>
|
||||||
|
Please use a Chromium-based browser such as Chrome, Edge or Chromium
|
||||||
|
instead.
|
||||||
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="pair">
|
|
||||||
<button onclick={connectDevice} class="primary"
|
|
||||||
><span class="icon">add</span>Connect</button
|
|
||||||
>
|
|
||||||
<a href="/ccos/zero_wasm/"><span class="icon">add</span>Virtual Device</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -116,6 +116,25 @@
|
|||||||
{/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
|
||||||
@@ -168,6 +187,11 @@
|
|||||||
|
|
||||||
$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,6 +17,11 @@
|
|||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
{
|
||||||
|
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",
|
||||||
@@ -36,14 +41,6 @@
|
|||||||
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
src/routes/(app)/ccos/+layout.server.ts
Normal file
1
src/routes/(app)/ccos/+layout.server.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const prerender = false;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { downloadBackup } from "$lib/backup/backup";
|
import { downloadBackup } from "$lib/backup/backup";
|
||||||
import { initSerial, serialPort } from "$lib/serial/connection";
|
import { initSerial, serialObject, serialPort } from "$lib/serial/connection";
|
||||||
import { fade, slide } from "svelte/transition";
|
import { fade, slide } from "svelte/transition";
|
||||||
import { lt as semverLt } from "semver";
|
import { lt as semverLt } from "semver";
|
||||||
import type { LoaderOptions, ESPLoader } from "esptool-js";
|
import type { LoaderOptions, ESPLoader } from "esptool-js";
|
||||||
@@ -95,9 +95,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connect() {
|
async function connect(serial: Serial) {
|
||||||
try {
|
try {
|
||||||
const port = await navigator.serial.requestPort();
|
const port = await serial.requestPort();
|
||||||
await initSerial(port!, true);
|
await initSerial(port!, true);
|
||||||
step = 1;
|
step = 1;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -138,9 +138,9 @@
|
|||||||
step = 4;
|
step = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function espBootloader() {
|
async function espBootloader(serial: Serial) {
|
||||||
$serialPort?.forget();
|
$serialPort?.forget();
|
||||||
const port = await navigator.serial.requestPort();
|
const port = await serial.requestPort();
|
||||||
port.open({ baudRate: 1200 });
|
port.open({ baudRate: 1200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,8 +172,8 @@
|
|||||||
return espLoader;
|
return espLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function flashImages() {
|
async function flashImages(serial: Serial) {
|
||||||
const port = await navigator.serial.requestPort();
|
const port = await serial.requestPort();
|
||||||
try {
|
try {
|
||||||
const esptool = data.meta.update.esptool!;
|
const esptool = data.meta.update.esptool!;
|
||||||
espLoader = await connectEsp(port);
|
espLoader = await connectEsp(port);
|
||||||
@@ -202,8 +202,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function eraseSPI() {
|
async function eraseSPI(serial: Serial) {
|
||||||
const port = await navigator.serial.requestPort();
|
const port = await serial.requestPort();
|
||||||
try {
|
try {
|
||||||
console.log(data.meta);
|
console.log(data.meta);
|
||||||
const spiFlash = data.meta.spiFlash!;
|
const spiFlash = data.meta.spiFlash!;
|
||||||
@@ -314,7 +314,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<ol>
|
<ol>
|
||||||
<li>
|
<li>
|
||||||
<button class="inline-button" onclick={connect}
|
<button class="inline-button" onclick={() => connect($serialObject)}
|
||||||
><span class="icon">usb</span>Connect</button
|
><span class="icon">usb</span>Connect</button
|
||||||
>
|
>
|
||||||
your device
|
your device
|
||||||
@@ -367,17 +367,17 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="esp-buttons">
|
<div class="esp-buttons">
|
||||||
<button onclick={espBootloader}
|
<button onclick={() => espBootloader($serialObject)}
|
||||||
><span class="icon">memory</span>ESP Bootloader</button
|
><span class="icon">memory</span>ESP Bootloader</button
|
||||||
>
|
>
|
||||||
<button onclick={flashImages}
|
<button onclick={() => flashImages($serialObject)}
|
||||||
><span class="icon">developer_board</span>Flash Images</button
|
><span class="icon">developer_board</span>Flash Images</button
|
||||||
>
|
>
|
||||||
<label
|
<label
|
||||||
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase
|
><input type="checkbox" id="erase" bind:checked={eraseAll} />Erase
|
||||||
All</label
|
All</label
|
||||||
>
|
>
|
||||||
<button onclick={eraseSPI}
|
<button onclick={() => eraseSPI($serialObject)}
|
||||||
><span class="icon">developer_board</span>Erase SPI Flash</button
|
><span class="icon">developer_board</span>Erase SPI Flash</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { initMatrixClient, isLoggedIn, matrix } from "$lib/chat/chat-rx";
|
|
||||||
import { flip } from "svelte/animate";
|
|
||||||
import { slide } from "svelte/transition";
|
|
||||||
import Login from "./Login.svelte";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (browser) {
|
|
||||||
await initMatrixClient();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let { children } = $props();
|
|
||||||
|
|
||||||
let spaces = $derived($matrix?.topLevelSpaces$);
|
|
||||||
|
|
||||||
function spaceShort(name: string) {
|
|
||||||
return name
|
|
||||||
.split(" ")
|
|
||||||
.map((it) => it[0])
|
|
||||||
.join("");
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $isLoggedIn}
|
|
||||||
<div class="layout">
|
|
||||||
<nav class="spaces">
|
|
||||||
<a href="/chat/chats" class="icon chats">chat</a>
|
|
||||||
<hr />
|
|
||||||
{#if $spaces}
|
|
||||||
<ul>
|
|
||||||
{#each $spaces as space (space.roomId)}
|
|
||||||
<li animate:flip transition:slide>
|
|
||||||
<a class="space" href="/chat/space/{space.roomId}">
|
|
||||||
{spaceShort(space.name)}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
{/each}
|
|
||||||
</ul>
|
|
||||||
{/if}
|
|
||||||
<button class="icon">add</button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Login />
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
width: 60%;
|
|
||||||
height: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
a {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--md-sys-color-surface-variant);
|
|
||||||
width: 56px;
|
|
||||||
height: 56px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chats {
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.space {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { matrixClient } from "$lib/chat/chat";
|
|
||||||
|
|
||||||
function passwordLogin() {
|
|
||||||
// TODO
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $matrixClient}
|
|
||||||
{#await $matrixClient.loginFlows() then flows}
|
|
||||||
{#each flows.flows as flow}
|
|
||||||
{#if flow.type === "m.login.sso"}
|
|
||||||
<a
|
|
||||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
|
||||||
>
|
|
||||||
{#each flow.identity_providers as idp}
|
|
||||||
{#if idp.icon}
|
|
||||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
|
||||||
{:else}
|
|
||||||
{idp.name}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</a>
|
|
||||||
{:else if flow.type === "m.login.password"}
|
|
||||||
<form onsubmit={passwordLogin}>
|
|
||||||
<input name="username" type="text" placeholder="Username" />
|
|
||||||
<input name="password" type="password" placeholder="Password" />
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/await}
|
|
||||||
{/if}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { onDestroy, onMount, setContext } from "svelte";
|
|
||||||
import type {
|
|
||||||
IndexedDBStore,
|
|
||||||
IndexedDBCryptoStore,
|
|
||||||
LoginResponse,
|
|
||||||
} from "matrix-js-sdk";
|
|
||||||
import MatrixTimeline from "$lib/chat/MatrixTimeline.svelte";
|
|
||||||
import { matrixClient, currentRoomId } from "$lib/chat/chat";
|
|
||||||
import MatrixRooms from "$lib/chat/MatrixRooms.svelte";
|
|
||||||
import MatrixRoomMembers from "$lib/chat/MatrixRoomMembers.svelte";
|
|
||||||
|
|
||||||
let loggedIn = $state(false);
|
|
||||||
let ready = $state(false);
|
|
||||||
|
|
||||||
let store: IndexedDBStore;
|
|
||||||
let cryptoStore: IndexedDBCryptoStore;
|
|
||||||
|
|
||||||
onMount(async () => {
|
|
||||||
if (!browser) return;
|
|
||||||
const { createClient, IndexedDBStore, IndexedDBCryptoStore } = await import(
|
|
||||||
"matrix-js-sdk"
|
|
||||||
);
|
|
||||||
|
|
||||||
const storedLogin = getStoredLogin();
|
|
||||||
|
|
||||||
store = new IndexedDBStore({
|
|
||||||
dbName: "matrix",
|
|
||||||
indexedDB: window.indexedDB,
|
|
||||||
});
|
|
||||||
cryptoStore = new IndexedDBCryptoStore(window.indexedDB, "matrix-crypto");
|
|
||||||
|
|
||||||
$matrixClient = createClient({
|
|
||||||
baseUrl: import.meta.env.VITE_MATRIX_URL,
|
|
||||||
userId: storedLogin?.user_id,
|
|
||||||
accessToken: storedLogin?.access_token,
|
|
||||||
timelineSupport: true,
|
|
||||||
store,
|
|
||||||
cryptoStore,
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginToken = new URLSearchParams(window.location.search).get(
|
|
||||||
"loginToken",
|
|
||||||
);
|
|
||||||
if (loginToken) {
|
|
||||||
await handleLogin(await $matrixClient.loginWithToken(loginToken));
|
|
||||||
window.history.replaceState({}, document.title, window.location.pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
await postLogin();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function passwordLogin(event: SubmitEvent) {
|
|
||||||
event.preventDefault();
|
|
||||||
const form = event.target as HTMLFormElement;
|
|
||||||
const username = (form.elements.namedItem("username") as HTMLInputElement)
|
|
||||||
.value;
|
|
||||||
const password = (form.elements.namedItem("password") as HTMLInputElement)
|
|
||||||
.value;
|
|
||||||
|
|
||||||
await handleLogin(
|
|
||||||
await $matrixClient.loginWithPassword(username, password),
|
|
||||||
);
|
|
||||||
await postLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogin(response: LoginResponse) {
|
|
||||||
localStorage.setItem("matrix-login", JSON.stringify(response));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function postLogin() {
|
|
||||||
loggedIn = $matrixClient.isLoggedIn();
|
|
||||||
|
|
||||||
if (loggedIn) {
|
|
||||||
await store.startup();
|
|
||||||
await cryptoStore.startup();
|
|
||||||
await $matrixClient.startClient();
|
|
||||||
$matrixClient.once("sync", function (state, prevState, res) {
|
|
||||||
ready = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredLogin(): LoginResponse | undefined {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem("matrix-login")!);
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
if ($matrixClient) {
|
|
||||||
$matrixClient.stopClient();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{#if $matrixClient && loggedIn}
|
|
||||||
{#if ready}
|
|
||||||
<div class="chat">
|
|
||||||
<div class="rooms">
|
|
||||||
<button
|
|
||||||
onclick={() => {
|
|
||||||
$matrixClient.logout(true);
|
|
||||||
$matrixClient.clearStores();
|
|
||||||
localStorage.removeItem("matrix-login");
|
|
||||||
window.location.reload();
|
|
||||||
}}>logout</button
|
|
||||||
>
|
|
||||||
<MatrixRooms rooms={$matrixClient.getRooms()} />
|
|
||||||
</div>
|
|
||||||
{#if $currentRoomId}
|
|
||||||
{@const room = $matrixClient.getRoom($currentRoomId)}
|
|
||||||
{#key room}
|
|
||||||
{#if room}
|
|
||||||
<div class="timeline">
|
|
||||||
<MatrixTimeline timeline={room.getLiveTimeline()} />
|
|
||||||
</div>
|
|
||||||
<div class="members">
|
|
||||||
<MatrixRoomMembers members={room.getJoinedMembers()} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/key}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{:else if $matrixClient}
|
|
||||||
{#await $matrixClient.loginFlows() then flows}
|
|
||||||
{#each flows.flows as flow}
|
|
||||||
{#if flow.type === "m.login.sso"}
|
|
||||||
<a
|
|
||||||
href={$matrixClient.getSsoLoginUrl(`${window.location.origin}/chat/`)}
|
|
||||||
>
|
|
||||||
{#each flow.identity_providers as idp}
|
|
||||||
{#if idp.icon}
|
|
||||||
<img src={$matrixClient.mxcUrlToHttp(idp.icon)} alt={idp.name} />
|
|
||||||
{:else}
|
|
||||||
{idp.name}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</a>
|
|
||||||
{:else if flow.type === "m.login.password"}
|
|
||||||
<!-- TODO: unambigous sso
|
|
||||||
<form onsubmit={passwordLogin}>
|
|
||||||
<input name="username" type="text" placeholder="Username" />
|
|
||||||
<input name="password" type="password" placeholder="Password" />
|
|
||||||
<button type="submit">Login</button>
|
|
||||||
</form>
|
|
||||||
-->
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{/await}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.chat {
|
|
||||||
display: flex;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
|
|
||||||
> *:not(:last-child) {
|
|
||||||
border-right: 1px solid var(--md-sys-color-outline);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline {
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rooms {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.members {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 200px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -187,8 +187,13 @@
|
|||||||
let supportsAutospace = $derived(
|
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(
|
||||||
isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
supportsAutospaceV2
|
||||||
|
? chord.phrase.at(-1) !== NO_CONCATENATOR_ACTION
|
||||||
|
: isPrintable || chord.phrase.at(-1) === JOIN_ACTION,
|
||||||
);
|
);
|
||||||
|
|
||||||
function isHidden(action: number, index: number, array: number[]) {
|
function isHidden(action: number, index: number, array: number[]) {
|
||||||
@@ -228,8 +233,10 @@
|
|||||||
moveCursor(cursorPosition + 1, true);
|
moveCursor(cursorPosition + 1, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await tick();
|
if (!supportsAutospaceV2) {
|
||||||
resolveAutospace(autospace);
|
await tick();
|
||||||
|
resolveAutospace(autospace);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -265,8 +272,24 @@
|
|||||||
<AutospaceSelector
|
<AutospaceSelector
|
||||||
variant="end"
|
variant="end"
|
||||||
value={!hasAutospace}
|
value={!hasAutospace}
|
||||||
onchange={(event) =>
|
onchange={async (event) => {
|
||||||
resolveAutospace((event.target as HTMLInputElement).checked)}
|
if (supportsAutospaceV2) {
|
||||||
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
|
if (chord.phrase.at(-1) === NO_CONCATENATOR_ACTION) {
|
||||||
|
deleteAction(chord.phrase.length - 1);
|
||||||
|
await tick();
|
||||||
|
moveCursor(cursorPosition, true);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (chord.phrase.at(-1) !== NO_CONCATENATOR_ACTION) {
|
||||||
|
insertAction(chord.phrase.length, NO_CONCATENATOR_ACTION);
|
||||||
|
moveCursor(cursorPosition, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolveAutospace((event.target as HTMLInputElement).checked);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<sup>•</sup>
|
<sup>•</sup>
|
||||||
|
|||||||
@@ -47,19 +47,6 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,356 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { chords } from "$lib/undo-redo";
|
|
||||||
import { EditorView } from "codemirror";
|
|
||||||
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
|
||||||
import "$lib/chord-editor/chords.grammar";
|
|
||||||
import { persistentWritable } from "$lib/storage";
|
|
||||||
import ActionList from "$lib/components/layout/ActionList.svelte";
|
|
||||||
import {
|
|
||||||
composeChordInput,
|
|
||||||
hashChord,
|
|
||||||
splitCompound,
|
|
||||||
} from "$lib/serial/chord";
|
|
||||||
import { loadPersistentState } from "$lib/chord-editor/persistent-state-plugin";
|
|
||||||
import { parsedChordsField } from "$lib/chord-editor/parsed-chords-plugin";
|
|
||||||
import type { CharaChordFile } from "$lib/share/chara-file";
|
|
||||||
import { chordSyncEffect } from "$lib/chord-editor/chord-sync-plugin";
|
|
||||||
import { KEYMAP_IDS, type KeyInfo } from "$lib/serial/keymap-codes";
|
|
||||||
|
|
||||||
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 editor: HTMLDivElement | undefined = $state(undefined);
|
|
||||||
let view: EditorView;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!editor) return;
|
|
||||||
const viewPromise = loadPersistentState({
|
|
||||||
rawCode: $rawCode,
|
|
||||||
storeName: "chord-editor-state-storage",
|
|
||||||
autocomplete(query) {
|
|
||||||
queryFilter = query;
|
|
||||||
},
|
|
||||||
}).then(
|
|
||||||
(state) =>
|
|
||||||
new EditorView({
|
|
||||||
parent: editor,
|
|
||||||
state,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
viewPromise.then((it) => (view = it));
|
|
||||||
return () => viewPromise.then((it) => it.destroy());
|
|
||||||
});
|
|
||||||
|
|
||||||
function regenerate() {
|
|
||||||
const doc = $chords
|
|
||||||
.map((chord) => {
|
|
||||||
const [actions, compound] = splitCompound(chord.actions);
|
|
||||||
return (
|
|
||||||
(compound ? "|0x" + compound.toString(16) + "|" : "") +
|
|
||||||
actions.map((it) => actionToValue(it)).join("") +
|
|
||||||
"=>" +
|
|
||||||
chord.phrase.map((it) => actionToValue(it)).join("")
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: doc },
|
|
||||||
effects: chordSyncEffect.of(
|
|
||||||
$chords.map((chord) => [chord.actions, chord.phrase] as const),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function largeFile() {
|
|
||||||
const chordCount = 100000;
|
|
||||||
const maxPhraseLength = 100;
|
|
||||||
const maxInputLength = 8;
|
|
||||||
const compoundChance = 0.05;
|
|
||||||
|
|
||||||
const actions = [...$KEYMAP_IDS.values()];
|
|
||||||
function randomAction(): KeyInfo {
|
|
||||||
return actions[Math.floor(actions.length * Math.random())]!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const backup: [KeyInfo[][], KeyInfo[]][] = Array.from(
|
|
||||||
{ length: chordCount },
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
[
|
|
||||||
Array.from(
|
|
||||||
{ length: Math.floor(Math.random() * maxInputLength) + 1 },
|
|
||||||
randomAction,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
Array.from(
|
|
||||||
{
|
|
||||||
length: Math.floor(Math.log(Math.random() * maxPhraseLength)) + 1,
|
|
||||||
},
|
|
||||||
randomAction,
|
|
||||||
),
|
|
||||||
] as const,
|
|
||||||
);
|
|
||||||
for (const chord of backup) {
|
|
||||||
if (Math.random() < compoundChance) {
|
|
||||||
chord[0] = [
|
|
||||||
...backup[Math.floor(backup.length * Math.random())]![0],
|
|
||||||
...chord[0],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const doc = backup
|
|
||||||
.map(([inputs, phrase]) => {
|
|
||||||
return (
|
|
||||||
inputs
|
|
||||||
.map((input) => input.map((it) => actionToValue(it)).join(""))
|
|
||||||
.join("|") +
|
|
||||||
"=>" +
|
|
||||||
phrase.map((it) => actionToValue(it)).join("")
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: doc },
|
|
||||||
effects: chordSyncEffect.of(
|
|
||||||
$chords.map((chord) => [chord.actions, chord.phrase] as const),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadBackup(event: Event) {
|
|
||||||
const input = event.target as HTMLInputElement;
|
|
||||||
if (!input.files || input.files.length === 0) return;
|
|
||||||
const file = input.files[0];
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = (e) => {
|
|
||||||
try {
|
|
||||||
const content = e.target?.result as string;
|
|
||||||
const backup: CharaChordFile = JSON.parse(content);
|
|
||||||
const doc = backup.chords
|
|
||||||
.map((chord) => {
|
|
||||||
const [actions, compound] = splitCompound(chord[0]);
|
|
||||||
return (
|
|
||||||
(compound
|
|
||||||
? "<0x" + compound.toString(16).padStart(8, "0") + ">"
|
|
||||||
: "") +
|
|
||||||
actions.map((it) => actionToValue(it)).join("") +
|
|
||||||
"=>" +
|
|
||||||
chord[1].map((it) => actionToValue(it)).join("")
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
view.dispatch({
|
|
||||||
changes: { from: 0, to: view.state.doc.length, insert: doc },
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
alert("Failed to load backup: " + err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
reader.readAsText(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBackup() {
|
|
||||||
const backup: CharaChordFile = {
|
|
||||||
charaVersion: 1,
|
|
||||||
type: "chords",
|
|
||||||
chords: view.state.field(parsedChordsField).result,
|
|
||||||
};
|
|
||||||
console.log(JSON.stringify(backup));
|
|
||||||
const blob = new Blob([JSON.stringify(backup)], {
|
|
||||||
type: "application/json",
|
|
||||||
});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement("a");
|
|
||||||
a.href = url;
|
|
||||||
a.download = "chord-backup.json";
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="vertical">
|
|
||||||
<div style:display="flex">
|
|
||||||
<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
|
|
||||||
>
|
|
||||||
<button onclick={regenerate}>Regenerate from current chords</button>
|
|
||||||
<!--<button onclick={largeFile}>Create Huge File</button>-->
|
|
||||||
<button onclick={downloadBackup}>Download Backup</button>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept="application/json"
|
|
||||||
onchange={loadBackup}
|
|
||||||
style="margin-left: 1rem"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.vertical {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split {
|
|
||||||
display: flex;
|
|
||||||
flex-grow: 1;
|
|
||||||
flex-shrink: 1;
|
|
||||||
width: calc(min(100%, 1400px));
|
|
||||||
min-height: 0;
|
|
||||||
|
|
||||||
> :global(*) {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor :global(.cm-deletedChunk) {
|
|
||||||
opacity: 0.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor {
|
|
||||||
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-child) {
|
|
||||||
background-image: none;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
: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>
|
|
||||||
@@ -5,18 +5,17 @@
|
|||||||
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();
|
||||||
@@ -47,15 +46,60 @@
|
|||||||
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>Editor</h2>
|
<h2>
|
||||||
|
CCOS Emulator
|
||||||
|
{#if $serialPort?.chipset === "WASM"}
|
||||||
|
<small>(Emulator Active)</small>
|
||||||
|
{:else}
|
||||||
|
<button class="primary" disabled={cc0Loading} onclick={connectCC0}>
|
||||||
|
<span class="icon">play_arrow</span>
|
||||||
|
Boot CCOS Emulator</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p style:max-width="600px">
|
||||||
|
Try a (limited) demo of CCOS running directly in your browser.<br /><span
|
||||||
|
style:color="var(--md-sys-color-primary)"
|
||||||
|
>Chording requires an <b>NKRO Keyboard</b> to work properly.</span
|
||||||
|
>
|
||||||
|
<br />Browsers usually report key timings with limited accuracy to revent
|
||||||
|
fingerprinting, which can impact chording.
|
||||||
|
<br /><i>Results may vary.</i>
|
||||||
|
<br />
|
||||||
|
Use sidebar tabs to configure <a href="/config/chords/">Chords</a>,
|
||||||
|
<a href="/config/layout/">Layout</a>
|
||||||
|
and <a href="/config/settings/">Settings</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
{#if replay}
|
{#if replay}
|
||||||
<div class="replay" transition:fade={{ duration: 100 }}>
|
<div class="replay" transition:fade={{ duration: 100 }}>
|
||||||
@@ -66,7 +110,9 @@
|
|||||||
{#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}>
|
||||||
@@ -95,15 +141,38 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<ul>
|
|
||||||
<li><a href="/learn/layout/">Layout</a></li>
|
|
||||||
<li><a href="/learn/chords/">Chords</a></li>
|
|
||||||
<li><a href="/learn/sentence/">Sentences</a></li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
ul {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
margin: 16px;
|
|
||||||
padding: 0;
|
|
||||||
list-style-type: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
border: 1px solid var(--md-sys-color-outline);
|
|
||||||
width: 128px;
|
|
||||||
height: 128px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
import {
|
|
||||||
words,
|
|
||||||
nextWord,
|
|
||||||
scores,
|
|
||||||
learnConfigDefault,
|
|
||||||
learnConfig,
|
|
||||||
learnConfigStored,
|
|
||||||
} from "$lib/learn/chords";
|
|
||||||
import { fade } from "svelte/transition";
|
|
||||||
import ChordActionEdit from "../../config/chords/ChordActionEdit.svelte";
|
|
||||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
|
||||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
|
||||||
|
|
||||||
let recorder = $derived(new ReplayRecorder($nextWord));
|
|
||||||
let start = performance.now();
|
|
||||||
$effect(() => {
|
|
||||||
start = recorder && performance.now();
|
|
||||||
});
|
|
||||||
|
|
||||||
let chords: InferredChord[] = $state([]);
|
|
||||||
|
|
||||||
function onkeyboard(event: KeyboardEvent) {
|
|
||||||
recorder.next(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
function lerp(a: number, b: number, t: number) {
|
|
||||||
return a + (b - a) * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const [chord] = chords;
|
|
||||||
if (!chord) return;
|
|
||||||
|
|
||||||
console.log(chord);
|
|
||||||
|
|
||||||
if (chord.output.trim() === $nextWord) {
|
|
||||||
scores.update((scores) => {
|
|
||||||
const score = Math.max(
|
|
||||||
$learnConfig.minScore,
|
|
||||||
$learnConfig.maxScore - (performance.now() - start) / 1000,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!scores[$nextWord]) {
|
|
||||||
scores[$nextWord] = {
|
|
||||||
score,
|
|
||||||
lastTyped: performance.now(),
|
|
||||||
total: 1,
|
|
||||||
};
|
|
||||||
return scores;
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldScore = scores[$nextWord].score;
|
|
||||||
scores[$nextWord].score = lerp(
|
|
||||||
score,
|
|
||||||
oldScore,
|
|
||||||
$learnConfig.scoreBlend,
|
|
||||||
);
|
|
||||||
scores[$nextWord].lastTyped = performance.now();
|
|
||||||
scores[$nextWord].total += 1;
|
|
||||||
|
|
||||||
return scores;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function skip() {
|
|
||||||
button?.blur();
|
|
||||||
scores.update((scores) => {
|
|
||||||
return scores;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let button = $state<HTMLButtonElement>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<h2>WIP</h2>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onkeyboard} onkeyup={onkeyboard} />
|
|
||||||
|
|
||||||
{#key $nextWord}
|
|
||||||
<h3>
|
|
||||||
{$nextWord}
|
|
||||||
{#if $scores[$nextWord!] === undefined}
|
|
||||||
<sup class="new-word">new</sup>
|
|
||||||
{:else if ($scores[$nextWord!]?.score ?? 0) < 0}
|
|
||||||
<sup class="weak">weak</sup>
|
|
||||||
{/if}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div class="chord" in:fade>
|
|
||||||
<CharRecorder replay={recorder.player} cursor={true}>
|
|
||||||
<TrackChords bind:chords />
|
|
||||||
</CharRecorder>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
{#key $nextWord}
|
|
||||||
<div class="hint" in:fade={{ delay: 2000, duration: 500 }}>
|
|
||||||
<ChordActionEdit chord={$words.get($nextWord!)} onsubmit={() => {}} />
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
<button onclick={skip} bind:this={button}>skip</button>
|
|
||||||
|
|
||||||
<section class="stats">
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Weak</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => a.score - b.score)
|
|
||||||
.splice(0, 10) as [word, score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
<td><i>{score.score.toFixed(2)}</i></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Strong</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => b.score - a.score)
|
|
||||||
.splice(0, 10) as [word, score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
<td><i>{score.score.toFixed(2)}</i></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table>
|
|
||||||
<thead>
|
|
||||||
<tr><th>Rehearse</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries($scores)
|
|
||||||
.sort(([, a], [, b]) => b.lastTyped - a.lastTyped)
|
|
||||||
.splice(0, 10) as [word, _score]}
|
|
||||||
<tr class="decay">
|
|
||||||
<td>{word}</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Settings</summary>
|
|
||||||
<button onclick={() => ($scores = {})}>Reset</button>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{#each Object.entries(learnConfigDefault) as [key, value]}
|
|
||||||
<tr>
|
|
||||||
<th>{key}</th>
|
|
||||||
<td
|
|
||||||
><input
|
|
||||||
type="number"
|
|
||||||
value={$learnConfig[key as keyof typeof $learnConfig] ?? value}
|
|
||||||
step="0.1"
|
|
||||||
oninput={(event) =>
|
|
||||||
($learnConfigStored[key as keyof typeof $learnConfig] = (
|
|
||||||
event.target as HTMLInputElement
|
|
||||||
).value as any)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
disabled={!$learnConfigStored[key as keyof typeof $learnConfig]}
|
|
||||||
onclick={() =>
|
|
||||||
($learnConfigStored[key as keyof typeof $learnConfigStored] =
|
|
||||||
undefined)}>⟲</button
|
|
||||||
>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
@use "sass:math";
|
|
||||||
|
|
||||||
input {
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
width: 5ch;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1ch;
|
|
||||||
min-width: 20ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
sup {
|
|
||||||
font-weight: normal;
|
|
||||||
font-size: 0.8em;
|
|
||||||
|
|
||||||
&.new-word {
|
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
&.weak {
|
|
||||||
color: var(--md-sys-color-error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@for $i from 1 through 10 {
|
|
||||||
tr.decay:nth-child(#{$i}) {
|
|
||||||
opacity: 1 - math.div($i, 10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { setContext } from "svelte";
|
|
||||||
import Layout from "$lib/components/layout/Layout.svelte";
|
|
||||||
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
|
|
||||||
import { writable, derived } from "svelte/store";
|
|
||||||
import { layout } from "$lib/undo-redo";
|
|
||||||
import Action from "$lib/components/Action.svelte";
|
|
||||||
import { serialPort } from "$lib/serial/connection";
|
|
||||||
|
|
||||||
let hasStarted = $state(false);
|
|
||||||
|
|
||||||
setContext<VisualLayoutConfig>("visual-layout-config", {
|
|
||||||
scale: 50,
|
|
||||||
inactiveScale: 0.5,
|
|
||||||
inactiveOpacity: 0.4,
|
|
||||||
strokeWidth: 1,
|
|
||||||
margin: 5,
|
|
||||||
fontSize: 9,
|
|
||||||
iconFontSize: 14,
|
|
||||||
});
|
|
||||||
|
|
||||||
const actions = derived(layout, (layout) => {
|
|
||||||
const result = new Set<number>();
|
|
||||||
for (const layer of layout) {
|
|
||||||
for (const key of layer) {
|
|
||||||
result.add(key[0].action);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...result];
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentAction = writable(0);
|
|
||||||
|
|
||||||
const expected = derived(
|
|
||||||
[layout, currentAction],
|
|
||||||
([layout, currentAction]) => {
|
|
||||||
const result: Array<{ layer: number; key: number }> = [];
|
|
||||||
for (let layer = 0; layer <= layout.length; layer++) {
|
|
||||||
const layerArr = layout[layer];
|
|
||||||
if (layerArr === undefined) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
for (let key = 0; key <= layerArr.length; key++) {
|
|
||||||
if (layerArr[key]?.[0].action === currentAction) {
|
|
||||||
result.push({ layer, key });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlight = derived(
|
|
||||||
expected,
|
|
||||||
(expected) => new Set(expected.map(({ key }) => key)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const highlightAction = derived(
|
|
||||||
currentAction,
|
|
||||||
(currentAction) => new Set([currentAction]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentLayer = writable(0);
|
|
||||||
|
|
||||||
setContext("highlight", highlight);
|
|
||||||
|
|
||||||
setContext("highlight-action", highlightAction);
|
|
||||||
|
|
||||||
setContext("active-layer", currentLayer);
|
|
||||||
|
|
||||||
async function next() {
|
|
||||||
console.log("Next");
|
|
||||||
const nextAction = $actions[Math.floor(Math.random() * $actions.length)];
|
|
||||||
if (nextAction !== undefined) {
|
|
||||||
currentAction.set(nextAction);
|
|
||||||
currentLayer.set($expected[0]?.layer ?? 0);
|
|
||||||
const key = await $serialPort?.queryKey();
|
|
||||||
if ($expected.some(({ key: expectedKey }) => expectedKey === key)) {
|
|
||||||
console.log("Correct", key);
|
|
||||||
} else {
|
|
||||||
console.log("Incorrect", key);
|
|
||||||
}
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if ($serialPort && $layout[0]?.[0] && !hasStarted) {
|
|
||||||
hasStarted = true;
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<div class="challenge">
|
|
||||||
<Action display="inline-keys" action={$currentAction}></Action>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Layout />
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.challenge {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100px;
|
|
||||||
|
|
||||||
font-size: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,652 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { page } from "$app/stores";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
import CharRecorder from "$lib/charrecorder/CharRecorder.svelte";
|
|
||||||
import debounce from "$lib/util/debounce";
|
|
||||||
import { ReplayRecorder } from "$lib/charrecorder/core/recorder";
|
|
||||||
import { shuffleInPlace } from "$lib/util/shuffle";
|
|
||||||
import { fade, fly, slide } from "svelte/transition";
|
|
||||||
import TrackChords from "$lib/charrecorder/TrackChords.svelte";
|
|
||||||
import ChordHud from "$lib/charrecorder/ChordHud.svelte";
|
|
||||||
import type { InferredChord } from "$lib/charrecorder/core/types";
|
|
||||||
import TrackText from "$lib/charrecorder/TrackText.svelte";
|
|
||||||
import { browser } from "$app/environment";
|
|
||||||
import { expoOut } from "svelte/easing";
|
|
||||||
import { goto } from "$app/navigation";
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import {
|
|
||||||
type PageParam,
|
|
||||||
SENTENCE_TRAINER_PAGE_PARAMS,
|
|
||||||
} from "./configuration";
|
|
||||||
import {
|
|
||||||
AVG_WORD_LENGTH,
|
|
||||||
MILLIS_IN_SECOND,
|
|
||||||
SECONDS_IN_MINUTE,
|
|
||||||
} from "./constants";
|
|
||||||
import { pickNextWord } from "./word-selector";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves parameter from search URL or returns default
|
|
||||||
* @param param {@link PageParam} generic parameter that can be provided
|
|
||||||
* in search url
|
|
||||||
* @return Value of the parameter converted to its type or default value
|
|
||||||
* if parameter is not present in the URL.
|
|
||||||
*/
|
|
||||||
function getParamOrDefault<T>(param: PageParam<T>): T {
|
|
||||||
if (browser) {
|
|
||||||
const value = $page.url.searchParams.get(param.key);
|
|
||||||
if (null !== value) {
|
|
||||||
return param.parse ? param.parse(value) : (value as unknown as T);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return param.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
function viaLocalStorage<T>(key: string, initial: T) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(localStorage.getItem(key) ?? "");
|
|
||||||
} catch {
|
|
||||||
return initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay to ensure cursor is visible after focus is set.
|
|
||||||
// it is a workaround for conflict between goto call on sentence update
|
|
||||||
// and cursor focus when next word is selected.
|
|
||||||
const CURSOR_FOCUS_DELAY_MS = 10;
|
|
||||||
|
|
||||||
let masteryThresholds: [slow: number, fast: number, title: string][] = $state(
|
|
||||||
viaLocalStorage("mastery-thresholds", [
|
|
||||||
[1500, 1050, "Words"],
|
|
||||||
[3000, 2500, "Pairs"],
|
|
||||||
[5000, 3500, "Trios"],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
localStorage.removeItem("mastery-thresholds");
|
|
||||||
localStorage.removeItem("idle-timeout");
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputSentence = $derived(
|
|
||||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.sentence),
|
|
||||||
);
|
|
||||||
|
|
||||||
const wpmTarget = $derived(
|
|
||||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.wpm),
|
|
||||||
);
|
|
||||||
|
|
||||||
const devTools = $derived(
|
|
||||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.showDevTools),
|
|
||||||
);
|
|
||||||
|
|
||||||
let chordInputContainer: HTMLDivElement | null = null;
|
|
||||||
|
|
||||||
let sentenceWords = $derived(inputSentence.trim().split(/\s+/));
|
|
||||||
|
|
||||||
let inputSentenceLength = $derived(inputSentence.length);
|
|
||||||
let msPerChar = $derived(
|
|
||||||
(1 / ((wpmTarget / SECONDS_IN_MINUTE) * AVG_WORD_LENGTH)) *
|
|
||||||
MILLIS_IN_SECOND,
|
|
||||||
);
|
|
||||||
let totalMs = $derived(inputSentenceLength * msPerChar);
|
|
||||||
let msPerWord = $derived(
|
|
||||||
(inputSentenceLength * msPerChar) / sentenceWords.length,
|
|
||||||
);
|
|
||||||
let currentWord = $state("");
|
|
||||||
let wordStats = new SvelteMap<string, number[]>();
|
|
||||||
let wordMastery = new SvelteMap<string, number>();
|
|
||||||
let text = $state("");
|
|
||||||
let level = $state(0);
|
|
||||||
let bestWPM = $state(0);
|
|
||||||
let wpm = $state(0);
|
|
||||||
let chords: InferredChord[] = $state([]);
|
|
||||||
let recorder = $state(new ReplayRecorder());
|
|
||||||
let idle = $state(true);
|
|
||||||
let idleTime = $state(viaLocalStorage("idle-timeout", 100));
|
|
||||||
|
|
||||||
let idleTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (wpm > bestWPM) {
|
|
||||||
bestWPM = wpm;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (browser && $page.url.searchParams) {
|
|
||||||
selectNextWord();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
localStorage.setItem("idle-timeout", idleTime.toString());
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
localStorage.setItem(
|
|
||||||
"mastery-thresholds",
|
|
||||||
JSON.stringify(masteryThresholds),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
let words = $derived.by(() => {
|
|
||||||
const words = sentenceWords;
|
|
||||||
switch (level) {
|
|
||||||
case 0: {
|
|
||||||
shuffleInPlace(words);
|
|
||||||
return words;
|
|
||||||
}
|
|
||||||
case 1: {
|
|
||||||
const pairs = [];
|
|
||||||
for (let i = 0; i < words.length - 1; i++) {
|
|
||||||
pairs.push(`${words[i]} ${words[i + 1]}`);
|
|
||||||
}
|
|
||||||
shuffleInPlace(pairs);
|
|
||||||
return pairs;
|
|
||||||
}
|
|
||||||
case 2: {
|
|
||||||
const trios = [];
|
|
||||||
for (let i = 0; i < words.length - 2; i++) {
|
|
||||||
trios.push(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
|
|
||||||
}
|
|
||||||
shuffleInPlace(trios);
|
|
||||||
return trios;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return [inputSentence];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
for (const [word, speeds] of wordStats.entries()) {
|
|
||||||
const level = word.split(" ").length - 1;
|
|
||||||
const masteryThreshold = masteryThresholds[level];
|
|
||||||
if (masteryThreshold === undefined) continue;
|
|
||||||
const averageSpeed = speeds.reduce((a, b) => a + b) / speeds.length;
|
|
||||||
wordMastery.set(
|
|
||||||
word,
|
|
||||||
1 -
|
|
||||||
Math.min(
|
|
||||||
1,
|
|
||||||
Math.max(
|
|
||||||
0,
|
|
||||||
(averageSpeed - masteryThreshold[1]) /
|
|
||||||
(masteryThreshold[0] - masteryThreshold[1]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let progress = $derived(
|
|
||||||
level === masteryThresholds.length
|
|
||||||
? Math.min(1, Math.max(0, bestWPM / wpmTarget))
|
|
||||||
: words.length > 0
|
|
||||||
? words.reduce((a, word) => a + (wordMastery.get(word) ?? 0), 0) /
|
|
||||||
words.length
|
|
||||||
: 0,
|
|
||||||
);
|
|
||||||
let mastered = $derived(
|
|
||||||
words.length > 0
|
|
||||||
? words.filter((it) => wordMastery.get(it) === 1).length / words.length
|
|
||||||
: 0,
|
|
||||||
);
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (progress === 1 && level < masteryThresholds.length) {
|
|
||||||
level++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function selectNextWord() {
|
|
||||||
const nextWord = pickNextWord(
|
|
||||||
words,
|
|
||||||
wordMastery,
|
|
||||||
untrack(() => currentWord),
|
|
||||||
);
|
|
||||||
currentWord = nextWord;
|
|
||||||
recorder = new ReplayRecorder(nextWord);
|
|
||||||
setTimeout(() => {
|
|
||||||
chordInputContainer?.focus();
|
|
||||||
}, CURSOR_FOCUS_DELAY_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkInput() {
|
|
||||||
if (recorder.player.stepper.challenge.length === 0) return;
|
|
||||||
const replay = recorder.finish(false);
|
|
||||||
const elapsed = replay.finish - replay.start! - idleTime;
|
|
||||||
if (elapsed < masteryThresholds[level]![0]) {
|
|
||||||
const prevStats = wordStats.get(currentWord) ?? [];
|
|
||||||
prevStats.push(elapsed);
|
|
||||||
wordStats.set(currentWord, prevStats.slice(-10));
|
|
||||||
}
|
|
||||||
|
|
||||||
text = "";
|
|
||||||
setTimeout(() => {
|
|
||||||
selectNextWord();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (!idle || !text) return;
|
|
||||||
if (text.trim() !== currentWord.trim()) return;
|
|
||||||
if (level === masteryThresholds.length) {
|
|
||||||
const replay = recorder.finish();
|
|
||||||
const elapsed = replay.finish - replay.start!;
|
|
||||||
text = "";
|
|
||||||
recorder = new ReplayRecorder(currentWord);
|
|
||||||
console.log(elapsed, totalMs);
|
|
||||||
wpm = (totalMs / elapsed) * wpmTarget;
|
|
||||||
} else {
|
|
||||||
checkInput();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function onkey(event: KeyboardEvent) {
|
|
||||||
if (idleTimeout) {
|
|
||||||
clearTimeout(idleTimeout);
|
|
||||||
}
|
|
||||||
idle = false;
|
|
||||||
recorder.next(event);
|
|
||||||
idleTimeout = setTimeout(() => {
|
|
||||||
idle = true;
|
|
||||||
}, idleTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSentence(event: Event) {
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
|
||||||
params.set(
|
|
||||||
SENTENCE_TRAINER_PAGE_PARAMS.sentence.key,
|
|
||||||
(event.target as HTMLInputElement).value,
|
|
||||||
);
|
|
||||||
goto(`?${params.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedUpdateSentence = debounce(
|
|
||||||
updateSentence,
|
|
||||||
getParamOrDefault(SENTENCE_TRAINER_PAGE_PARAMS.textAreaDebounceInMillis),
|
|
||||||
);
|
|
||||||
|
|
||||||
function handleInputAreaKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === "Enter" && !event.shiftKey) {
|
|
||||||
event.preventDefault(); // Prevent new line.
|
|
||||||
debouncedUpdateSentence.cancel(); // Cancel any pending debounced update
|
|
||||||
updateSentence(event); // Update immediately
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h1>Sentence Trainer</h1>
|
|
||||||
<textarea
|
|
||||||
rows="7"
|
|
||||||
cols="80"
|
|
||||||
oninput={debouncedUpdateSentence}
|
|
||||||
onkeydown={handleInputAreaKeyDown}>{untrack(() => inputSentence)}</textarea
|
|
||||||
>
|
|
||||||
|
|
||||||
<div class="levels">
|
|
||||||
{#each masteryThresholds as [, , title], i}
|
|
||||||
<button
|
|
||||||
class:active={level === i}
|
|
||||||
class:mastered={i < level || progress === 1}
|
|
||||||
class="threshold"
|
|
||||||
onclick={() => {
|
|
||||||
level = i;
|
|
||||||
selectNextWord();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
<button
|
|
||||||
class:active={level === masteryThresholds.length}
|
|
||||||
class:mastered={masteryThresholds.length < level || progress === 1}
|
|
||||||
class="threshold"
|
|
||||||
onclick={() => {
|
|
||||||
level = masteryThresholds.length;
|
|
||||||
selectNextWord();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{wpmTarget} WPM
|
|
||||||
</button>
|
|
||||||
{#each masteryThresholds as _, i}
|
|
||||||
<div
|
|
||||||
class="progress"
|
|
||||||
style:--progress="{-100 *
|
|
||||||
(1 - (level === i ? progress : i < level ? 1 : 0))}%"
|
|
||||||
style:--mastered="{-100 *
|
|
||||||
(1 - (level === i ? mastered : i < level ? 1 : 0))}%"
|
|
||||||
class:active={level === i}
|
|
||||||
></div>
|
|
||||||
{/each}
|
|
||||||
<div
|
|
||||||
class="progress"
|
|
||||||
style:--progress="-100%"
|
|
||||||
style:--mastered="{-100 *
|
|
||||||
(1 -
|
|
||||||
(level === masteryThresholds.length
|
|
||||||
? progress
|
|
||||||
: masteryThresholds.length < level
|
|
||||||
? 1
|
|
||||||
: 0))}%"
|
|
||||||
class:active={level === masteryThresholds.length}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
<div class="sentence">
|
|
||||||
{#each sentenceWords as _, i}
|
|
||||||
{#if i !== sentenceWords.length - 1}
|
|
||||||
{@const word = sentenceWords.slice(i, i + 2).join(" ")}
|
|
||||||
{@const mastery = wordMastery.get(word) ?? 0}
|
|
||||||
<div
|
|
||||||
class="arch"
|
|
||||||
class:mastered={mastery === 1}
|
|
||||||
style:opacity={mastery}
|
|
||||||
style:grid-row={(i % 2) + 1}
|
|
||||||
style:grid-column="{i + 1} / span 2"
|
|
||||||
style:border-bottom="none"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
{#each sentenceWords as word, i}
|
|
||||||
{@const mastery = wordMastery.get(word)}
|
|
||||||
<div
|
|
||||||
class="word"
|
|
||||||
class:mastered={mastery === 1}
|
|
||||||
style:opacity={mastery ?? 0}
|
|
||||||
style:grid-row={3}
|
|
||||||
style:grid-column={i + 1}
|
|
||||||
>
|
|
||||||
{word}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{#each sentenceWords as _, i}
|
|
||||||
{#if i < sentenceWords.length - 2}
|
|
||||||
{@const word = sentenceWords.slice(i, i + 3).join(" ")}
|
|
||||||
{@const mastery = wordMastery.get(word) ?? 0}
|
|
||||||
<div
|
|
||||||
class="arch"
|
|
||||||
class:mastered={mastery === 1}
|
|
||||||
style:opacity={mastery}
|
|
||||||
style:grid-row={(i % 3) + 4}
|
|
||||||
style:grid-column="{i + 1} / span 3"
|
|
||||||
style:border-top="none"
|
|
||||||
></div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{#if level === masteryThresholds.length}
|
|
||||||
{@const maxDigits = 4}
|
|
||||||
{@const indices = Array.from({ length: maxDigits }, (_, i) => i)}
|
|
||||||
{@const wpmString = Math.floor(bestWPM).toString().padStart(maxDigits, " ")}
|
|
||||||
<div class="finish" transition:slide>
|
|
||||||
<div
|
|
||||||
class="wpm"
|
|
||||||
style:grid-template-columns="repeat({maxDigits}, 1ch) 1ch auto"
|
|
||||||
style:opacity={progress}
|
|
||||||
style:font-size="3rem"
|
|
||||||
style:color="var(--md-sys-color-{progress === 1
|
|
||||||
? 'primary'
|
|
||||||
: 'on-background'})"
|
|
||||||
style:scale={(progress + 0.5) / 2}
|
|
||||||
>
|
|
||||||
{#each indices as i}
|
|
||||||
{@const char = wpmString[i]}
|
|
||||||
{#key char}
|
|
||||||
<div
|
|
||||||
style:grid-column={i + 1}
|
|
||||||
in:fly={{ y: 20, duration: 1000, easing: expoOut }}
|
|
||||||
out:fly={{ y: -20, duration: 1000, easing: expoOut }}
|
|
||||||
>
|
|
||||||
{char}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
{/each}
|
|
||||||
<div style:grid-column={maxDigits + 3} style:justify-self="start">
|
|
||||||
WPM
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="wpm"
|
|
||||||
style:grid-template-columns="4ch 1ch auto"
|
|
||||||
style:font-size="1.5rem"
|
|
||||||
>
|
|
||||||
{#key wpm}
|
|
||||||
<div
|
|
||||||
style:grid-column={1}
|
|
||||||
style:justify-self="end"
|
|
||||||
transition:fade={{ duration: 200 }}
|
|
||||||
>
|
|
||||||
{Math.floor(wpm)}
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
<div style:grid-column={3} style:justify-self="start">WPM</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<ChordHud {chords} />
|
|
||||||
<div class="container">
|
|
||||||
<div
|
|
||||||
bind:this={chordInputContainer}
|
|
||||||
class="input-section"
|
|
||||||
onkeydown={onkey}
|
|
||||||
onkeyup={onkey}
|
|
||||||
tabindex="0"
|
|
||||||
role="textbox"
|
|
||||||
>
|
|
||||||
{#key recorder}
|
|
||||||
<div class="input" transition:fade={{ duration: 200 }}>
|
|
||||||
<CharRecorder replay={recorder.player} cursor={true} keys={true}>
|
|
||||||
<TrackText bind:text />
|
|
||||||
<TrackChords bind:chords />
|
|
||||||
</CharRecorder>
|
|
||||||
</div>
|
|
||||||
{/key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{#if devTools}
|
|
||||||
<div>Dev Tools</div>
|
|
||||||
<button onclick={reset}>Reset</button>
|
|
||||||
<label>Idle Time <input bind:value={idleTime} /></label>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<th>Total</th>
|
|
||||||
<td
|
|
||||||
><span style:color="var(--md-sys-color-tertiary)"
|
|
||||||
>{Math.round(totalMs)}</span
|
|
||||||
>ms
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Char</th>
|
|
||||||
<td
|
|
||||||
><span style:color="var(--md-sys-color-tertiary)"
|
|
||||||
>{Math.round(msPerChar)}</span
|
|
||||||
>ms
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th>Word</th>
|
|
||||||
<td
|
|
||||||
><span style:color="var(--md-sys-color-tertiary)"
|
|
||||||
>{Math.round(msPerWord)}</span
|
|
||||||
>ms
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{#each masteryThresholds as _, i}
|
|
||||||
<tr>
|
|
||||||
<th>L{i + 1}</th>
|
|
||||||
<td><input bind:value={masteryThresholds[i]![0]} /></td>
|
|
||||||
<td><input bind:value={masteryThresholds[i]![1]} /></td>
|
|
||||||
<td><input bind:value={masteryThresholds[i]![2]} /></td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
{#each wordStats.entries() as [word, stats]}
|
|
||||||
{@const mastery = wordMastery.get(word) ?? 0}
|
|
||||||
<tr>
|
|
||||||
<th>{word}</th>
|
|
||||||
<td
|
|
||||||
style:color="var(--md-sys-color-{mastery === 1
|
|
||||||
? 'primary'
|
|
||||||
: 'tertiary'})"
|
|
||||||
>{Math.round(mastery * 100)}%
|
|
||||||
</td>
|
|
||||||
{#each stats as stat}
|
|
||||||
<td>{stat}</td>
|
|
||||||
{/each}
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.levels {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 2px;
|
|
||||||
|
|
||||||
button {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.wpm {
|
|
||||||
display: grid;
|
|
||||||
transition: scale 0.2s ease;
|
|
||||||
width: min-content;
|
|
||||||
|
|
||||||
* {
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.finish {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: repeat(2, 1fr);
|
|
||||||
align-items: center;
|
|
||||||
justify-items: center;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sentence {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: repeat(4, auto);
|
|
||||||
gap: 4px 1ch;
|
|
||||||
margin-block: 1rem;
|
|
||||||
width: min-content;
|
|
||||||
|
|
||||||
.word,
|
|
||||||
.arch {
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
|
|
||||||
&.mastered {
|
|
||||||
border-color: var(--md-sys-color-primary);
|
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.arch {
|
|
||||||
border: 2px solid var(--md-sys-color-outline);
|
|
||||||
height: 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
position: relative;
|
|
||||||
grid-row: 2;
|
|
||||||
border: none;
|
|
||||||
background: var(--md-sys-color-outline-variant);
|
|
||||||
width: auto;
|
|
||||||
height: 1rem;
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&::before,
|
|
||||||
&::after {
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
content: "";
|
|
||||||
}
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
transform: translateX(var(--progress));
|
|
||||||
background: var(--md-sys-color-outline);
|
|
||||||
}
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
transform: translateX(var(--mastered));
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.threshold {
|
|
||||||
grid-row: 1;
|
|
||||||
justify-self: center;
|
|
||||||
opacity: 0.5;
|
|
||||||
transition: opacity 0.2s;
|
|
||||||
width: auto;
|
|
||||||
|
|
||||||
&.mastered,
|
|
||||||
&.active {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.mastered {
|
|
||||||
color: var(--md-sys-color-primary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-section {
|
|
||||||
display: grid;
|
|
||||||
cursor: text;
|
|
||||||
|
|
||||||
:global(.cursor) {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
display: flex;
|
|
||||||
grid-row: 1;
|
|
||||||
grid-column: 1;
|
|
||||||
transition:
|
|
||||||
outline 0.2s ease,
|
|
||||||
border-radius 0.2s ease;
|
|
||||||
margin-block: 1rem;
|
|
||||||
outline: 2px dashed transparent;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
padding: 1rem;
|
|
||||||
max-width: 16cm;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-section:focus-within {
|
|
||||||
outline: none;
|
|
||||||
|
|
||||||
.input {
|
|
||||||
outline-color: var(--md-sys-color-primary);
|
|
||||||
border-radius: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
:global(.cursor) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
export interface PageParam<T> {
|
|
||||||
key: string;
|
|
||||||
default: T;
|
|
||||||
parse?: (value: string) => T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SENTENCE_TRAINER_PAGE_PARAMS: {
|
|
||||||
sentence: PageParam<string>;
|
|
||||||
wpm: PageParam<number>;
|
|
||||||
showDevTools: PageParam<boolean>;
|
|
||||||
textAreaDebounceInMillis: PageParam<number>;
|
|
||||||
} = {
|
|
||||||
sentence: {
|
|
||||||
key: "sentence",
|
|
||||||
default: "This text has been typed at the speed of thought",
|
|
||||||
},
|
|
||||||
wpm: {
|
|
||||||
key: "wpm",
|
|
||||||
default: 250,
|
|
||||||
parse: (value) => Number(value),
|
|
||||||
},
|
|
||||||
showDevTools: {
|
|
||||||
key: "dev",
|
|
||||||
default: false,
|
|
||||||
parse: (value) => value === "true",
|
|
||||||
},
|
|
||||||
textAreaDebounceInMillis: {
|
|
||||||
key: "debounceMillis",
|
|
||||||
default: 5000,
|
|
||||||
parse: (value) => Number(value),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
// Domain constants
|
|
||||||
export const AVG_WORD_LENGTH = 5;
|
|
||||||
export const SECONDS_IN_MINUTE = 60;
|
|
||||||
export const MILLIS_IN_SECOND = 1000;
|
|
||||||
|
|
||||||
// Error messages.
|
|
||||||
export const TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE =
|
|
||||||
"The sentence is too short to make N-Grams, please enter longer sentence";
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, it, beforeEach, expect, vi } from "vitest";
|
|
||||||
import { pickNextWord } from "./word-selector";
|
|
||||||
import { untrack } from "svelte";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
|
||||||
|
|
||||||
// Mock untrack so it simply executes the callback, allowing us to spy on its usage.
|
|
||||||
vi.mock("svelte", () => ({
|
|
||||||
untrack: vi.fn((fn: any) => fn()),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("pickNextWord", () => {
|
|
||||||
let words: string[];
|
|
||||||
let wordMastery: SvelteMap<string, number>;
|
|
||||||
let currentWord: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
|
|
||||||
// Set up sample words and mastery values.
|
|
||||||
words = ["alpha", "beta", "gamma"];
|
|
||||||
wordMastery = new SvelteMap<string, number>();
|
|
||||||
// For this test, assume none of the words are mastered.
|
|
||||||
words.forEach((word) => wordMastery.set(word, 0));
|
|
||||||
currentWord = "alpha";
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return a word different from current", () => {
|
|
||||||
// Force Math.random() to return a predictable value.
|
|
||||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.3);
|
|
||||||
|
|
||||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
|
||||||
|
|
||||||
// Since currentWord ("alpha") should be skipped, we expect next word.
|
|
||||||
expect(nextWord).toBe("beta");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should randomly skip words", () => {
|
|
||||||
// Force Math.random() to return a predictable value.
|
|
||||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.3);
|
|
||||||
|
|
||||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
|
||||||
|
|
||||||
// Since currentWord ("alpha") should be skipped as current
|
|
||||||
// and "beta" should be randomly skipped we expect "gamma".
|
|
||||||
expect(nextWord).toBe("gamma");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return current word if all other words were randomly skipped", () => {
|
|
||||||
// Force Math.random() to return a predictable value.
|
|
||||||
vi.spyOn(Math, "random").mockReturnValueOnce(0.6).mockReturnValueOnce(0.6);
|
|
||||||
|
|
||||||
const nextWord = pickNextWord(words, wordMastery, currentWord);
|
|
||||||
|
|
||||||
// Since all other words have been randomly skipped, we expect
|
|
||||||
// current word to be returned.
|
|
||||||
expect(nextWord).toBe("alpha");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("current word should be passed untracked", () => {
|
|
||||||
pickNextWord(words, wordMastery, currentWord);
|
|
||||||
expect(untrack).toHaveBeenCalledTimes(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should return TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE if the words array is empty", () => {
|
|
||||||
const result = pickNextWord([], wordMastery, currentWord);
|
|
||||||
expect(result).toBe(TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE } from "./constants";
|
|
||||||
import { SvelteMap } from "svelte/reactivity";
|
|
||||||
|
|
||||||
export function pickNextWord(
|
|
||||||
words: string[],
|
|
||||||
wordMastery: SvelteMap<string, number>,
|
|
||||||
untrackedCurrentWord: string,
|
|
||||||
) {
|
|
||||||
const unmasteredWords = words
|
|
||||||
.map((it) => [it, wordMastery.get(it) ?? 0] as const)
|
|
||||||
.filter(([, it]) => it !== 1);
|
|
||||||
unmasteredWords.sort(([, a], [, b]) => a - b);
|
|
||||||
let nextWord =
|
|
||||||
unmasteredWords[0]?.[0] ??
|
|
||||||
words[0] ??
|
|
||||||
TOO_SHORT_SENTENCE_FOR_NGRAMS_MESSAGE;
|
|
||||||
// This is important to break infinite loop created by
|
|
||||||
// reading and writing `currentWord` inside $effect rune
|
|
||||||
for (const [word] of unmasteredWords) {
|
|
||||||
if (word === untrackedCurrentWord || Math.random() > 0.5) continue;
|
|
||||||
nextWord = word;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return nextWord;
|
|
||||||
}
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import { basicSetup, EditorView } from "codemirror";
|
|
||||||
import { javascript, javascriptLanguage } from "@codemirror/lang-javascript";
|
|
||||||
import { defaultKeymap } from "@codemirror/commands";
|
|
||||||
import { keymap } from "@codemirror/view";
|
|
||||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
|
||||||
import { tags } from "@lezer/highlight";
|
|
||||||
import LL from "$i18n/i18n-svelte";
|
|
||||||
import type { CompletionContext, Completion } from "@codemirror/autocomplete";
|
|
||||||
import { syntaxTree } from "@codemirror/language";
|
|
||||||
import { serialPort } from "$lib/serial/connection";
|
|
||||||
import examplePlugin from "./example-plugin.js?raw";
|
|
||||||
import {
|
|
||||||
charaMethods,
|
|
||||||
type ChannelCharaEventData,
|
|
||||||
type ChannelResponseEventData,
|
|
||||||
} from "./plugin-types";
|
|
||||||
|
|
||||||
let theme = EditorView.baseTheme({
|
|
||||||
".cm-editor .cm-content": {
|
|
||||||
fontFamily: '"Noto Sans Mono", monospace',
|
|
||||||
},
|
|
||||||
".cm-FoldPlaceholder": {
|
|
||||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
|
||||||
color: "var(--md-sys-color-on-surface-variant)",
|
|
||||||
},
|
|
||||||
".cm-gutters": {
|
|
||||||
backgroundColor: "var(--md-sys-color-surface-variant)",
|
|
||||||
color: "var(--md-sys-color-on-surface-variant)",
|
|
||||||
borderColor: "var(--md-sys-color-outline)",
|
|
||||||
},
|
|
||||||
".cm-activeLineGutter": {
|
|
||||||
backgroundColor: "var(--md-sys-color-tertiary)",
|
|
||||||
color: "var(--md-sys-color-on-tertiary)",
|
|
||||||
},
|
|
||||||
".cm-activeLine": {
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
},
|
|
||||||
".cm-cursor": {
|
|
||||||
borderColor: "var(--md-sys-color-on-background)",
|
|
||||||
},
|
|
||||||
".cm-selectionBackground": {
|
|
||||||
background: "transparent !important",
|
|
||||||
backdropFilter: "invert(0.3)",
|
|
||||||
},
|
|
||||||
".cm-tooltip": {
|
|
||||||
backgroundColor: "var(--md-sys-color-background) !important",
|
|
||||||
color: "var(--md-sys-color-on-background)",
|
|
||||||
borderColor: "var(--md-sys-color-outline)",
|
|
||||||
},
|
|
||||||
".cm-tooltip-autocomplete ul li[aria-selected]": {
|
|
||||||
backgroundColor: "var(--md-sys-color-primary) !important",
|
|
||||||
color: "var(--md-sys-color-on-primary) !important",
|
|
||||||
},
|
|
||||||
".cm-completionIcon.cm-completionIcon-keyword::after": {
|
|
||||||
content: "'🗝'",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const highlightStyle = HighlightStyle.define(
|
|
||||||
[
|
|
||||||
{ tag: tags.keyword, color: "var(--md-sys-color-primary)" },
|
|
||||||
{ tag: tags.number, color: "var(--md-sys-color-secondary)" },
|
|
||||||
{ tag: tags.string, color: "var(--md-sys-color-tertiary)" },
|
|
||||||
{
|
|
||||||
tag: tags.comment,
|
|
||||||
color: "var(--md-sys-color-on-background)",
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
{
|
|
||||||
all: { fontFamily: '"Noto Sans Mono", monospace', fontSize: "14px" },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const globalsCompletion: Completion[] = [
|
|
||||||
{ label: "Chara", type: "class", boost: 90 },
|
|
||||||
{ label: "Actions", type: "class", boost: 90 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const actionsCompletion: Completion[] = Array.from(
|
|
||||||
$KEYMAP_CODES,
|
|
||||||
([id, info]) => {
|
|
||||||
const isValidIdentifier =
|
|
||||||
info.id && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(info.id);
|
|
||||||
return {
|
|
||||||
label: info.id
|
|
||||||
? isValidIdentifier
|
|
||||||
? info.id
|
|
||||||
: `["${info.id}"]`
|
|
||||||
: info.id!,
|
|
||||||
displayLabel: info.id,
|
|
||||||
detail: [info.title, `(0x${id.toString(16)})`, info.description]
|
|
||||||
.filter((it) => !!it)
|
|
||||||
.join(" "),
|
|
||||||
section: info.category,
|
|
||||||
boost: isValidIdentifier ? Math.min(info.id?.length ?? 0, 10) + 50 : 40,
|
|
||||||
type: "property",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
).filter((it) => it.label !== undefined);
|
|
||||||
|
|
||||||
const completion = javascriptLanguage.data.of({
|
|
||||||
autocomplete: function completeGlobals(context: CompletionContext) {
|
|
||||||
let nodeBefore = syntaxTree(context.state).resolveInner(context.pos, -1);
|
|
||||||
if (nodeBefore.name === "VariableName") {
|
|
||||||
return {
|
|
||||||
from: nodeBefore.from,
|
|
||||||
options: globalsCompletion,
|
|
||||||
};
|
|
||||||
} else if (nodeBefore.name === "Script") {
|
|
||||||
return {
|
|
||||||
from: context.pos,
|
|
||||||
options: globalsCompletion,
|
|
||||||
};
|
|
||||||
} else if (
|
|
||||||
(nodeBefore.name === "PropertyName" || nodeBefore.name === ".") &&
|
|
||||||
nodeBefore.parent?.name === "MemberExpression" &&
|
|
||||||
nodeBefore.parent.firstChild
|
|
||||||
) {
|
|
||||||
const variable = nodeBefore.parent.firstChild;
|
|
||||||
const variableName = context.state.sliceDoc(variable.from, variable.to);
|
|
||||||
if (variableName === "Actions") {
|
|
||||||
return {
|
|
||||||
from:
|
|
||||||
nodeBefore.name === "PropertyName"
|
|
||||||
? nodeBefore.from
|
|
||||||
: nodeBefore.to,
|
|
||||||
options: actionsCompletion,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
let parent = nodeBefore.prevSibling;
|
|
||||||
while (parent !== null && parent?.name !== "VariableName") {
|
|
||||||
parent = parent.prevSibling;
|
|
||||||
}
|
|
||||||
if (parent) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
editorView = new EditorView({
|
|
||||||
extensions: [
|
|
||||||
basicSetup,
|
|
||||||
javascript(),
|
|
||||||
keymap.of(defaultKeymap),
|
|
||||||
theme,
|
|
||||||
syntaxHighlighting(highlightStyle),
|
|
||||||
completion,
|
|
||||||
],
|
|
||||||
parent: editor,
|
|
||||||
doc: examplePlugin,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
let channels = $derived.by(() => {
|
|
||||||
if (!$serialPort) return {} as any;
|
|
||||||
return {
|
|
||||||
getVersion: (..._args: unknown[]) => Promise.resolve($serialPort.version),
|
|
||||||
getDevice: (..._args: unknown[]) => Promise.resolve($serialPort.device),
|
|
||||||
commit: (..._args: unknown[]) => {
|
|
||||||
if (
|
|
||||||
confirm(
|
|
||||||
"Perform a commit? Settings are already applied until the next reboot.\n\n" +
|
|
||||||
"Excessive commits can lead to premature breakdowns, as the settings storage is only rated for 10,000-25,000 commits.\n\n" +
|
|
||||||
"Click OK to perform the commit anyways.",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return Promise.resolve($serialPort.commit());
|
|
||||||
}
|
|
||||||
return Promise.resolve();
|
|
||||||
},
|
|
||||||
...Object.fromEntries(
|
|
||||||
charaMethods.map(
|
|
||||||
(it) => [it, $serialPort[it].bind($serialPort)] as const,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
} satisfies Record<string, Function>;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function onMessage(event: MessageEvent) {
|
|
||||||
if (event.origin !== "null" || event.source !== frame.contentWindow) return;
|
|
||||||
|
|
||||||
const [channel, params] = event.data;
|
|
||||||
const response = channels[channel as keyof typeof channels](...params);
|
|
||||||
frame.contentWindow!.postMessage(
|
|
||||||
{ response: await response } satisfies ChannelResponseEventData,
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runPlugin() {
|
|
||||||
frame.contentWindow?.postMessage(
|
|
||||||
{
|
|
||||||
actionCodes: $KEYMAP_CODES,
|
|
||||||
script: editorView.state.doc.toString(),
|
|
||||||
charaChannels: Object.keys(channels),
|
|
||||||
} satisfies ChannelCharaEventData,
|
|
||||||
"*",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame: HTMLIFrameElement;
|
|
||||||
let editor: HTMLDivElement;
|
|
||||||
let editorView: EditorView;
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onmessage={onMessage} />
|
|
||||||
<section>
|
|
||||||
<h3>Plugin</h3>
|
|
||||||
<button onclick={runPlugin}
|
|
||||||
><span class="icon">play_arrow</span>{$LL.plugin.editor.RUN()}</button
|
|
||||||
>
|
|
||||||
<div class="editor-root" bind:this={editor}></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<iframe
|
|
||||||
aria-hidden="true"
|
|
||||||
title="code sandbox"
|
|
||||||
bind:this={frame}
|
|
||||||
src="/sandbox/"
|
|
||||||
sandbox="allow-scripts"
|
|
||||||
></iframe>
|
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
background: var(--md-sys-color-primary);
|
|
||||||
padding-inline-start: 0;
|
|
||||||
padding-inline-end: 8px;
|
|
||||||
|
|
||||||
width: min-content;
|
|
||||||
color: var(--md-sys-color-on-primary);
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.editor-root {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
// @ts-nocheck
|
|
||||||
/*******************************
|
|
||||||
* HOLD UP AND READ THIS FIRST *
|
|
||||||
*******************************
|
|
||||||
*
|
|
||||||
* Chara devices have a LIMITED number of commits.
|
|
||||||
* calling `Chara.commit()` can be a dangerous operation, which is why a confirmation dialog will be shown.
|
|
||||||
* Devices are only rated for 10,000-25,000 commits, exceeding that limit may result in premature breakdowns.
|
|
||||||
* `Chara.setSetting` or `Chara.setLayoutKey` is not affected by this, they last however only until the next boot.
|
|
||||||
*
|
|
||||||
* Chord writing is more forgiving, but keep in mind that excessive large-scale writing can still damage the device.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
|
|
||||||
const count = await Chara.getChordCount(); // => 499
|
|
||||||
const chord = await Chara.getChord(2); // => {actions: [1, 2, 3], phrase: [4, 5, 6]}
|
|
||||||
|
|
||||||
const setting = await Chara.getSetting(5); // => 0
|
|
||||||
|
|
||||||
// This, for example, would return all chords
|
|
||||||
const chords = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
chords.push(await Chara.getChord(i));
|
|
||||||
}
|
|
||||||
|
|
||||||
// You can also print values to the browser console (F12)
|
|
||||||
console.log("Chords:", chords);
|
|
||||||
|
|
||||||
// You can access the actions by ID!
|
|
||||||
Actions.SPACE; // => {id: "SPACE", code: 32, icon: "space_bar", description: ...}
|
|
||||||
Actions[32]; // This also works
|
|
||||||
Actions[0x20]; // Or this!
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { CharaDevice } from "$lib/serial/device";
|
|
||||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
|
||||||
|
|
||||||
export const charaMethods = [
|
|
||||||
"reboot",
|
|
||||||
"bootloader",
|
|
||||||
"getRamBytesAvailable",
|
|
||||||
"getSetting",
|
|
||||||
"setSetting",
|
|
||||||
"getLayoutKey",
|
|
||||||
"setLayoutKey",
|
|
||||||
"deleteChord",
|
|
||||||
"setChord",
|
|
||||||
"getChordPhrase",
|
|
||||||
"getChordCount",
|
|
||||||
"getChord",
|
|
||||||
"send",
|
|
||||||
] as const satisfies Array<keyof CharaDevice>;
|
|
||||||
|
|
||||||
export interface ChannelResponseEventData {
|
|
||||||
response: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChannelCharaEventData {
|
|
||||||
charaChannels: string[];
|
|
||||||
script: string;
|
|
||||||
actionCodes: Map<number, KeyInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ChannelEventData = ChannelResponseEventData | ChannelCharaEventData;
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import AnimatedNumber from "$lib/components/AnimatedNumber.svelte";
|
|
||||||
import { onDestroy, onMount } from "svelte";
|
|
||||||
|
|
||||||
let interval: ReturnType<typeof setInterval>;
|
|
||||||
|
|
||||||
let value = $state(Math.round(Math.random() * 100));
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
interval = setInterval(() => {
|
|
||||||
value = Math.round(Math.random() * 100);
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onDestroy(() => {
|
|
||||||
clearInterval(interval);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<p>The number is <AnimatedNumber {value} /></p>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<script>
|
|
||||||
/** @type {Promise<unknown> | undefined} */
|
|
||||||
let ongoingRequest = undefined;
|
|
||||||
/** @type {(data: unknown) => void | undefined} */
|
|
||||||
let resolveRequest = undefined;
|
|
||||||
/** @type {MessageEventSource | undefined} */
|
|
||||||
let source = undefined;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} channel
|
|
||||||
* @param {unknown} args
|
|
||||||
* @returns {Promise<unknown>}
|
|
||||||
*/
|
|
||||||
async function post(channel, args) {
|
|
||||||
while (ongoingRequest) {
|
|
||||||
await ongoingRequest;
|
|
||||||
}
|
|
||||||
ongoingRequest = new Promise((resolve) => {
|
|
||||||
resolveRequest = resolve;
|
|
||||||
source?.postMessage([channel, args], { targetOrigin: "*" });
|
|
||||||
});
|
|
||||||
ongoingRequest.then(() => {
|
|
||||||
ongoingRequest = undefined;
|
|
||||||
});
|
|
||||||
return ongoingRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {MessageEvent<import('../../src/routes/plugin/plugin-types').ChannelEventData>} event
|
|
||||||
*/
|
|
||||||
function onMessage(event) {
|
|
||||||
if ("response" in event.data) {
|
|
||||||
resolveRequest?.(event.data.response);
|
|
||||||
} else {
|
|
||||||
source = event.source ?? undefined;
|
|
||||||
|
|
||||||
const Action = event.data.actionCodes;
|
|
||||||
Object.assign(
|
|
||||||
Action,
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.values(event.data.actionCodes)
|
|
||||||
.filter((it) => !!it.id)
|
|
||||||
.map((it) => [it.id, it]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
new Function("Action", "Chara", event.data.script)(
|
|
||||||
Action,
|
|
||||||
Object.fromEntries(
|
|
||||||
event.data.charaChannels.map((name) => [
|
|
||||||
name,
|
|
||||||
(...args) => post(name, args),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener("message", onMessage);
|
|
||||||
</script>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
FROM llama3.1
|
|
||||||
|
|
||||||
TEMPLATE """
|
|
||||||
<|system|>
|
|
||||||
Only output the sentence
|
|
||||||
<|user|>
|
|
||||||
Create a typical sentence with around 10 words that includes the word "{{ .Prompt }}".
|
|
||||||
<|assistant|>
|
|
||||||
Create a sentence that includes "{{ .Prompt }}".
|
|
||||||
"""
|
|
||||||
@@ -21,11 +21,12 @@ const { homepage, bugs, repository } = JSON.parse(
|
|||||||
process.env["VITE_HOMEPAGE_URL"] = repository.url.replace(/\.git$/, "");
|
process.env["VITE_HOMEPAGE_URL"] = repository.url.replace(/\.git$/, "");
|
||||||
process.env["VITE_DOCS_URL"] = homepage;
|
process.env["VITE_DOCS_URL"] = homepage;
|
||||||
process.env["VITE_BUGS_URL"] = bugs.url;
|
process.env["VITE_BUGS_URL"] = bugs.url;
|
||||||
process.env["VITE_LEARN_URL"] = "https://www.iq-eq.io/";
|
process.env["VITE_LEARN_URL"] = "https://adventure.charachorder.io/";
|
||||||
process.env["VITE_LATEST_FIRMWARE"] = "1.1.4";
|
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: {
|
||||||
@@ -62,7 +63,6 @@ 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