mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-20 08:52:59 +00:00
@@ -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",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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}}",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user