mirror of
https://github.com/CharaChorder/DeviceManager.git
synced 2026-01-21 09:23:00 +00:00
feat: new chord editor prototype
This commit is contained in:
@@ -96,6 +96,7 @@ const config = {
|
|||||||
"undo",
|
"undo",
|
||||||
"redo",
|
"redo",
|
||||||
"replay",
|
"replay",
|
||||||
|
"clock_loader_80",
|
||||||
"reply",
|
"reply",
|
||||||
"navigate_before",
|
"navigate_before",
|
||||||
"navigate_next",
|
"navigate_next",
|
||||||
|
|||||||
@@ -38,11 +38,14 @@
|
|||||||
"@codemirror/commands": "^6.8.1",
|
"@codemirror/commands": "^6.8.1",
|
||||||
"@codemirror/lang-javascript": "^6.2.4",
|
"@codemirror/lang-javascript": "^6.2.4",
|
||||||
"@codemirror/language": "^6.11.2",
|
"@codemirror/language": "^6.11.2",
|
||||||
|
"@codemirror/merge": "^6.11.2",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.5.2",
|
||||||
"@codemirror/view": "^6.38.1",
|
"@codemirror/view": "^6.38.1",
|
||||||
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
|
"@fontsource-variable/material-symbols-rounded": "^5.2.17",
|
||||||
"@fontsource-variable/noto-sans-mono": "^5.2.7",
|
"@fontsource-variable/noto-sans-mono": "^5.2.7",
|
||||||
|
"@lezer/generator": "^1.8.0",
|
||||||
"@lezer/highlight": "^1.2.1",
|
"@lezer/highlight": "^1.2.1",
|
||||||
|
"@lezer/lr": "^1.4.5",
|
||||||
"@material/material-color-utilities": "^0.3.0",
|
"@material/material-color-utilities": "^0.3.0",
|
||||||
"@melt-ui/pp": "^0.3.2",
|
"@melt-ui/pp": "^0.3.2",
|
||||||
"@melt-ui/svelte": "^0.86.6",
|
"@melt-ui/svelte": "^0.86.6",
|
||||||
|
|||||||
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@@ -20,6 +20,9 @@ importers:
|
|||||||
'@codemirror/language':
|
'@codemirror/language':
|
||||||
specifier: ^6.11.2
|
specifier: ^6.11.2
|
||||||
version: 6.11.2
|
version: 6.11.2
|
||||||
|
'@codemirror/merge':
|
||||||
|
specifier: ^6.11.2
|
||||||
|
version: 6.11.2
|
||||||
'@codemirror/state':
|
'@codemirror/state':
|
||||||
specifier: ^6.5.2
|
specifier: ^6.5.2
|
||||||
version: 6.5.2
|
version: 6.5.2
|
||||||
@@ -32,9 +35,15 @@ importers:
|
|||||||
'@fontsource-variable/noto-sans-mono':
|
'@fontsource-variable/noto-sans-mono':
|
||||||
specifier: ^5.2.7
|
specifier: ^5.2.7
|
||||||
version: 5.2.7
|
version: 5.2.7
|
||||||
|
'@lezer/generator':
|
||||||
|
specifier: ^1.8.0
|
||||||
|
version: 1.8.0
|
||||||
'@lezer/highlight':
|
'@lezer/highlight':
|
||||||
specifier: ^1.2.1
|
specifier: ^1.2.1
|
||||||
version: 1.2.1
|
version: 1.2.1
|
||||||
|
'@lezer/lr':
|
||||||
|
specifier: ^1.4.5
|
||||||
|
version: 1.4.5
|
||||||
'@material/material-color-utilities':
|
'@material/material-color-utilities':
|
||||||
specifier: ^0.3.0
|
specifier: ^0.3.0
|
||||||
version: 0.3.0
|
version: 0.3.0
|
||||||
@@ -795,6 +804,9 @@ packages:
|
|||||||
'@codemirror/lint@6.8.1':
|
'@codemirror/lint@6.8.1':
|
||||||
resolution: {integrity: sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==}
|
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':
|
'@codemirror/search@6.5.6':
|
||||||
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
|
resolution: {integrity: sha512-rpMgcsh7o0GuCDUXKPvww+muLA1pDJaFrpq/CCHtpQJYz8xopu4D1hPcKRoDD0YlF8gZaqTNIRa4VRBWyhyy7Q==}
|
||||||
|
|
||||||
@@ -1075,14 +1087,18 @@ packages:
|
|||||||
'@lezer/common@1.2.1':
|
'@lezer/common@1.2.1':
|
||||||
resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==}
|
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':
|
'@lezer/highlight@1.2.1':
|
||||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||||
|
|
||||||
'@lezer/javascript@1.4.17':
|
'@lezer/javascript@1.4.17':
|
||||||
resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==}
|
resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==}
|
||||||
|
|
||||||
'@lezer/lr@1.4.1':
|
'@lezer/lr@1.4.5':
|
||||||
resolution: {integrity: sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==}
|
resolution: {integrity: sha512-/YTRKP5yPPSo1xImYQk7AZZMAgap0kegzqCSYHjAL9x1AZ0ZQW+IpcEzMKagCsbTsLnVeWkxYrCNeXG8xEPrjg==}
|
||||||
|
|
||||||
'@marijn/find-cluster-break@1.0.2':
|
'@marijn/find-cluster-break@1.0.2':
|
||||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||||
@@ -5157,7 +5173,7 @@ snapshots:
|
|||||||
'@codemirror/view': 6.38.1
|
'@codemirror/view': 6.38.1
|
||||||
'@lezer/common': 1.2.1
|
'@lezer/common': 1.2.1
|
||||||
'@lezer/highlight': 1.2.1
|
'@lezer/highlight': 1.2.1
|
||||||
'@lezer/lr': 1.4.1
|
'@lezer/lr': 1.4.5
|
||||||
style-mod: 4.1.2
|
style-mod: 4.1.2
|
||||||
|
|
||||||
'@codemirror/lint@6.8.1':
|
'@codemirror/lint@6.8.1':
|
||||||
@@ -5166,6 +5182,14 @@ snapshots:
|
|||||||
'@codemirror/view': 6.38.1
|
'@codemirror/view': 6.38.1
|
||||||
crelt: 1.0.6
|
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':
|
'@codemirror/search@6.5.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/state': 6.5.2
|
'@codemirror/state': 6.5.2
|
||||||
@@ -5385,6 +5409,11 @@ snapshots:
|
|||||||
|
|
||||||
'@lezer/common@1.2.1': {}
|
'@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':
|
'@lezer/highlight@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.1
|
'@lezer/common': 1.2.1
|
||||||
@@ -5393,9 +5422,9 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@lezer/common': 1.2.1
|
'@lezer/common': 1.2.1
|
||||||
'@lezer/highlight': 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:
|
dependencies:
|
||||||
'@lezer/common': 1.2.1
|
'@lezer/common': 1.2.1
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ const de = {
|
|||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
UNDO: "Rückgängig (<kbd class='icon'>shift</kbd> halten um alle Änderungen rückgängig zu machen)",
|
||||||
REDO: "Wiederholen",
|
REDO: "Wiederholen",
|
||||||
SAVE: "Speichern",
|
SAVE: "Anwended",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Gerät aktualisieren",
|
TITLE: "Gerät aktualisieren",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const en = {
|
|||||||
saveActions: {
|
saveActions: {
|
||||||
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
UNDO: "Undo (hold <kbd class='icon'>shift</kbd> to undo all changes)",
|
||||||
REDO: "Redo",
|
REDO: "Redo",
|
||||||
SAVE: "Save",
|
SAVE: "Apply",
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
TITLE: "Update your device",
|
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">
|
<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 type { KeyInfo } from "$lib/serial/keymap-codes";
|
||||||
import { osLayout } from "$lib/os-layout";
|
import { osLayout } from "$lib/os-layout";
|
||||||
import { tooltip } from "$lib/hover-popover";
|
import { tooltip } from "$lib/hover-popover";
|
||||||
@@ -9,33 +9,54 @@
|
|||||||
let {
|
let {
|
||||||
action,
|
action,
|
||||||
display,
|
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"
|
typeof action === "number"
|
||||||
? ($KEYMAP_CODES.get(action) ?? { code: action })
|
? $KEYMAP_CODES.get(action)
|
||||||
: 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 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>
|
</script>
|
||||||
|
|
||||||
{#snippet popover()}
|
{#snippet popover()}
|
||||||
{#if info.icon || info.display || !info.id}
|
{#if retrievedInfo}
|
||||||
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
{#if info.icon || info.display || !info.id}
|
||||||
{/if}
|
<<b>{info.id ?? `0x${info.code.toString(16)}`}</b>>
|
||||||
{#if info.title}
|
{/if}
|
||||||
{info.title}
|
{#if info.title}
|
||||||
{/if}
|
{info.title}
|
||||||
{#if info.variant === "left"}
|
{/if}
|
||||||
(Left)
|
{#if info.variant === "left"}
|
||||||
{:else if info.variant === "right"}
|
(Left)
|
||||||
(Right)
|
{:else if info.variant === "right"}
|
||||||
{/if}
|
(Right)
|
||||||
{#if info.description}
|
{/if}
|
||||||
<br />
|
{#if info.description}
|
||||||
<small>{info.description}</small>
|
<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}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
@@ -51,6 +72,8 @@
|
|||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
|
class:error={info.code > 1023}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
|
{@attach withPopover && hasPopover ? actionTooltip(popover) : null}
|
||||||
>
|
>
|
||||||
{@render kbdText()}
|
{@render kbdText()}
|
||||||
@@ -60,21 +83,30 @@
|
|||||||
{#if !info.icon && dynamicMapping?.length === 1}
|
{#if !info.icon && dynamicMapping?.length === 1}
|
||||||
<span
|
<span
|
||||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
{@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:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{dynamicMapping}</span
|
class:right={info.variant === "right"}>{dynamicMapping}</span
|
||||||
>
|
>
|
||||||
{:else if !info.icon && info.id?.length === 1}
|
{:else if !info.icon && info.id?.length === 1}
|
||||||
<span
|
<span
|
||||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
{@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:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}>{info.id}</span
|
class:right={info.variant === "right"}>{info.id}</span
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
<kbd
|
<kbd
|
||||||
class="inline-kbd"
|
class="inline-kbd"
|
||||||
|
class:in-text={display === "inline-text"}
|
||||||
class:left={info.variant === "left"}
|
class:left={info.variant === "left"}
|
||||||
class:right={info.variant === "right"}
|
class:right={info.variant === "right"}
|
||||||
class:icon={!!info.icon}
|
class:icon={!!info.icon}
|
||||||
|
class:warn={!retrievedInfo}
|
||||||
|
class:error={info.code > 1023}
|
||||||
{@attach hasPopover ? actionTooltip(popover) : null}
|
{@attach hasPopover ? actionTooltip(popover) : null}
|
||||||
>
|
>
|
||||||
{@render kbdText()}
|
{@render kbdText()}
|
||||||
@@ -93,7 +125,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
{@render inlineKbdSnippet()}
|
{@render inlineKbdSnippet()}
|
||||||
{/if}
|
{/if}
|
||||||
{:else if display === "inline-keys"}
|
{:else if display === "inline-keys" || display === "inline-text"}
|
||||||
{@render inlineKbdSnippet()}
|
{@render inlineKbdSnippet()}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -102,6 +134,23 @@
|
|||||||
transition: color 250ms ease;
|
transition: color 250ms ease;
|
||||||
padding-block: auto;
|
padding-block: auto;
|
||||||
height: 24px;
|
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 {
|
.left {
|
||||||
@@ -113,6 +162,10 @@
|
|||||||
|
|
||||||
.inline-kbd {
|
.inline-kbd {
|
||||||
margin-inline-end: 2px;
|
margin-inline-end: 2px;
|
||||||
|
|
||||||
|
&.in-text.icon {
|
||||||
|
translate: 0 -4em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:global(span) + .inline-kbd {
|
: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">
|
<script lang="ts">
|
||||||
import {
|
import ActionList from "./ActionList.svelte";
|
||||||
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";
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
currentAction = undefined,
|
currentAction = undefined,
|
||||||
@@ -26,205 +12,24 @@
|
|||||||
onselect: (id: number) => void;
|
onselect: (id: number) => void;
|
||||||
onclose: () => void;
|
onclose: () => void;
|
||||||
} = $props();
|
} = $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>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={keyboardNavigation} />
|
|
||||||
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
||||||
<dialog
|
<dialog
|
||||||
open
|
open
|
||||||
onclick={(event) => {
|
onclick={(event) => {
|
||||||
if (event.target === event.currentTarget) onclose();
|
if (event.target === event.currentTarget) onclose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="content">
|
<ActionList
|
||||||
<div class="search-row">
|
autofocus={true}
|
||||||
<input
|
{currentAction}
|
||||||
type="search"
|
{nextAction}
|
||||||
bind:this={searchBox}
|
{onselect}
|
||||||
oninput={search}
|
{onclose}
|
||||||
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>
|
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
<style lang="scss">
|
<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 {
|
dialog {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -237,146 +42,4 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 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>
|
</style>
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ kbd {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-block: 6px;
|
margin-block: 6px;
|
||||||
|
|
||||||
border: 1px solid currentcolor;
|
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|
||||||
|
//border: 1px solid currentcolor;
|
||||||
|
background: color-mix(
|
||||||
|
in srgb,
|
||||||
|
var(--md-sys-color-surface-variant) 50%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
|
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
@@ -52,9 +52,21 @@
|
|||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
theme.subscribe((it) => {
|
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
|
const dark = it.mode === "dark"; // window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
applyTheme(theme, { target: document.body, dark });
|
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) {
|
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 ViteYaml from "@modyfi/vite-plugin-yaml";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile } from "fs/promises";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
import { lezerGrammarPlugin } from "./vite-plugin-lezer";
|
||||||
|
|
||||||
const isTauri = "TAURI_FAMILY" in process.env;
|
const isTauri = "TAURI_FAMILY" in process.env;
|
||||||
console.info(isTauri ? "Building for Tauri" : "Building for PWA");
|
console.info(isTauri ? "Building for Tauri" : "Building for PWA");
|
||||||
@@ -48,6 +49,7 @@ export default defineConfig({
|
|||||||
plugins: [
|
plugins: [
|
||||||
ViteYaml(),
|
ViteYaml(),
|
||||||
sveltekit(),
|
sveltekit(),
|
||||||
|
lezerGrammarPlugin(),
|
||||||
...(isTauri
|
...(isTauri
|
||||||
? []
|
? []
|
||||||
: [
|
: [
|
||||||
|
|||||||
Reference in New Issue
Block a user