mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-18 16:02:57 +00:00
feat: new chord editor prototype
This commit is contained in:
@@ -96,6 +96,7 @@ const config = {
|
||||
"undo",
|
||||
"redo",
|
||||
"replay",
|
||||
"clock_loader_80",
|
||||
"reply",
|
||||
"navigate_before",
|
||||
"navigate_next",
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
"@codemirror/commands": "^6.8.1",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.11.2",
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/state": "^6.5.2",
|
||||
"@codemirror/view": "^6.38.1",
|
||||
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.2.7",
|
||||
"@lezer/generator": "^1.8.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@lezer/lr": "^1.4.5",
|
||||
"@material/material-color-utilities": "^0.3.0",
|
||||
"@melt-ui/pp": "^0.3.2",
|
||||
"@melt-ui/svelte": "^0.86.6",
|
||||
|
||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
||||
'@codemirror/language':
|
||||
specifier: ^6.11.2
|
||||
version: 6.11.2
|
||||
'@codemirror/merge':
|
||||
specifier: ^6.11.2
|
||||
version: 6.11.2
|
||||
'@codemirror/state':
|
||||
specifier: ^6.5.2
|
||||
version: 6.5.2
|
||||
@@ -32,9 +35,15 @@ importers:
|
||||
'@fontsource-variable/noto-sans-mono':
|
||||
specifier: ^5.2.7
|
||||
version: 5.2.7
|
||||
'@lezer/generator':
|
||||
specifier: ^1.8.0
|
||||
version: 1.8.0
|
||||
'@lezer/highlight':
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
'@lezer/lr':
|
||||
specifier: ^1.4.5
|
||||
version: 1.4.5
|
||||
'@material/material-color-utilities':
|
||||
specifier: ^0.3.0
|
||||
version: 0.3.0
|
||||
@@ -795,6 +804,9 @@ packages:
|
||||
'@codemirror/lint@6.8.1':
|
||||
resolution: {integrity: sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==}
|
||||
|
||||
'@codemirror/merge@6.11.2':
|
||||
resolution: {integrity: sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==}
|
||||
|
||||
'@codemirror/search@6.5.6':
|
||||
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
|
||||
|
||||
@@ -1075,14 +1087,18 @@ packages:
|
||||
'@lezer/common@1.2.1':
|
||||
resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
|
||||
|
||||
'@lezer/generator@1.8.0':
|
||||
resolution: {integrity: sha512-/SF4EDWowPqV1jOgoGSGTIFsE7Ezdr7ZYxyihl5eMKVO5tlnpIhFcDavgm1hHY5GEonoOAEnJ0CU0x+tvuAuUg==}
|
||||
hasBin: true
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||
|
||||
'@lezer/javascript@1.4.17':
|
||||
resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==}
|
||||
|
||||
'@lezer/lr@1.4.1':
|
||||
resolution: {integrity: sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==}
|
||||
'@lezer/lr@1.4.5':
|
||||
resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
@@ -5157,7 +5173,7 @@ snapshots:
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.2.1
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.1
|
||||
'@lezer/lr': 1.4.5
|
||||
style-mod: 4.1.2
|
||||
|
||||
'@codemirror/lint@6.8.1':
|
||||
@@ -5166,6 +5182,14 @@ snapshots:
|
||||
'@codemirror/view': 6.38.1
|
||||
crelt: 1.0.6
|
||||
|
||||
'@codemirror/merge@6.11.2':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/highlight': 1.2.1
|
||||
style-mod: 4.1.2
|
||||
|
||||
'@codemirror/search@6.5.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
@@ -5385,6 +5409,11 @@ snapshots:
|
||||
|
||||
'@lezer/common@1.2.1': {}
|
||||
|
||||
'@lezer/generator@1.8.0':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.1
|
||||
'@lezer/lr': 1.4.5
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.1
|
||||
@@ -5393,9 +5422,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.1
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.1
|
||||
'@lezer/lr': 1.4.5
|
||||
|
||||
'@lezer/lr@1.4.1':
|
||||
'@lezer/lr@1.4.5':
|
||||
dependencies:
|
||||
'@lezer/common': 1.2.1
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const de = {
|
||||
saveActions: {
|
||||
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
||||
REDO: "Wiederholen",
|
||||
SAVE: "Speichern",
|
||||
SAVE: "Anwended",
|
||||
},
|
||||
update: {
|
||||
TITLE: "Gerät aktualisieren",
|
||||
|
||||
@@ -7,7 +7,7 @@ const en = {
|
||||
saveActions: {
|
||||
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
||||
REDO: "Redo",
|
||||
SAVE: "Save",
|
||||
SAVE: "Apply",
|
||||
},
|
||||
update: {
|
||||
TITLE: "Update your device",
|
||||
|
||||
103
src/lib/chord-editor/action-plugin.ts
Normal file
103
src/lib/chord-editor/action-plugin.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
import { mount, unmount } from "svelte";
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Range } from "@codemirror/state";
|
||||
|
||||
export class ActionWidget extends WidgetType {
|
||||
component: {};
|
||||
element: HTMLElement;
|
||||
|
||||
constructor(readonly id: string | number) {
|
||||
super();
|
||||
this.id = id;
|
||||
this.element = document.createElement("span");
|
||||
this.element.style.paddingInline = "2px";
|
||||
|
||||
this.component = mount(Action, {
|
||||
target: this.element,
|
||||
props: { action: id, display: "keys" },
|
||||
});
|
||||
}
|
||||
|
||||
override eq(other: ActionWidget) {
|
||||
return this.id == other.id;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
return this.element;
|
||||
}
|
||||
|
||||
override ignoreEvent() {
|
||||
return true;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
unmount(this.component);
|
||||
}
|
||||
}
|
||||
|
||||
function actionWidgets(view: EditorView) {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (node.name !== "ExplicitAction") return;
|
||||
const value =
|
||||
node.node.getChild("ActionId") ??
|
||||
node.node.getChild("HexNumber") ??
|
||||
node.node.getChild("DecimalNumber");
|
||||
if (!value) return;
|
||||
if (!node.node.getChild("ExplicitDelimEnd")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const id = view.state.doc.sliceString(value.from, value.to);
|
||||
let deco = Decoration.replace({
|
||||
widget: new ActionWidget(
|
||||
value.name === "ActionId" ? id : parseInt(id),
|
||||
),
|
||||
});
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export const actionPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = actionWidgets(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||
)
|
||||
this.decorations = actionWidgets(update.view);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(instance) {
|
||||
return instance.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of(
|
||||
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
16
src/lib/chord-editor/action-serializer.ts
Normal file
16
src/lib/chord-editor/action-serializer.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { KEYMAP_CODES, type KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export function canUseIdAsString(info: KeyInfo): boolean {
|
||||
return !!info.id && /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(info.id);
|
||||
}
|
||||
|
||||
export function actionToValue(action: number | KeyInfo) {
|
||||
const info =
|
||||
typeof action === "number" ? get(KEYMAP_CODES).get(action) : action;
|
||||
if (info && info.id?.length === 1)
|
||||
return /^[<>\\\s]$/.test(info.id) ? `\\${info.id}` : info.id;
|
||||
if (!info || !canUseIdAsString(info))
|
||||
return `<0x${(info?.code ?? action).toString(16).padStart(2, "0")}>`;
|
||||
return `<${info.id}>`;
|
||||
}
|
||||
72
src/lib/chord-editor/autocomplete.ts
Normal file
72
src/lib/chord-editor/autocomplete.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { KEYMAP_CATEGORIES, KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import type {
|
||||
Completion,
|
||||
CompletionSection,
|
||||
CompletionSource,
|
||||
} from "@codemirror/autocomplete";
|
||||
import { derived, get } from "svelte/store";
|
||||
import { actionToValue, canUseIdAsString } from "./action-serializer";
|
||||
|
||||
const completionSections = derived(
|
||||
KEYMAP_CATEGORIES,
|
||||
(categories) =>
|
||||
new Map(
|
||||
categories.map(
|
||||
(category) =>
|
||||
[
|
||||
category,
|
||||
{
|
||||
name: category.name,
|
||||
} satisfies CompletionSection,
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
export const actionAutocompleteItems = derived(
|
||||
[KEYMAP_CODES, completionSections],
|
||||
([codes, sections]) =>
|
||||
codes
|
||||
.values()
|
||||
.map((info) => {
|
||||
const canUseId = canUseIdAsString(info);
|
||||
const completionValue =
|
||||
(canUseId && info.id) ||
|
||||
`0x${info.code.toString(16).padStart(2, "0")}`;
|
||||
return {
|
||||
label:
|
||||
[
|
||||
canUseId || !info.id ? undefined : `"${info.id}"`,
|
||||
info.title,
|
||||
info.variant?.replace(/^[a-z]/g, (c) => c.toUpperCase()),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ") || completionValue,
|
||||
detail: actionToValue(info),
|
||||
section: info.category ? sections.get(info.category) : undefined,
|
||||
info: info.description,
|
||||
type: "keyword",
|
||||
apply: completionValue + ">",
|
||||
} satisfies Completion;
|
||||
})
|
||||
.filter(
|
||||
(item) => typeof item.label === "string" && item.apply !== undefined,
|
||||
)
|
||||
.toArray(),
|
||||
);
|
||||
|
||||
export const actionAutocomplete = ((context) => {
|
||||
let word = context.tokenBefore([
|
||||
"ExplicitDelimStart",
|
||||
"ActionId",
|
||||
"HexNumber",
|
||||
"DecimalNumber",
|
||||
]);
|
||||
if (!word) return null;
|
||||
console.log(get(actionAutocompleteItems));
|
||||
return {
|
||||
from: word.type.name === "ExplicitDelimStart" ? word.to : word.from,
|
||||
validFor: /^<?[a-zA-Z0-9_]*$/,
|
||||
options: get(actionAutocompleteItems),
|
||||
};
|
||||
}) satisfies CompletionSource;
|
||||
17
src/lib/chord-editor/changes-plugin.ts
Normal file
17
src/lib/chord-editor/changes-plugin.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
type PluginValue,
|
||||
} from "@codemirror/view";
|
||||
|
||||
export const changesPlugin = ViewPlugin.fromClass(
|
||||
class implements PluginValue {
|
||||
constructor(readonly view: EditorView) {}
|
||||
|
||||
update(update: ViewUpdate) {}
|
||||
},
|
||||
{
|
||||
eventHandlers: {},
|
||||
},
|
||||
);
|
||||
80
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
80
src/lib/chord-editor/chord-delim-plugin.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
WidgetType,
|
||||
} from "@codemirror/view";
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Range } from "@codemirror/state";
|
||||
|
||||
export class DelimWidget extends WidgetType {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
override eq(other: DelimWidget) {
|
||||
return true;
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const element = document.createElement("span");
|
||||
element.innerHTML = " ⇛ ";
|
||||
element.style.scale = "1.8";
|
||||
element.style.opacity = "0.5";
|
||||
return element;
|
||||
}
|
||||
|
||||
override ignoreEvent() {
|
||||
return false;
|
||||
}
|
||||
|
||||
override destroy() {}
|
||||
}
|
||||
|
||||
function actionWidgets(view: EditorView) {
|
||||
const widgets: Range<Decoration>[] = [];
|
||||
for (const { from, to } of view.visibleRanges) {
|
||||
syntaxTree(view.state).iterate({
|
||||
from,
|
||||
to,
|
||||
enter: (node) => {
|
||||
if (node.name !== "PhraseDelim") return;
|
||||
let deco = Decoration.replace({
|
||||
widget: new DelimWidget(),
|
||||
});
|
||||
widgets.push(deco.range(node.from, node.to));
|
||||
},
|
||||
});
|
||||
}
|
||||
return Decoration.set(widgets);
|
||||
}
|
||||
|
||||
export const delimPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations = Decoration.none;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = actionWidgets(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.docChanged ||
|
||||
update.viewportChanged ||
|
||||
syntaxTree(update.startState) != syntaxTree(update.state)
|
||||
)
|
||||
this.decorations = actionWidgets(update.view);
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations(instance) {
|
||||
return instance.decorations;
|
||||
},
|
||||
provide(plugin) {
|
||||
return EditorView.atomicRanges.of(
|
||||
(view) => view.plugin(plugin)?.decorations ?? Decoration.none,
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
57
src/lib/chord-editor/chords-grammar-plugin.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { parser } from "./chords.grammar";
|
||||
import {
|
||||
LRLanguage,
|
||||
LanguageSupport,
|
||||
HighlightStyle,
|
||||
} from "@codemirror/language";
|
||||
import { styleTags, tags } from "@lezer/highlight";
|
||||
import { actionAutocomplete } from "./autocomplete";
|
||||
|
||||
export const chordHighlightStyle = HighlightStyle.define([
|
||||
{
|
||||
tag: tags.keyword,
|
||||
paddingInline: "2px",
|
||||
opacity: "0.5",
|
||||
},
|
||||
{
|
||||
tag: tags.className,
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--md-sys-color-surface-variant) 50%, transparent)",
|
||||
borderRadius: "4px",
|
||||
paddingInline: "4px",
|
||||
marginInline: "-4px",
|
||||
},
|
||||
{
|
||||
tag: tags.integer,
|
||||
color: "var(--md-sys-color-tertiary)",
|
||||
},
|
||||
{
|
||||
tag: tags.angleBracket,
|
||||
opacity: "0.5",
|
||||
},
|
||||
{ tag: tags.modifier, opacity: "0.25" },
|
||||
{ tag: tags.escape, color: "var(--md-sys-color-primary)" },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
]);
|
||||
|
||||
export const chordLanguage = LRLanguage.define({
|
||||
name: "chords",
|
||||
parser: parser.configure({
|
||||
props: [
|
||||
styleTags({
|
||||
"PhraseDelim CompoundDelim": [tags.keyword, tags.strong],
|
||||
"HexNumber DecimalNumber": [tags.className, tags.integer],
|
||||
"ExplicitDelimStart ExplicitDelimEnd": tags.angleBracket,
|
||||
ActionId: tags.className,
|
||||
EscapedLetter: tags.escape,
|
||||
Escape: [tags.escape, tags.modifier],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
export function chordLanguageSupport() {
|
||||
return new LanguageSupport(chordLanguage, [
|
||||
chordLanguage.data.of({ autocomplete: actionAutocomplete }),
|
||||
]);
|
||||
}
|
||||
27
src/lib/chord-editor/chords.grammar
Normal file
27
src/lib/chord-editor/chords.grammar
Normal file
@@ -0,0 +1,27 @@
|
||||
@top Program { Chord* }
|
||||
|
||||
ExplicitAction { ExplicitDelimStart (HexNumber | DecimalNumber | ActionId) ExplicitDelimEnd }
|
||||
EscapedSingleAction { Escape EscapedLetter }
|
||||
Action { SingleLetter | ExplicitAction | EscapedSingleAction }
|
||||
ActionString { Action* }
|
||||
ChordInput { (ActionString CompoundDelim)* ActionString }
|
||||
ChordPhrase { ActionString }
|
||||
Chord { ChordInput PhraseDelim ChordPhrase ChordDelim }
|
||||
|
||||
@tokens {
|
||||
@precedence {HexNumber, DecimalNumber}
|
||||
@precedence {CompoundDelim, PhraseDelim, ExplicitDelimStart, ChordDelim, SingleLetter}
|
||||
@precedence {EscapedLetter}
|
||||
ExplicitDelimStart {"<"}
|
||||
ExplicitDelimEnd {">"}
|
||||
CompoundDelim {"+>"}
|
||||
PhraseDelim {"=>"}
|
||||
Escape { "\\" }
|
||||
HexNumber { "0x" $[a-fA-F0-9]+ }
|
||||
DecimalNumber { $[0-9]+ }
|
||||
ActionId { $[a-zA-Z_]$[a-zA-Z0-9_]* }
|
||||
SingleLetter { ![\\] }
|
||||
EscapedLetter { ![] }
|
||||
ChordDelim { ($[\n] | @eof) }
|
||||
}
|
||||
|
||||
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
3
src/lib/chord-editor/grammar.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare module "*.grammar" {
|
||||
export const parser: import("@lezer/lr").LRParser;
|
||||
}
|
||||
16
src/lib/chord-editor/test.txt
Normal file
16
src/lib/chord-editor/test.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
.=<LEFT_SHIFT> => =>
|
||||
;ims => <0x219><IMPULSE>
|
||||
-;<KSC_2C><LEFT_SHIFT> => <0x23e>_<0x23e>
|
||||
.;g => <0x23e>...<0x23e><LH_THUMB_3_3D>
|
||||
'dg => <0x23e>'<0x23e>
|
||||
'gl => <0x23e>'ll<0x23e>
|
||||
'ar => <0x23e>'re<0x23e>
|
||||
'gs => <0x23e>'s<0x23e>
|
||||
'ev => <0x23e>'ve<0x23e>
|
||||
<SPACE>-; => <0x23e><0x223>-<0x223><KSC_00>
|
||||
<SPACE>;<LEFT_SHIFT> => <0x23e><0x223><0x23d><0x223><KSC_00>
|
||||
<SPACE>;g => <0x23e><0x223><SPACE><0x223><KSC_00>
|
||||
deg => <0x23e>ed<0x23e>
|
||||
;gr => <0x23e>er<0x23e>
|
||||
;es => <0x23e>es<0x23e>
|
||||
;est => <0x23e>est<0x23e>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { KEYMAP_CODES } from "$lib/serial/keymap-codes";
|
||||
import { KEYMAP_CODES, KEYMAP_IDS } from "$lib/serial/keymap-codes";
|
||||
import type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||
import { osLayout } from "$lib/os-layout";
|
||||
import { tooltip } from "$lib/hover-popover";
|
||||
@@ -9,33 +9,54 @@
|
||||
let {
|
||||
action,
|
||||
display,
|
||||
}: { action: number | KeyInfo; display: "inline-keys" | "keys" | "verbose" } =
|
||||
$props();
|
||||
}: {
|
||||
action: string | number | KeyInfo;
|
||||
display: "inline-text" | "inline-keys" | "keys" | "verbose";
|
||||
} = $props();
|
||||
|
||||
let info = $derived(
|
||||
let retrievedInfo = $derived(
|
||||
typeof action === "number"
|
||||
? ($KEYMAP_CODES.get(action) ?? { code: action })
|
||||
: action,
|
||||
? $KEYMAP_CODES.get(action)
|
||||
: typeof action === "string"
|
||||
? $KEYMAP_IDS.get(action)
|
||||
: action,
|
||||
);
|
||||
let info = $derived(
|
||||
retrievedInfo ??
|
||||
(typeof action === "number"
|
||||
? ({ code: action } satisfies KeyInfo)
|
||||
: typeof action === "string"
|
||||
? ({ code: 1024, id: action } satisfies KeyInfo)
|
||||
: action),
|
||||
);
|
||||
let dynamicMapping = $derived(info.keyCode && $osLayout.get(info.keyCode));
|
||||
let hasPopover = $derived(!info.id || info.title || info.description);
|
||||
let hasPopover = $derived(
|
||||
!retrievedInfo || !info.id || info.title || info.description,
|
||||
);
|
||||
</script>
|
||||
|
||||
{#snippet popover()}
|
||||
{#if info.icon || info.display || !info.id}
|
||||
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||
{/if}
|
||||
{#if info.title}
|
||||
{info.title}
|
||||
{/if}
|
||||
{#if info.variant === "left"}
|
||||
(Left)
|
||||
{:else if info.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
{#if info.description}
|
||||
<br />
|
||||
<small>{info.description}</small>
|
||||
{#if retrievedInfo}
|
||||
{#if info.icon || info.display || !info.id}
|
||||
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||
{/if}
|
||||
{#if info.title}
|
||||
{info.title}
|
||||
{/if}
|
||||
{#if info.variant === "left"}
|
||||
(Left)
|
||||
{:else if info.variant === "right"}
|
||||
(Right)
|
||||
{/if}
|
||||
{#if info.description}
|
||||
<br />
|
||||
<small>{info.description}</small>
|
||||
{/if}
|
||||
{:else}
|
||||
<b>Unknown Action</b><br />
|
||||
{#if info.code > 1023}
|
||||
This action cannot be translated and will be ingored.
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -51,6 +72,8 @@
|
||||
class:icon={!!info.icon}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:error={info.code > 1023}
|
||||
class:warn={!retrievedInfo}
|
||||
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
|
||||
>
|
||||
{@render kbdText()}
|
||||
@@ -60,21 +83,30 @@
|
||||
{#if !info.icon && dynamicMapping?.length === 1}
|
||||
<span
|
||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||
class:in-text={display === "inline-text"}
|
||||
class:error={info.code > 1023}
|
||||
class:warn={!retrievedInfo}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||
>
|
||||
{:else if !info.icon && info.id?.length === 1}
|
||||
<span
|
||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||
class:in-text={display === "inline-text"}
|
||||
class:error={info.code > 1023}
|
||||
class:warn={!retrievedInfo}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}>{info.id}</span
|
||||
>
|
||||
{:else}
|
||||
<kbd
|
||||
class="inline-kbd"
|
||||
class:in-text={display === "inline-text"}
|
||||
class:left={info.variant === "left"}
|
||||
class:right={info.variant === "right"}
|
||||
class:icon={!!info.icon}
|
||||
class:warn={!retrievedInfo}
|
||||
class:error={info.code > 1023}
|
||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||
>
|
||||
{@render kbdText()}
|
||||
@@ -93,7 +125,7 @@
|
||||
{:else}
|
||||
{@render inlineKbdSnippet()}
|
||||
{/if}
|
||||
{:else if display === "inline-keys"}
|
||||
{:else if display === "inline-keys" || display === "inline-text"}
|
||||
{@render inlineKbdSnippet()}
|
||||
{/if}
|
||||
|
||||
@@ -102,6 +134,23 @@
|
||||
transition: color 250ms ease;
|
||||
padding-block: auto;
|
||||
height: 24px;
|
||||
|
||||
&.in-text {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
margin-block: auto;
|
||||
padding-block: revert;
|
||||
}
|
||||
}
|
||||
|
||||
.warn:not(.error) {
|
||||
border-color: var(--md-sys-color-error);
|
||||
color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
.error {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.left {
|
||||
@@ -113,6 +162,10 @@
|
||||
|
||||
.inline-kbd {
|
||||
margin-inline-end: 2px;
|
||||
|
||||
&.in-text.icon {
|
||||
translate: 0 -4em;
|
||||
}
|
||||
}
|
||||
|
||||
:global(span) + .inline-kbd {
|
||||
|
||||
387
src/lib/components/layout/ActionList.svelte
Normal file
387
src/lib/components/layout/ActionList.svelte
Normal file
@@ -0,0 +1,387 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
KEYMAP_CATEGORIES,
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import { onMount } from "svelte";
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
import { get } from "svelte/store";
|
||||
import type { KeymapCategory } from "$lib/meta/types/actions";
|
||||
import Action from "../Action.svelte";
|
||||
import { isVerbose } from "../verbose-action";
|
||||
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
||||
|
||||
let {
|
||||
currentAction = undefined,
|
||||
nextAction = undefined,
|
||||
autofocus = false,
|
||||
onselect,
|
||||
onclose,
|
||||
}: {
|
||||
currentAction?: number;
|
||||
nextAction?: number;
|
||||
autofocus?: boolean;
|
||||
onselect: (id: number) => void;
|
||||
onclose?: () => void;
|
||||
} = $props();
|
||||
|
||||
onMount(() => {
|
||||
search();
|
||||
});
|
||||
|
||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||
|
||||
$effect(() => {
|
||||
createIndex($KEYMAP_CODES);
|
||||
});
|
||||
|
||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||
for (const [, action] of codes) {
|
||||
await index?.addAsync(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const groups = new Map(
|
||||
$KEYMAP_CATEGORIES.map(
|
||||
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||
),
|
||||
);
|
||||
const result =
|
||||
searchBox.value === ""
|
||||
? Array.from($KEYMAP_CODES.keys())
|
||||
: await index!.searchAsync(searchBox.value);
|
||||
for (const id of result) {
|
||||
const action = $KEYMAP_CODES.get(id as number);
|
||||
if (action?.category) {
|
||||
groups.get(action.category)?.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
function sortValue(action: KeyInfo): number {
|
||||
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
|
||||
}
|
||||
for (const actions of groups.values()) {
|
||||
actions.sort((a, b) => sortValue(b) - sortValue(a));
|
||||
}
|
||||
results = groups;
|
||||
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||
code = Number(searchBox.value);
|
||||
}
|
||||
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
onselect(id);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||
onselect(exact);
|
||||
} 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: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
|
||||
let exact: number | undefined = $state(undefined);
|
||||
let code: number = $state(Number.NaN);
|
||||
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
type="search"
|
||||
{autofocus}
|
||||
bind:this={searchBox}
|
||||
oninput={search}
|
||||
onkeypress={keyboardNavigation}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
{#if onclose}
|
||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||
>{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||
class="icon"
|
||||
onclick={onclose}>close</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{#if currentAction !== undefined}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||
<ActionListItem id={currentAction} />
|
||||
</aside>
|
||||
{#if nextAction}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
||||
<ActionListItem id={nextAction} />
|
||||
</aside>
|
||||
{/if}
|
||||
{/if}
|
||||
<ul bind:this={resultList}>
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as [category, actions] (category)}
|
||||
{#if actions.length > 0}
|
||||
<div class="category">
|
||||
<h3>{category.name}</h3>
|
||||
<div class="description">{category.description}</div>
|
||||
<ul>
|
||||
{#each actions as action (action.code)}
|
||||
<button
|
||||
class="action-item"
|
||||
draggable="true"
|
||||
ondragstart={(event) => {
|
||||
if (!event.dataTransfer) return;
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData(
|
||||
"text/plain",
|
||||
actionToValue(action.code),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Action {action} display="verbose"></Action>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
|
||||
label {
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 6px;
|
||||
padding-inline: 4px;
|
||||
padding-block: 2px;
|
||||
height: unset;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&:has(:checked) {
|
||||
background: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
cursor: grab;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
border: none;
|
||||
|
||||
background: rgba(0 0 0 / 60%);
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
aside {
|
||||
opacity: 0.4;
|
||||
|
||||
margin: 8px;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
> h3 {
|
||||
margin-inline-start: 16px;
|
||||
margin-block-start: -13px;
|
||||
margin-block-end: 0;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
padding-inline: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
opacity: 1;
|
||||
color: GrayText;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
transform-origin: top left;
|
||||
border-radius: 16px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
width: calc(min(30cm, 90%));
|
||||
height: calc(min(100% - 128px, 90%));
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
transition: all 250ms ease;
|
||||
margin-block-end: 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
color: currentcolor;
|
||||
font-size: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline: 4px;
|
||||
height: 100%;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
}
|
||||
|
||||
.category {
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
margin-block-start: -16px;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-block: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background: Mark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,19 +1,5 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
KEYMAP_CATEGORIES,
|
||||
KEYMAP_CODES,
|
||||
KEYMAP_IDS,
|
||||
type KeyInfo,
|
||||
} from "$lib/serial/keymap-codes";
|
||||
import FlexSearch from "flexsearch";
|
||||
import { onMount } from "svelte";
|
||||
import ActionListItem from "$lib/components/ActionListItem.svelte";
|
||||
import LL from "$i18n/i18n-svelte";
|
||||
import { action } from "$lib/title";
|
||||
import { get } from "svelte/store";
|
||||
import type { KeymapCategory } from "$lib/meta/types/actions";
|
||||
import Action from "../Action.svelte";
|
||||
import { isVerbose } from "../verbose-action";
|
||||
import ActionList from "./ActionList.svelte";
|
||||
|
||||
let {
|
||||
currentAction = undefined,
|
||||
@@ -26,205 +12,24 @@
|
||||
onselect: (id: number) => void;
|
||||
onclose: () => void;
|
||||
} = $props();
|
||||
|
||||
onMount(() => {
|
||||
searchBox.focus();
|
||||
search();
|
||||
});
|
||||
|
||||
const index = new FlexSearch.Index({ tokenize: "full" });
|
||||
|
||||
$effect(() => {
|
||||
createIndex($KEYMAP_CODES);
|
||||
});
|
||||
|
||||
async function createIndex(codes: Map<number, KeyInfo>) {
|
||||
for (const [, action] of codes) {
|
||||
await index?.addAsync(
|
||||
action.code,
|
||||
`${action.title || ""} ${action.variant || ""} ${action.category} ${action.id || ""} ${
|
||||
action.description || ""
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function search() {
|
||||
const groups = new Map(
|
||||
$KEYMAP_CATEGORIES.map(
|
||||
(category) => [category, []] as [KeymapCategory, KeyInfo[]],
|
||||
),
|
||||
);
|
||||
const result =
|
||||
searchBox.value === ""
|
||||
? Array.from($KEYMAP_CODES.keys())
|
||||
: await index!.searchAsync(searchBox.value);
|
||||
for (const id of result) {
|
||||
const action = $KEYMAP_CODES.get(id as number);
|
||||
if (action?.category) {
|
||||
groups.get(action.category)?.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
function sortValue(action: KeyInfo): number {
|
||||
return isVerbose(action) ? 0 : action.id?.length === 1 ? 2 : 1;
|
||||
}
|
||||
for (const actions of groups.values()) {
|
||||
actions.sort((a, b) => sortValue(b) - sortValue(a));
|
||||
}
|
||||
results = groups;
|
||||
exact = get(KEYMAP_IDS).get(searchBox.value)?.code;
|
||||
code = Number(searchBox.value);
|
||||
}
|
||||
|
||||
function select(id?: number) {
|
||||
if (id !== undefined) {
|
||||
onselect(id);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardNavigation(event: KeyboardEvent) {
|
||||
if (event.shiftKey && event.key === "Enter" && exact !== undefined) {
|
||||
onselect(exact);
|
||||
} 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: Map<KeymapCategory, KeyInfo[]> = $state(new Map());
|
||||
let exact: number | undefined = $state(undefined);
|
||||
let code: number = $state(Number.NaN);
|
||||
|
||||
let searchBox: HTMLInputElement;
|
||||
let resultList: HTMLUListElement;
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={keyboardNavigation} />
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<dialog
|
||||
open
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) onclose();
|
||||
}}
|
||||
>
|
||||
<div class="content">
|
||||
<div class="search-row">
|
||||
<input
|
||||
type="search"
|
||||
bind:this={searchBox}
|
||||
oninput={search}
|
||||
onkeypress={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
select(exact);
|
||||
}
|
||||
}}
|
||||
placeholder={$LL.actionSearch.PLACEHOLDER()}
|
||||
/>
|
||||
<button onclick={() => select(0)} use:action={{ shortcut: "shift+esc" }}
|
||||
>{$LL.actionSearch.DELETE()}</button
|
||||
>
|
||||
<button
|
||||
use:action={{ title: $LL.modal.CLOSE(), shortcut: "esc" }}
|
||||
class="icon"
|
||||
onclick={onclose}>close</button
|
||||
>
|
||||
</div>
|
||||
{#if currentAction !== undefined}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.CURRENT_ACTION()}</h3>
|
||||
<ActionListItem id={currentAction} />
|
||||
</aside>
|
||||
{#if nextAction}
|
||||
<aside>
|
||||
<h3>{$LL.actionSearch.NEXT_ACTION()}</h3>
|
||||
<ActionListItem id={nextAction} />
|
||||
</aside>
|
||||
{/if}
|
||||
{/if}
|
||||
<ul bind:this={resultList}>
|
||||
{#if exact !== undefined}
|
||||
<li class="exact">
|
||||
<i>Exact match</i>
|
||||
<ActionListItem id={exact} onclick={() => select(exact)} />
|
||||
</li>
|
||||
{/if}
|
||||
{#if !exact && code}
|
||||
{#if code >= 2 ** 5 && code < 2 ** 13}
|
||||
<li><button onclick={() => select(code)}>USE CODE</button></li>
|
||||
{:else}
|
||||
<li>Action code is out of range</li>
|
||||
{/if}
|
||||
{/if}
|
||||
{#each results as [category, actions] (category)}
|
||||
{#if actions.length > 0}
|
||||
<div class="category">
|
||||
<h3>{category.name}</h3>
|
||||
<div class="description">{category.description}</div>
|
||||
<ul>
|
||||
{#each actions as action (action.code)}
|
||||
<button class="action-item" onclick={() => select(action.code)}>
|
||||
<Action {action} display="verbose"></Action>
|
||||
</button>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
<ActionList
|
||||
autofocus={true}
|
||||
{currentAction}
|
||||
{nextAction}
|
||||
{onselect}
|
||||
{onclose}
|
||||
/>
|
||||
</dialog>
|
||||
|
||||
<style lang="scss">
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border: none;
|
||||
|
||||
label {
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 6px;
|
||||
padding-inline: 4px;
|
||||
padding-block: 2px;
|
||||
height: unset;
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&:has(:checked) {
|
||||
background: var(--md-sys-color-secondary);
|
||||
color: var(--md-sys-color-on-secondary);
|
||||
}
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-item {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: auto;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -237,146 +42,4 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
aside {
|
||||
opacity: 0.4;
|
||||
|
||||
margin: 8px;
|
||||
border: 1px dashed var(--md-sys-color-outline);
|
||||
border-radius: 8px;
|
||||
pointer-events: none;
|
||||
|
||||
> h3 {
|
||||
margin-inline-start: 16px;
|
||||
margin-block-start: -13px;
|
||||
margin-block-end: 0;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
padding-inline: 8px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
opacity: 1;
|
||||
color: GrayText;
|
||||
}
|
||||
}
|
||||
|
||||
.search-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-inline: 16px;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
transform-origin: top left;
|
||||
border-radius: 16px;
|
||||
|
||||
background: var(--md-sys-color-background);
|
||||
|
||||
width: calc(min(30cm, 90%));
|
||||
height: calc(min(100% - 128px, 90%));
|
||||
|
||||
overflow: hidden;
|
||||
|
||||
color: var(--md-sys-color-on-background);
|
||||
|
||||
@media (forced-colors: active) {
|
||||
border: 1px solid CanvasText;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="search"] {
|
||||
transition: all 250ms ease;
|
||||
margin-block-end: 8px;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
|
||||
background: none;
|
||||
padding-inline: 16px;
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
color: currentcolor;
|
||||
font-size: 16px;
|
||||
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom: 1px solid var(--md-sys-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
--scrollbar-color: var(--md-sys-color-surface-variant);
|
||||
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-inline: 4px;
|
||||
height: 100%;
|
||||
|
||||
overflow-y: auto;
|
||||
|
||||
scrollbar-gutter: both-edges stable;
|
||||
}
|
||||
|
||||
.category {
|
||||
.description {
|
||||
opacity: 0.8;
|
||||
margin-block-start: -16px;
|
||||
font-style: italic;
|
||||
font-size: 14px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-block: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.exact {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block-start: 8px;
|
||||
|
||||
border: 1px solid var(--md-sys-color-primary);
|
||||
border-radius: 8px;
|
||||
|
||||
width: 100%;
|
||||
|
||||
> i {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 0 0 8px 8px;
|
||||
|
||||
background: var(--md-sys-color-primary);
|
||||
|
||||
padding-inline: 6px;
|
||||
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
@media (forced-colors: active) {
|
||||
background: Mark;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,9 +3,14 @@ kbd {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
margin-block: 6px;
|
||||
|
||||
border: 1px solid currentcolor;
|
||||
border-radius: 4px;
|
||||
|
||||
//border: 1px solid currentcolor;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-surface-variant) 50%,
|
||||
transparent
|
||||
);
|
||||
padding: 4px;
|
||||
|
||||
height: 20px;
|
||||
|
||||
@@ -52,9 +52,21 @@
|
||||
|
||||
onMount(async () => {
|
||||
theme.subscribe((it) => {
|
||||
const theme = themeFromSourceColor(argbFromHex(it.color));
|
||||
const theme = themeFromSourceColor(argbFromHex(it.color), [
|
||||
{
|
||||
name: "success",
|
||||
value: argbFromHex("#4CAF50"),
|
||||
blend: true,
|
||||
},
|
||||
]);
|
||||
const dark = it.mode === "dark"; // window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
applyTheme(theme, { target: document.body, dark });
|
||||
for (const custom of theme.customColors) {
|
||||
document.body.style.setProperty(
|
||||
`--md-sys-color-${custom.color.name}`,
|
||||
`#${custom.value.toString(16).padStart(8, "0").substring(2)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if (import.meta.env.TAURI_FAMILY === undefined) {
|
||||
|
||||
195
src/routes/(app)/config/cv2/+page.svelte
Normal file
195
src/routes/(app)/config/cv2/+page.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { chords } from "$lib/undo-redo";
|
||||
import { EditorView } from "codemirror";
|
||||
import { actionToValue } from "$lib/chord-editor/action-serializer";
|
||||
import { actionPlugin } from "$lib/chord-editor/action-plugin";
|
||||
import { delimPlugin } from "$lib/chord-editor/chord-delim-plugin";
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
} from "@codemirror/view";
|
||||
import { history, standardKeymap } from "@codemirror/commands";
|
||||
import "$lib/chord-editor/chords.grammar";
|
||||
import {
|
||||
chordHighlightStyle,
|
||||
chordLanguageSupport,
|
||||
} from "$lib/chord-editor/chords-grammar-plugin";
|
||||
import { syntaxHighlighting } from "@codemirror/language";
|
||||
import { autocompletion } from "@codemirror/autocomplete";
|
||||
import { persistentWritable } from "$lib/storage";
|
||||
import ActionList from "$lib/components/layout/ActionList.svelte";
|
||||
|
||||
const rawCode = persistentWritable("chord-editor-raw-code", false);
|
||||
const showEdits = persistentWritable("chord-editor-show-edits", true);
|
||||
let originalDoc = $derived(
|
||||
$chords
|
||||
.map(
|
||||
(chord) =>
|
||||
chord.actions
|
||||
.filter((it) => it !== 0)
|
||||
.map((it) => actionToValue(it))
|
||||
.join("") +
|
||||
"=>" +
|
||||
chord.phrase.map((it) => actionToValue(it)).join(""),
|
||||
)
|
||||
.join("\n"),
|
||||
);
|
||||
let editor: HTMLDivElement | undefined = $state(undefined);
|
||||
let view: EditorView;
|
||||
|
||||
$effect(() => {
|
||||
if (!editor) return;
|
||||
view = new EditorView({
|
||||
parent: editor,
|
||||
doc: originalDoc,
|
||||
extensions: [
|
||||
...($rawCode ? [] : [delimPlugin, actionPlugin]),
|
||||
chordLanguageSupport(),
|
||||
autocompletion({ icons: false, selectOnOpen: true }),
|
||||
history(),
|
||||
dropCursor(),
|
||||
syntaxHighlighting(chordHighlightStyle),
|
||||
highlightActiveLine(),
|
||||
drawSelection(),
|
||||
highlightSpecialChars(),
|
||||
keymap.of(standardKeymap),
|
||||
],
|
||||
});
|
||||
return () => view.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
<label><input type="checkbox" bind:checked={$rawCode} />View as code</label>
|
||||
<label><input type="checkbox" bind:checked={$showEdits} />Show edits</label>
|
||||
|
||||
<div class="split">
|
||||
<div
|
||||
class="editor"
|
||||
class:hide-edits={!$showEdits}
|
||||
class:raw={$rawCode}
|
||||
bind:this={editor}
|
||||
></div>
|
||||
<ActionList />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.split {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
|
||||
> :global(:last-child) {
|
||||
max-width: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.editor:not(.raw) :global(.cm-line) {
|
||||
margin-inline: auto;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.editor :global(.cm-deletedChunk) {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.editor {
|
||||
min-width: 600px;
|
||||
height: 100%;
|
||||
font-size: 16px;
|
||||
|
||||
:global(.cm-tooltip) {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-surface-variant);
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
|
||||
:global(ul) {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
:global(li[role="option"][aria-selected="true"]) {
|
||||
border-radius: 4px;
|
||||
background-color: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
:global(completion-section) {
|
||||
margin-block: 8px;
|
||||
border-bottom: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.change-button) {
|
||||
height: 24px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:global(.cm-deletedLineGutter) {
|
||||
background-color: var(--md-sys-color-error);
|
||||
}
|
||||
|
||||
:global(.cm-changedLineGutter) {
|
||||
background-color: var(--md-sys-color-success);
|
||||
}
|
||||
|
||||
:global(.cm-changedText) {
|
||||
background: linear-gradient(
|
||||
var(--md-sys-color-primary),
|
||||
var(--md-sys-color-primary)
|
||||
)
|
||||
bottom / 100% 1px no-repeat;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor) {
|
||||
outline: none;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:global(.cm-line) {
|
||||
border-bottom: 1px solid transparent;
|
||||
line-height: 3em;
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
overflow: auto;
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
:global(.cm-cursor) {
|
||||
border-color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
:global(.cm-changedLine) {
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-primary) 5%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
:global(.cm-activeLine) {
|
||||
border-bottom: 1px solid var(--md-sys-color-surface-variant);
|
||||
/*background-color: color-mix(
|
||||
in srgb,
|
||||
var(--md-sys-color-surface-variant) 40%,
|
||||
transparent
|
||||
) !important;*/
|
||||
|
||||
&:not(.cm-changedLine) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.cm-selectionBackground) {
|
||||
background-color: var(--md-sys-color-surface-variant) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
71
src/routes/(app)/config/cv2/ChordEdit.svelte
Normal file
71
src/routes/(app)/config/cv2/ChordEdit.svelte
Normal file
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
import Action from "$lib/components/Action.svelte";
|
||||
import type { ChordInfo } from "$lib/undo-redo";
|
||||
import { onMount, tick } from "svelte";
|
||||
|
||||
let { chord }: { chord: ChordInfo } = $props();
|
||||
|
||||
let actualElements: HTMLDivElement | undefined = $state(undefined);
|
||||
let pseudoElements: HTMLDivElement | undefined = $state(undefined);
|
||||
|
||||
let widths: number[] = $state([]);
|
||||
|
||||
onMount(async () => {
|
||||
for (const letter of chord.phrase) {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = String.fromCodePoint(letter);
|
||||
pseudoElements?.appendChild(span);
|
||||
}
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
await tick();
|
||||
await tick();
|
||||
|
||||
update();
|
||||
});
|
||||
|
||||
function update() {
|
||||
console.log(document.getSelection());
|
||||
pseudoElements?.childNodes.forEach((node, index) => {
|
||||
if (node instanceof HTMLElement) {
|
||||
const range = document.createRange();
|
||||
const actual = actualElements?.childNodes[index];
|
||||
range.setStartBefore(actual);
|
||||
range.setEndAfter(actual);
|
||||
const rect = range.getBoundingClientRect();
|
||||
console.log(rect);
|
||||
node.style.width = rect.width + "px";
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor">
|
||||
<div class="visual" bind:this={actualElements}>
|
||||
{#each chord.phrase as action, index}
|
||||
<Action {action} display="inline-keys" />
|
||||
{/each}
|
||||
</div>
|
||||
<div contenteditable="true" bind:this={pseudoElements}></div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.editor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
[contenteditable="true"] {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
> :global(span) {
|
||||
display: inline-block;
|
||||
background: red;
|
||||
|
||||
&:nth-child(even) {
|
||||
background: blue;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
34
src/routes/(app)/config/cv2/DropTarget.svelte
Normal file
34
src/routes/(app)/config/cv2/DropTarget.svelte
Normal file
@@ -0,0 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from "svelte";
|
||||
|
||||
let {
|
||||
children,
|
||||
onofferdropbefore,
|
||||
onofferdropafter,
|
||||
onofferreplace,
|
||||
}: {
|
||||
children: Snippet;
|
||||
onofferdropbefore: () => void;
|
||||
onofferdropafter: () => void;
|
||||
onofferreplace: () => void;
|
||||
} = $props();
|
||||
|
||||
let element: HTMLSpanElement | undefined = $state(undefined);
|
||||
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="droptarget"
|
||||
bind:this={element}
|
||||
{ondrop}
|
||||
{ondragenter}
|
||||
{ondragleave}
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.droptarget {
|
||||
position: relative;
|
||||
}
|
||||
</style>
|
||||
19
vite-plugin-lezer.ts
Normal file
19
vite-plugin-lezer.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { buildParserFile } from "@lezer/generator";
|
||||
import type { Plugin, Rollup } from "vite";
|
||||
|
||||
const fileRegex = /\.(grammar)$/;
|
||||
|
||||
export function lezerGrammarPlugin() {
|
||||
return {
|
||||
name: "lezer-grammar",
|
||||
transform(code, id) {
|
||||
if (fileRegex.test(id)) {
|
||||
return {
|
||||
code: buildParserFile(code).parser,
|
||||
map: null,
|
||||
} satisfies Rollup.TransformResult;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} satisfies Plugin;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { SvelteKitPWA } from "@vite-pwa/sveltekit";
|
||||
import ViteYaml from "@modyfi/vite-plugin-yaml";
|
||||
import { readFile } from "fs/promises";
|
||||
import { fileURLToPath } from "url";
|
||||
import { lezerGrammarPlugin } from "./vite-plugin-lezer";
|
||||
|
||||
const isTauri = "TAURI_FAMILY" in process.env;
|
||||
console.info(isTauri ? "Building for Tauri" : "Building for PWA");
|
||||
@@ -48,6 +49,7 @@ export default defineConfig({
|
||||
plugins: [
|
||||
ViteYaml(),
|
||||
sveltekit(),
|
||||
lezerGrammarPlugin(),
|
||||
...(isTauri
|
||||
? []
|
||||
: [
|
||||
|
||||
Reference in New Issue
Block a user