provisional/decisions.md の仮決定(T-1〜T-28)に従い、mock/ に Vue 3 + Vite + TypeScript のモックを置いた。 T-3 の1〜3(回転したクラスター内のドラッグと保存の往復、IDX・MAPとの連携、ELCと出力の対応表)まで。疑似デバイスとの往復(T-24)は未着手。 - KLE JSONの入出力は、KLE本家の serial.js の deserialize の結果を期待値とする fixture(tests/fixtures/kle-oracle.json)で照合した。本家のコードは取り込んでいない(T-7) - 基本キーコードの表は QMK の data/constants/keycodes/keycodes_0.0.1_basic.hjson(1e340cd)から抽出した - 実装は Codex(Luna)、レビューは Codex(Sol)。検証: Vitest 53件、Playwright 20件、vue-tsc、vite build - TypeScript は vue-tsc が 7 系に未対応のため 6 系に固定した - 操作性の改善(ELCの提供者の表示、一括配線とGeneratorのピン設定の引き継ぎ、キー内の文字のはみ出し)は別途行う Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AATqjRyp6rrnwTNdYw8XBT
338 lines
10 KiB
Vue
338 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref } from "vue";
|
|
import { flattenKeys } from "../../domain/geometry";
|
|
import {
|
|
assignKey,
|
|
deleteLayer,
|
|
isEditableKeycode,
|
|
parseKeycode,
|
|
reorderLayer,
|
|
renameLayer,
|
|
unassignKey,
|
|
unassignedKeys,
|
|
addLayer,
|
|
} from "../../domain/map";
|
|
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
|
import { getKeycodeLabel } from "../../domain/keycodeLabels";
|
|
import { qmkBasicKeycodes } from "../../domain/qmkBasicKeycodes";
|
|
import type { Dispatch } from "../types";
|
|
import KeyboardView from "../common/KeyboardView.vue";
|
|
|
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch; editable?: boolean }>();
|
|
const selectedLayer = ref("Base"),
|
|
selectedKey = ref(""),
|
|
draft = ref(""),
|
|
newLayerName = ref(""),
|
|
renameValue = ref(""),
|
|
error = ref("");
|
|
const selection = computed(() => ({
|
|
...defaultVariantSelection(props.project.phy),
|
|
...props.project.output.variantSelection,
|
|
}));
|
|
const keys = computed(() => flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal));
|
|
const layer = computed(
|
|
() => props.project.map.layers.find((x) => x.name === selectedLayer.value) ?? props.project.map.layers[0],
|
|
);
|
|
const keyLabels = computed(
|
|
() => new Map(keys.value.map((x) => [x.key.id, x.key.labels?.find(Boolean) || x.key.id])),
|
|
);
|
|
const assignments = computed(() => new Map((layer.value?.keymap ?? []).map((a) => [a.keyId, a.keycode])));
|
|
const selectedCode = computed(() => assignments.value.get(selectedKey.value) ?? "");
|
|
const selectedCodeEditable = computed(() => !selectedCode.value || isEditableKeycode(selectedCode.value));
|
|
const missing = computed(() =>
|
|
unassignedKeys(
|
|
props.project.map,
|
|
layer.value?.name ?? "",
|
|
keys.value.map((x) => x.key.id),
|
|
),
|
|
);
|
|
const candidates = computed(() => {
|
|
const keycodes = qmkBasicKeycodes
|
|
.filter((entry) => entry.group === "basic" || entry.group === "modifiers" || entry.group === "internal")
|
|
.map((entry) => entry.key);
|
|
const modifierFunctions = ["LCTL", "LSFT", "LALT", "LGUI", "RCTL", "RSFT", "RALT", "RGUI"];
|
|
const out = [...keycodes];
|
|
for (const l of props.project.map.layers)
|
|
for (const code of ["MO", "TG", "TO", "TT", "DF", "OSL"]) out.push(`${code}(${l.name})`);
|
|
for (const l of props.project.map.layers) for (const kc of keycodes) out.push(`LT(${l.name}, ${kc})`);
|
|
for (const mod of modifierFunctions) for (const kc of keycodes) out.push(`${mod}(${kc})`);
|
|
return [...new Set(out)];
|
|
});
|
|
function fail(e: unknown) {
|
|
error.value = e instanceof Error ? e.message : String(e);
|
|
}
|
|
function pickLayer(name: string) {
|
|
selectedLayer.value = name;
|
|
selectedKey.value = "";
|
|
error.value = "";
|
|
}
|
|
function chooseKey(id: string) {
|
|
selectedKey.value = id;
|
|
draft.value = assignments.value.get(id) ?? "";
|
|
error.value = "";
|
|
}
|
|
function saveAssignment() {
|
|
if (props.editable === false || !selectedKey.value || !layer.value || !selectedCodeEditable.value) return;
|
|
if (draft.value.trim() === selectedCode.value) return;
|
|
try {
|
|
props.dispatch("MAPキー割り当て", (p) => ({
|
|
...p,
|
|
map: assignKey(p.map, layer.value!.name, selectedKey.value, draft.value.trim()),
|
|
}));
|
|
error.value = "";
|
|
} catch (e) {
|
|
fail(e);
|
|
}
|
|
}
|
|
function add() {
|
|
if (props.editable === false) return;
|
|
if (!newLayerName.value.trim()) {
|
|
error.value = "レイヤー名を入力してください。";
|
|
return;
|
|
}
|
|
try {
|
|
props.dispatch("MAPレイヤー追加", (p) => ({ ...p, map: addLayer(p.map, newLayerName.value.trim()) }));
|
|
selectedLayer.value = newLayerName.value.trim();
|
|
newLayerName.value = "";
|
|
error.value = "";
|
|
} catch (e) {
|
|
fail(e);
|
|
}
|
|
}
|
|
function rename() {
|
|
if (props.editable === false) return;
|
|
if (!layer.value) return;
|
|
if (!renameValue.value.trim()) {
|
|
error.value = "新しいレイヤー名を入力してください。";
|
|
return;
|
|
}
|
|
try {
|
|
props.dispatch("MAPレイヤー改名", (p) => ({
|
|
...p,
|
|
map: renameLayer(p.map, layer.value!.name, renameValue.value.trim()),
|
|
}));
|
|
selectedLayer.value = renameValue.value.trim();
|
|
renameValue.value = "";
|
|
error.value = "";
|
|
} catch (e) {
|
|
fail(e);
|
|
}
|
|
}
|
|
function remove() {
|
|
if (props.editable === false) return;
|
|
if (!layer.value) return;
|
|
try {
|
|
props.dispatch("MAPレイヤー削除", (p) => ({ ...p, map: deleteLayer(p.map, layer.value!.name) }));
|
|
selectedLayer.value = props.project.map.layers[0]?.name ?? "";
|
|
error.value = "";
|
|
} catch (e) {
|
|
fail(e);
|
|
}
|
|
}
|
|
function move(delta: number) {
|
|
if (props.editable === false) return;
|
|
if (!layer.value) return;
|
|
const i = props.project.map.layers.findIndex((x) => x.name === layer.value!.name),
|
|
j = i + delta;
|
|
if (j < 0 || j >= props.project.map.layers.length) return;
|
|
props.dispatch("MAPレイヤー順序変更", (p) => ({ ...p, map: reorderLayer(p.map, layer.value!.name, j) }));
|
|
}
|
|
function unassign() {
|
|
if (props.editable === false || !layer.value || !selectedKey.value || !selectedCodeEditable.value) return;
|
|
props.dispatch("MAP割り当て解除", (p) => ({
|
|
...p,
|
|
map: unassignKey(p.map, layer.value!.name, selectedKey.value),
|
|
}));
|
|
draft.value = "";
|
|
error.value = "";
|
|
}
|
|
function keyText(id: string) {
|
|
const code = assignments.value.get(id);
|
|
if (!code) return "";
|
|
const parsed = parseKeycode(code);
|
|
const label = parsed.kind === "basic" ? getKeycodeLabel(code) : undefined;
|
|
return label ? `${code}\n${label}` : code;
|
|
}
|
|
function keyClass(id: string) {
|
|
const code = assignments.value.get(id);
|
|
if (!code) return "warn";
|
|
return isEditableKeycode(code) ? undefined : "muted";
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="map-panel">
|
|
<h2>Keymap Designer</h2>
|
|
<div class="map-columns">
|
|
<aside class="map-layers">
|
|
<h3>レイヤー</h3>
|
|
<div class="layer-tabs" role="tablist">
|
|
<button
|
|
v-for="(item, i) in project.map.layers"
|
|
:key="item.name"
|
|
role="tab"
|
|
:aria-selected="selectedLayer === item.name"
|
|
:class="{ active: selectedLayer === item.name }"
|
|
@click="pickLayer(item.name)"
|
|
>
|
|
{{ item.name }}
|
|
</button>
|
|
</div>
|
|
<label
|
|
>追加する名前
|
|
<input
|
|
v-model="newLayerName"
|
|
aria-label="追加するレイヤー名"
|
|
:disabled="editable === false" /></label
|
|
><button data-testid="map-add-layer" :disabled="editable === false" @click="add">追加</button>
|
|
<label
|
|
>新しい名前
|
|
<input
|
|
v-model="renameValue"
|
|
aria-label="新しい名前"
|
|
:placeholder="layer?.name"
|
|
:disabled="editable === false" /></label
|
|
><button data-testid="map-rename-layer" :disabled="editable === false" @click="rename">改名</button>
|
|
<div>
|
|
<button
|
|
aria-label="レイヤーを上へ"
|
|
:disabled="editable === false || !layer || project.map.layers[0]?.name === layer.name"
|
|
@click="move(-1)"
|
|
>
|
|
↑</button
|
|
><button
|
|
aria-label="レイヤーを下へ"
|
|
:disabled="editable === false || !layer || project.map.layers.at(-1)?.name === layer.name"
|
|
@click="move(1)"
|
|
>
|
|
↓</button
|
|
><button data-testid="map-delete-layer" :disabled="editable === false" @click="remove">削除</button>
|
|
</div>
|
|
<p v-if="error" role="alert" class="map-error">{{ error }}</p>
|
|
</aside>
|
|
<main class="map-editor">
|
|
<h3>{{ layer?.name }} · キーマップ</h3>
|
|
<KeyboardView
|
|
:phy="project.phy"
|
|
:selection="selection"
|
|
:selected-ids="selectedKey ? [selectedKey] : []"
|
|
:key-text="keyText"
|
|
:key-class="keyClass"
|
|
@key-click="(id) => chooseKey(id)"
|
|
/>
|
|
<div v-if="selectedKey" class="map-assignment">
|
|
<strong>{{ keyLabels.get(selectedKey) }} · {{ selectedKey }}</strong>
|
|
<label
|
|
>キーコード
|
|
<input
|
|
v-model="draft"
|
|
list="map-keycode-candidates"
|
|
aria-label="キーコード"
|
|
@keydown.enter.prevent="saveAssignment"
|
|
:disabled="editable === false || !selectedCodeEditable"
|
|
/></label>
|
|
<datalist id="map-keycode-candidates">
|
|
<option v-for="value in candidates" :key="value" :value="value" />
|
|
</datalist>
|
|
<button @click="saveAssignment" :disabled="editable === false || !selectedCodeEditable">
|
|
割り当て</button
|
|
><button @click="unassign" :disabled="editable === false || !selectedCodeEditable">
|
|
割り当て解除
|
|
</button>
|
|
<p v-if="selectedCode && !selectedCodeEditable" role="status">
|
|
未知のキーコードは保持し、表示のみです。
|
|
</p>
|
|
<p v-if="draft && !isEditableKeycode(draft.trim())" class="map-error">
|
|
このキーコードは編集対象外です。
|
|
</p>
|
|
</div>
|
|
<section class="map-missing">
|
|
<h3>未割り当て · {{ missing.length }} keys</h3>
|
|
<ul>
|
|
<li v-for="id in missing" :key="id">
|
|
<button @click="chooseKey(id)">{{ keyLabels.get(id) }} · {{ id }}</button>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.map-panel {
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: 10px;
|
|
background: #fff;
|
|
border: 1px solid #cbd1da;
|
|
border-radius: 6px;
|
|
}
|
|
.map-panel h2 {
|
|
margin: 0 0 10px;
|
|
}
|
|
.map-columns {
|
|
display: grid;
|
|
grid-template-columns: 260px minmax(400px, 1fr);
|
|
gap: 16px;
|
|
}
|
|
.map-layers {
|
|
border-right: 1px solid #d5dae2;
|
|
padding-right: 12px;
|
|
}
|
|
.map-layers h3,
|
|
.map-editor h3 {
|
|
margin: 8px 0;
|
|
}
|
|
.map-layers label,
|
|
.map-assignment label {
|
|
display: flex;
|
|
gap: 6px;
|
|
margin: 9px 0;
|
|
align-items: center;
|
|
}
|
|
.map-layers input {
|
|
min-width: 0;
|
|
width: 120px;
|
|
}
|
|
.layer-tabs {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 4px;
|
|
margin: 8px 0;
|
|
}
|
|
.layer-tabs button.active {
|
|
background: #dce9ff;
|
|
}
|
|
.map-editor {
|
|
min-width: 0;
|
|
}
|
|
.map-assignment {
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin: 12px 0;
|
|
padding: 8px;
|
|
background: #f5f7fa;
|
|
}
|
|
.map-assignment input {
|
|
width: 240px;
|
|
}
|
|
.map-missing ul {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 5px;
|
|
padding: 0;
|
|
list-style: none;
|
|
}
|
|
.map-error {
|
|
color: #a21d16;
|
|
background: #fff0ef;
|
|
padding: 6px;
|
|
}
|
|
.map-panel :deep(.keyboard-view) {
|
|
max-height: 48vh;
|
|
}
|
|
</style>
|