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
276 lines
8.9 KiB
Vue
276 lines
8.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, ref, watch } from "vue";
|
|
import { defaultVariantSelection, type ProjectFile } from "../../domain/project";
|
|
import type { Node, VariantGroupNode } from "../../domain/phy";
|
|
import { flattenKeys } from "../../domain/geometry";
|
|
import { mcuPinCandidates } from "../../domain/elc";
|
|
import { generateMockTarget } from "../../domain/generator";
|
|
import type { Dispatch } from "../types";
|
|
import KeyboardView from "../common/KeyboardView.vue";
|
|
|
|
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
|
|
const selection = computed(() => ({
|
|
...defaultVariantSelection(props.project.phy),
|
|
...props.project.output.variantSelection,
|
|
}));
|
|
const groups = computed(() => {
|
|
const all: VariantGroupNode[] = [];
|
|
const visit = (ns: Node[]) =>
|
|
ns.forEach((n) => {
|
|
if (n.type === "cluster") visit(n.children);
|
|
else if (n.type === "variantGroup") {
|
|
all.push(n);
|
|
n.children.forEach((v) => visit(v.children));
|
|
}
|
|
});
|
|
visit(props.project.phy.root);
|
|
return all;
|
|
});
|
|
const pins = computed(() => [
|
|
...new Set(
|
|
props.project.elc.providers.flatMap((p) =>
|
|
p.kind === "mcu"
|
|
? mcuPinCandidates(p).map((x) => `${p.id}.${x}`)
|
|
: Array.from({ length: p.channels }, (_, i) => `${p.id}.${i}`),
|
|
),
|
|
),
|
|
]);
|
|
const rowAdd = ref(""),
|
|
colAdd = ref("");
|
|
const result = ref<ReturnType<typeof generateMockTarget> | null>(null);
|
|
watch(
|
|
() => props.project,
|
|
() => {
|
|
result.value = null;
|
|
},
|
|
);
|
|
const rows = computed(() => props.project.output.matrix?.rowPins ?? []),
|
|
cols = computed(() => props.project.output.matrix?.colPins ?? []);
|
|
const flatKeys = computed(() => flattenKeys(props.project.phy, selection.value).filter((x) => !x.key.decal));
|
|
const mappingById = computed(() => new Map((result.value?.target?.mapping ?? []).map((m) => [m.keyId, m])));
|
|
function setVariant(groupId: string, variantId: string) {
|
|
props.dispatch("出力構成", (p) => ({
|
|
...p,
|
|
output: { ...p.output, variantSelection: { ...selection.value, [groupId]: variantId } },
|
|
}));
|
|
result.value = null;
|
|
}
|
|
function setPins(kind: "rowPins" | "colPins", value: string[]) {
|
|
props.dispatch(kind === "rowPins" ? "rowピン順序" : "colピン順序", (p) => ({
|
|
...p,
|
|
output: {
|
|
...p.output,
|
|
matrix: {
|
|
...p.output.matrix,
|
|
kind: "matrix",
|
|
rowPins: [...(p.output.matrix?.rowPins ?? [])],
|
|
colPins: [...(p.output.matrix?.colPins ?? [])],
|
|
[kind]: value,
|
|
},
|
|
},
|
|
}));
|
|
result.value = null;
|
|
}
|
|
function addPin(kind: "rowPins" | "colPins", pin: string) {
|
|
if (!pin) return;
|
|
const current = kind === "rowPins" ? rows.value : cols.value;
|
|
if (current.includes(pin)) return;
|
|
setPins(kind, [...current, pin]);
|
|
if (kind === "rowPins") rowAdd.value = "";
|
|
else colAdd.value = "";
|
|
}
|
|
function movePin(kind: "rowPins" | "colPins", index: number, d: number) {
|
|
const next = [...(kind === "rowPins" ? rows.value : cols.value)],
|
|
to = index + d;
|
|
if (to < 0 || to >= next.length) return;
|
|
[next[index], next[to]] = [next[to]!, next[index]!];
|
|
setPins(kind, next);
|
|
}
|
|
function removePin(kind: "rowPins" | "colPins", index: number) {
|
|
const next = [...(kind === "rowPins" ? rows.value : cols.value)];
|
|
next.splice(index, 1);
|
|
setPins(kind, next);
|
|
}
|
|
function generate() {
|
|
result.value = generateMockTarget(props.project);
|
|
}
|
|
function download() {
|
|
if (!result.value?.target) return;
|
|
const blob = new Blob([JSON.stringify(result.value.target, null, 2)], { type: "application/json" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "mock-target.json";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
function keyText(id: string) {
|
|
const m = mappingById.value.get(id);
|
|
return m ? `${m.number}\nr${m.row} c${m.col}` : id.slice(-3);
|
|
}
|
|
function keyClass(id: string) {
|
|
return mappingById.value.has(id) ? "ok" : "muted";
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<section class="generator-panel">
|
|
<h2>Generator · プレビュー</h2>
|
|
<p class="notice">
|
|
これは検証用の <code>mock-target.json</code> です。QMKにそのまま渡せる生成物ではありません。
|
|
</p>
|
|
<div class="settings">
|
|
<section class="box">
|
|
<h3>出力構成</h3>
|
|
<label v-for="g in groups" :key="g.id"
|
|
>{{ g.name }}
|
|
<select
|
|
:value="selection[g.id] ?? g.default"
|
|
@change="setVariant(g.id, ($event.target as HTMLSelectElement).value)"
|
|
>
|
|
<option v-for="v in g.children" :key="v.id" :value="v.id">{{ v.name }}</option>
|
|
</select></label
|
|
>
|
|
<p v-if="!groups.length">バリアントグループなし</p>
|
|
</section>
|
|
<section class="box">
|
|
<h3>Matrix row / col</h3>
|
|
<p>ELCで使うピン候補から順序を設定します。番号はリスト順です。</p>
|
|
<h4>rowPins</h4>
|
|
<ol>
|
|
<li v-for="(pin, i) in rows" :key="`${pin}-${i}`">
|
|
{{ i }}: {{ pin }} <button @click="movePin('rowPins', i, -1)">↑</button
|
|
><button @click="movePin('rowPins', i, 1)">↓</button
|
|
><button @click="removePin('rowPins', i)">削除</button>
|
|
</li>
|
|
</ol>
|
|
<select v-model="rowAdd" data-testid="row-pin-select">
|
|
<option value="">ピンを選択…</option>
|
|
<option v-for="pin in pins" :key="`r${pin}`">{{ pin }}</option></select
|
|
><button @click="addPin('rowPins', rowAdd)">row追加</button>
|
|
<h4>colPins</h4>
|
|
<ol>
|
|
<li v-for="(pin, i) in cols" :key="`${pin}-${i}`">
|
|
{{ i }}: {{ pin }} <button @click="movePin('colPins', i, -1)">↑</button
|
|
><button @click="movePin('colPins', i, 1)">↓</button
|
|
><button @click="removePin('colPins', i)">削除</button>
|
|
</li>
|
|
</ol>
|
|
<select v-model="colAdd" data-testid="col-pin-select">
|
|
<option value="">ピンを選択…</option>
|
|
<option v-for="pin in pins" :key="`c${pin}`">{{ pin }}</option></select
|
|
><button @click="addPin('colPins', colAdd)">col追加</button>
|
|
<p v-if="!pins.length">ELCのピン提供者を先に追加してください。</p>
|
|
</section>
|
|
<section class="box preview">
|
|
<h3>生成結果</h3>
|
|
<button data-testid="generate-button" @click="generate">生成</button
|
|
><span v-if="result?.target" data-testid="mapping-count"> {{ result.target.mapping.length }}行</span>
|
|
<div v-if="result && !result.target" class="errors">
|
|
<h4>生成を止めた理由 ({{ result.errors.length }})</h4>
|
|
<ul>
|
|
<li v-for="(e, i) in result.errors" :key="`${e.code}-${i}`">
|
|
<b>{{ e.code }}</b> {{ e.message }} <small>{{ e.ids?.join(", ") }}</small>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<template v-if="result?.target"
|
|
><button data-testid="download-target" @click="download">mock-target.jsonをダウンロード</button>
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>出力番号</th>
|
|
<th>keyId</th>
|
|
<th>row</th>
|
|
<th>col</th>
|
|
<th>ラベル</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="m in result.target.mapping" :key="m.keyId">
|
|
<td>{{ m.number }}</td>
|
|
<td>{{ m.keyId }}</td>
|
|
<td>{{ m.row }}</td>
|
|
<td>{{ m.col }}</td>
|
|
<td>{{ m.label }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<h4>レイヤー配列</h4>
|
|
<section v-for="layer in result.target.keymaps" :key="layer.name">
|
|
<b>{{ layer.name }}</b
|
|
><code class="array">{{ JSON.stringify(layer.keycodes) }}</code>
|
|
</section>
|
|
</template>
|
|
</section>
|
|
</div>
|
|
<section class="layout-box">
|
|
<h3>選択構成 · 番号 / row,col</h3>
|
|
<KeyboardView :phy="project.phy" :selection="selection" :key-text="keyText" :key-class="keyClass" />
|
|
</section>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.generator-panel {
|
|
height: 100%;
|
|
overflow: auto;
|
|
padding: 12px;
|
|
background: #fff;
|
|
border: 1px solid #cbd1da;
|
|
border-radius: 6px;
|
|
}
|
|
.notice {
|
|
padding: 8px;
|
|
background: #fff4d6;
|
|
}
|
|
.settings {
|
|
display: grid;
|
|
grid-template-columns: minmax(220px, 0.8fr) minmax(260px, 1fr) minmax(380px, 1.5fr);
|
|
gap: 10px;
|
|
}
|
|
.box,
|
|
.layout-box {
|
|
padding: 10px;
|
|
border: 1px solid #d2d7df;
|
|
border-radius: 5px;
|
|
overflow: auto;
|
|
}
|
|
.box label {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
margin: 8px 0;
|
|
}
|
|
.box h3,
|
|
.layout-box h3 {
|
|
margin-top: 0;
|
|
}
|
|
.preview table {
|
|
border-collapse: collapse;
|
|
width: 100%;
|
|
margin-top: 8px;
|
|
}
|
|
.preview th,
|
|
.preview td {
|
|
border: 1px solid #d8dde5;
|
|
padding: 3px 5px;
|
|
text-align: left;
|
|
}
|
|
.errors {
|
|
color: #9b1c1c;
|
|
}
|
|
.array {
|
|
display: block;
|
|
overflow-wrap: anywhere;
|
|
margin: 5px 0 10px;
|
|
}
|
|
.layout-box {
|
|
margin-top: 10px;
|
|
}
|
|
.layout-box :deep(.kv-key text) {
|
|
font-size: 0.16px;
|
|
white-space: pre;
|
|
}
|
|
</style>
|