feat: improvements

This commit is contained in:
2024-07-08 18:43:06 +02:00
parent 21e8c291b0
commit 65a5a2517e
53 changed files with 7591 additions and 11877 deletions

View File

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

View File

@@ -0,0 +1,5 @@
<script>
import LL from "$i18n/i18n-svelte";
</script>
{$LL.share.URL_COPIED()}

View File

@@ -0,0 +1,402 @@
<script lang="ts">
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
import FlexSearch from "flexsearch";
import LL from "$i18n/i18n-svelte";
import { action } from "$lib/title";
import { onDestroy, onMount, setContext, tick } from "svelte";
import { changes, ChangeType, chords } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
import { derived, writable } from "svelte/store";
import ChordEdit from "./ChordEdit.svelte";
import { crossfade, fly } from "svelte/transition";
import ChordActionEdit from "./ChordActionEdit.svelte";
import { browser } from "$app/environment";
import { expoOut } from "svelte/easing";
import { osLayout } from "$lib/os-layout";
import randomTips from "$lib/assets/random-tips/en.json";
const resultSize = 38;
let results: HTMLElement;
const pageSize = writable(0);
let resizeObserver: ResizeObserver;
let abortIndexing: (() => void) | undefined;
let progress = 0;
onMount(() => {
resizeObserver = new ResizeObserver(() => {
pageSize.set(Math.floor(results.clientHeight / resultSize));
});
pageSize.set(Math.floor(results.clientHeight / resultSize));
resizeObserver.observe(results);
});
onDestroy(() => {
resizeObserver?.disconnect();
});
let index = new FlexSearch.Index();
let searchIndex = writable<FlexSearch.Index | undefined>(undefined);
$: {
abortIndexing?.();
progress = 0;
buildIndex($chords, $osLayout).then(searchIndex.set);
}
function encodeChord(chord: ChordInfo, osLayout: Map<string, string>) {
const plainPhrase: string[] = [""];
const extraActions: string[] = [];
const extraCodes: string[] = [];
for (const actionCode of chord.phrase ?? []) {
const action = KEYMAP_CODES.get(actionCode);
if (!action) {
extraCodes.push(`0x${actionCode.toString(16)}`);
continue;
}
const osCode = action.keyCode && osLayout.get(action.keyCode);
const token = osCode?.length === 1 ? osCode : action.display || action.id;
if (!token) {
extraCodes.push(`0x${action.code.toString(16)}`);
continue;
}
if (/^\s$/.test(token) && plainPhrase.at(-1) !== "") {
plainPhrase.push("");
} else if (token.length === 1) {
plainPhrase[plainPhrase.length - 1] =
plainPhrase[plainPhrase.length - 1] + token;
} else {
extraActions.push(token);
}
}
if (chord.phrase?.[0] === 298) {
plainPhrase.push("suffix");
}
if (
["ARROW_LT", "ARROW_RT", "ARROW_UP", "ARROW_DN"].some((it) =>
extraActions.includes(it),
)
) {
plainPhrase.push("cursor warp");
}
if (
["CTRL", "ALT", "GUI", "ENTER", "TAB"].some((it) =>
extraActions.includes(it),
)
) {
plainPhrase.push("macro");
}
if (chord.actions[0] !== 0) {
plainPhrase.push("compound");
}
const input = chord.actions
.slice(chord.actions.lastIndexOf(0) + 1)
.map((it) => {
const info = KEYMAP_CODES.get(it);
if (!info) return `0x${it.toString(16)}`;
const osCode = info.keyCode && osLayout.get(info.keyCode);
const result = osCode?.length === 1 ? osCode : info.id;
return result ?? `0x${it.toString(16)}`;
});
return [
...plainPhrase,
`+${input.join("+")}`,
...new Set(extraActions),
...new Set(extraCodes),
].join(" ");
}
async function buildIndex(
chords: ChordInfo[],
osLayout: Map<string, string>,
): Promise<FlexSearch.Index> {
if (chords.length === 0 || !browser) return index;
index = new FlexSearch.Index({
tokenize: "full",
encode(phrase: string) {
return phrase.split(/\s+/).flatMap((it) => {
if (/^[A-Z_]+$/.test(it)) {
return it;
}
if (it.startsWith("+")) {
return it
.slice(1)
.split("+")
.map((it) => `+${it}`);
}
return it.toLowerCase();
});
},
});
let abort = false;
abortIndexing = () => {
abort = true;
};
for (let i = 0; i < chords.length; i++) {
if (abort) return index;
const chord = chords[i]!;
progress = i;
if ("phrase" in chord) {
console.log(encodeChord(chord, osLayout));
await index.addAsync(i, encodeChord(chord, osLayout));
}
}
return index;
}
const searchFilter = writable<number[] | undefined>(undefined);
async function search(index: FlexSearch.Index, event: Event) {
const query = (event.target as HTMLInputElement).value;
searchFilter.set(
query && searchIndex
? ((await index.searchAsync(query)) as number[])
: undefined,
);
page = 0;
}
function insertChord(actions: number[]) {
const id = JSON.stringify(actions);
if ($chords.some((it) => JSON.stringify(it.actions) === id)) {
alert($LL.configure.chords.DUPLICATE());
return;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: actions,
actions,
phrase: [],
});
return changes;
});
}
function downloadVocabulary() {
const vocabulary = new Set(
$chords.map((it) =>
"phrase" in it ? plainPhrase(it.phrase, $osLayout).trim() : "",
),
);
vocabulary.delete("");
const blob = new Blob([Array.from(vocabulary).join("|")], {
type: "text/plain",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "vocabulary.txt";
a.click();
URL.revokeObjectURL(url);
}
const items = derived(
[searchFilter, chords],
([filter, chords]) =>
filter?.map((it) => [chords[it], it] as const) ??
chords.map((it, i) => [it, i] as const),
);
const lastPage = derived(
[items, pageSize],
([items, pageSize]) => Math.ceil((items.length + 1) / pageSize) - 1,
);
setContext("cursor-crossfade", crossfade({}));
let page = 0;
</script>
<svelte:head>
<title>Chord Manager - CharaChorder Device Manager</title>
<meta name="description" content="Manage your chords" />
</svelte:head>
<div class="search-container">
<input
type="search"
placeholder={$LL.configure.chords.search.PLACEHOLDER(progress + 1)}
on:input={(event) => $searchIndex && search($searchIndex, event)}
class:loading={progress !== $chords.length - 1}
/>
<div class="paginator">
{#if $lastPage !== -1}
{page + 1} / {$lastPage + 1}
{:else}
- / -
{/if}
</div>
<button
class="icon"
on:click={() => (page = Math.max(page - 1, 0))}
use:action={{ shortcut: "ctrl+left" }}>navigate_before</button
>
<button
class="icon"
on:click={() => (page = Math.min(page + 1, $lastPage))}
use:action={{ shortcut: "ctrl+right" }}>navigate_next</button
>
</div>
<section bind:this={results}>
<!-- fixes some unresponsiveness -->
{#await tick() then}
<div class="results">
<table transition:fly={{ y: 48, easing: expoOut }}>
{#if $lastPage !== -1}
{#if page === 0}
<tr
><th class="new-chord"
><ChordActionEdit
on:submit={({ detail }) => insertChord(detail)}
/></th
><td /><td /></tr
>
{/if}
{#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord?.id))}
{#if chord}
<tr>
<ChordEdit {chord} on:duplicate={() => (page = 0)} />
</tr>
{/if}
{/each}
{:else}
<caption>{$LL.configure.chords.search.NO_RESULTS()}</caption>
{/if}
</table>
</div>
<div class="sidebar">
<textarea
placeholder={$LL.configure.chords.TRY_TYPING() +
"\n\nDid you know? " +
randomTips[Math.floor(randomTips.length * Math.random())]}
></textarea>
<button on:click={downloadVocabulary}
><span class="icon">download</span>
{$LL.configure.chords.VOCABULARY()}</button
>
</div>
{/await}
</section>
<style lang="scss">
.search-container {
display: flex;
align-items: center;
justify-content: center;
}
.paginator {
display: flex;
justify-content: flex-end;
min-width: 8ch;
}
.new-chord :global(.add) {
visibility: hidden;
}
.sidebar {
display: flex;
flex-direction: column;
> button {
padding-inline-start: 0;
}
}
textarea {
flex: 1;
transition: outline-color 250ms ease;
background: none;
color: inherit;
border: 1px dashed var(--md-sys-color-outline);
outline: 2px solid transparent;
outline-offset: -1px;
margin: 2px;
padding: 8px;
border-radius: 4px;
&:focus {
outline-color: var(--md-sys-color-primary);
}
}
@keyframes pulse {
0% {
opacity: 0.4;
}
50% {
opacity: 1;
}
100% {
opacity: 0.4;
}
}
input[type="search"] {
width: 512px;
margin-block-start: 16px;
padding-block: 8px;
padding-inline: 16px;
font-size: 16px;
color: inherit;
background: none;
border: 0 solid var(--md-sys-color-surface-variant);
border-bottom-width: 1px;
transition: all 250ms ease;
@media (prefers-contrast: more) {
border-color: var(--md-sys-color-outline);
border-style: dashed;
}
&::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 0.8;
}
&:focus {
border-color: var(--md-sys-color-primary);
border-style: solid;
outline: none;
}
&.loading {
opacity: 0.4;
}
}
section {
position: relative;
display: flex;
overflow: hidden;
height: 100%;
padding-inline: 8px;
border-radius: 16px;
}
.results {
height: 100%;
min-width: min(90vw, 16.5cm);
}
table {
height: fit-content;
overflow: hidden;
transition: all 1s ease;
}
</style>

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import type { ChordInfo } from "$lib/undo-redo";
import { changes, ChangeType } from "$lib/undo-redo";
import { createEventDispatcher } from "svelte";
import LL from "$i18n/i18n-svelte";
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
import { inputToAction } from "./input-converter";
export let chord: ChordInfo | undefined = undefined;
const dispatch = createEventDispatcher();
let pressedKeys = new Set<number>();
let editing = false;
function compare(a: number, b: number) {
return a - b;
}
function makeChordInput(...actions: number[]) {
const compound = compoundIndices ?? [];
return [
...compound,
...Array.from(
{
length: 12 - (compound.length + actions.length + 1),
},
() => 0,
),
...actions.toSorted(compare),
];
}
function edit() {
pressedKeys = new Set();
editing = true;
}
function keydown(event: KeyboardEvent) {
// This is obviously a tradeoff
if (event.key === "Tab" || event.key === "Escape") return;
if (!editing) return;
event.preventDefault();
const input = inputToAction(event, get(serialPort)?.device === "X");
if (input == undefined) {
alert("Invalid key");
return;
}
pressedKeys.add(input);
pressedKeys = pressedKeys;
}
function keyup() {
if (!editing) return;
editing = false;
if (pressedKeys.size < 1) return;
if (!chord) return dispatch("submit", makeChordInput(...pressedKeys));
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: makeChordInput(...pressedKeys),
phrase: chord!.phrase,
});
return changes;
});
return undefined;
}
function addSpecial(event: MouseEvent) {
selectAction(event, (action) => {
changes.update((changes) => {
console.log(compoundIndices, chordActions, action);
changes.push({
type: ChangeType.Chord,
id: chord!.id,
actions: makeChordInput(...chordActions!, action),
phrase: chord!.phrase,
});
return changes;
});
});
}
$: chordActions = chord?.actions
.slice(chord.actions.lastIndexOf(0) + 1)
.toSorted(compare);
$: compoundIndices = chord?.actions.slice(0, chord.actions.indexOf(0));
</script>
<button
class:deleted={chord && chord.deleted}
class:edited={chord && chord.actionsChanged}
class:invalid={chord &&
chordActions &&
(chordActions.length < 2 ||
chordActions.some((it, i) => chordActions[i] !== it))}
class="chord"
on:click={edit}
on:keydown={keydown}
on:keyup={keyup}
on:blur={keyup}
>
{#if editing && pressedKeys.size === 0}
<span>{$LL.configure.chords.HOLD_KEYS()}</span>
{:else if !editing && !chord}
<span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if}
{#if !editing}
{#each compoundIndices ?? [] as index}
<sub>{index}</sub>
{/each}
{#if compoundIndices?.length}
<span>&rarr;</span>
{/if}
{/if}
<ActionString
display="keys"
actions={editing ? [...pressedKeys].sort(compare) : chordActions ?? []}
/>
<sup></sup>
<button class="icon add" on:click|stopPropagation={addSpecial}
>add_circle</button
>
</button>
<style lang="scss">
span {
opacity: 0.5;
@media (prefers-contrast: more) {
opacity: 0.8;
}
}
sup {
translate: 0 -60%;
opacity: 0;
transition: opacity 250ms ease;
}
.add {
font-size: 18px;
height: 20px;
opacity: 0;
--icon-fill: 1;
}
.chord:hover .add {
opacity: 1;
}
.chord {
position: relative;
display: inline-flex;
gap: 4px;
height: 32px;
margin-inline: 4px;
&:focus-within {
outline: none;
}
}
.chord::after {
content: "";
position: absolute;
top: 50%;
transform-origin: center left;
translate: -20px 0;
scale: 0 1;
width: calc(100% - 60px);
height: 1px;
background: currentcolor;
transition:
scale 250ms ease,
color 250ms ease;
}
.edited {
color: var(--md-sys-color-primary);
& > sup {
opacity: 1;
}
}
.invalid {
color: var(--md-sys-color-error);
}
.deleted {
color: var(--md-sys-color-error);
&::after {
scale: 1;
}
}
</style>

View File

@@ -0,0 +1,149 @@
<script lang="ts">
import { changes, ChangeType, chords } from "$lib/undo-redo.js";
import type { ChordInfo } from "$lib/undo-redo.js";
import ChordPhraseEdit from "./ChordPhraseEdit.svelte";
import ChordActionEdit from "./ChordActionEdit.svelte";
import type { Chord } from "$lib/serial/chord";
import { slide } from "svelte/transition";
import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte";
import tippy from "tippy.js";
import { createEventDispatcher } from "svelte";
export let chord: ChordInfo;
const dispatch = createEventDispatcher<{ duplicate: void }>();
function remove() {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase,
deleted: true,
});
return changes;
});
}
function isSameChord(a: Chord, b: Chord) {
return (
a.actions.length === b.actions.length &&
a.actions.every((it, i) => it === b.actions[i])
);
}
function restore() {
changes.update((changes) =>
changes.filter(
(it) => !(it.type === ChangeType.Chord && isSameChord(it, chord)),
),
);
}
function duplicate() {
const id = [...chord.id];
id.splice(id.indexOf(0), 1);
id.push(0);
while ($chords.some((it) => JSON.stringify(it.id) === JSON.stringify(id))) {
id[id.length - 1]++;
}
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id,
actions: [...chord.actions],
phrase: [...chord.phrase],
});
return changes;
});
dispatch("duplicate");
}
async function share(event: Event) {
const url = new URL(window.location.href);
url.searchParams.set(
"import",
await charaFileToUriComponent({
charaVersion: 1,
type: "chords",
chords: [[chord.actions, chord.phrase]],
}),
);
await navigator.clipboard.writeText(url.toString());
let shareComponent: SharePopup;
tippy(event.target as HTMLElement, {
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = new SharePopup({ target });
},
onHidden(instance) {
instance.destroy();
},
onDestroy(_instance) {
shareComponent.$destroy();
},
}).show();
}
</script>
<th>
<ChordActionEdit {chord} />
</th>
<td>
<ChordPhraseEdit {chord} />
</td>
<td class="table-buttons">
{#if !chord.deleted}
<button transition:slide class="icon compact" on:click={remove}
>delete</button
>
{:else}
<button transition:slide class="icon compact" on:click={restore}
>restore_from_trash</button
>
{/if}
<button disabled={chord.deleted} class="icon compact" on:click={duplicate}
>content_copy</button
>
<button
class="icon compact"
class:disabled={chord.isApplied}
on:click={restore}>undo</button
>
<div class="separator" />
<button class="icon compact" on:click={share}>share</button>
</td>
<style lang="scss">
.separator {
display: inline-flex;
width: 1px;
height: 24px;
opacity: 0.2;
background: currentcolor;
}
button {
transition: opacity 75ms ease;
}
td {
position: relative;
}
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
}
:global(tr):focus-within > .table-buttons,
:global(tr):hover > .table-buttons {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,223 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { changes, ChangeType } from "$lib/undo-redo";
import type { ChordInfo } from "$lib/undo-redo";
import { scale } from "svelte/transition";
import ActionString from "$lib/components/ActionString.svelte";
import { selectAction } from "./action-selector";
import { inputToAction } from "./input-converter";
import { serialPort } from "$lib/serial/connection";
import { get } from "svelte/store";
export let chord: ChordInfo;
onMount(() => {
if (chord.phrase.length === 0) {
box.focus();
}
});
function keypress(event: KeyboardEvent) {
if (event.key === "ArrowUp") {
addSpecial(event);
} else if (event.key === "ArrowLeft") {
moveCursor(cursorPosition - 1);
} else if (event.key === "ArrowRight") {
moveCursor(cursorPosition + 1);
} else if (event.key === "Backspace") {
deleteAction(cursorPosition - 1);
moveCursor(cursorPosition - 1);
} else if (event.key === "Delete") {
deleteAction(cursorPosition);
} else {
if (event.key === "Shift") return;
const action = inputToAction(event, get(serialPort)?.device === "X");
if (action !== undefined) {
insertAction(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
}
}
}
function moveCursor(to: number) {
cursorPosition = Math.max(0, Math.min(to, chord.phrase.length));
const item = box.children.item(cursorPosition) as HTMLElement;
cursorOffset = item.offsetLeft + item.offsetWidth;
}
function deleteAction(at: number, count = 1) {
if (!(at in chord.phrase)) return;
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, count),
});
return changes;
});
}
function insertAction(at: number, action: number) {
changes.update((changes) => {
changes.push({
type: ChangeType.Chord,
id: chord.id,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, 0, action),
});
return changes;
});
}
function clickCursor(event: MouseEvent) {
if (event.target === button) return;
const distance = (event as unknown as { layerX: number }).layerX;
let i = 0;
for (const child of box.children) {
const { offsetLeft, offsetWidth } = child as HTMLElement;
if (distance < offsetLeft + offsetWidth / 2) {
moveCursor(i - 1);
return;
}
i++;
}
moveCursor(i - 1);
}
function addSpecial(event: MouseEvent | KeyboardEvent) {
selectAction(
event,
(action) => {
insertAction(cursorPosition, action);
tick().then(() => moveCursor(cursorPosition + 1));
},
() => box.focus(),
);
}
let button: HTMLButtonElement;
let box: HTMLDivElement;
let cursorPosition = 0;
let cursorOffset = 0;
let hasFocus = false;
</script>
<!-- svelte-ignore a11y-autofocus -->
<div
on:keydown={keypress}
on:mousedown={clickCursor}
role="textbox"
tabindex="0"
bind:this={box}
class:edited={!chord.deleted && chord.phraseChanged}
on:focusin={() => (hasFocus = true)}
on:focusout={(event) => {
if (event.relatedTarget !== button) hasFocus = false;
}}
>
{#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} on:click={addSpecial}>add</button>
</div>
{:else}
<div />
<!-- placeholder for cursor placement -->
{/if}
<ActionString actions={chord.phrase} />
<sup></sup>
</div>
<style lang="scss">
sup {
translate: 0 -40%;
opacity: 0;
transition: opacity 250ms ease;
}
.cursor {
position: absolute;
transform: translateX(-50%);
translate: 0 0;
width: 2px;
height: 100%;
background: var(--md-sys-color-on-secondary-container);
transition: translate 50ms ease;
button {
position: absolute;
top: -24px;
left: 0;
height: 24px;
padding: 0;
color: var(--md-sys-color-on-secondary-container);
background: var(--md-sys-color-secondary-container);
border: 2px solid currentcolor;
border-radius: 12px 12px 12px 0;
}
}
.edited {
color: var(--md-sys-color-primary);
sup {
opacity: 1;
}
}
[role="textbox"] {
cursor: text;
position: relative;
display: flex;
align-items: center;
height: 1em;
padding-block: 4px;
&::after,
&::before {
content: "";
position: absolute;
bottom: -4px;
width: 100%;
height: 1px;
opacity: 0;
background: currentcolor;
transition:
opacity 150ms ease,
scale 250ms ease;
}
&::after {
scale: 0 1;
transition-duration: 250ms;
}
&:hover::before {
opacity: 0.3;
}
&:focus-within {
outline: none;
&::after {
scale: 1;
opacity: 1;
}
}
}
</style>

View File

@@ -0,0 +1,53 @@
import ActionSelector from "$lib/components/layout/ActionSelector.svelte";
import { tick } from "svelte";
export function selectAction(
event: MouseEvent | KeyboardEvent,
select: (action: number) => void,
dismissed?: () => void,
) {
const component = new ActionSelector({ target: document.body });
const dialog = document.querySelector("dialog > div") as HTMLDivElement;
const backdrop = document.querySelector("dialog") as HTMLDialogElement;
const dialogRect = dialog.getBoundingClientRect();
const groupRect = (event.target as HTMLElement).getBoundingClientRect();
const scale = 0.5;
const dialogScale = `${
1 - scale * (1 - groupRect.width / dialogRect.width)
} ${1 - scale * (1 - groupRect.height / dialogRect.height)}`;
const dialogTranslate = `${scale * (groupRect.x - dialogRect.x)}px ${
scale * (groupRect.y - dialogRect.y)
}px`;
const duration = 150;
const options = { duration, easing: "ease" };
const dialogAnimation = dialog.animate(
[
{ scale: dialogScale, translate: dialogTranslate },
{ translate: "0 0", scale: "1" },
],
options,
);
const backdropAnimation = backdrop.animate(
[{ opacity: 0 }, { opacity: 1 }],
options,
);
async function closed() {
dialogAnimation.reverse();
backdropAnimation.reverse();
await dialogAnimation.finished;
component.$destroy();
await tick();
dismissed?.();
}
component.$on("close", closed);
component.$on("select", ({ detail }) => {
select(detail);
closed();
});
}

View File

@@ -0,0 +1,12 @@
import { KEYMAP_IDS, KEYMAP_KEYCODES } from "$lib/serial/keymap-codes";
export function inputToAction(
event: KeyboardEvent,
useKeycodes?: boolean,
): number | undefined {
if (useKeycodes) {
return KEYMAP_KEYCODES.get(event.code);
} else {
return KEYMAP_IDS.get(event.key)?.code ?? KEYMAP_KEYCODES.get(event.code);
}
}

View File

@@ -0,0 +1,76 @@
<script lang="ts">
import { share } from "$lib/share";
import tippy from "tippy.js";
import { setContext } from "svelte";
import Layout from "$lib/components/layout/Layout.svelte";
import { charaFileToUriComponent } from "$lib/share/share-url";
import SharePopup from "../SharePopup.svelte";
import type { VisualLayoutConfig } from "$lib/components/layout/visual-layout";
import { writable } from "svelte/store";
import { layout } from "$lib/undo-redo";
async function shareLayout(event: Event) {
const url = new URL(window.location.href);
url.searchParams.set(
"import",
await charaFileToUriComponent({
charaVersion: 1,
type: "layout",
device: "one",
layout: $layout.map((it) => it.map((it) => it.action)) as [
number[],
number[],
number[],
],
}),
);
await navigator.clipboard.writeText(url.toString());
let shareComponent: SharePopup;
tippy(event.target as HTMLElement, {
onCreate(instance) {
const target = instance.popper.querySelector(".tippy-content")!;
shareComponent = new SharePopup({ target });
},
onHidden(instance) {
instance.destroy();
},
onDestroy() {
shareComponent.$destroy();
},
}).show();
}
setContext<VisualLayoutConfig>("visual-layout-config", {
scale: 50,
inactiveScale: 0.5,
inactiveOpacity: 0.4,
strokeWidth: 1,
margin: 5,
fontSize: 9,
iconFontSize: 14,
});
setContext("active-layer", writable(0));
</script>
<svelte:head>
<title>Layout Manager - CharaChorder Device Manager</title>
<meta name="description" content="Edit your layout" />
</svelte:head>
<svelte:window use:share={shareLayout} />
<section>
<Layout />
</section>
<style lang="scss">
section {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
</style>

View File

@@ -0,0 +1,438 @@
<script>
import Action from "$lib/components/Action.svelte";
import { popup } from "$lib/popup";
import { serialPort } from "$lib/serial/connection";
import { setting } from "$lib/setting";
import ResetPopup from "./ResetPopup.svelte";
</script>
<svelte:head>
<title>Device Settings - CharaChorder Device Manager</title>
<meta name="description" content="Change your device's settings" />
</svelte:head>
{#if $serialPort}
<section>
<fieldset>
<legend
><label
><input type="checkbox" use:setting={{ id: 0x41 }} />Spurring</label
></legend
>
<p>
"Chording only" mode which tells your device to output chords on a press
rather than a press & release. It also enables you to jump from one
chord to another without releasing everything and can be activated in
GTM or by chording both mirror keys. It can provide significant speed
gains with chording, but also takes away the flexibility of character
entry.
</p>
<p>
Spurring also helps new users learn how to chord by eliminating the need
to focus on timing.
</p>
<p>
Spurring is toggled by chording <Action display="keys" action={540} /> and
<Action display="keys" action={542} /> together.
</p>
<label
>Character Counter Timeout<span class="unit"
><input
type="number"
step="0.001"
min="0"
max="240"
use:setting={{ id: 0x43, scale: 0.001 }}
/>s</span
></label
>
</fieldset>
<fieldset>
<legend
><label
><input
type="checkbox"
use:setting={{ id: 0x51 }}
/>Arpeggiates</label
></legend
>
<p>
A quick, single key press and release used to indicate a suffix, prefix,
or modifier to be associated with a chord.
</p>
<p>
The following keys have special behavior when arpeggiates are enabled:
</p>
<ul>
<li>
<Action display="keys" action={44} />, <Action
display="keys"
action={59}
/> and <Action display="keys" action={58} /> will be placed before the
auto-inserted space
</li>
<li>
<Action display="keys" action={46} />, <Action
display="keys"
action={63}
/> and <Action display="keys" action={33} /> will be placed before the
auto-inserted space and capitalize the next word
</li>
<li>
<Action display="keys" action={45} /> and <Action
display="keys"
action={47}
/> will replace the auto-inserted space
</li>
</ul>
<label
>Timeout After Chord<span class="unit"
><input type="number" step="1" use:setting={{ id: 0x54 }} />ms</span
></label
>
</fieldset>
<fieldset>
<legend>Chord Modifiers</legend>
<p>
Chord modifiers change a chord when held with the chord or when pressed
after (arpeggiated), <b>provided that arpeggiates are enabled.</b>
</p>
<ul>
<li>
<Action display="keys" action={513} /> Capitalizes the first letter of
a chord
</li>
<li>
<Action display="keys" action={540} /> Present Tense (supported words only)
</li>
<li>
<Action display="keys" action={542} /> Plural (supported words only)
</li>
<li>
<Action display="keys" action={550} /> Past Tense (supported words only)
</li>
<li>
<Action display="keys" action={551} /> Comparative (supported words only)
</li>
</ul>
</fieldset>
<fieldset>
<legend>Character Entry</legend>
{#if $serialPort.device === "LITE"}
<label
>Swap Keymap 0 and 1<input
type="checkbox"
use:setting={{ id: 0x13 }}
/></label
>
{/if}
<label>
Character Entry (chentry)
<input type="checkbox" use:setting={{ id: 0x12 }} />
</label>
<label>
Key Scan Rate
<span class="unit"
><input
type="number"
use:setting={{ id: 0x14, inverse: 1000 }}
/>Hz</span
></label
>
<label>
Key Debounce Press<span class="unit"
><input type="number" use:setting={{ id: 0x15 }} />ms</span
></label
>
<label
>Key Debounce Release<span class="unit"
><input type="number" use:setting={{ id: 0x16 }} />ms</span
></label
>
<label
>Output Character Delay<span class="unit"
><input type="number" use:setting={{ id: 0x17 }} />µs</span
></label
>
</fieldset>
<fieldset>
<legend
><label><input type="checkbox" use:setting={{ id: 0x21 }} />Mouse</label
></legend
>
<label
>Mouse Speed<input type="number" use:setting={{ id: 0x22 }} /><input
type="number"
use:setting={{ id: 0x23 }}
/></label
>
<label
>Scroll Speed<input type="number" use:setting={{ id: 0x25 }} /></label
>
<label>
<span>
Active Mouse
<p>Bounces mouse by 1px every 60s if enabled</p></span
>
<input type="checkbox" use:setting={{ id: 0x24 }} /></label
>
<label
>Poll Rate<span class="unit"
><input
type="number"
use:setting={{ id: 0x26, inverse: 1000 }}
/>Hz</span
></label
>
</fieldset>
<fieldset>
<legend
><label
><input type="checkbox" use:setting={{ id: 0x31 }} />Chording</label
></legend
>
<label
>Auto-delete Timeout <span class="unit"
><input
type="number"
min="0"
max="25500"
step="10"
use:setting={{ id: 0x33 }}
/>ms</span
></label
>
<label
>Press Tolerance<span class="unit"
><input
type="number"
min="1"
max="150"
step="1"
use:setting={{ id: 0x34 }}
/>ms</span
></label
>
<label
>Release Tolerance<span class="unit"
><input
type="number"
min="1"
max="150"
step="1"
use:setting={{ id: 0x35 }}
/>ms</span
></label
>
<label
>Compound Chording<input
type="checkbox"
use:setting={{ id: 0x61 }}
/></label
>
</fieldset>
<fieldset>
<legend>Device</legend>
<label
>Boot message<input type="checkbox" use:setting={{ id: 0x93 }} /></label
>
<label
>GTM Realtime Feedback<input
type="checkbox"
use:setting={{ id: 0x92 }}
/></label
>
<button class="outline" use:popup={ResetPopup}>Reset...</button>
</fieldset>
{#if $serialPort.device === "LITE"}
<fieldset>
<legend
><label><input type="checkbox" use:setting={{ id: 0x84 }} />RGB</label
></legend
>
<label
>Brightness<input
use:setting={{ id: 0x81 }}
type="number"
min="0"
max="50"
step="1"
/></label
>
<select use:setting={{ id: 0x82 }}>
<option value="0">White</option>
<option value="1">Red</option>
<option value="2">Orange</option>
<option value="3">Yellow</option>
<option value="4">Lime</option>
<option value="5">Green</option>
<option value="7">Cyan</option>
<option value="9">Blue</option>
<option value="10">Violet</option>
<option value="11">Pink</option>
<option value="13">Multicolor</option>
</select>
</fieldset>
{/if}
</section>
{/if}
<style lang="scss">
section {
overflow-y: auto;
display: flex;
flex-flow: row wrap;
gap: 16px;
justify-content: center;
margin-block: auto;
padding-block-end: 48px;
}
button.outline {
border: 1px solid currentcolor;
border-radius: 8px;
height: 2em;
margin-block: 2em;
margin-inline: auto;
}
legend,
legend > label {
font-size: 24px;
font-weight: bold;
position: relative;
padding: 0 16px;
}
legend:has(label) {
padding: 0;
}
legend:not(:has(label)) {
opacity: 0.8;
}
input[type="checkbox"] {
font-size: 12px;
}
fieldset {
max-width: 400px;
border: 1px solid var(--md-sys-color-outline);
border-radius: 24px;
&:has(> legend input:not(:checked)) > :not(legend) {
pointer-events: none;
opacity: 0.7;
}
> label {
position: relative;
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
margin-block: 4px;
font-size: 14px;
> input[type="number"] {
border-radius: 16px 4px 4px 16px;
height: 24px;
text-align: center;
&:last-child:not(:only-child) {
border-radius: 4px 16px 16px 4px;
}
&:only-child {
border-radius: 16px;
}
}
&:has(input[type="number"]) {
cursor: text;
&:hover {
filter: none;
}
}
}
.unit {
overflow: hidden;
display: flex;
gap: 4px;
align-items: center;
justify-content: flex-start;
width: 67px;
padding-inline-end: auto;
font-size: 12px;
font-weight: bold;
background: var(--md-sys-color-secondary-container);
border-radius: 16px;
}
input[type="number"] {
display: flex;
width: 5ch;
height: 100%;
padding-block: 4px;
font-family: "Noto Sans Mono", monospace;
color: var(--md-sys-color-on-secondary);
text-align: end;
background: var(--md-sys-color-secondary);
border: none;
&::-webkit-inner-spin-button {
display: none;
}
&::after {
content: "bleh";
}
&:focus {
outline: none;
}
}
ul,
p {
font-size: 10px;
:global(kbd) {
font-size: 12px;
height: 18px;
}
}
}
// stylelint-disable-next-line
label:global(:has(.pending-changes)) {
color: var(--md-sys-color-primary);
&::before {
position: absolute;
font-size: 16px;
top: 0.5em;
right: 0.25em;
content: "•";
}
}
</style>

View File

@@ -0,0 +1,47 @@
<script lang="ts">
import { serialPort } from "$lib/serial/connection";
import { createEventDispatcher } from "svelte";
export let challenge: string;
let challengeInput = "";
$: challengeString = `${challenge} ${$serialPort!.device}`;
$: isValid = challengeInput === challengeString;
const dispatch = createEventDispatcher();
</script>
<h3>Type the following to confirm the action</h3>
<p>{challengeString}</p>
<!-- svelte-ignore a11y-autofocus -->
<input
autofocus
type="text"
bind:value={challengeInput}
placeholder={challengeString}
/>
<button disabled={!isValid} on:click={() => dispatch("confirm")}
>Confirm {challenge}</button
>
<style lang="scss">
input[type="text"] {
color: inherit;
font-family: inherit;
background: none;
border: none;
border-bottom: 1px solid currentcolor;
width: 100%;
&:focus {
outline: none;
border-color: var(--md-sys-color-secondary);
}
}
button {
color: var(--md-sys-color-error);
}
</style>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { confirmChallenge } from "./confirm-challenge";
import { serialPort } from "$lib/serial/connection";
const options = [
[["FACTORY", "Factory Reset"]],
[
["PARAMS", "Reset Settings"],
["KEYMAPS", "Reset Layout"],
["CLEARCML", "Clear Chords"],
],
[
["STARTER", "Add starter chords"],
["FUNC", "Add functional chords"],
],
] as const;
</script>
<h3>Reset Device</h3>
{#each options as category, i}
{#if i > 0}
<hr />
{/if}
{#each category as [command, description]}
<button
class="error"
use:confirmChallenge={{
onConfirm() {
$serialPort?.reset(command);
$serialPort = undefined;
},
challenge: description,
}}>{description}...</button
>
{/each}
{/each}
<style lang="scss">
hr {
opacity: 0.25;
}
</style>

View File

@@ -0,0 +1,37 @@
import type { Action } from "svelte/action";
import ConfirmChallenge from "./ConfirmChallenge.svelte";
import tippy from "tippy.js";
export const confirmChallenge: Action<
HTMLElement,
{ onConfirm: () => void; challenge: string }
> = (node, { onConfirm, challenge }) => {
let component: ConfirmChallenge | undefined;
let target: HTMLElement | undefined;
const edit = tippy(node, {
interactive: true,
trigger: "click",
onShow(instance) {
target = instance.popper.querySelector(".tippy-content") as HTMLElement;
target.classList.add("active");
if (component === undefined) {
component = new ConfirmChallenge({ target, props: { challenge } });
component.$on("confirm", () => {
edit.hide();
onConfirm();
});
}
},
onHidden() {
component?.$destroy();
target?.classList.remove("active");
component = undefined;
},
});
return {
destroy() {
edit.destroy();
},
};
};