mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2025-12-10 12:56:14 +00:00
feat: chord editing prototype
feat: lazy device connections feat: backup docs feat: chord library pagination
This commit is contained in:
64
docs/BACKUP.md
Normal file
64
docs/BACKUP.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Chara Backup Format, Version 1
|
||||
|
||||
JSON Schema files: TBD
|
||||
|
||||
Chara backups are serialized using JSON, in this general format:
|
||||
|
||||
```json
|
||||
{
|
||||
"charaVersion": 1,
|
||||
"type": "..."
|
||||
}
|
||||
```
|
||||
|
||||
The presence of the key `charaVersion` uniquely identifies the JSON file as a chara backup file and serves
|
||||
as a discriminator against other generic JSON files. This key is mandatory for that reason.
|
||||
|
||||
## Type `layout`
|
||||
|
||||
```json
|
||||
{
|
||||
"charaVersion": 1,
|
||||
"type": "layout",
|
||||
"device": "one",
|
||||
"layers": [[], [], []]
|
||||
}
|
||||
```
|
||||
|
||||
Devices at the current point in time may be identified as either `lite` or `one`, more to come in the future.
|
||||
|
||||
Layers are serialized as an array of `[layer1, layer2, layer3]` in the internal order of the key, each specifying
|
||||
an action code. Action codes of `0` are considered unassigned.
|
||||
|
||||
## Type `chords`
|
||||
|
||||
```json
|
||||
{
|
||||
"charaVersion": 1,
|
||||
"type": "chords",
|
||||
"chords": [
|
||||
[
|
||||
[1, 2, 3],
|
||||
[3, 4, 5]
|
||||
],
|
||||
[
|
||||
[6, 7, 8],
|
||||
[9, 10, 11]
|
||||
]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Chords are serialized using a key-value mapping of chord action codes to actions.
|
||||
|
||||
## Type `settings`
|
||||
|
||||
```json
|
||||
{
|
||||
"charaVersion": 1,
|
||||
"type": "settings",
|
||||
"settings": [0, 1, 3, 6]
|
||||
}
|
||||
```
|
||||
|
||||
Settings are serialized as an array of the values in the way they appear on the device.
|
||||
@@ -67,6 +67,8 @@ const config: IconsConfig = {
|
||||
"bolt",
|
||||
"undo",
|
||||
"redo",
|
||||
"navigate_before",
|
||||
"navigate_next",
|
||||
],
|
||||
codePoints: {
|
||||
speed: "e9e4",
|
||||
|
||||
@@ -62,6 +62,14 @@ const de = {
|
||||
INFO_BROWSER_SUFFIX: " sich bewusst dazu entschieden die API zu deaktivieren.",
|
||||
DOWNLOAD_APP: "Desktop-app herunterladen",
|
||||
},
|
||||
changes: {
|
||||
TITLE: "Änderungen anwenden",
|
||||
CHORD_ADD: "{0} Akkord{{|e}} hinzugefügt",
|
||||
CHORD_EDIT: "{0} Akkord{{|e}} bearbeitet",
|
||||
CHORD_DELETE: "{0} Akkord{{|e}} entfernt",
|
||||
SETTING_CHANGE: "{0} Einstellung{{|en}} geändert",
|
||||
LAYOUT_CHANGE: "{0} Layout-belegung{{|en}} geändert",
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Akkorde",
|
||||
|
||||
@@ -60,6 +60,14 @@ const en = {
|
||||
INFO_BROWSER_SUFFIX: ".",
|
||||
DOWNLOAD_APP: "Download the desktop app",
|
||||
},
|
||||
changes: {
|
||||
TITLE: "Apply changes",
|
||||
CHORD_ADD: "{0} chord{{|s}} added",
|
||||
CHORD_EDIT: "{0} chord{{|s}} edited",
|
||||
CHORD_DELETE: "{0} chord{{|s}} deleted",
|
||||
SETTING_CHANGE: "{0} setting{{|s}} changed",
|
||||
LAYOUT_CHANGE: "{0} layout key{{|s}} changed",
|
||||
},
|
||||
configure: {
|
||||
chords: {
|
||||
TITLE: "Chords",
|
||||
|
||||
113
src/lib/components/ActionStringEdit.svelte
Normal file
113
src/lib/components/ActionStringEdit.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import {KEYMAP_CODES, KEYMAP_IDS, specialKeycodes} from "$lib/serial/keymap-codes"
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import type ActionSelector from "$lib/components/layout/ActionSelector.svelte"
|
||||
|
||||
export let actions: number[]
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("selectionchange", select)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
document.removeEventListener("selectionchange", select)
|
||||
})
|
||||
|
||||
function input(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case "ArrowLeft":
|
||||
case "ArrowRight":
|
||||
case "Escape":
|
||||
case "Tab": {
|
||||
return
|
||||
}
|
||||
case "Backspace": {
|
||||
caretPosition--
|
||||
if (caretPosition >= 0) actions = actions.toSpliced(caretPosition, 1)
|
||||
else caretPosition = 0
|
||||
break
|
||||
}
|
||||
case "Delete": {
|
||||
if (caretPosition < actions.length) actions = actions.toSpliced(caretPosition, 1)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
if (specialKeycodes.has(event.key)) {
|
||||
actions = actions.toSpliced(caretPosition, 0, 32)
|
||||
} else if (KEYMAP_IDS.has(event.key)) {
|
||||
actions = actions.toSpliced(caretPosition, 0, KEYMAP_IDS.get(event.key)!.code)
|
||||
} else {
|
||||
break
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
event.preventDefault()
|
||||
console.log(event.key)
|
||||
}
|
||||
|
||||
function select() {
|
||||
const selection = document.getSelection()
|
||||
if (!selection || !selection.containsNode(field, true)) return
|
||||
|
||||
let node = selection.anchorNode?.parentNode
|
||||
let i = 0
|
||||
while (node) {
|
||||
i++
|
||||
node = node.previousSibling
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0)
|
||||
const clonedRange = range.cloneRange()
|
||||
clonedRange.selectNodeContents(field)
|
||||
clonedRange.setEnd(range.endContainer, range.endOffset)
|
||||
|
||||
caretPosition = (i - 1) / 2 + clonedRange.endOffset
|
||||
console.log(caretPosition)
|
||||
}
|
||||
|
||||
let editDialog: ActionSelector
|
||||
let caretPosition: number
|
||||
let field: HTMLSpanElement
|
||||
</script>
|
||||
|
||||
<svelte:window on:selectionchange={select} />
|
||||
|
||||
<span
|
||||
bind:this={field}
|
||||
contenteditable
|
||||
on:keydown={input}
|
||||
spellcheck="false"
|
||||
on:select|preventDefault={select}
|
||||
role="textbox"
|
||||
tabindex="0"
|
||||
>
|
||||
{#each actions as char}
|
||||
{@const action = KEYMAP_CODES[char]}
|
||||
{#if action?.id && /^\w$/.test(action.id)}
|
||||
<span data-action={char}>{KEYMAP_CODES[char].id}</span>
|
||||
{:else if action}
|
||||
<kbd data-action={char} title={action.title} class:icon={!!action.icon}>{action.icon || action.id}</kbd>
|
||||
{:else}
|
||||
<kbd data-action={char}>{action}</kbd>
|
||||
{/if}
|
||||
{/each}
|
||||
<!-- <kbd class="icon" style="background: red">abc</kbd> -->
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
kbd {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
|
||||
:not(kbd) + kbd {
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
span[contenteditable]:focus-within {
|
||||
outline-offset: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,7 +4,7 @@
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<p>{title}</p>
|
||||
<p>{@html title}</p>
|
||||
{/if}
|
||||
|
||||
{#if shortcut}
|
||||
|
||||
@@ -25,7 +25,7 @@ export const layout = persistentWritable<CharaLayout>(
|
||||
|
||||
export interface Change {
|
||||
layout?: Record<number, Record<number, number>>
|
||||
chords?: never
|
||||
chords?: Array<Record<"delete" | "edit" | "add", Chord>>
|
||||
settings?: Record<number, number>
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ export class CharaDevice {
|
||||
!manual && ports.length === 1
|
||||
? ports[0]
|
||||
: await navigator.serial.requestPort({filters: [{usbVendorId: VENDOR_ID}]})
|
||||
|
||||
await this.port.open({baudRate: this.baudRate})
|
||||
const info = this.port.getInfo()
|
||||
serialLog.update(it => {
|
||||
@@ -53,7 +54,27 @@ export class CharaDevice {
|
||||
})
|
||||
return it
|
||||
})
|
||||
await this.port.close()
|
||||
|
||||
const [version] = await this.send("VERSION")
|
||||
this.version = version.split(".").map(Number) as [number, number, number]
|
||||
const [company, device, chipset] = await this.send("ID")
|
||||
this.company = company as "CHARACHORDER"
|
||||
this.device = device as "ONE" | "LITE"
|
||||
this.chipset = chipset as "M0" | "S2"
|
||||
}
|
||||
|
||||
private async suspend() {
|
||||
await this.reader.cancel()
|
||||
await this.streamClosed.catch(() => {
|
||||
/** noop */
|
||||
})
|
||||
this.reader.releaseLock()
|
||||
await this.port.close()
|
||||
}
|
||||
|
||||
private async wake() {
|
||||
await this.port.open({baudRate: this.baudRate})
|
||||
const decoderStream = new TextDecoderStream()
|
||||
this.streamClosed = this.port.readable!.pipeTo(decoderStream.writable, {
|
||||
signal: this.abortController1.signal,
|
||||
@@ -64,13 +85,6 @@ export class CharaDevice {
|
||||
signal: this.abortController2.signal,
|
||||
})
|
||||
.getReader()
|
||||
|
||||
const [version] = await this.send("VERSION")
|
||||
this.version = version.split(".").map(Number) as [number, number, number]
|
||||
const [company, device, chipset] = await this.send("ID")
|
||||
this.company = company as "CHARACHORDER"
|
||||
this.device = device as "ONE" | "LITE"
|
||||
this.chipset = chipset as "M0" | "S2"
|
||||
}
|
||||
|
||||
private async internalRead() {
|
||||
@@ -105,19 +119,9 @@ export class CharaDevice {
|
||||
}
|
||||
|
||||
async forget() {
|
||||
await this.disconnect()
|
||||
await this.port.forget()
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
await this.reader.cancel()
|
||||
await this.streamClosed.catch(() => {
|
||||
/** noop */
|
||||
})
|
||||
this.reader.releaseLock()
|
||||
await this.port.close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Read/write to serial port
|
||||
*/
|
||||
@@ -132,8 +136,10 @@ export class CharaDevice {
|
||||
const exec = new Promise<T>(async resolve => {
|
||||
let result!: T
|
||||
try {
|
||||
await this.wake()
|
||||
result = await callback(send, read)
|
||||
} finally {
|
||||
await this.suspend()
|
||||
this.lock = undefined
|
||||
resolve(result)
|
||||
}
|
||||
@@ -252,7 +258,6 @@ export class CharaDevice {
|
||||
*/
|
||||
async reboot() {
|
||||
await this.send("RST")
|
||||
await this.disconnect()
|
||||
// TODO: reconnect
|
||||
}
|
||||
|
||||
@@ -261,7 +266,6 @@ export class CharaDevice {
|
||||
*/
|
||||
async bootloader() {
|
||||
await this.send("RST BOOTLOADER")
|
||||
await this.disconnect()
|
||||
// TODO: more...
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,17 @@ export const KEYMAP_CODES: Record<number, KeyInfo> = Object.fromEntries(
|
||||
]),
|
||||
),
|
||||
)
|
||||
|
||||
export const KEYMAP_IDS: Map<string, KeyInfo> = new Map(
|
||||
keymaps
|
||||
.flatMap(category =>
|
||||
Object.entries(category.actions).map(
|
||||
([code, action]) => [action.id!, {...action, code: Number(code), category}] as const,
|
||||
),
|
||||
)
|
||||
.filter(([id]) => id !== undefined),
|
||||
)
|
||||
|
||||
export const specialKeycodes = new Map([
|
||||
[" ", 32], // Space
|
||||
])
|
||||
|
||||
@@ -9,7 +9,11 @@ export interface CharaLayoutFile extends CharaFile<"layout"> {
|
||||
}
|
||||
|
||||
export interface CharaChordFile extends CharaFile<"chords"> {
|
||||
chords: [number[], number[]]
|
||||
chords: [number[], number[]][]
|
||||
}
|
||||
|
||||
export interface CharaChordSettings extends CharaFile<"settings"> {
|
||||
settings: number[]
|
||||
}
|
||||
|
||||
export type CharaFiles = CharaLayoutFile | CharaChordFile
|
||||
|
||||
@@ -28,6 +28,8 @@ button {
|
||||
transition: all 250ms ease;
|
||||
|
||||
&.icon {
|
||||
display: inline-flex;
|
||||
|
||||
aspect-ratio: 1;
|
||||
padding-block: 0;
|
||||
padding-inline: 0;
|
||||
@@ -41,6 +43,10 @@ button {
|
||||
color: var(--md-sys-color-on-primary);
|
||||
background: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
&.compact {
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
|
||||
label:has(input):hover,
|
||||
|
||||
@@ -40,8 +40,14 @@
|
||||
<div class="separator" />
|
||||
<button use:action={{title: $LL.saveActions.SAVE(), shortcut: "ctrl+shift+s"}} class="icon">save</button>
|
||||
{#if $changes.length !== 0}
|
||||
<button class="click-me" transition:fly={{x: 8}} use:action={{shortcut: "ctrl+s"}}
|
||||
><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
|
||||
<button
|
||||
class="click-me"
|
||||
transition:fly={{x: 8}}
|
||||
on:click={apply}
|
||||
use:action={{
|
||||
title: $LL.changes.TITLE(),
|
||||
shortcut: "ctrl+s",
|
||||
}}><span class="icon">bolt</span>{$LL.saveActions.APPLY()}</button
|
||||
>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,9 +1,29 @@
|
||||
<script lang="ts">
|
||||
import {chords} from "$lib/serial/connection"
|
||||
import {changes, chords} from "$lib/serial/connection"
|
||||
import {KEYMAP_CODES} from "$lib/serial/keymap-codes"
|
||||
import Index from "flexsearch"
|
||||
import type {Chord} from "$lib/serial/chord"
|
||||
import LL from "../../../i18n/i18n-svelte"
|
||||
import {action} from "$lib/title"
|
||||
import {onDestroy, onMount} from "svelte"
|
||||
import ActionStringEdit from "$lib/components/ActionStringEdit.svelte"
|
||||
|
||||
const resultSize = 38
|
||||
let results: HTMLElement
|
||||
let pageSize: number
|
||||
let resizeObserver: ResizeObserver
|
||||
|
||||
onMount(() => {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
pageSize = Math.floor(results.clientHeight / resultSize)
|
||||
})
|
||||
pageSize = Math.floor(results.clientHeight / resultSize)
|
||||
resizeObserver.observe(results)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
$: searchIndex = $chords?.length > 0 ? buildIndex($chords) : undefined
|
||||
|
||||
@@ -23,6 +43,9 @@
|
||||
}
|
||||
|
||||
$: items = searchFilter?.map(it => [$chords[it], it] as const) ?? $chords.map((it, i) => [it, i] as const)
|
||||
$: lastPage = Math.ceil(items.length / pageSize) - 1
|
||||
|
||||
let page = 0
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -35,35 +58,32 @@
|
||||
placeholder={$LL.configure.chords.search.PLACEHOLDER($chords.length)}
|
||||
on:input={search}
|
||||
/>
|
||||
<span>{page + 1} / {lastPage + 1}</span>
|
||||
<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>
|
||||
|
||||
<!--
|
||||
{#if searchIndex}
|
||||
<input
|
||||
on:input={search}
|
||||
type="search"
|
||||
|
||||
/>
|
||||
{/if}-->
|
||||
|
||||
<section>
|
||||
<section bind:this={results}>
|
||||
<table>
|
||||
{#each items.slice(0, 50) as [{ phrase, actions }, i]}
|
||||
<tr style="view-transition-name: chord-{i}">
|
||||
{#each items.slice(page * pageSize, (page + 1) * pageSize) as [chord]}
|
||||
<tr>
|
||||
<th>
|
||||
{#each phrase as char}
|
||||
{KEYMAP_CODES[char].id}
|
||||
{/each}
|
||||
<ActionStringEdit actions={chord.phrase} />
|
||||
</th>
|
||||
<td>
|
||||
{#each actions as action}
|
||||
{@const keyInfo = KEYMAP_CODES[action]}
|
||||
{#if keyInfo}
|
||||
<abbr title={keyInfo.title} class:icon={!!keyInfo.icon}>{keyInfo.icon || keyInfo.id}</abbr>
|
||||
{:else}
|
||||
<pre>{action}</pre>
|
||||
{/if}
|
||||
{/each}
|
||||
<ActionStringEdit actions={chord.actions} />
|
||||
</td>
|
||||
<td class="table-buttons">
|
||||
<button class="icon compact">share</button>
|
||||
<button class="icon compact" on:click={() => $changes.push({chords: [{delete: chord}]})}
|
||||
>delete</button
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
@@ -98,16 +118,11 @@
|
||||
}
|
||||
|
||||
section {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
position: relative;
|
||||
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
|
||||
height: 100%;
|
||||
padding-inline: 8px;
|
||||
|
||||
border-radius: 16px;
|
||||
@@ -143,10 +158,12 @@
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
td {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
justify-content: flex-end;
|
||||
.table-buttons {
|
||||
opacity: 0;
|
||||
transition: opacity 75ms ease;
|
||||
}
|
||||
|
||||
tr:hover > .table-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user