feat: layout editing (sorta)

This commit is contained in:
2023-09-22 20:27:15 +02:00
parent f03b4d586b
commit e7a52221d2
21 changed files with 500 additions and 505 deletions

View File

@@ -62,6 +62,11 @@ const config: IconsConfig = {
"upload_file",
"commit",
"bug_report",
"delete",
"remove_selection",
"bolt",
"undo",
"redo",
],
codePoints: {
speed: "e9e4",

View File

@@ -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",

View File

@@ -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",

View 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)
},
}
}

View File

@@ -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()],
}
}

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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}
&nbsp;
{/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>

View File

@@ -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&nbsp;<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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)
},
}
}

View File

@@ -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>())

View File

@@ -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"},

View 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>

View File

@@ -20,6 +20,7 @@
position: absolute;
bottom: 0;
left: 0;
opacity: 0.4;
}
ul {

View File

@@ -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 {

View File

@@ -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>

View File

@@ -1,6 +0,0 @@
import {redirect} from "@sveltejs/kit"
import type {PageLoad} from "./$types"
export const load = (() => {
throw redirect(302, "/train/cpm/")
}) satisfies PageLoad

View File

@@ -1,5 +0,0 @@
<script>
import TypingInput from "$lib/components/TypingInput.svelte"
</script>
<TypingInput />