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
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { flattenKeys } from "../../domain/geometry";
|
||||
import type { Node, VariantGroupNode } from "../../domain/phy";
|
||||
import { appendKeys, excludeKey, reorderKeys, unregisteredKeys } from "../../domain/idx";
|
||||
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
||||
import type { Dispatch } from "../types";
|
||||
import KeyboardView from "../common/KeyboardView.vue";
|
||||
|
||||
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
||||
const selectedKey = ref("");
|
||||
const selection = computed(() => ({
|
||||
...defaultVariantSelection(props.project.phy),
|
||||
...props.project.output.variantSelection,
|
||||
}));
|
||||
const groups = computed(() => {
|
||||
const out: VariantGroupNode[] = [];
|
||||
const walk = (nodes: Node[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children);
|
||||
if (n.type === "variantGroup") {
|
||||
out.push(n);
|
||||
n.children.forEach((v) => walk(v.children));
|
||||
}
|
||||
});
|
||||
walk(props.project.phy.root);
|
||||
return out;
|
||||
});
|
||||
type Entry = { id: string; label: string; path: string; pathIds: string[] };
|
||||
const entries = computed<Entry[]>(() => {
|
||||
const out: Entry[] = [];
|
||||
const walk = (nodes: Node[], path: string[]) =>
|
||||
nodes.forEach((n) => {
|
||||
if (n.type === "cluster") walk(n.children, [...path, n.name || n.id]);
|
||||
else if (n.type === "key") {
|
||||
if (!n.decal)
|
||||
out.push({
|
||||
id: n.id,
|
||||
label: n.labels?.find(Boolean) || "(ラベルなし)",
|
||||
path: path.join(" / ") || "ルート",
|
||||
pathIds: path,
|
||||
});
|
||||
} else if (n.type === "variantGroup") {
|
||||
n.children.forEach((v) => walk(v.children, [...path, `${n.name} / ${v.name}`]));
|
||||
}
|
||||
});
|
||||
walk(props.project.phy.root, []);
|
||||
return out;
|
||||
});
|
||||
const byId = computed(() => new Map(entries.value.map((e) => [e.id, e])));
|
||||
const unregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
|
||||
const displayKeys = computed(() =>
|
||||
flattenKeys(props.project.phy, selection.value).filter((k) => !k.key.decal),
|
||||
);
|
||||
const outputNumbers = computed(() => {
|
||||
const active = new Set(displayKeys.value.map((k) => k.key.id));
|
||||
return new Map(props.project.idx.order.filter((id) => active.has(id)).map((id, index) => [id, index]));
|
||||
});
|
||||
const rowEntries = computed(() =>
|
||||
props.project.idx.order.map(
|
||||
(id) => byId.value.get(id) ?? { id, label: "(PHYに存在しないキー)", path: "不明", pathIds: [] },
|
||||
),
|
||||
);
|
||||
function setVariant(groupId: string, variantId: string) {
|
||||
props.dispatch("IDX出力構成変更", (p) => ({
|
||||
...p,
|
||||
output: { ...p.output, variantSelection: { ...p.output.variantSelection, [groupId]: variantId } },
|
||||
}));
|
||||
}
|
||||
function register(id: string) {
|
||||
props.dispatch("IDXキー登録", (p) => ({ ...p, idx: appendKeys(p.idx, [id]) }));
|
||||
}
|
||||
function exclude(id: string) {
|
||||
props.dispatch("IDXキー除外", (p) => ({ ...p, idx: excludeKey(p.idx, id) }));
|
||||
}
|
||||
function move(id: string, delta: number) {
|
||||
const i = props.project.idx.order.indexOf(id),
|
||||
j = i + delta;
|
||||
if (j < 0 || j >= props.project.idx.order.length) return;
|
||||
const order = [...props.project.idx.order];
|
||||
[order[i], order[j]] = [order[j]!, order[i]!];
|
||||
props.dispatch("IDX順序変更", (p) => ({ ...p, idx: reorderKeys(p.idx, order) }));
|
||||
}
|
||||
function registerAll() {
|
||||
props.dispatch("IDX未登録キーを登録", (p) => ({
|
||||
...p,
|
||||
idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)),
|
||||
}));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="idx-panel">
|
||||
<h2>Key Indexer</h2>
|
||||
<div class="idx-layout">
|
||||
<div class="idx-list">
|
||||
<div class="idx-head">
|
||||
<h3>IDX order</h3>
|
||||
<button data-testid="idx-register-all" :disabled="!unregistered.size" @click="registerAll">
|
||||
未登録をすべて登録
|
||||
</button>
|
||||
</div>
|
||||
<p>選択構成での出力番号をプレビューします。</p>
|
||||
<ol>
|
||||
<li
|
||||
v-for="(entry, i) in rowEntries"
|
||||
:key="entry.id"
|
||||
:data-testid="`idx-row-${entry.id}`"
|
||||
:class="{ chosen: selectedKey === entry.id }"
|
||||
>
|
||||
<span class="idx-number" :aria-label="`保存順 ${i}`">保存順 {{ i }}</span
|
||||
><b>{{ entry.id }}</b
|
||||
><span>{{ entry.label }}</span
|
||||
><small>{{ entry.path }}</small
|
||||
><span>{{
|
||||
outputNumbers.has(entry.id) ? `出力 ${outputNumbers.get(entry.id)}` : "出力対象外"
|
||||
}}</span>
|
||||
<button :aria-label="`${entry.id}を上へ`" :disabled="i === 0" @click="move(entry.id, -1)">
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
:aria-label="`${entry.id}を下へ`"
|
||||
:disabled="i === rowEntries.length - 1"
|
||||
@click="move(entry.id, 1)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button :aria-label="`${entry.id}を除外`" @click="exclude(entry.id)">除外</button>
|
||||
</li>
|
||||
</ol>
|
||||
<h3>全バリアントのキー</h3>
|
||||
<ul class="idx-all-keys">
|
||||
<li
|
||||
v-for="entry in entries"
|
||||
:key="entry.id"
|
||||
:data-testid="`idx-key-${entry.id}`"
|
||||
:class="{ chosen: selectedKey === entry.id, unregistered: unregistered.has(entry.id) }"
|
||||
>
|
||||
<b>{{ entry.id }}</b> · {{ entry.label }} <small>— {{ entry.path }}</small>
|
||||
<strong v-if="unregistered.has(entry.id)">未登録</strong>
|
||||
<span v-else-if="outputNumbers.has(entry.id)" class="idx-preview"
|
||||
>出力 {{ outputNumbers.get(entry.id) }}</span
|
||||
>
|
||||
<span v-else class="idx-preview muted">選択構成外</span>
|
||||
<button v-if="unregistered.has(entry.id)" @click="register(entry.id)">登録</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="idx-preview-pane">
|
||||
<h3>出力構成</h3>
|
||||
<label v-for="group in groups" :key="group.id"
|
||||
>{{ group.name }}
|
||||
<select
|
||||
:aria-label="`${group.name} 出力バリアント`"
|
||||
:value="selection[group.id] ?? group.default"
|
||||
@change="setVariant(group.id, ($event.target as HTMLSelectElement).value)"
|
||||
>
|
||||
<option v-for="variant in group.children" :key="variant.id" :value="variant.id">
|
||||
{{ variant.name }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<KeyboardView
|
||||
:phy="project.phy"
|
||||
:selection="selection"
|
||||
:selected-ids="selectedKey ? [selectedKey] : []"
|
||||
:key-text="(id) => (outputNumbers.has(id) ? String(outputNumbers.get(id)) : '')"
|
||||
@key-click="(id) => (selectedKey = id)"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.idx-panel {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border: 1px solid #cbd1da;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.idx-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(440px, 1.2fr) minmax(320px, 1fr);
|
||||
gap: 18px;
|
||||
}
|
||||
.idx-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.idx-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.idx-panel h3 {
|
||||
margin: 12px 0 6px;
|
||||
}
|
||||
.idx-panel ol,
|
||||
.idx-all-keys {
|
||||
padding-left: 24px;
|
||||
}
|
||||
.idx-panel li {
|
||||
padding: 5px;
|
||||
margin: 3px 0;
|
||||
}
|
||||
.idx-panel ol li {
|
||||
display: grid;
|
||||
grid-template-columns: 86px minmax(95px, auto) 1fr 1fr auto auto auto auto;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
}
|
||||
.idx-panel small {
|
||||
color: #586273;
|
||||
}
|
||||
.idx-number {
|
||||
font-weight: bold;
|
||||
}
|
||||
.idx-all-keys {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
.idx-all-keys li {
|
||||
border-bottom: 1px solid #e3e6eb;
|
||||
}
|
||||
.idx-panel .chosen {
|
||||
background: #dce9ff;
|
||||
}
|
||||
.idx-all-keys .unregistered {
|
||||
background: #fff1d6;
|
||||
}
|
||||
.idx-all-keys strong {
|
||||
color: #9a4c00;
|
||||
margin: 0 8px;
|
||||
}
|
||||
.idx-preview-pane {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
align-self: start;
|
||||
}
|
||||
.idx-preview-pane label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin: 5px 0;
|
||||
}
|
||||
.idx-preview {
|
||||
font-size: 12px;
|
||||
color: #1769d2;
|
||||
}
|
||||
.muted {
|
||||
color: #778;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user