feat: new chord button, fixes #9

feat: improved backups
This commit is contained in:
2023-11-10 17:31:52 +01:00
parent 034436f93e
commit e19a57efac
9 changed files with 177 additions and 70 deletions

View File

@@ -77,6 +77,8 @@ const de = {
configure: { configure: {
chords: { chords: {
TITLE: "Akkorde", TITLE: "Akkorde",
HOLD_KEYS: "Akkord halten",
NEW_CHORD: "Neuer Akkord",
search: { search: {
PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen", PLACEHOLDER: "{0} Akkord{{|e}} durchsuchen",
}, },

View File

@@ -75,6 +75,8 @@ const en = {
configure: { configure: {
chords: { chords: {
TITLE: "Chords", TITLE: "Chords",
HOLD_KEYS: "Hold chord",
NEW_CHORD: "New chord",
search: { search: {
PLACEHOLDER: "Search {0} chord{{|s}}", PLACEHOLDER: "Search {0} chord{{|s}}",
}, },

View File

@@ -4,7 +4,7 @@ export interface CharaFile<T extends string> {
} }
export interface CharaLayoutFile extends CharaFile<"layout"> { export interface CharaLayoutFile extends CharaFile<"layout"> {
device: "one" | "lite" | string device?: "ONE" | "LITE" | string
layout: [number[], number[], number[]] layout: [number[], number[], number[]]
} }
@@ -12,8 +12,12 @@ export interface CharaChordFile extends CharaFile<"chords"> {
chords: [number[], number[]][] chords: [number[], number[]][]
} }
export interface CharaChordSettings extends CharaFile<"settings"> { export interface CharaSettingsFile extends CharaFile<"settings"> {
settings: number[] settings: number[]
} }
export interface CharaBackupFile extends CharaFile<"backup"> {
history: [CharaChordFile, CharaLayoutFile, CharaSettingsFile][]
}
export type CharaFiles = CharaLayoutFile | CharaChordFile export type CharaFiles = CharaLayoutFile | CharaChordFile

View File

@@ -46,7 +46,6 @@ export interface Overlay {
} }
export const overlay = derived(changes, changes => { export const overlay = derived(changes, changes => {
console.time("overlay building")
const overlay: Overlay = { const overlay: Overlay = {
layout: [new Map(), new Map(), new Map()], layout: [new Map(), new Map(), new Map()],
chords: new Map(), chords: new Map(),
@@ -66,7 +65,6 @@ export const overlay = derived(changes, changes => {
break break
} }
} }
console.timeEnd("overlay building")
return overlay return overlay
}) })
@@ -91,33 +89,48 @@ export const layout = derived([overlay, deviceLayout], ([overlay, layout]) =>
export type ChordInfo = Chord & export type ChordInfo = Chord &
ChangeInfo & {phraseChanged: boolean; actionsChanged: boolean; sortBy: string} & {id: number[]} ChangeInfo & {phraseChanged: boolean; actionsChanged: boolean; sortBy: string} & {id: number[]}
export const chords = derived([overlay, deviceChords], ([overlay, chords]) => export const chords = derived([overlay, deviceChords], ([overlay, chords]) => {
chords const newChords = new Set(overlay.chords.keys())
.map<ChordInfo>(chord => {
const id = JSON.stringify(chord.actions) const changedChords = chords.map<ChordInfo>(chord => {
if (overlay.chords.has(id)) { const id = JSON.stringify(chord.actions)
const changedChord = overlay.chords.get(id)! if (overlay.chords.has(id)) {
return { newChords.delete(id)
id: chord.actions, const changedChord = overlay.chords.get(id)!
// use the old phrase for stable editing return {
sortBy: chord.phrase.map(it => KEYMAP_CODES[it].id || it).join(), id: chord.actions,
actions: changedChord.actions, // use the old phrase for stable editing
phrase: changedChord.phrase, sortBy: chord.phrase.map(it => KEYMAP_CODES[it].id || it).join(),
actionsChanged: id !== JSON.stringify(changedChord.actions), actions: changedChord.actions,
phraseChanged: JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase), phrase: changedChord.phrase,
isApplied: false, actionsChanged: id !== JSON.stringify(changedChord.actions),
} phraseChanged: JSON.stringify(chord.phrase) !== JSON.stringify(changedChord.phrase),
} else { isApplied: false,
return {
id: chord.actions,
sortBy: chord.phrase.map(it => KEYMAP_CODES[it].id || it).join(),
actions: chord.actions,
phrase: chord.phrase,
phraseChanged: false,
actionsChanged: false,
isApplied: true,
}
} }
} else {
return {
id: chord.actions,
sortBy: chord.phrase.map(it => KEYMAP_CODES[it].id || it).join(),
actions: chord.actions,
phrase: chord.phrase,
phraseChanged: false,
actionsChanged: false,
isApplied: true,
}
}
})
for (const id of newChords) {
const chord = overlay.chords.get(id)!
changedChords.push({
sortBy: "",
isApplied: false,
actionsChanged: true,
phraseChanged: false,
id: JSON.parse(id),
phrase: chord.phrase,
actions: chord.actions,
}) })
.sort(({sortBy: a}, {sortBy: b}) => a.localeCompare(b)), }
)
return changedChords.sort(({sortBy: a}, {sortBy: b}) => a.localeCompare(b))
})

View File

@@ -1,27 +1,42 @@
<script lang="ts"> <script lang="ts">
import {parseCompressed, stringifyCompressed} from "$lib/serial/serialization" import {serialPort} from "$lib/serial/connection"
import {deviceChords, deviceLayout} from "$lib/serial/connection"
import {preference} from "$lib/preferences" import {preference} from "$lib/preferences"
import type {Chord} from "$lib/serial/chord"
import type {CharaLayout} from "$lib/serialization/layout"
import LL from "../i18n/i18n-svelte" import LL from "../i18n/i18n-svelte"
import type {
interface Backup { CharaBackupFile,
isCharaBackup: string CharaChordFile,
chords: Chord[] CharaSettingsFile,
layout: CharaLayout CharaLayoutFile,
} } from "$lib/share/chara-file.js"
import {changes, ChangeType, chords, layout, settings} from "$lib/undo-redo.js"
import type {Change} from "$lib/undo-redo.js"
async function downloadBackup() { async function downloadBackup() {
const downloadUrl = URL.createObjectURL( const downloadUrl = URL.createObjectURL(
await stringifyCompressed({ new Blob(
isCharaBackup: "v1.0", [
chords: $deviceChords, JSON.stringify({
layout: $deviceLayout, charaVersion: 1,
}), type: "backup",
history: [
[
{charaVersion: 1, type: "chords", chords: $chords.map(it => [it.actions, it.phrase])},
{
charaVersion: 1,
type: "layout",
device: $serialPort?.device,
layout: $layout.map(it => it.map(it => it.action)) as [number[], number[], number[]],
},
{charaVersion: 1, type: "settings", settings: $settings.map(it => it.value)},
],
],
} satisfies CharaBackupFile),
],
{type: "application/json"},
),
) )
const element = document.createElement("a") const element = document.createElement("a")
element.setAttribute("download", "chords.chb") element.setAttribute("download", "backup.json")
element.href = downloadUrl element.href = downloadUrl
element.setAttribute("target", "_blank") element.setAttribute("target", "_blank")
element.click() element.click()
@@ -31,14 +46,57 @@
async function restoreBackup(event: Event) { async function restoreBackup(event: Event) {
const input = (event.target as HTMLInputElement).files![0] const input = (event.target as HTMLInputElement).files![0]
if (!input) return if (!input) return
const backup = await parseCompressed<Backup>(input) const backup: CharaBackupFile = JSON.parse(await input.text())
if (backup.isCharaBackup !== "v1.0") throw new Error("Invalid Backup") if (backup.charaVersion !== 1 || backup.type !== "backup") throw new Error("Invalid Backup")
if (backup.chords) {
$deviceChords = backup.chords const recent = backup.history[0]
if (recent[1].device !== $serialPort?.device) throw new Error("Backup is incompatible with this device")
changes.update(changes => {
changes.push(
...getChangesFromChordFile(recent[0]),
...getChangesFromLayoutFile(recent[1]),
...getChangesFromSettingsFile(recent[2]),
)
return changes
})
}
function getChangesFromChordFile(file: CharaChordFile) {
const changes: Change[] = []
// TODO...
return changes
}
function getChangesFromSettingsFile(file: CharaSettingsFile) {
const changes: Change[] = []
for (const [id, value] of file.settings.entries()) {
if ($settings[id].value !== value) {
changes.push({
type: ChangeType.Setting,
id,
setting: value,
})
}
} }
if (backup.layout) { return changes
$deviceLayout = backup.layout }
function getChangesFromLayoutFile(file: CharaLayoutFile) {
const changes: Change[] = []
for (const [layer, keys] of file.layout.entries()) {
for (const [id, action] of keys.entries()) {
if ($layout[layer][id].action !== action) {
changes.push({
type: ChangeType.Layout,
layer,
id,
action,
})
}
}
} }
return changes
} }
</script> </script>

View File

@@ -4,11 +4,12 @@
import LL from "../../../i18n/i18n-svelte" import LL from "../../../i18n/i18n-svelte"
import {action} from "$lib/title" import {action} from "$lib/title"
import {onDestroy, onMount, setContext} from "svelte" import {onDestroy, onMount, setContext} from "svelte"
import {chords} from "$lib/undo-redo" import {changes, ChangeType, chords} from "$lib/undo-redo"
import type {ChordInfo} from "$lib/undo-redo" import type {ChordInfo} from "$lib/undo-redo"
import {derived, writable} from "svelte/store" import {derived, writable} from "svelte/store"
import ChordEdit from "./ChordEdit.svelte" import ChordEdit from "./ChordEdit.svelte"
import {crossfade} from "svelte/transition" import {crossfade} from "svelte/transition"
import ChordActionEdit from "./ChordActionEdit.svelte"
const resultSize = 38 const resultSize = 38
let results: HTMLElement let results: HTMLElement
@@ -46,12 +47,27 @@
searchFilter.set(query && searchIndex ? searchIndex.search(query) : undefined) searchFilter.set(query && searchIndex ? searchIndex.search(query) : undefined)
} }
function insertChord(actions: number[]) {
changes.update(changes => {
changes.push({
type: ChangeType.Chord,
id: actions,
actions,
phrase: [],
})
return changes
})
}
const items = derived( const items = derived(
[searchFilter, chords], [searchFilter, chords],
([filter, chords]) => ([filter, chords]) =>
filter?.map(it => [chords[it], it] as const) ?? chords.map((it, i) => [it, i] as const), 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) const lastPage = derived(
[items, pageSize],
([items, pageSize]) => Math.ceil((items.length + 1) / pageSize) - 1,
)
setContext("cursor-crossfade", crossfade({})) setContext("cursor-crossfade", crossfade({}))
@@ -91,8 +107,11 @@
<section bind:this={results}> <section bind:this={results}>
<table> <table>
{#if page === 0}
<tr><th><ChordActionEdit on:submit={({detail}) => insertChord(detail)} /></th><td /><td /></tr>
{/if}
{#if $lastPage !== -1} {#if $lastPage !== -1}
{#each $items.slice(page * $pageSize, (page + 1) * $pageSize) as [chord] (chord.id)} {#each $items.slice(page * $pageSize - (page === 0 ? 0 : 1), (page + 1) * $pageSize - 1) as [chord] (JSON.stringify(chord.id))}
<tr> <tr>
<ChordEdit {chord} /> <ChordEdit {chord} />
</tr> </tr>

View File

@@ -2,8 +2,12 @@
import {KEYMAP_CODES, KEYMAP_IDS} from "$lib/serial/keymap-codes" import {KEYMAP_CODES, KEYMAP_IDS} from "$lib/serial/keymap-codes"
import type {ChordInfo} from "$lib/undo-redo" import type {ChordInfo} from "$lib/undo-redo"
import {changes, ChangeType} from "$lib/undo-redo" import {changes, ChangeType} from "$lib/undo-redo"
import {createEventDispatcher} from "svelte"
import LL from "../../../i18n/i18n-svelte"
export let chord: ChordInfo export let chord: ChordInfo | undefined = undefined
const dispatch = createEventDispatcher()
let pressedKeys = new Set<number>() let pressedKeys = new Set<number>()
let editing = false let editing = false
@@ -24,12 +28,13 @@
if (!editing) return if (!editing) return
editing = false editing = false
if (pressedKeys.size < 2) return if (pressedKeys.size < 2) return
if (!chord) return dispatch("submit", [...pressedKeys])
changes.update(changes => { changes.update(changes => {
changes.push({ changes.push({
type: ChangeType.Chord, type: ChangeType.Chord,
id: chord.id, id: chord!.id,
actions: [...pressedKeys], actions: [...pressedKeys],
phrase: chord.phrase, phrase: chord!.phrase,
}) })
return changes return changes
}) })
@@ -37,16 +42,18 @@
</script> </script>
<button <button
class:deleted={chord.phrase.length === 0} class:deleted={chord && chord.phrase.length === 0}
class:edited={chord.actionsChanged} class:edited={chord && chord.actionsChanged}
on:click={edit} on:click={edit}
on:keydown={keydown} on:keydown={keydown}
on:keyup={keyup} on:keyup={keyup}
> >
{#if editing && pressedKeys.size === 0} {#if editing && pressedKeys.size === 0}
<span>Press keys</span> <span>{$LL.configure.chords.HOLD_KEYS()}</span>
{:else if !editing && !chord}
<span>{$LL.configure.chords.NEW_CHORD()}</span>
{/if} {/if}
{#each editing ? [...pressedKeys].sort() : chord.actions as actionId} {#each editing ? [...pressedKeys].sort() : chord?.actions ?? [] as actionId}
{@const {icon, id, code} = KEYMAP_CODES[actionId] ?? {code: actionId}} {@const {icon, id, code} = KEYMAP_CODES[actionId] ?? {code: actionId}}
<kbd class:icon={!!icon}> <kbd class:icon={!!icon}>
{icon ?? id ?? `0x${code.toString(16)}`} {icon ?? id ?? `0x${code.toString(16)}`}
@@ -92,9 +99,10 @@
position: absolute; position: absolute;
top: 50%; top: 50%;
transform-origin: center left; transform-origin: center left;
translate: -6px 0;
scale: 0 1; scale: 0 1;
width: calc(100% - 16px); width: calc(100% - 32px);
height: 1px; height: 1px;
background: currentcolor; background: currentcolor;

View File

@@ -31,10 +31,10 @@
<ChordPhraseEdit {chord} /> <ChordPhraseEdit {chord} />
</td> </td>
<td class="table-buttons"> <td class="table-buttons">
{#if chord.phrase.length === 0} {#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> <button transition:slide class="icon compact" on:click={remove}>delete</button>
{:else if chord.phraseChanged}
<button transition:slide class="icon compact" on:click={restore}>restore_from_trash</button>
{/if} {/if}
<button class="icon compact" class:disabled={chord.isApplied} on:click={restore}>undo</button> <button class="icon compact" class:disabled={chord.isApplied} on:click={restore}>undo</button>
<div class="separator" /> <div class="separator" />

View File

@@ -135,7 +135,7 @@
role="textbox" role="textbox"
tabindex="0" tabindex="0"
bind:this={box} bind:this={box}
class:edited={chord.phraseChanged} class:edited={chord.phrase.length !== 0 && chord.phraseChanged}
on:focusin={() => (hasFocus = true)} on:focusin={() => (hasFocus = true)}
on:focusout={event => { on:focusout={event => {
if (event.relatedTarget !== button) hasFocus = false if (event.relatedTarget !== button) hasFocus = false
@@ -236,12 +236,13 @@
background: currentcolor; background: currentcolor;
transition: transition:
opacity 250ms ease, opacity 150ms ease,
scale 250ms ease; scale 250ms ease;
} }
&::after { &::after {
scale: 0 1; scale: 0 1;
transition-duration: 250ms;
} }
&:hover::before { &:hover::before {