mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-08 11:02:50 +00:00
feat: layout editing (sorta)
This commit is contained in:
@@ -62,6 +62,11 @@ const config: IconsConfig = {
|
||||
"upload_file",
|
||||
"commit",
|
||||
"bug_report",
|
||||
"delete",
|
||||
"remove_selection",
|
||||
"bolt",
|
||||
"undo",
|
||||
"redo",
|
||||
],
|
||||
codePoints: {
|
||||
speed: "e9e4",
|
||||
|
||||
@@ -2,6 +2,12 @@ import type {Translation} from "../i18n-types"
|
||||
|
||||
const de = {
|
||||
TITLE: "amaCC1ng",
|
||||
saveActions: {
|
||||
UNDO: "Rückgängig",
|
||||
REDO: "Wiederholen",
|
||||
APPLY: "Anwenden",
|
||||
SAVE: "Änderungen auf das Gerät schreiben",
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Sicherungskopie",
|
||||
DISCLAIMER:
|
||||
@@ -9,6 +15,14 @@ const de = {
|
||||
DOWNLOAD: "Kopie Speichern",
|
||||
RESTORE: "Wiederherstellen",
|
||||
},
|
||||
modal: {
|
||||
CLOSE: "Schließen",
|
||||
},
|
||||
actionSearch: {
|
||||
PLACEHOLDER: "Nach Aktionen suchen",
|
||||
CURRENT_ACTION: "Aktuelle Aktion",
|
||||
DELETE: "Entfernen",
|
||||
},
|
||||
share: {
|
||||
URL_COPIED: "Teilbare URL kopiert!",
|
||||
EXTRA_DOWNLOAD: "Als Datei herunterladen",
|
||||
|
||||
@@ -2,12 +2,26 @@ import type {BaseTranslation} from "../i18n-types"
|
||||
|
||||
const en = {
|
||||
TITLE: "amaCC1ng",
|
||||
saveActions: {
|
||||
UNDO: "Undo",
|
||||
REDO: "Redo",
|
||||
APPLY: "Apply",
|
||||
SAVE: "Write changes to your device",
|
||||
},
|
||||
backup: {
|
||||
TITLE: "Local Backup",
|
||||
DISCLAIMER: "Backups remain on your computer and are never shared with us or uploaded to our servers.",
|
||||
DOWNLOAD: "Download Backup",
|
||||
RESTORE: "Restore",
|
||||
},
|
||||
modal: {
|
||||
CLOSE: "Close",
|
||||
},
|
||||
actionSearch: {
|
||||
PLACEHOLDER: "Search for actions",
|
||||
CURRENT_ACTION: "Current action",
|
||||
DELETE: "Remove",
|
||||
},
|
||||
share: {
|
||||
URL_COPIED: "Sharable URL copied!",
|
||||
EXTRA_DOWNLOAD: "Download as file",
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {Action} from "svelte/action"
|
||||
import Index from "flexsearch"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import tippy from "tippy.js"
|
||||
import ActionAutocomplete from "$lib/components/ActionAutocomplete.svelte"
|
||||
import {browser} from "$app/environment"
|
||||
|
||||
const index = browser ? new Index({tokenize: "full"}) : undefined
|
||||
for (const action of Object.values(KEYMAP_CODES)) {
|
||||
index?.add(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
)
|
||||
}
|
||||
const exact = Object.fromEntries(
|
||||
Object.values(KEYMAP_CODES)
|
||||
.filter(it => !!it.id)
|
||||
.map(it => [it.id, it] as const),
|
||||
)
|
||||
|
||||
export const actionAutocomplete: Action<HTMLInputElement> = node => {
|
||||
if (!browser) return
|
||||
|
||||
let completionComponent: ActionAutocomplete
|
||||
const completionDialog = tippy(node, {
|
||||
interactive: true,
|
||||
placement: "bottom-start",
|
||||
hideOnClick: false,
|
||||
theme: "surface-variant search-completion",
|
||||
arrow: false,
|
||||
trigger: "focus",
|
||||
offset: [0, 0],
|
||||
onCreate(instance) {
|
||||
const target = instance.popper.querySelector(".tippy-content")!
|
||||
completionComponent = new ActionAutocomplete({target, props: {width: node.clientWidth}})
|
||||
},
|
||||
onDestroy() {
|
||||
completionComponent.$destroy()
|
||||
},
|
||||
})
|
||||
|
||||
function input(event: Event) {
|
||||
completionComponent.$set({
|
||||
results: index!.search(node.value),
|
||||
exact: exact[node.value],
|
||||
code: Number(node.value),
|
||||
})
|
||||
}
|
||||
|
||||
node.addEventListener("input", input)
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
node.removeEventListener("input", input)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
|
||||
interface Language {
|
||||
name: string
|
||||
noLazyMode?: boolean
|
||||
orderedByFrequency?: boolean
|
||||
words: string[]
|
||||
}
|
||||
|
||||
export async function calculateChordCoverage(chords: Chord[]) {
|
||||
const language: Language = await fetch("/languages/english.json").then(it => it.json())
|
||||
|
||||
const words = new Set(language.words)
|
||||
for (const chord of chords) {
|
||||
words.delete(chord.phrase.map(it => KEYMAP_CODES[it].id!).join(""))
|
||||
}
|
||||
|
||||
return {
|
||||
coverage: words.size / language.words.length,
|
||||
missing: [...words.values()],
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
||||
|
||||
export let exact: number | undefined = undefined
|
||||
export let code: number = Number.NaN
|
||||
export let results: number[] = []
|
||||
|
||||
export let width: number
|
||||
</script>
|
||||
|
||||
<div class="list" style="width: {width}px">
|
||||
{#if exact !== undefined}
|
||||
<div class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} />
|
||||
</div>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<button>USE CODE</button>
|
||||
{:else}
|
||||
<div>Action code is out of range</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as id (id)}
|
||||
<ActionListItem {id} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.list {
|
||||
--scrollbar-color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
scrollbar-gutter: stable both-edges;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
max-height: 500px;
|
||||
padding-block: 8px;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
> i {
|
||||
padding-inline: 8px;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -7,7 +7,7 @@
|
||||
$: key = (typeof id === "number" ? KEYMAP_CODES[id] ?? id : id) as number | KeyInfo
|
||||
</script>
|
||||
|
||||
<button>
|
||||
<button on:click>
|
||||
{#if typeof key === "object"}
|
||||
<div class="title">
|
||||
<b>
|
||||
@@ -43,6 +43,13 @@
|
||||
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-visible {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
<script lang="ts">
|
||||
import LayoutCC1 from "$lib/components/layout/LayoutCC1.svelte"
|
||||
import {chords, highlightActions} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
|
||||
|
||||
$: content = Array.from({length: 10}).map(() => $chords[Math.floor(Math.random() * $chords.length)])
|
||||
|
||||
let cursor = [0, 0]
|
||||
let input = []
|
||||
|
||||
$: {
|
||||
$highlightActions = content[cursor[0]]?.actions ?? []
|
||||
}
|
||||
|
||||
function keypress(event: KeyboardEvent) {
|
||||
cursor++
|
||||
input.push(event.key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keypress={keypress} />
|
||||
|
||||
<div>
|
||||
<section>
|
||||
<!-- <div class="cursor" style="translate: calc({cursor}ch - 50%) -50%" /> -->
|
||||
{#each content as word, i}
|
||||
{#if word}
|
||||
{#each word.phrase as letter, j}
|
||||
<span>{KEYMAP_CODES[letter].id}</span>
|
||||
{/each}
|
||||
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<LayoutCC1 />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
section {
|
||||
position: relative;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
font-size: 1.3rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.letter {
|
||||
position: relative;
|
||||
filter: brightness(50%);
|
||||
}
|
||||
|
||||
.cursor {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
translate: -50% -50%;
|
||||
|
||||
width: 2px;
|
||||
height: 1em;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
</style>
|
||||
@@ -1,167 +1,306 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes.js"
|
||||
import charaActions from "$lib/assets/keymaps/chara-chorder.yml"
|
||||
import mouseActions from "$lib/assets/keymaps/mouse.yml"
|
||||
import keyboardActions from "$lib/assets/keymaps/keyboard.yml"
|
||||
import asciiActions from "$lib/assets/keymaps/ascii.yml"
|
||||
import cp1252Actions from "$lib/assets/keymaps/cp-1252.yml"
|
||||
import FlexSearch from "flexsearch"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
import Index from "flexsearch"
|
||||
import {createEventDispatcher} from "svelte"
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte"
|
||||
import LL from "../../../i18n/i18n-svelte"
|
||||
|
||||
const index = new FlexSearch({tokenize: "full"})
|
||||
export let currentAction: number
|
||||
|
||||
for (const code in KEYMAP_CODES) {
|
||||
const key = KEYMAP_CODES[code]
|
||||
index.add(
|
||||
code,
|
||||
`${key.id || key.code} ${key.title || ""} ${key.variant || ""} ${key.description || ""}`.trim(),
|
||||
const index = new Index({tokenize: "full"})
|
||||
for (const action of Object.values(KEYMAP_CODES)) {
|
||||
index?.add(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
)
|
||||
}
|
||||
const exactIndex: Record<string, KeyInfo> = Object.fromEntries(
|
||||
Object.values(KEYMAP_CODES)
|
||||
.filter(it => !!it.id)
|
||||
.map(it => [it.id, it] as const),
|
||||
)
|
||||
|
||||
function search() {
|
||||
const query = searchInput.value
|
||||
customValue = query && !Number.isNaN(Number(query)) ? Number(query) : undefined
|
||||
results = query ? index.search(searchInput.value) : defaultActions
|
||||
results = index!.search(searchBox.value)
|
||||
exact = exactIndex[searchBox.value]?.code
|
||||
code = Number(searchBox.value)
|
||||
}
|
||||
|
||||
let customValue: number | undefined = undefined
|
||||
const defaultActions: string[] = [
|
||||
charaActions,
|
||||
mouseActions,
|
||||
keyboardActions,
|
||||
asciiActions,
|
||||
cp1252Actions,
|
||||
].flatMap(it => Object.keys(it.actions))
|
||||
let results: string[] = defaultActions
|
||||
let searchInput: HTMLInputElement
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
dispatch("select", id)
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter") {
|
||||
dispatch("select", exact)
|
||||
} else if (event.shiftKey && event.key === "Escape") {
|
||||
dispatch("select", 0)
|
||||
} else if (event.key === "Escape") {
|
||||
dispatch("close")
|
||||
} else if (event.key === "ArrowDown") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.nextSibling ?? resultList.querySelector("li:not(.exact)")
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus()
|
||||
}
|
||||
} else if (event.key === "ArrowUp") {
|
||||
const element =
|
||||
resultList.querySelector("li:focus-within")?.previousSibling ??
|
||||
resultList.querySelector("li:not(.exact)")
|
||||
if (element instanceof HTMLLIElement) {
|
||||
element.querySelector("button")?.focus()
|
||||
}
|
||||
} else {
|
||||
searchBox.focus()
|
||||
return
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
let results: number[] = []
|
||||
let exact: number | undefined = undefined
|
||||
let code: number = Number.NaN
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let searchBox: HTMLInputElement
|
||||
let resultList: HTMLUListElement
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<input type="search" on:input={search} placeholder="Search Actions" bind:this={searchInput} />
|
||||
<svelte:window on:keydown={keyboardNavigation} />
|
||||
|
||||
<div class="results">
|
||||
{#if customValue !== undefined}
|
||||
<button class="custom">
|
||||
Custom ActionID
|
||||
<span class="key">0x{customValue.toString(16).toUpperCase()}</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#each results as id}
|
||||
{@const key = KEYMAP_CODES[id]}
|
||||
<button title={key.description}>
|
||||
<div class="title">
|
||||
<b>
|
||||
{key.title || ""}
|
||||
{#if key.variant === "left"}
|
||||
(Left)
|
||||
{:else if key.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
</b>
|
||||
{#if key.description}
|
||||
<i>{key.description}</i>
|
||||
{/if}
|
||||
</div>
|
||||
<span class:icon={!!key.icon} class="key">{key.icon || key.id || key.code}</span>
|
||||
</button>
|
||||
{/each}
|
||||
<dialog open on:click|self={() => dispatch("close")}>
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchBox}
|
||||
autofocus
|
||||
on:input={search}
|
||||
on:keypress={event => {
|
||||
if (event.key === "Enter") {
|
||||
select(exact)
|
||||
}
|
||||
}}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
<button on:click={() => select(0)}
|
||||
><div><span class="icon key-hint">shift</span>+<span class="key-hint">ESC</span></div>
|
||||
{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button title={$LL.modal.CLOSE()} class="icon" on:click={() => dispatch("close")}>close</button>
|
||||
</div>
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||
<ActionListItem id={currentAction} />
|
||||
</aside>
|
||||
<ul bind:this={resultList}>
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i
|
||||
>Exact match <span class="icon key-hint">shift</span>+<span class="icon key-hint"
|
||||
>keyboard_return</span
|
||||
></i
|
||||
>
|
||||
<ActionListItem id={exact} on:click={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button on:click={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as id (id)}
|
||||
<li><ActionListItem {id} on:click={() => select(id)} /></li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
width: calc(min(100vw - 10px, 512px));
|
||||
height: calc(min(90vh, 600px));
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-size: 18px;
|
||||
color: var(--md-sys-color-on-primary);
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
border: none;
|
||||
border-radius: 24px;
|
||||
|
||||
&::placeholder {
|
||||
color: inherit;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "plus";
|
||||
}
|
||||
}
|
||||
|
||||
.key {
|
||||
overflow: hidden;
|
||||
dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 4px;
|
||||
|
||||
font-size: 18px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
border: 1px solid var(--md-sys-color-outline);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
align-items: flex-start;
|
||||
|
||||
text-align: start;
|
||||
|
||||
> b {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
font-family: "Noto Sans Mono", monospace;
|
||||
font-size: 14px;
|
||||
color: inherit;
|
||||
|
||||
background: transparent;
|
||||
background: rgba(0 0 0 / 60%);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.custom {
|
||||
padding: 8px;
|
||||
padding-inline-start: 16px;
|
||||
aside {
|
||||
pointer-events: none;
|
||||
|
||||
margin: 8px;
|
||||
|
||||
opacity: 0.4;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
|
||||
> h3 {
|
||||
width: fit-content;
|
||||
margin-block-start: -13px;
|
||||
margin-block-end: 0;
|
||||
margin-inline-start: 16px;
|
||||
padding-inline: 8px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
margin-inline: 16px;
|
||||
|
||||
> button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: fit-content;
|
||||
|
||||
color: currentcolor;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 100%;
|
||||
|
||||
&:not(.icon) {
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
aspect-ratio: 1;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
width: calc(min(30cm, 90%));
|
||||
height: calc(min(100% - 128px, 90%));
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.results {
|
||||
overflow-y: scroll;
|
||||
input[type="search"] {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
margin-block-end: 8px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
color: currentcolor;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-primary-container);
|
||||
|
||||
transition: all 250ms ease;
|
||||
|
||||
&:focus {
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
border-radius: 0 0 8px 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
height: 20px;
|
||||
margin-block: 6px;
|
||||
padding: 2px;
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: normal;
|
||||
color: currentcolor;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 4px;
|
||||
|
||||
&.icon {
|
||||
padding: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,11 +13,6 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<select bind:value={device}>
|
||||
<option value="ONE">CC1</option>
|
||||
<option value="LITE">Lite</option>
|
||||
</select>
|
||||
|
||||
<fieldset>
|
||||
{#each layers as [title, icon, value]}
|
||||
<button
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
<div class="col layout" style="gap: 0">
|
||||
<div class="row" style="gap: 156px">
|
||||
<div class="row">
|
||||
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} type="tertiary" />
|
||||
<RingInput {activeLayer} keys={{d: 30, e: 31, n: 32, w: 33, s: 34}} />
|
||||
<div class="col">
|
||||
<RingInput {activeLayer} keys={{d: 25, e: 26, n: 27, w: 28, s: 29}} />
|
||||
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} type="secondary" />
|
||||
<RingInput {activeLayer} keys={{d: 40, e: 41, n: 42, w: 43, s: 44}} />
|
||||
</div>
|
||||
<div class="col">
|
||||
<RingInput {activeLayer} keys={{d: 20, e: 21, n: 22, w: 23, s: 24}} />
|
||||
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} type="secondary" />
|
||||
<RingInput {activeLayer} keys={{d: 35, e: 36, n: 37, w: 38, s: 39}} />
|
||||
</div>
|
||||
<RingInput {activeLayer} keys={{d: 15, e: 16, n: 17, w: 18, s: 19}} />
|
||||
</div>
|
||||
@@ -41,8 +41,8 @@
|
||||
<RingInput {activeLayer} keys={{d: 50, w: 51, n: 52, e: 53, s: 54}} />
|
||||
</div>
|
||||
<div class="row" style="gap: 320px; margin-top: -12px">
|
||||
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} type="secondary" />
|
||||
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} type="secondary" />
|
||||
<RingInput {activeLayer} keys={{d: 0, e: 1, n: 2, w: 3, s: 4}} />
|
||||
<RingInput {activeLayer} keys={{d: 45, w: 46, n: 47, e: 48, s: 49}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import {highlightActions, layout} from "$lib/serial/connection"
|
||||
import {changes, highlightActions, layout} from "$lib/serial/connection"
|
||||
import type {Change} from "$lib/serial/connection"
|
||||
import type {CharaLayout} from "$lib/serialization/layout"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import type {KeyInfo} from "$lib/serial/keymap-codes"
|
||||
@@ -7,9 +8,6 @@
|
||||
|
||||
export let activeLayer = 0
|
||||
export let keys: Record<"d" | "s" | "n" | "w" | "e", number>
|
||||
export let type: "primary" | "secondary" | "tertiary" = "primary"
|
||||
|
||||
const layerNames = ["Primary Layer", "Number Layer", "Function Layer"]
|
||||
|
||||
const virtualLayerMap = [1, 0, 2]
|
||||
const characterOffset = 8
|
||||
@@ -20,26 +18,32 @@
|
||||
return 25 * quadrant + layerOffsetIndex * layerOffset
|
||||
}
|
||||
|
||||
function getActions(id: number, layout: CharaLayout): KeyInfo[] {
|
||||
function getActions(id: number, layout: CharaLayout, changes: Change[]): [KeyInfo, KeyInfo | undefined][] {
|
||||
return Array.from({length: 3}).map((_, i) => {
|
||||
const actionId = layout?.[i][id]
|
||||
return KEYMAP_CODES[actionId]
|
||||
const changedId = changes.findLast(it => it?.layout?.[i]?.[id] !== undefined)?.layout![i]![id]
|
||||
if (changedId !== undefined) {
|
||||
return [KEYMAP_CODES[changedId], KEYMAP_CODES[actionId]]
|
||||
} else {
|
||||
return [KEYMAP_CODES[actionId], undefined]
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="radial {type}">
|
||||
<div class="radial">
|
||||
{#each [keys.n, keys.e, keys.s, keys.w, keys.d] as id, quadrant}
|
||||
{@const actions = getActions(id, $layout)}
|
||||
{@const actions = getActions(id, $layout, $changes)}
|
||||
<button
|
||||
use:editableLayout={{id, quadrant}}
|
||||
class:active={actions.some(it => it && $highlightActions?.includes(it.code))}
|
||||
use:editableLayout={{activeLayer, id}}
|
||||
class:active={actions.some(([it]) => it && $highlightActions?.includes(it.code))}
|
||||
>
|
||||
{#each actions as keyInfo, layer}
|
||||
{#each actions as [keyInfo, old], layer}
|
||||
{#if keyInfo}
|
||||
<span
|
||||
class:active={virtualLayerMap[activeLayer] === virtualLayerMap[layer]}
|
||||
class:icon={!!keyInfo.icon}
|
||||
class:changed={!!old}
|
||||
style="offset-distance: {offsetDistance(quadrant, layer, activeLayer)}%"
|
||||
>{keyInfo.icon || keyInfo.id || keyInfo.code}</span
|
||||
>
|
||||
@@ -95,7 +99,9 @@
|
||||
|
||||
opacity: 0.2;
|
||||
|
||||
transition: scale $transition-time ease, opacity $transition-time ease,
|
||||
transition:
|
||||
scale $transition-time ease,
|
||||
opacity $transition-time ease,
|
||||
offset-distance $transition-time ease;
|
||||
|
||||
&.active {
|
||||
@@ -107,6 +113,11 @@
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
&.changed {
|
||||
color: var(--md-sys-color-on-secondary-container);
|
||||
background: var(--md-sys-color-secondary-container);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -170,12 +181,4 @@
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.secondary > button {
|
||||
filter: brightness(80%) contrast(120%);
|
||||
}
|
||||
|
||||
.tertiary > button {
|
||||
filter: brightness(80%) contrast(110%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import tippy from "tippy.js"
|
||||
import InputEdit from "$lib/components/layout/InputEdit.svelte"
|
||||
import type {Action} from "svelte/action"
|
||||
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
|
||||
import {changes, layout} from "$lib/serial/connection"
|
||||
import {get} from "svelte/store"
|
||||
|
||||
export const editableLayout: Action<HTMLButtonElement, {id: number; quadrant: number}> = (
|
||||
export const editableLayout: Action<HTMLButtonElement, {activeLayer: number; id: number}> = (
|
||||
node,
|
||||
{id, quadrant},
|
||||
{id, activeLayer},
|
||||
) => {
|
||||
let component: InputEdit | undefined
|
||||
const edit = tippy(node, {
|
||||
interactive: true,
|
||||
appendTo: document.body,
|
||||
trigger: "click",
|
||||
placement: (["top", "right", "bottom", "left"] as const)[quadrant],
|
||||
onShow(instance) {
|
||||
component ??= new InputEdit({
|
||||
target: instance.popper.querySelector(".tippy-content")!,
|
||||
props: {id},
|
||||
let component: ActionSelector | undefined
|
||||
function present() {
|
||||
component?.$destroy()
|
||||
component = new ActionSelector({
|
||||
target: document.body,
|
||||
props: {currentAction: get(layout)[activeLayer][id]},
|
||||
})
|
||||
component.$on("close", () => {
|
||||
component!.$destroy()
|
||||
})
|
||||
component.$on("select", ({detail}) => {
|
||||
changes.update(changes => {
|
||||
changes.push({layout: {[activeLayer]: {[id]: detail}}})
|
||||
return changes
|
||||
})
|
||||
},
|
||||
onHidden() {
|
||||
component?.$destroy()
|
||||
component = undefined
|
||||
},
|
||||
})
|
||||
component!.$destroy()
|
||||
})
|
||||
}
|
||||
|
||||
node.addEventListener("click", present)
|
||||
return {
|
||||
destroy() {
|
||||
edit.destroy()
|
||||
node.removeEventListener("click", present)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ export const layout = persistentWritable<CharaLayout>(
|
||||
() => get(userPreferences).backup,
|
||||
)
|
||||
|
||||
export interface Change {
|
||||
layout?: Record<number, Record<number, number>>
|
||||
chords?: never
|
||||
settings?: Record<number, number>
|
||||
}
|
||||
|
||||
export const changes = persistentWritable<Change[]>("changes", [])
|
||||
|
||||
export const settings = writable({})
|
||||
|
||||
export const unsavedChanges = writable(new Map<number, number>())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import {page} from "$app/stores"
|
||||
import LL from "../../i18n/i18n-svelte"
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
|
||||
$: paths = [
|
||||
{href: "/config/chords/", title: $LL.configure.chords.TITLE(), icon: "piano"},
|
||||
85
src/routes/EditActions.svelte
Normal file
85
src/routes/EditActions.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
import {changes} from "$lib/serial/connection"
|
||||
import type {Change} from "$lib/serial/connection"
|
||||
import {fly} from "svelte/transition"
|
||||
|
||||
function undo() {
|
||||
redoQueue = [$changes.pop()!, ...redoQueue]
|
||||
changes.update(it => it)
|
||||
}
|
||||
|
||||
function redo() {
|
||||
const [change, ...queue] = redoQueue
|
||||
changes.update(it => {
|
||||
it.push(change)
|
||||
return it
|
||||
})
|
||||
redoQueue = queue
|
||||
}
|
||||
let redoQueue: Change[] = []
|
||||
|
||||
function apply() {
|
||||
// TODO
|
||||
}
|
||||
</script>
|
||||
|
||||
<button title={$LL.saveActions.UNDO()} class="icon" disabled={$changes.length === 0} on:click={undo}
|
||||
>undo</button
|
||||
>
|
||||
<button title={$LL.saveActions.REDO()} class="icon" disabled={redoQueue.length === 0} on:click={redo}
|
||||
>redo</button
|
||||
>
|
||||
<div class="separator" />
|
||||
<button title={$LL.saveActions.SAVE()} class="icon">save</button>
|
||||
{#if $changes.length !== 0}
|
||||
<button class="click-me" transition:fly={{x: 8}}
|
||||
><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
cursor: pointer;
|
||||
|
||||
padding: 0;
|
||||
|
||||
color: currentcolor;
|
||||
|
||||
background: none;
|
||||
border: none;
|
||||
|
||||
transition: all 250ms ease;
|
||||
}
|
||||
|
||||
:disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.click-me {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
margin-inline: 8px;
|
||||
padding-block: 2px;
|
||||
padding-inline-start: 4px;
|
||||
padding-inline-end: 8px;
|
||||
|
||||
font-family: inherit;
|
||||
font-weight: bold;
|
||||
color: var(--md-sys-color-primary);
|
||||
|
||||
border: 2px solid var(--md-sys-color-primary);
|
||||
border-radius: 18px;
|
||||
outline: 2px dashed var(--md-sys-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--md-sys-color-outline-variant);
|
||||
}
|
||||
</style>
|
||||
@@ -20,6 +20,7 @@
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
ul {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {serialPort, syncStatus, unsavedChanges} from "$lib/serial/connection"
|
||||
import {page} from "$app/stores"
|
||||
import {slide, fly} from "svelte/transition"
|
||||
import {canShare, triggerShare} from "$lib/share"
|
||||
import {popup} from "$lib/popup"
|
||||
@@ -11,17 +10,8 @@
|
||||
import {userPreferences} from "$lib/preferences"
|
||||
import LL from "../i18n/i18n-svelte"
|
||||
import Profile from "./Profile.svelte"
|
||||
|
||||
const training = [
|
||||
{slug: "cpm", title: "CPM - Characters Per Minute", icon: "music_note"},
|
||||
{slug: "chords", title: "ChM - Chords Mastered", icon: "piano"},
|
||||
{slug: "avg-wpm", title: "aWPM - Average Words Per Minute", icon: "avg_pace"},
|
||||
{slug: "sentences", title: "StM - Sentences Mastered", icon: "lyrics"},
|
||||
{slug: "top-wpm", title: "tWPM - Top Words Per Minute", icon: "speed"},
|
||||
{slug: "cm", title: "CM - Concepts Mastered", icon: "cognition"},
|
||||
]
|
||||
|
||||
let placeboProgress = false
|
||||
import ConfigTabs from "./ConfigTabs.svelte"
|
||||
import EditActions from "./EditActions.svelte"
|
||||
|
||||
async function flashChanges() {
|
||||
$syncStatus = "uploading"
|
||||
@@ -49,34 +39,32 @@
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<a href="/" class="title">{$LL.TITLE()}</a>
|
||||
|
||||
<div class="steps">
|
||||
{#each training as {slug, title, icon}}
|
||||
<a
|
||||
href="/train/{slug}/"
|
||||
{title}
|
||||
class="icon train {slug}"
|
||||
class:active={$page.url.pathname === `/train/${slug}/`}>{icon}</a
|
||||
>
|
||||
{/each}
|
||||
<div class="actions">
|
||||
<EditActions />
|
||||
</div>
|
||||
|
||||
<ConfigTabs />
|
||||
|
||||
<div class="actions">
|
||||
{#if $canShare}
|
||||
<button transition:fly={{x: -8}} class="icon" on:click={triggerShare}>share</button>
|
||||
<div transition:slide class="separator"/>
|
||||
<div transition:slide class="separator" />
|
||||
{/if}
|
||||
{#if import.meta.env.TAURI_FAMILY === undefined}
|
||||
{#await import("$lib/components/PwaStatus.svelte") then {default: PwaStatus}}
|
||||
<PwaStatus/>
|
||||
{#await import("$lib/components/PwaStatus.svelte") then { default: PwaStatus }}
|
||||
<PwaStatus />
|
||||
{/await}
|
||||
{/if}
|
||||
{#if $unsavedChanges.size > 0}
|
||||
<button disabled={$syncStatus === 'uploading'} on:click={flashChanges} transition:fly={{x: -8}}
|
||||
title={$LL.deviceManager.APPLY_SETTINGS()} class="icon">save
|
||||
<button
|
||||
disabled={$syncStatus === "uploading"}
|
||||
on:click={flashChanges}
|
||||
transition:fly={{x: -8}}
|
||||
title={$LL.deviceManager.APPLY_SETTINGS()}
|
||||
class="icon"
|
||||
>save
|
||||
</button>
|
||||
<div transition:slide class="separator"/>
|
||||
<div transition:slide class="separator" />
|
||||
{/if}
|
||||
{#if $serialPort}
|
||||
<button title={$LL.backup.TITLE()} use:popup={BackupPopup} class="icon {$syncStatus}">
|
||||
@@ -92,11 +80,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
bind:this={connectButton}
|
||||
title="Devices"
|
||||
use:popup={ConnectionPopup}
|
||||
class="icon connect"
|
||||
class:error={$serialPort === undefined}
|
||||
bind:this={connectButton}
|
||||
title="Devices"
|
||||
use:popup={ConnectionPopup}
|
||||
class="icon connect"
|
||||
class:error={$serialPort === undefined}
|
||||
>
|
||||
cable
|
||||
</button>
|
||||
@@ -167,16 +155,20 @@
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
width: calc(min(100%, 28cm));
|
||||
margin-block: 8px;
|
||||
margin-inline: 16px;
|
||||
margin-inline: auto;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
margin-block: 0;
|
||||
|
||||
font-size: 1.5rem;
|
||||
@@ -195,7 +187,7 @@
|
||||
justify-content: center;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding: 4px;
|
||||
padding: 2px;
|
||||
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@@ -210,49 +202,16 @@
|
||||
color: var(--md-sys-color-on-error);
|
||||
background: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.steps {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
translate: -50% 0;
|
||||
display: flex;
|
||||
|
||||
> a.icon {
|
||||
aspect-ratio: unset;
|
||||
margin-inline: -4px;
|
||||
padding-inline: 16px;
|
||||
|
||||
font-size: 24px;
|
||||
color: var(--md-sys-on-surface-variant);
|
||||
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
clip-path: polygon(25% 50%, 0% 0%, 75% 0%, 100% 50%, 75% 100%, 0% 100%);
|
||||
border-radius: 0;
|
||||
|
||||
&.active,
|
||||
&:active {
|
||||
color: var(--md-sys-color-on-tertiary);
|
||||
background: var(--md-sys-color-tertiary);
|
||||
|
||||
&,
|
||||
~ * {
|
||||
translate: 8px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
&:last-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.icon.account {
|
||||
|
||||
@@ -2,10 +2,8 @@
|
||||
import {chords} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import Index from "flexsearch"
|
||||
import {tick} from "svelte"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import LL from "../../../i18n/i18n-svelte"
|
||||
import {actionAutocomplete} from "$lib/action-autocomplete"
|
||||
|
||||
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
|
||||
|
||||
@@ -20,11 +18,8 @@
|
||||
let searchFilter: number[] | undefined
|
||||
|
||||
function search(event: Event) {
|
||||
document.startViewTransition(async () => {
|
||||
const query = (event.target as HTMLInputElement).value
|
||||
searchFilter = query && searchIndex ? searchIndex.search(query) : undefined
|
||||
await tick()
|
||||
})
|
||||
const query = (event.target as HTMLInputElement).value
|
||||
searchFilter = query && searchIndex ? searchIndex.search(query) : undefined
|
||||
}
|
||||
|
||||
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
|
||||
@@ -38,7 +33,7 @@
|
||||
<input
|
||||
type="search"
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
|
||||
use:actionAutocomplete
|
||||
on:input={search}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {redirect} from "@sveltejs/kit"
|
||||
import type {PageLoad} from "./$types"
|
||||
|
||||
export const load = (() => {
|
||||
throw redirect(302, "/train/cpm/")
|
||||
}) satisfies PageLoad
|
||||
@@ -1,5 +0,0 @@
|
||||
<script>
|
||||
import TypingInput from "$lib/components/TypingInput.svelte"
|
||||
</script>
|
||||
|
||||
<TypingInput />
|
||||
Reference in New Issue
Block a user