Files
Beyond-KLE/mock/src/ui/panels/MapPanel.vue
T
ewdernaandClaude Opus 5.5 fccbc28bee mock: 仮決定に沿ってモックを実装する(PHY Editor・IDX・ELC・MAP・Generator)
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
2026-09-25 22:42:30 +09:00

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>