feat: new chord editing

feat: clear all changes with shift undo, fixes #7
This commit is contained in:
2023-11-10 01:17:36 +01:00
parent c661a4b30b
commit 94cfaf40e5
10 changed files with 306 additions and 99 deletions

View File

@@ -71,6 +71,7 @@ const config: IconsConfig = {
"navigate_before",
"navigate_next",
"print",
"restore_from_trash",
],
codePoints: {
speed: "e9e4",

View File

@@ -4,7 +4,7 @@ const de = {
TITLE: "CharaChorder Gerätemanager",
DESCRIPTION: "Gerätemanager und Konfigurationstool für CharaChorder Geräte.",
saveActions: {
UNDO: "Rückgängig",
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
REDO: "Wiederholen",
APPLY: "Anwenden",
SAVE: "Änderungen auf das Gerät schreiben",

View File

@@ -4,7 +4,7 @@ const en = {
TITLE: "CharaChorder Device Manager",
DESCRIPTION: "The device manager and configuration tool for CharaChorder devices.",
saveActions: {
UNDO: "Undo",
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
REDO: "Redo",
APPLY: "Apply",
SAVE: "Write changes to your device",

View File

@@ -90,20 +90,22 @@ export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
export type ChordInfo = Chord & ChangeInfo
export const chords = derived([overlay, deviceChords], ([overlay, chords]) =>
chords.map<ChordInfo>(chord => {
const key = serializeActions(chord.actions)
if (overlay.chords.has(key)) {
return {
actions: chord.actions,
phrase: overlay.chords.get(key)!,
isApplied: false,
chords
.map<ChordInfo>(chord => {
const key = serializeActions(chord.actions)
if (overlay.chords.has(key)) {
return {
actions: chord.actions,
phrase: overlay.chords.get(key)!,
isApplied: false,
}
} else {
return {
actions: chord.actions,
phrase: chord.phrase,
isApplied: true,
}
}
} else {
return {
actions: chord.actions,
phrase: chord.phrase,
isApplied: true,
}
}
}),
})
.sort((a, b) => (a.actions.some((it, i) => it > b.actions[i]) ? 1 : -1)),
)

View File

@@ -7,9 +7,13 @@
import {deviceChords, deviceLayout, deviceSettings, serialPort, syncStatus} from "$lib/serial/connection"
import {deserializeActions} from "$lib/serial/chord"
function undo() {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
function undo(event: MouseEvent) {
if (event.shiftKey) {
changes.set([])
} else {
redoQueue = [$changes.pop()!, ...redoQueue]
changes.update(it => it)
}
}
function redo() {

View File

@@ -92,7 +92,7 @@
content: "";
position: absolute;
top: 12px;
top: 20px;
left: 50%;
transform-origin: top;
translate: -50% 0;

View File

@@ -3,11 +3,12 @@
import Index from "flexsearch"
import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title"
import {onDestroy, onMount} from "svelte"
import ActionStringEdit from "$lib/components/ActionStringEdit.svelte"
import {changes, ChangeType, chords} from "$lib/undo-redo"
import {onDestroy, onMount, setContext} from "svelte"
import {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} from "svelte/transition"
const resultSize = 38
let results: HTMLElement
@@ -45,13 +46,15 @@
searchFilter.set(query && searchIndex ? searchIndex.search(query) : undefined)
}
const items = derived([searchFilter, chords], ([filter, chords]) =>
(filter?.map(it => [chords[it], it] as const) ?? chords.map((it, i) => [it, i] as const)).filter(
([{phrase}]) => phrase.length > 0,
),
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 / pageSize) - 1)
setContext("cursor-crossfade", crossfade({}))
let page = 0
$: {
$items
@@ -89,26 +92,8 @@
<section bind:this={results}>
<table>
{#if $lastPage !== -1}
{#each $items.slice(page * $pageSize, (page + 1) * $pageSize) as [{ actions, phrase, isApplied }]}
<tr style:color={isApplied ? "" : "var(--md-sys-color-secondary"}>
<th>
<ActionStringEdit {actions} />
</th>
<td>
<ActionStringEdit actions={phrase} />
</td>
<td class="table-buttons">
<button class="icon compact">share</button>
<button
class="icon compact"
on:click={() =>
changes.update(changes => {
changes.push({type: ChangeType.Chord, actions, phrase: []})
return changes
})}>delete</button
>
</td>
</tr>
{#each $items.slice(page * $pageSize, (page + 1) * $pageSize) as [chord], i (`${page}:${i}`)}
<ChordEdit {chord} isApplied={chord.isApplied} />
{/each}
{:else}
<caption> No Results </caption>
@@ -175,17 +160,4 @@
min-width: min(90vw, 16.5cm);
transition: all 1s ease;
}
th {
text-align: start;
}
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
}
tr:hover > .table-buttons {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,84 @@
<script lang="ts">
import type {Chord} from "$lib/serial/chord"
import {KEYMAP_CODES, KEYMAP_IDS} from "$lib/serial/keymap-codes"
export let chord: Chord
let pressedKeys = new Set<number>()
let editing = false
function edit() {
pressedKeys = new Set()
editing = true
}
function keydown(event: KeyboardEvent) {
// TODO...
pressedKeys.add(KEYMAP_IDS.get(event.key)!.code)
pressedKeys = pressedKeys
}
function keyup() {
editing = false
// TODO: apply
}
</script>
<button class:deleted={chord.phrase.length === 0} on:click={edit} on:keydown={keydown} on:keyup={keyup}>
{#if editing && pressedKeys.size === 0}
<span>Press keys</span>
{/if}
{#each editing ? [...pressedKeys].sort() : chord.actions as actionId}
{@const {icon, id, code} = KEYMAP_CODES[actionId] ?? {code: actionId}}
<kbd class:icon={!!icon}>
{icon ?? id ?? `0x${code.toString(16)}`}
</kbd>
{/each}
</button>
<style lang="scss">
span {
opacity: 0.5;
}
button {
position: relative;
display: inline-flex;
gap: 4px;
&:focus-within {
outline: none;
}
}
kbd {
transition: color 250ms ease;
}
span::after {
content: "";
position: absolute;
top: 50%;
left: -4px;
transform-origin: center left;
scale: 0 1;
width: calc(100% + 8px);
height: 1px;
background: currentcolor;
transition:
scale 250ms ease,
color 250ms ease;
}
.deleted {
color: var(--md-sys-color-error);
&::after {
scale: 1;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<script lang="ts">
import {changes, ChangeType} 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"
export let chord: Chord
export let isApplied: boolean
function remove() {
changes.update(changes => {
changes.push({type: ChangeType.Chord, actions: chord.actions, phrase: []})
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))))
}
</script>
<tr>
<th>
<ChordActionEdit {chord} />
</th>
<td>
<ChordPhraseEdit {chord} edited={!isApplied} />
</td>
<td class="table-buttons">
{#if chord.phrase.length === 0}
<button transition:slide class="icon compact" on:click={restore}>restore_from_trash</button>
{:else}
<button transition:slide class="icon compact" on:click={remove}>delete</button>
{/if}
<button class="icon compact" class:disabled={isApplied} on:click={restore}>undo</button>
<div class="separator" />
<button class="icon compact">share</button>
</td>
</tr>
<style lang="scss">
.separator {
display: inline-flex;
width: 1px;
height: 24px;
opacity: 0.2;
background: currentcolor;
}
td {
position: relative;
}
.table-buttons {
opacity: 0;
transition: opacity 75ms ease;
}
tr:focus-within > .table-buttons,
tr:hover > .table-buttons {
opacity: 1;
}
</style>

View File

@@ -2,9 +2,12 @@
import {KEYMAP_CODES, KEYMAP_IDS, specialKeycodes} from "$lib/serial/keymap-codes"
import {tick} from "svelte"
import ActionSelector from "$lib/components/layout/ActionSelector.svelte"
import type {Chord} from "$lib/serial/chord"
import {changes, ChangeType} from "$lib/undo-redo"
import {scale} from "svelte/transition"
export let actions: number[]
export let chord: Chord
export let edited: boolean
function keypress(event: KeyboardEvent) {
if (event.key === "ArrowUp") {
@@ -28,17 +31,31 @@
}
function moveCursor(to: number) {
cursorPosition = Math.max(0, Math.min(to, actions.length))
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) {
actions = actions.toSpliced(at, count)
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, count),
})
return changes
})
}
function insertAction(at: number, action: number) {
actions = actions.toSpliced(at, 0, action)
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
actions: chord.actions,
phrase: chord.phrase.toSpliced(at, 0, action),
})
return changes
})
}
function clickCursor(event: unknown) {
@@ -105,13 +122,29 @@
let box: HTMLDivElement
let cursorPosition = 0
let cursorOffset = 0
let hasFocus = false
</script>
<div on:keydown={keypress} on:mousedown={clickCursor} role="textbox" tabindex="0" bind:this={box}>
<div class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} on:click={selectAction}>add</button>
</div>
{#each actions as actionId}
<div
on:keydown={keypress}
on:mousedown={clickCursor}
role="textbox"
tabindex="0"
bind:this={box}
class:edited
on:focusin={() => (hasFocus = true)}
on:focusout={() => (hasFocus = false)}
>
{#if hasFocus}
<div transition:scale class="cursor" style:translate="{cursorOffset}px 0">
<button class="icon" bind:this={button} on:click={selectAction}>add</button>
</div>
{:else}
<div />
<!-- placeholder for cursor placement -->
{/if}
{#each chord.phrase as actionId, i (`${actionId}:${i}`)}
{@const {icon, id, code} = KEYMAP_CODES[actionId] ?? {code: actionId}}
{#if !icon && id?.length === 1}
<span>{id}</span>
@@ -119,11 +152,50 @@
<kbd class:icon={!!icon}>{icon ?? id ?? `0x${code.toString(16)}`}</kbd>
{/if}
{/each}
<sup></sup>
</div>
<style lang="scss">
sup {
translate: 0 -40%;
opacity: 0;
transition: opacity 250ms ease;
}
.cursor {
display: none;
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;
}
}
:not(.cursor) + kbd {
@@ -143,38 +215,40 @@
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 250ms ease,
scale 250ms ease;
}
&::after {
scale: 0 1;
}
&:hover::before {
opacity: 0.3;
}
&:focus-within {
outline: none;
.cursor {
position: absolute;
transform: translateX(-50%);
translate: 0 0;
display: block;
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;
}
&::after {
scale: 1;
opacity: 1;
}
}
}