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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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"];
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user