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
+39
View File
@@ -0,0 +1,39 @@
import type { FlatKey } from "./phy";
/** World position of a key origin and its rotation, as read from an existing definition. T-31. */
export type Placement = { x: number; y: number; rotation: number };
const TOLERANCE = 0.01;
const angle = (deg: number) => ((deg % 360) + 360) % 360;
function sameRotation(a: number, b: number): boolean {
const d = Math.abs(angle(a) - angle(b));
return Math.min(d, 360 - d) <= TOLERANCE;
}
/** Matches placements one-to-one to the shown keys by position and rotation, in placement order. */
export function matchPlacements(
placements: readonly Placement[],
keys: readonly FlatKey[],
): { ids: string[]; unmatched: number } {
const used = new Set<string>();
const ids: string[] = [];
for (const p of placements) {
let best: FlatKey | undefined,
distance = Infinity;
for (const k of keys) {
if (used.has(k.key.id) || !sameRotation(p.rotation, k.rotation)) continue;
const dx = Math.abs(p.x - k.x),
dy = Math.abs(p.y - k.y);
if (dx > TOLERANCE || dy > TOLERANCE || dx + dy >= distance) continue;
best = k;
distance = dx + dy;
}
if (best) {
used.add(best.key.id);
ids.push(best.key.id);
}
}
return { ids, unmatched: placements.length - ids.length };
}
+110
View File
@@ -0,0 +1,110 @@
import type { Node, PhyFile } from "./phy";
/** Keys shown in the edited configuration and the VariantGroups each key sits under. T-31. */
export type OrderContext = {
visible: ReadonlySet<string>;
groups: ReadonlyMap<string, readonly string[]>;
};
/** Lists the VariantGroup ancestors of every key, outermost first. Common keys get an empty list. */
export function keyVariantGroups(phy: PhyFile): Map<string, string[]> {
const out = new Map<string, string[]>();
const visit = (nodes: Node[], groups: string[]) =>
nodes.forEach((n) => {
if (n.type === "key") out.set(n.id, groups);
else if (n.type === "cluster") visit(n.children, groups);
else if (n.type === "variantGroup") n.children.forEach((v) => visit(v.children, [...groups, n.id]));
});
visit(phy.root, []);
return out;
}
function sharesGroup(ctx: OrderContext, a: string, b: string): boolean {
const mine = ctx.groups.get(a) ?? [];
return (ctx.groups.get(b) ?? []).some((g) => mine.includes(g));
}
/**
* Splits `order` into one segment per visible key, each holding the hidden keys attached to it.
* A hidden key attaches to the nearer visible key that shares a VariantGroup with it,
* preferring the one before it; once a hidden key attaches forward, the rest of that run does too.
* Concatenating the segments gives `order` back. Without visible keys there are no segments.
*/
export function orderSegments(order: readonly string[], ctx: OrderContext): string[][] {
const at = order.flatMap((id, i) => (ctx.visible.has(id) ? [i] : []));
if (!at.length) return [];
const segments = at.map((i) => [order[i]!]);
segments[0]!.unshift(...order.slice(0, at[0]));
segments.at(-1)!.push(...order.slice(at.at(-1)! + 1));
for (let k = 0; k + 1 < at.length; k++) {
const prev = order[at[k]!]!,
next = order[at[k + 1]!]!,
run = order.slice(at[k]! + 1, at[k + 1]);
let split = run.findIndex((h) => sharesGroup(ctx, h, next) && !sharesGroup(ctx, h, prev));
if (split < 0) split = run.length;
segments[k]!.push(...run.slice(0, split));
segments[k + 1]!.unshift(...run.slice(split));
}
return segments;
}
/**
* Moves `keyId` to gap `gap` (0..V) between the visible keys left after taking it out.
* Hidden keys that were attached to it stay where they are. Unregistered keys are registered there.
*/
export function insertAtGap(
order: readonly string[],
ctx: OrderContext,
keyId: string,
gap: number,
): string[] {
const rest = order.filter((id) => id !== keyId);
const segments = orderSegments(rest, ctx);
if (gap >= segments.length) return [...rest, keyId];
return [...segments.slice(0, gap).flat(), keyId, ...segments.slice(gap).flat()];
}
/** Moves `keyId` right before (`after` false) or right after the visible key `target`. */
export function insertNextTo(
order: readonly string[],
ctx: OrderContext,
keyId: string,
target: string,
after: boolean,
): string[] {
if (keyId === target) return [...order];
const visibleRest = order.filter((id) => id !== keyId && ctx.visible.has(id));
const index = visibleRest.indexOf(target);
if (index < 0) throw new Error(`Not a visible registered key: ${target}`);
return insertAtGap(order, ctx, keyId, index + (after ? 1 : 0));
}
/** Swaps two registered keys. Every other key keeps its position. */
export function swapKeys(order: readonly string[], a: string, b: string): string[] {
const i = order.indexOf(a),
j = order.indexOf(b);
if (i < 0 || j < 0) throw new Error("Both keys must be registered");
const out = [...order];
[out[i], out[j]] = [out[j]!, out[i]!];
return out;
}
/**
* Places the visible keys of `sequence` consecutively, starting where its first registered key was
* among the other visible keys. Other visible keys keep their relative order, hidden keys move with
* the visible key they are attached to, and unregistered keys in `sequence` are registered. T-31.
*/
export function renumber(order: readonly string[], ctx: OrderContext, sequence: readonly string[]): string[] {
const registered = new Set(order);
const seq = [...new Set(sequence)].filter((id) => ctx.visible.has(id) || !registered.has(id));
const inSeq = new Set(seq);
const anchor = seq.find((id) => registered.has(id));
if (!anchor) return [...order, ...seq];
const segments = orderSegments(order, ctx);
const byKey = new Map(segments.map((s) => [s.find((id) => ctx.visible.has(id))!, s]));
const others = segments.filter((s) => !s.some((id) => inSeq.has(id)));
const anchorAt = order.indexOf(anchor);
const gap = others.filter((s) => order.indexOf(s.find((id) => ctx.visible.has(id))!) < anchorAt).length;
const block = seq.flatMap((id) => byKey.get(id) ?? [id]);
return [...others.slice(0, gap).flat(), ...block, ...others.slice(gap).flat()];
}
+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];
}
+54
View File
@@ -0,0 +1,54 @@
import { flattenKeys } from "../domain/geometry";
import type { Placement } from "../domain/idxImport";
import { importKle } from "./kleImport";
type Obj = Record<string, unknown>;
const isObj = (v: unknown): v is Obj => typeof v === "object" && v !== null && !Array.isArray(v);
const num = (v: unknown, fallback = 0) => (typeof v === "number" ? v : fallback);
/** Result of reading an existing definition. `layouts` lists the choices when one must be picked. */
export type LayoutRead = { placements: Placement[]; layouts?: string[]; layout?: string };
/** QMK keyboard.json/info.json layout names, or undefined when the input is not a QMK definition. */
export function qmkLayoutNames(input: unknown): string[] | undefined {
if (!isObj(input) || !isObj(input.layouts) || Array.isArray(input.layouts.keymap)) return undefined;
const names = Object.entries(input.layouts)
.filter(([, v]) => isObj(v) && Array.isArray(v.layout))
.map(([name]) => name);
return names.length ? names : undefined;
}
/**
* Reads key placements, in definition order, from QMK `layouts.<NAME>.layout[]`, a VIA definition's
* `layouts.keymap`, or KLE JSON. KLE and VIA go through importKle so coordinates match the project.
* QMK r/rx/ry are read with the KLE meaning. Without `layout`, a QMK file with several layouts
* returns only the names. T-31.
*/
export function readLayoutPlacements(input: unknown, layout?: string): LayoutRead {
const names = qmkLayoutNames(input);
if (names) {
const name = layout ?? (names.length === 1 ? names[0] : undefined);
if (!name) return { placements: [], layouts: names };
const entries = ((input as { layouts: Obj }).layouts[name] as { layout: unknown[] }).layout;
return { placements: entries.filter(isObj).map(qmkPlacement), layouts: names, layout: name };
}
const { phy, keyOrder } = importKle(input);
const flat = new Map(flattenKeys(phy).map((k) => [k.key.id, k]));
const placements = keyOrder.flatMap((id) => {
const k = flat.get(id);
return k && !k.key.decal ? [{ x: k.x, y: k.y, rotation: k.rotation }] : [];
});
return { placements };
}
function qmkPlacement(e: Obj): Placement {
const x = num(e.x),
y = num(e.y),
r = num(e.r),
rx = num(e.rx),
ry = num(e.ry);
const a = (r * Math.PI) / 180,
c = Math.cos(a),
s = Math.sin(a);
return { x: rx + (x - rx) * c - (y - ry) * s, y: ry + (x - rx) * s + (y - ry) * c, rotation: r };
}
+8 -6
View File
@@ -9,7 +9,7 @@ import { exportKle } from "../io/kleExport";
import { defaultLayout } from "./defaultLayout";
import type { Dispatch } from "./types";
import PhyEditor from "./phy/PhyEditor.vue";
import IdxPanel from "./panels/IdxPanel.vue";
import IdxEditor from "./idx/IdxEditor.vue";
import ElcPanel from "./panels/ElcPanel.vue";
import MapPanel from "./panels/MapPanel.vue";
import GeneratorPanel from "./panels/GeneratorPanel.vue";
@@ -186,12 +186,14 @@ function exportSelected() {
:commit-history="commitHistory"
@error="errorText = $event"
/>
<IdxEditor
v-else-if="activeTab === 'IDX'"
:project="project"
:dispatch="dispatch"
@error="errorText = $event"
/>
<section v-else class="other-panel">
<IdxPanel v-if="activeTab === 'IDX'" :project="project" :dispatch="dispatch" /><ElcPanel
v-else-if="activeTab === 'ELC'"
:project="project"
:dispatch="dispatch"
/><MapPanel
<ElcPanel v-if="activeTab === 'ELC'" :project="project" :dispatch="dispatch" /><MapPanel
v-else-if="activeTab === 'MAP'"
:project="project"
:dispatch="dispatch"
+657
View File
@@ -0,0 +1,657 @@
<script setup lang="ts">
// IDX Editor。キャンバス上のドラッグとプリセットで order を並べ替える。T-31。
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { ProjectFile } from "../../domain/project";
import { defaultVariantSelection } from "../../domain/project";
import { flattenKeys } from "../../domain/geometry";
import type { FlatKey, Node } from "../../domain/phy";
import { appendKeys, unregisteredKeys } from "../../domain/idx";
import { keyVariantGroups, renumber, type OrderContext } from "../../domain/idxOrder";
import { keyRect, matrixPositions, matrixSequence, scanKeys, type ScanResult } from "../../domain/idxScan";
import { matchPlacements, type Placement } from "../../domain/idxImport";
import { fitViewport, zoomAround } from "../phy/viewport";
import type { Dispatch } from "../types";
import IdxTree from "./IdxTree.vue";
import IdxPresets from "./IdxPresets.vue";
import IdxImport from "./IdxImport.vue";
import { applyDrop, keyCenter, outputNumbers, type DropAction } from "./logic";
import { defaultPresetSettings, lineColors, type PresetSettings } from "./presets";
import { useIdxPointer } from "./useIdxPointer";
const props = defineProps<{ project: ProjectFile; dispatch: Dispatch }>();
const emit = defineEmits<{ error: [text: string] }>();
const variants = ref<Record<string, string>>({}),
selected = ref<string[]>([]),
zoom = ref(52),
pan = ref({ x: 50, y: 50 }),
size = ref({ w: 900, h: 600 }),
svg = ref<SVGSVGElement>(),
settings = ref<PresetSettings>(defaultPresetSettings()),
preview = ref<{ offsets: Map<number, number>; cross: number | null } | null>(null),
importMessage = ref("");
/** Displayed configuration: starts from output.variantSelection, never written back. T-31. */
function resetVariants() {
variants.value = {
...defaultVariantSelection(props.project.phy),
...props.project.output.variantSelection,
};
}
resetVariants();
watch(
() => props.project.projectId,
() => {
resetVariants();
selected.value = [];
preview.value = null;
importMessage.value = "";
fit();
},
);
watch(
() => props.project.phy,
() => {
// groups made in PHY after opening get their default; removed keys leave the selection
variants.value = { ...defaultVariantSelection(props.project.phy), ...variants.value };
const shown = new Set(keys.value.map((k) => k.key.id));
selected.value = selected.value.filter((id) => shown.has(id));
},
);
const keys = computed<FlatKey[]>(() => {
try {
return flattenKeys(props.project.phy, variants.value).filter((k) => !k.key.decal);
} catch {
return flattenKeys(props.project.phy).filter((k) => !k.key.decal);
}
});
const keyById = computed(() => new Map(keys.value.map((k) => [k.key.id, k])));
const visible = computed(() => new Set(keys.value.map((k) => k.key.id)));
const registered = computed(() => new Set(props.project.idx.order));
const allUnregistered = computed(() => new Set(unregisteredKeys(props.project.phy, props.project.idx)));
const ctx = computed<OrderContext>(() => ({
visible: visible.value,
groups: keyVariantGroups(props.project.phy),
}));
const positions = computed(() => matrixPositions(props.project));
// ---- preview of a preset ----
const targetKeys = computed(() =>
settings.value.target === "selection"
? keys.value.filter((k) => selected.value.includes(k.key.id))
: keys.value,
);
const scan = computed<ScanResult | null>(() => {
if (!preview.value) return null;
const target = targetKeys.value;
if (settings.value.kind === "matrix") {
const at = (id: string) => (registered.value.has(id) ? props.project.idx.order.indexOf(id) : Infinity);
const ids = target.map((k) => k.key.id).sort((a, b) => at(a) - at(b));
return { sequence: matrixSequence(ids, positions.value ?? new Map()), lines: [], crossLines: [] };
}
return scanKeys(target.map(keyRect), {
...settings.value,
kind: settings.value.kind,
offsets: preview.value.offsets,
});
});
const shownOrder = computed(() =>
scan.value ? renumber(props.project.idx.order, ctx.value, scan.value.sequence) : props.project.idx.order,
);
const numbers = computed(() => outputNumbers(shownOrder.value, visible.value));
const crossAxis = computed(() => (settings.value.kind === "columns" ? "row" : "column"));
const shiftHint = computed(() => {
if (!scan.value?.crossLines.length) return "";
if (preview.value?.cross == null)
return `キーをクリックして${crossAxis.value === "column" ? "列" : "行"}を選ぶと、${crossAxis.value === "column" ? "↑/↓で行" : "←/→で列"}の割り当てを1つずらせます。`;
return `選んだ${crossAxis.value === "column" ? "列" : "行"}: ${crossAxis.value === "column" ? "↑/↓" : "←/→"}でずらす(${preview.value.offsets.get(preview.value.cross) ?? 0})。Escで取り消し。`;
});
const previewCross = computed(() => {
const i = preview.value?.cross;
return new Set(i == null ? [] : (scan.value?.crossLines[i] ?? []));
});
watch(
settings,
() => {
if (preview.value) preview.value = { offsets: new Map(), cross: null };
},
{ deep: true },
);
watch(
() => [settings.value.target, selected.value.length] as const,
([target, count]) => {
if (target === "selection" && !count) settings.value = { ...settings.value, target: "all" };
},
);
function startPreview() {
preview.value = { offsets: new Map(), cross: null };
}
function applyPreview() {
if (!scan.value) return;
const sequence = scan.value.sequence;
run("IDXプリセット適用", (p) => ({
...p,
idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, sequence) },
}));
preview.value = null;
}
function shiftCross(delta: number) {
const p = preview.value;
if (!p || p.cross == null) return;
const offsets = new Map(p.offsets);
offsets.set(p.cross, (offsets.get(p.cross) ?? 0) + delta);
preview.value = { ...p, offsets };
}
function pickCross(id: string) {
const i = scan.value?.crossLines.findIndex((line) => line.includes(id)) ?? -1;
if (preview.value) preview.value = { ...preview.value, cross: i < 0 ? null : i };
}
function linePoints(ids: string[]): string {
return ids
.map((id) => keyById.value.get(id))
.filter((k): k is FlatKey => !!k)
.map((k) => {
const c = keyCenter(k);
return `${c.x},${c.y}`;
})
.join(" ");
}
// ---- editing ----
function run(label: string, update: (p: ProjectFile) => ProjectFile) {
try {
props.dispatch(label, update);
} catch (e) {
emit("error", String(e));
}
}
function drop(source: string, action: DropAction) {
run(action.kind === "swap" ? "IDX入れ替え" : "IDX挿入", (p) => ({
...p,
idx: { ...p.idx, order: applyDrop(p.idx.order, ctx.value, source, action) },
}));
}
function registerAll() {
run("IDX未登録キーを登録", (p) => ({ ...p, idx: appendKeys(p.idx, unregisteredKeys(p.phy, p.idx)) }));
}
function importPlacements(placements: Placement[], source: string) {
const { ids, unmatched } = matchPlacements(placements, keys.value);
if (ids.length)
run("IDX取り込み", (p) => ({ ...p, idx: { ...p.idx, order: renumber(p.idx.order, ctx.value, ids) } }));
const missing = keys.value.length - ids.length;
importMessage.value =
`${source}: ${ids.length}件を照合して並べ直しました。` +
(unmatched ? `定義側の${unmatched}件は表示中のキーと一致しませんでした。` : "") +
(missing ? `表示中の${missing}件は定義になく、今の順序のまま残しました。` : "");
}
function selectVariant(groupId: string, variantId: string) {
variants.value = { ...variants.value, [groupId]: variantId };
const shown = new Set(keys.value.map((k) => k.key.id));
selected.value = selected.value.filter((id) => shown.has(id));
}
const { drag, band, spaceHeld, begin, move, end, cancel } = useIdxPointer({
svg,
zoom,
pan,
selected,
keys: () => keys.value,
registered: () => registered.value,
previewing: () => !!preview.value,
onDrop: drop,
onPreviewPick: pickCross,
});
const dropText = computed(() => {
const action = drag.value?.action;
if (!drag.value) return "";
if (!action) return "ここには置けません";
const n = numbers.value.get(action.target)!;
if (action.kind === "swap") return `${n} と入れ替え`;
return action.after
? `${n} と ${n + 1} の間に挿入`
: n === 0
? "先頭に挿入"
: `${n - 1} と ${n} の間に挿入`;
});
// ---- selection info ----
const info = computed(() => {
const id = selected.value.at(-1);
const k = id ? keyById.value.get(id) : undefined;
if (!k || !id) return null;
const names = new Map<string, string>();
const walk = (nodes: Node[]) =>
nodes.forEach((n) => {
if (n.type === "cluster") walk(n.children);
if (n.type === "variantGroup")
n.children.forEach((v) => {
names.set(v.id, `${n.name} / ${v.name}`);
walk(v.children);
});
});
walk(props.project.phy.root);
const path = k.path.flatMap((p) => (names.has(p) ? [names.get(p)!] : []));
const saved = props.project.idx.order.indexOf(id);
return {
label: k.key.labels?.find(Boolean) || "(ラベルなし)",
id,
saved,
number: numbers.value.get(id),
path: path.join(" › ") || "共通キー",
};
});
// ---- viewport ----
function fit() {
const rects = keys.value.map(keyRect);
if (!rects.length) return;
const x = Math.min(...rects.map((r) => r.x)),
y = Math.min(...rects.map((r) => r.y));
const bounds = {
x,
y,
width: Math.max(...rects.map((r) => r.x + r.w)) - x,
height: Math.max(...rects.map((r) => r.y + r.h)) - y,
};
const v = fitViewport(bounds, size.value.w, size.value.h);
zoom.value = v.zoom;
pan.value = v.pan;
}
function wheel(event: WheelEvent) {
const el = svg.value;
if (!el) return;
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(el.getScreenCTM()!.inverse());
const v = zoomAround(local, { zoom: zoom.value, pan: pan.value }, Math.exp(-event.deltaY * 0.001));
zoom.value = v.zoom;
pan.value = v.pan;
}
const bounds = computed(() => {
const rects = keys.value.map(keyRect);
return rects.length
? {
top: Math.min(...rects.map((r) => r.y)) - 0.4,
bottom: Math.max(...rects.map((r) => r.y + r.h)) + 0.4,
}
: { top: 0, bottom: 1 };
});
function keydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest("input,textarea,select,[contenteditable=true]")) return;
if (e.code === "Space") spaceHeld.value = true;
if (e.key === "Escape") {
if (drag.value) cancel();
else if (preview.value) preview.value = null;
else selected.value = [];
} else if (preview.value?.cross != null && e.key.startsWith("Arrow")) {
const vertical = crossAxis.value === "column";
const delta = {
ArrowUp: vertical ? -1 : 0,
ArrowDown: vertical ? 1 : 0,
ArrowLeft: vertical ? 0 : -1,
ArrowRight: vertical ? 0 : 1,
}[e.key];
if (delta) {
e.preventDefault();
shiftCross(delta);
}
} else if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
selected.value = keys.value.map((k) => k.key.id);
} else if (e.key === "Enter" && preview.value) applyPreview();
}
function keyup(e: KeyboardEvent) {
if (e.code === "Space") spaceHeld.value = false;
}
let observer: ResizeObserver | null = null;
onMounted(() => {
window.addEventListener("keydown", keydown);
window.addEventListener("keyup", keyup);
if (svg.value) {
observer = new ResizeObserver((entries) => {
const rect = entries[0]!.contentRect;
size.value = { w: rect.width, h: rect.height };
fit();
});
observer.observe(svg.value);
}
fit();
});
onUnmounted(() => {
window.removeEventListener("keydown", keydown);
window.removeEventListener("keyup", keyup);
observer?.disconnect();
});
</script>
<template>
<section class="workspace">
<aside class="tree pane">
<h2>構成</h2>
<IdxTree
:nodes="project.phy.root"
:variants="variants"
:unregistered="allUnregistered"
@select="selectVariant"
/>
<p v-if="!Object.keys(variants).length" class="hint">バリアントグループはありません。</p>
</aside>
<section class="canvas-pane pane">
<div class="canvas-toolbar">
<button @click="fit">全体表示</button>
<span class="status" data-testid="idx-status">{{
drag
? dropText
: preview
? "プレビュー中(適用するまで保存されません)"
: "キーをドラッグして並べ替え。左右の端で挿入、中央で入れ替え"
}}</span>
</div>
<svg
ref="svg"
class="canvas"
:class="{ previewing: !!preview, panning: spaceHeld }"
data-testid="idx-canvas"
:viewBox="`0 0 ${size.w} ${size.h}`"
@wheel.prevent="wheel"
@pointerdown="begin($event)"
@pointermove="move"
@pointerup="end"
@pointercancel="cancel"
>
<g :transform="`translate(${pan.x} ${pan.y}) scale(${zoom})`">
<g
v-for="k in keys"
:key="k.key.id"
class="key"
:data-key-id="k.key.id"
:transform="`translate(${k.x} ${k.y}) rotate(${k.rotation})`"
:class="{
chosen: selected.includes(k.key.id),
unregistered: !registered.has(k.key.id),
ghost: k.key.ghost,
dragging: drag?.id === k.key.id,
cross: previewCross.has(k.key.id),
}"
@pointerdown.stop="begin($event, k.key.id)"
>
<rect :width="k.key.size.w" :height="k.key.size.h" rx=".06" class="face" />
<text class="label" x=".08" y=".22">{{ k.key.labels?.find(Boolean) }}</text>
<!-- 番号は回転したキーでも正立させる -->
<text
class="number"
data-testid="idx-number"
:x="k.key.size.w / 2"
:y="k.key.size.h / 2 + 0.12"
:transform="`rotate(${-k.rotation} ${k.key.size.w / 2} ${k.key.size.h / 2})`"
>
{{ numbers.has(k.key.id) ? numbers.get(k.key.id) : "未登録" }}
</text>
</g>
<template v-if="scan">
<polyline
v-for="(line, i) in scan.lines"
:key="i"
class="scan-line"
:points="linePoints(line)"
:stroke="lineColors[i % lineColors.length]"
/>
<line
v-if="scan.boundary !== undefined"
class="boundary"
data-testid="idx-boundary"
:x1="scan.boundary"
:x2="scan.boundary"
:y1="bounds.top"
:y2="bounds.bottom"
/>
</template>
<g
v-if="drag?.action && keyById.get(drag.action.target)"
:transform="`translate(${keyById.get(drag.action.target)!.x} ${keyById.get(drag.action.target)!.y}) rotate(${keyById.get(drag.action.target)!.rotation})`"
class="drop-mark"
>
<rect
v-if="drag.action.kind === 'swap'"
class="swap-mark"
:width="keyById.get(drag.action.target)!.key.size.w"
:height="keyById.get(drag.action.target)!.key.size.h"
rx=".06"
/>
<line
v-else
class="insert-mark"
:x1="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
:x2="drag.action.after ? keyById.get(drag.action.target)!.key.size.w : 0"
y1="-0.1"
:y2="keyById.get(drag.action.target)!.key.size.h + 0.1"
/>
</g>
<g v-if="drag" class="drag-ghost" :transform="`translate(${drag.point.x} ${drag.point.y})`">
<circle r=".28" />
<text y=".1">{{ numbers.get(drag.id) ?? "+" }}</text>
</g>
<rect
v-if="band"
class="range-box"
:x="Math.min(band.start.x, band.end.x)"
:y="Math.min(band.start.y, band.end.y)"
:width="Math.abs(band.end.x - band.start.x)"
:height="Math.abs(band.end.y - band.start.y)"
/>
</g>
</svg>
</section>
<aside class="side pane">
<IdxPresets
:settings="settings"
:selected-count="selected.length"
:matrix-available="!!positions"
:previewing="!!preview"
:shift-hint="shiftHint"
@update="settings = $event"
@preview="startPreview"
@apply="applyPreview"
@cancel="preview = null"
/>
<IdxImport :message="importMessage" @apply="importPlacements" @error="emit('error', $event)" />
<section class="register">
<h3>未登録のキー</h3>
<p class="hint">
PHYに足したキーは、ここで末尾に登録するか、キャンバスでドラッグして位置を決めます。
</p>
<button data-testid="idx-register-all" :disabled="!allUnregistered.size" @click="registerAll">
未登録をすべて末尾に登録({{ allUnregistered.size }})
</button>
</section>
<section v-if="info" class="info" data-testid="idx-info">
<h3>選択中のキー</h3>
<dl>
<dt>ラベル</dt>
<dd>{{ info.label }}</dd>
<dt>出力番号</dt>
<dd>{{ info.number ?? "未登録" }}</dd>
<dt>保存順</dt>
<dd>{{ info.saved < 0 ? "未登録" : info.saved }}</dd>
<dt>所属</dt>
<dd>{{ info.path }}</dd>
<dt>ID</dt>
<dd class="id">{{ info.id }}</dd>
</dl>
<p v-if="selected.length > 1" class="hint">ほか {{ selected.length - 1 }} 件を選択中</p>
</section>
</aside>
</section>
</template>
<style scoped>
.workspace {
min-height: 0;
display: grid;
grid-template-columns: 220px minmax(300px, 1fr) 270px;
gap: 8px;
}
.pane {
padding: 10px;
min-height: 0;
overflow: auto;
background: #fff;
border: 1px solid #cbd1da;
border-radius: 6px;
}
h2 {
font-size: 15px;
margin: 0 0 6px;
}
h3 {
font-size: 14px;
margin: 0 0 6px;
}
.hint {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.canvas-pane {
display: flex;
flex-direction: column;
overflow: hidden;
}
.canvas-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 6px;
}
.status {
font-size: 12px;
color: #445;
}
.canvas {
width: 100%;
flex: 1;
min-height: 0;
background: #fbfcfe;
touch-action: none;
user-select: none;
}
.canvas.panning {
cursor: grab;
}
.key {
cursor: grab;
}
.previewing .key {
cursor: pointer;
}
.face {
fill: #f4f6f9;
stroke: #8d97a5;
stroke-width: 0.03;
}
.chosen .face {
fill: #dce9ff;
stroke: #1769d2;
stroke-width: 0.06;
}
.unregistered .face {
fill: #fff1d6;
stroke: #c07a12;
stroke-dasharray: 0.08 0.06;
}
.ghost .face {
fill-opacity: 0.5;
}
.dragging {
opacity: 0.45;
}
.cross .face {
stroke: #9b30d9;
stroke-width: 0.08;
}
.label {
font-size: 0.16px;
fill: #667;
pointer-events: none;
}
.number {
font-size: 0.34px;
font-weight: 600;
text-anchor: middle;
fill: #20242b;
pointer-events: none;
}
.unregistered .number {
font-size: 0.18px;
fill: #9a4c00;
}
.scan-line {
fill: none;
stroke-width: 3px;
stroke-opacity: 0.7;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.boundary {
stroke: #9a4c00;
stroke-width: 2px;
stroke-dasharray: 6 4;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.drop-mark {
pointer-events: none;
}
.insert-mark {
stroke: #d2142b;
stroke-width: 4px;
vector-effect: non-scaling-stroke;
}
.swap-mark {
fill: #d2142b22;
stroke: #d2142b;
stroke-width: 3px;
stroke-dasharray: 6 3;
vector-effect: non-scaling-stroke;
}
.drag-ghost {
pointer-events: none;
}
.drag-ghost circle {
fill: #1769d2;
}
.drag-ghost text {
font-size: 0.26px;
fill: #fff;
text-anchor: middle;
}
.range-box {
fill: #4b8de822;
stroke: #1769d2;
stroke-width: 1px;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.register {
margin: 0 0 14px;
}
.info dl {
display: grid;
grid-template-columns: auto 1fr;
gap: 3px 10px;
margin: 0;
font-size: 13px;
}
.info dt {
color: #667;
}
.info dd {
margin: 0;
}
.info .id {
font-family: ui-monospace, monospace;
font-size: 11px;
word-break: break-all;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// 既存の定義(QMK keyboard.json / VIA定義 / KLE JSON)からの取り込み。照合は親が表示中のキーで行う。T-31。
import { ref } from "vue";
import type { Placement } from "../../domain/idxImport";
import { readLayoutPlacements } from "../../io/layoutImport";
defineProps<{ message: string }>();
const emit = defineEmits<{ apply: [placements: Placement[], source: string]; error: [text: string] }>();
const input = ref<HTMLInputElement>();
const pending = ref<{ name: string; data: unknown; layouts: string[] } | null>(null);
const layout = ref("");
async function read(file: File) {
try {
const data = JSON.parse(await file.text()) as unknown;
const result = readLayoutPlacements(data);
if (result.layouts && !result.layout) {
pending.value = { name: file.name, data, layouts: result.layouts };
layout.value = result.layouts[0]!;
return;
}
pending.value = null;
emit("apply", result.placements, result.layout ? `${file.name} / ${result.layout}` : file.name);
} catch (e) {
emit("error", String(e));
}
}
function applyLayout() {
if (!pending.value) return;
const { placements } = readLayoutPlacements(pending.value.data, layout.value);
emit("apply", placements, `${pending.value.name} / ${layout.value}`);
pending.value = null;
}
</script>
<template>
<section class="import">
<h3>既存の定義から取り込む</h3>
<p class="hint">
QMK keyboard.json・VIA定義・KLE JSON のキー順を、位置が一致する表示中のキーに写します。
</p>
<button @click="input?.click()">ファイルを選ぶ…</button>
<input
ref="input"
hidden
type="file"
accept=".json,application/json"
data-testid="idx-import-file"
@change="
(e) => {
const f = (e.target as HTMLInputElement).files?.[0];
if (f) void read(f);
(e.target as HTMLInputElement).value = '';
}
"
/>
<div v-if="pending" class="layouts">
<label
>レイアウト
<select v-model="layout" aria-label="取り込むレイアウト">
<option v-for="name in pending.layouts" :key="name" :value="name">{{ name }}</option>
</select>
</label>
<button @click="applyLayout">取り込む</button>
<button @click="pending = null">やめる</button>
</div>
<p v-if="message" class="result" data-testid="idx-import-result">{{ message }}</p>
</section>
</template>
<style scoped>
.import {
margin: 0 0 14px;
}
h3 {
font-size: 14px;
margin: 0 0 6px;
}
.hint,
.result {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.result {
color: #20242b;
}
.layouts {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-top: 6px;
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script setup lang="ts">
// プリセット(対象・走査・向き)の指定と、プレビュー・適用のボタン。T-31。
import type { PresetSettings } from "./presets";
const props = defineProps<{
settings: PresetSettings;
selectedCount: number;
matrixAvailable: boolean;
previewing: boolean;
shiftHint: string;
}>();
const emit = defineEmits<{
update: [settings: PresetSettings];
preview: [];
apply: [];
cancel: [];
}>();
const scans = [
["rows", "行優先"],
["columns", "列優先"],
["halves", "半身ごと(左手側が先)"],
["alternating", "行ごとに左右交互"],
["matrix", "マトリクス順(row → col)"],
] as const;
function set<K extends keyof PresetSettings>(key: K, value: PresetSettings[K]) {
emit("update", { ...props.settings, [key]: value });
}
</script>
<template>
<fieldset class="presets">
<legend>一括の並べ替え</legend>
<div class="row" role="radiogroup" aria-label="対象">
<label
><input
type="radio"
name="idx-target"
:checked="settings.target === 'all'"
@change="set('target', 'all')"
/>全体</label
>
<label
><input
type="radio"
name="idx-target"
:checked="settings.target === 'selection'"
:disabled="!selectedCount"
@change="set('target', 'selection')"
/>選択範囲({{ selectedCount }})</label
>
</div>
<label class="field"
>走査
<select
aria-label="走査"
:value="settings.kind"
@change="set('kind', ($event.target as HTMLSelectElement).value as PresetSettings['kind'])"
>
<option
v-for="[value, label] in scans"
:key="value"
:value="value"
:disabled="value === 'matrix' && !matrixAvailable"
>
{{ label }}
</option>
</select>
</label>
<p v-if="!matrixAvailable" class="hint">マトリクス順には、出力設定の rowPins / colPins が必要です。</p>
<label v-if="settings.kind === 'rows' || settings.kind === 'columns'" class="field"
>向き
<select
aria-label="向き"
:value="settings.direction"
@change="set('direction', ($event.target as HTMLSelectElement).value as PresetSettings['direction'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
<template v-else-if="settings.kind !== 'matrix'">
<label class="field"
>左手側
<select
aria-label="左手側の向き"
:value="settings.left"
@change="set('left', ($event.target as HTMLSelectElement).value as PresetSettings['left'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
<label class="field"
>右手側
<select
aria-label="右手側の向き"
:value="settings.right"
@change="set('right', ($event.target as HTMLSelectElement).value as PresetSettings['right'])"
>
<option value="normal">通常</option>
<option value="reversed">左右逆</option>
</select>
</label>
</template>
<div class="row">
<button v-if="!previewing" @click="emit('preview')">プレビュー</button>
<template v-else>
<button class="primary" @click="emit('apply')">適用</button>
<button @click="emit('cancel')">取り消し</button>
</template>
</div>
<p v-if="previewing && shiftHint" class="hint">{{ shiftHint }}</p>
</fieldset>
</template>
<style scoped>
.presets {
border: 0;
padding: 0;
margin: 0 0 14px;
min-width: 0;
}
legend {
font-weight: 600;
padding: 0;
margin-bottom: 6px;
}
.row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
}
.field {
display: flex;
gap: 6px;
align-items: center;
justify-content: space-between;
margin: 6px 0;
}
.field select {
max-width: 160px;
}
.hint {
font-size: 12px;
color: #667;
margin: 4px 0;
}
.primary {
background: #dce9ff;
}
</style>
+91
View File
@@ -0,0 +1,91 @@
<script setup lang="ts">
// VariantGroupとバリアントのツリー。クラスターは出さず、その中のグループだけを拾う。T-31。
import { computed } from "vue";
import type { Node, VariantGroupNode } from "../../domain/phy";
defineOptions({ name: "IdxTree" });
const props = defineProps<{
nodes: Node[];
variants: Record<string, string>;
unregistered: ReadonlySet<string>;
}>();
const emit = defineEmits<{ select: [groupId: string, variantId: string] }>();
function groupsIn(nodes: Node[]): VariantGroupNode[] {
return nodes.flatMap((n) =>
n.type === "cluster" ? groupsIn(n.children) : n.type === "variantGroup" ? [n] : [],
);
}
function keysIn(nodes: Node[]): string[] {
return nodes.flatMap((n) =>
n.type === "key"
? n.decal
? []
: [n.id]
: n.type === "cluster"
? keysIn(n.children)
: n.type === "variantGroup"
? n.children.flatMap((v) => keysIn(v.children))
: [],
);
}
const groups = computed(() => groupsIn(props.nodes));
const chosen = (g: VariantGroupNode) => props.variants[g.id] ?? g.default;
</script>
<template>
<ul v-if="groups.length" class="tree-list">
<li v-for="g in groups" :key="g.id">
<div class="tree-group" :data-tree-id="g.id">{{ g.name }}</div>
<ul class="tree-list">
<li v-for="v in g.children" :key="v.id">
<button
class="tree-node"
:data-tree-id="v.id"
:class="{ chosen: chosen(g) === v.id }"
:aria-pressed="chosen(g) === v.id"
@click="emit('select', g.id, v.id)"
>
{{ v.name }}
<small
>{{ g.default === v.id ? "既定 · " : "" }}{{ keysIn(v.children).length }}件<template
v-if="keysIn(v.children).some((id) => unregistered.has(id))"
>
· 未登録 {{ keysIn(v.children).filter((id) => unregistered.has(id)).length }}</template
></small
>
</button>
<IdxTree
:nodes="v.children"
:variants="variants"
:unregistered="unregistered"
@select="(group, variant) => emit('select', group, variant)"
/>
</li>
</ul>
</li>
</ul>
</template>
<style scoped>
.tree-list {
list-style: none;
padding-left: 12px;
margin: 3px 0;
}
.tree-group {
padding: 4px;
font-weight: 600;
white-space: nowrap;
}
.tree-node {
width: 100%;
text-align: left;
border: 0;
background: transparent;
padding: 4px;
white-space: nowrap;
}
.tree-node small {
color: #667;
}
.chosen {
background: #dce9ff;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
import type { FlatKey } from "../../domain/phy";
import { insertNextTo, swapKeys, type OrderContext } from "../../domain/idxOrder";
type Point = { x: number; y: number };
export type DropAction =
{ kind: "insert"; target: string; after: boolean } | { kind: "swap"; target: string };
/** World point in the key's own frame (origin at its top-left, x along its width). */
export function toKeyLocal(k: FlatKey, p: Point): Point {
const a = (-k.rotation * Math.PI) / 180,
dx = p.x - k.x,
dy = p.y - k.y;
return { x: dx * Math.cos(a) - dy * Math.sin(a), y: dx * Math.sin(a) + dy * Math.cos(a) };
}
/** World center of a key. */
export function keyCenter(k: FlatKey): Point {
const a = (k.rotation * Math.PI) / 180,
hw = k.key.size.w / 2,
hh = k.key.size.h / 2;
return { x: k.x + hw * Math.cos(a) - hh * Math.sin(a), y: k.y + hw * Math.sin(a) + hh * Math.cos(a) };
}
/** Topmost key under a world point, with the point's position across the key width (0..1). */
export function hitKey(keys: readonly FlatKey[], p: Point): { id: string; fraction: number } | undefined {
for (let i = keys.length - 1; i >= 0; i--) {
const k = keys[i]!,
local = toKeyLocal(k, p);
if (local.x >= 0 && local.y >= 0 && local.x <= k.key.size.w && local.y <= k.key.size.h)
return { id: k.key.id, fraction: local.x / k.key.size.w };
}
return undefined;
}
/**
* Drop result of `source` at a hit: the outer quarters insert before or after the key, the middle
* swaps. Unregistered keys cannot swap, so their drop splits the key in halves. Unregistered targets
* and the source itself take no drop. T-31.
*/
export function dropAction(
source: string,
hit: { id: string; fraction: number } | undefined,
registered: ReadonlySet<string>,
): DropAction | undefined {
if (!hit || hit.id === source || !registered.has(hit.id)) return undefined;
if (!registered.has(source)) return { kind: "insert", target: hit.id, after: hit.fraction >= 0.5 };
if (hit.fraction < 0.25) return { kind: "insert", target: hit.id, after: false };
if (hit.fraction > 0.75) return { kind: "insert", target: hit.id, after: true };
return { kind: "swap", target: hit.id };
}
/** New order after dropping `source`. */
export function applyDrop(
order: readonly string[],
ctx: OrderContext,
source: string,
action: DropAction,
): string[] {
return action.kind === "swap"
? swapKeys(order, source, action.target)
: insertNextTo(order, ctx, source, action.target, action.after);
}
/** Output number of each shown key: its position after dropping keys outside the configuration. */
export function outputNumbers(order: readonly string[], visible: ReadonlySet<string>): Map<string, number> {
return new Map(order.filter((id) => visible.has(id)).map((id, i) => [id, i]));
}
+21
View File
@@ -0,0 +1,21 @@
import type { Direction, ScanKind } from "../../domain/idxScan";
/** Preset choices in the side pane. `matrix` sorts by ELC row → col instead of scanning. T-31. */
export type PresetSettings = {
target: "all" | "selection";
kind: ScanKind | "matrix";
direction: Direction;
left: Direction;
right: Direction;
};
export const defaultPresetSettings = (): PresetSettings => ({
target: "all",
kind: "rows",
direction: "normal",
left: "normal",
right: "normal",
});
/** Line colors of the preview, cycled by line index. */
export const lineColors = ["#1769d2", "#d2691e", "#2e8b57", "#9b30d9", "#c0392b", "#00838f"];
+108
View File
@@ -0,0 +1,108 @@
import { ref, type Ref } from "vue";
import type { FlatKey } from "../../domain/phy";
import { dropAction, hitKey, keyCenter, type DropAction } from "./logic";
type Point = { x: number; y: number };
type Options = {
svg: Ref<SVGSVGElement | undefined>;
zoom: Ref<number>;
pan: Ref<Point>;
selected: Ref<string[]>;
keys: () => FlatKey[];
registered: () => ReadonlySet<string>;
previewing: () => boolean;
onDrop: (source: string, action: DropAction) => void;
onPreviewPick: (id: string) => void;
};
/** Pointer travel (px) before a press on a key becomes a drag. */
const DRAG_START = 4;
/** Canvas pointer handling: click/Shift+click selection, dragging a key to reorder, range selection, pan. T-31. */
export function useIdxPointer(o: Options) {
const drag = ref<{ id: string; point: Point; action?: DropAction } | null>(null);
const band = ref<{ start: Point; end: Point } | null>(null);
const spaceHeld = ref(false);
let press: { id?: string; client: Point; add: boolean; moved: boolean; pan?: Point } | null = null;
function world(event: PointerEvent): Point {
const svg = o.svg.value!;
const local = new DOMPoint(event.clientX, event.clientY).matrixTransform(svg.getScreenCTM()!.inverse());
return { x: (local.x - o.pan.value.x) / o.zoom.value, y: (local.y - o.pan.value.y) / o.zoom.value };
}
function begin(event: PointerEvent, id?: string) {
const add = event.shiftKey || event.ctrlKey || event.metaKey;
const panning = event.button === 1 || spaceHeld.value;
if (event.button !== 0 && !panning) return;
event.preventDefault();
if (!panning && id && o.previewing()) {
o.onPreviewPick(id);
return;
}
o.svg.value?.setPointerCapture?.(event.pointerId);
press = {
id: panning ? undefined : id,
client: { x: event.clientX, y: event.clientY },
add,
moved: false,
};
if (panning) press.pan = { ...o.pan.value };
else if (!id && !o.previewing()) band.value = { start: world(event), end: world(event) };
}
function move(event: PointerEvent) {
if (!press) return;
const dx = event.clientX - press.client.x,
dy = event.clientY - press.client.y;
if (!press.moved && Math.hypot(dx, dy) < DRAG_START) return;
press.moved = true;
if (press.pan) {
// viewBox matches the SVG's pixel size, so client deltas are viewBox deltas
o.pan.value = { x: press.pan.x + dx, y: press.pan.y + dy };
return;
}
const point = world(event);
if (press.id) {
const action = dropAction(press.id, hitKey(o.keys(), point), o.registered());
drag.value = { id: press.id, point, action };
} else if (band.value) band.value = { ...band.value, end: point };
}
function end() {
if (!press) return;
const { id, add, moved } = press;
if (id && !moved) {
o.selected.value = add
? o.selected.value.includes(id)
? o.selected.value.filter((x) => x !== id)
: [...o.selected.value, id]
: [id];
} else if (id && drag.value?.action) o.onDrop(id, drag.value.action);
else if (!id && !press.pan) {
const b = band.value;
if (!moved || !b) {
if (!add) o.selected.value = [];
} else {
const [x0, x1] = [Math.min(b.start.x, b.end.x), Math.max(b.start.x, b.end.x)];
const [y0, y1] = [Math.min(b.start.y, b.end.y), Math.max(b.start.y, b.end.y)];
const inside = o
.keys()
.filter((k) => {
const c = keyCenter(k);
return c.x >= x0 && c.x <= x1 && c.y >= y0 && c.y <= y1;
})
.map((k) => k.key.id);
o.selected.value = add ? [...new Set([...o.selected.value, ...inside])] : inside;
}
}
cancel();
}
function cancel() {
press = null;
drag.value = null;
band.value = null;
}
return { drag, band, spaceHeld, begin, move, end, cancel };
}
-254
View File
@@ -1,254 +0,0 @@
<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>