mock: IDX Editorを作り直す(T-31)

provisional/idx-editor-feedback.md の合意事項と T-31 の仮決定に沿って、IdxPanel を捨てて ui/idx/ に一から書き直した。
並びのリストは置かず、左にバリアントのツリー、中央のキャンバスでドラッグして並べ替え、右にプリセット・取り込み・未登録の登録を置いた。

- order の操作(隠れたキーのくっつき先、挿入、入れ替え、連番の並べ直し)を domain/idxOrder.ts に、
  走査・半身の分割・行の割り当てのずらし・マトリクス順を domain/idxScan.ts に、純粋関数として置いた。行と列の判定は rows.ts をそのまま使う
- QMK keyboard.json・VIA定義・KLE JSON の取り込みは、位置と回転で表示中のキーに照合する(io/layoutImport.ts、domain/idxImport.ts)
- 回転したキーでも出力番号を正立させる
- Codex の設定ファイルが壊れていて起動できなかったため、実装は Claude が直接行った
- 検証: vue-tsc 0件、Vitest 101件(うち新規17件)、Playwright 48件(IDX 8件を書き直し)、prettier --check

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GzX13A7HseQz9n6etGbFDg
This commit is contained in:
2026-10-25 10:59:44 +09:00
co-authored by Claude Opus 5.5
parent 2321c068f9
commit c180642fa8
16 changed files with 1985 additions and 281 deletions
+158
View File
@@ -0,0 +1,158 @@
import type { FlatKey } from "./phy";
import type { ProjectFile } from "./project";
import { detectColumns, detectRows, type KeyRect } from "./rows";
/** Gaps narrower than this do not separate the two halves. T-31. */
const MIN_HALF_GAP = 0.5;
export type ScanKind = "rows" | "columns" | "halves" | "alternating";
export type Direction = "normal" | "reversed";
export type ScanOptions = {
kind: ScanKind;
/** Whole-board direction for `rows` and `columns`. */
direction: Direction;
/** Per-half directions for `halves` and `alternating`. */
left: Direction;
right: Direction;
/** Shift of the line assignment per cross line index (columns for row scans, rows for `columns`). */
offsets: ReadonlyMap<number, number>;
};
export type ScanResult = {
sequence: string[];
/** Lines the scan walks, for the preview: rows, or columns for `columns`. */
lines: string[][];
/** Lines that can be shifted with `offsets`, in index order. */
crossLines: string[][];
/** x of the split between the halves, for scans that use halves. */
boundary?: number;
};
/** Axis-aligned bounding box of a key in world coordinates; rotated keys are judged by it. T-31. */
export function keyRect(k: FlatKey): KeyRect {
const r = (k.rotation * Math.PI) / 180,
c = Math.cos(r),
s = Math.sin(r);
const { w, h } = k.key.size;
const xs = [0, w * c, -h * s, w * c - h * s].map((dx) => k.x + dx);
const ys = [0, w * s, h * c, w * s + h * c].map((dy) => k.y + dy);
const x = Math.min(...xs),
y = Math.min(...ys);
return { id: k.key.id, x, y, w: Math.max(...xs) - x, h: Math.max(...ys) - y };
}
/**
* Splits keys into left and right halves at the widest gap between detected columns.
* When no gap reaches MIN_HALF_GAP, splits at the middle of the keys' x range by key center.
*/
export function splitHalves(rects: KeyRect[]): { left: Set<string>; right: Set<string>; boundary: number } {
const columns = detectColumns(rects);
const byId = new Map(rects.map((r) => [r.id, r]));
const span = columns.map((col) => {
const rs = col.map((id) => byId.get(id)!);
return { min: Math.min(...rs.map((r) => r.x)), max: Math.max(...rs.map((r) => r.x + r.w)) };
});
let best = -1,
width = -Infinity;
for (let i = 0; i + 1 < span.length; i++) {
const gap = span[i + 1]!.min - span[i]!.max;
if (gap > width) [best, width] = [i, gap];
}
if (best >= 0 && width >= MIN_HALF_GAP) {
const left = new Set(columns.slice(0, best + 1).flat());
return {
left,
right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))),
boundary: (span[best]!.max + span[best + 1]!.min) / 2,
};
}
const boundary = rects.length
? (Math.min(...rects.map((r) => r.x)) + Math.max(...rects.map((r) => r.x + r.w))) / 2
: 0;
const left = new Set(rects.filter((r) => r.x + r.w / 2 < boundary).map((r) => r.id));
return { left, right: new Set(rects.map((r) => r.id).filter((id) => !left.has(id))), boundary };
}
/**
* Detects rows (or columns), then moves every key of cross line `i` by `offsets.get(i)` lines.
* Rows are rebuilt top to bottom and sorted by x; columns left to right and sorted by y.
*/
export function assignLines(
rects: KeyRect[],
kind: "row" | "column",
offsets: ReadonlyMap<number, number>,
): { lines: string[][]; crossLines: string[][] } {
const base = kind === "row" ? detectRows(rects) : detectColumns(rects);
const crossLines = kind === "row" ? detectColumns(rects) : detectRows(rects);
const line = new Map(base.flatMap((ids, i) => ids.map((id) => [id, i] as const)));
crossLines.forEach((ids, i) => ids.forEach((id) => line.set(id, line.get(id)! + (offsets.get(i) ?? 0))));
const byId = new Map(rects.map((r) => [r.id, r]));
const along = (id: string) => (kind === "row" ? byId.get(id)!.x : byId.get(id)!.y);
const indexes = [...new Set(line.values())].sort((a, b) => a - b);
const lines = indexes.map((i) =>
[...line]
.filter(([, at]) => at === i)
.map(([id]) => id)
.sort((a, b) => along(a) - along(b)),
);
return { lines, crossLines };
}
const directed = <T>(items: T[], d: Direction) => (d === "reversed" ? [...items].reverse() : items);
/** Builds the key sequence of a preset scan over the target keys. T-31. */
export function scanKeys(rects: KeyRect[], options: ScanOptions): ScanResult {
if (options.kind === "columns") {
const { lines, crossLines } = assignLines(rects, "column", options.offsets);
return { sequence: directed(lines, options.direction).flat(), lines, crossLines };
}
const { lines, crossLines } = assignLines(rects, "row", options.offsets);
if (options.kind === "rows")
return { sequence: lines.flatMap((row) => directed(row, options.direction)), lines, crossLines };
const { left, boundary } = splitHalves(rects);
const leftPart = (row: string[]) =>
directed(
row.filter((id) => left.has(id)),
options.left,
);
const rightPart = (row: string[]) =>
directed(
row.filter((id) => !left.has(id)),
options.right,
);
const sequence =
options.kind === "halves"
? [...lines.flatMap(leftPart), ...lines.flatMap(rightPart)]
: lines.flatMap((row) => [...leftPart(row), ...rightPart(row)]);
return { sequence, lines, crossLines, boundary };
}
/**
* Finds each contact key's (row, col) from the matrix pins, by the same rule as the Generator.
* Returns undefined when the project has no matrix settings. T-19.
*/
export function matrixPositions(p: ProjectFile): Map<string, { row: number; col: number }> | undefined {
const matrix = p.output.matrix;
if (!matrix) return undefined;
const rowIndex = new Map(matrix.rowPins.map((x, i) => [x, i])),
colIndex = new Map(matrix.colPins.map((x, i) => [x, i]));
const out = new Map<string, { row: number; col: number }>();
for (const w of p.elc.keys) {
if (w.detect !== "contact") continue;
const { a, b } = w.terminals;
if (rowIndex.has(a) && colIndex.has(b)) out.set(w.key, { row: rowIndex.get(a)!, col: colIndex.get(b)! });
else if (rowIndex.has(b) && colIndex.has(a))
out.set(w.key, { row: rowIndex.get(b)!, col: colIndex.get(a)! });
}
return out;
}
/** Sorts keys by row then col. Keys without a matrix position keep their input order at the end. */
export function matrixSequence(
ids: readonly string[],
positions: ReadonlyMap<string, { row: number; col: number }>,
) {
const placed = ids.filter((id) => positions.has(id));
const rest = ids.filter((id) => !positions.has(id));
const key = (id: string) => positions.get(id)!;
return [...placed.sort((a, b) => key(a).row - key(b).row || key(a).col - key(b).col), ...rest];
}