Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-01 13:06:19 +09:00
2 changed files with 764 additions and 0 deletions
@@ -0,0 +1,251 @@
/* 시트 로직 — 위 가로 다이어그램 */
.m01-sldg {
--sldg-in: var(--color-accent);
--sldg-out: #c2610c;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
font-size: var(--text-body-sm);
outline: none;
}
:root[data-theme="dark"] .m01-sldg {
--sldg-out: #e5a866;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .m01-sldg {
--sldg-out: #e5a866;
}
}
.m01-sldg__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
min-height: 2rem;
}
.m01-sldg__note {
color: var(--color-danger);
}
.m01-sldg__muted {
margin: 0;
color: var(--color-text-muted);
}
.m01-sldg__menu {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m01-sldg__menu[hidden] {
display: none;
}
/* 띠 — 화면 위 3분의 1 안쪽 · 길면 가로로 밀기 */
.m01-sldg__band {
max-height: 33vh;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 5%, var(--color-canvas));
}
.m01-sldg__inner {
position: relative;
display: grid;
grid-template-columns: repeat(5, minmax(11rem, 1fr));
gap: var(--spacing-24);
min-width: max-content;
padding: var(--spacing-12) var(--spacing-16);
}
.m01-sldg__lines {
position: absolute;
inset: 0 auto auto 0;
overflow: visible;
pointer-events: none;
z-index: 0;
}
.m01-sldg__col {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 11rem;
}
.m01-sldg__col-head {
color: var(--color-text-muted);
font-size: var(--text-caption);
letter-spacing: 0.04em;
}
/* 상자 */
.m01-sldg__box {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
cursor: pointer;
}
.m01-sldg__box:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
.m01-sldg__box.is-selected {
border: 2px solid var(--color-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.m01-sldg__box-head {
display: flex;
flex-direction: column;
}
.m01-sldg__kind {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01-sldg__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.m01-sldg__pills {
display: flex;
justify-content: space-between;
gap: var(--spacing-8);
}
.m01-sldg__ins,
.m01-sldg__outs {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m01-sldg__outs {
align-items: flex-end;
}
/* 알약 — 입력 파랑 · 출력 주황 · 빈 자리 회색 */
.m01-sldg__pill {
display: inline-flex;
align-items: baseline;
gap: var(--spacing-4);
max-width: 10rem;
padding: 0 var(--spacing-8);
border: 1px solid var(--sldg-in);
border-radius: 999px;
background: color-mix(in srgb, var(--sldg-in) 12%, var(--color-surface));
color: var(--sldg-in);
font: inherit;
font-size: var(--text-caption);
cursor: pointer;
white-space: nowrap;
}
.m01-sldg__pill.is-out {
border-color: var(--sldg-out);
background: color-mix(in srgb, var(--sldg-out) 14%, var(--color-surface));
color: var(--sldg-out);
cursor: grab;
touch-action: none;
}
.m01-sldg__pill.is-empty {
border-style: dashed;
border-color: var(--color-text-muted);
background: transparent;
color: var(--color-text-muted);
}
.m01-sldg__pill.is-chosen {
box-shadow: 0 0 0 2px currentColor;
}
.m01-sldg.is-linking .m01-sldg__pill.is-target {
box-shadow: 0 0 0 2px var(--sldg-in);
animation: m01-sldg-blink 1s ease-in-out infinite alternate;
}
@keyframes m01-sldg-blink {
to {
background: color-mix(in srgb, var(--sldg-in) 30%, var(--color-surface));
}
}
.m01-sldg__pill-name {
overflow: hidden;
text-overflow: ellipsis;
}
.m01-sldg__pill-value {
color: var(--color-text-secondary);
font-variant-numeric: tabular-nums;
}
.m01-sldg__pill-value:empty {
display: none;
}
/* 선 */
.m01-sldg__edge {
pointer-events: auto;
cursor: pointer;
}
.m01-sldg__hit {
fill: none;
stroke: transparent;
stroke-width: 12;
}
.m01-sldg__wire {
fill: none;
stroke: var(--color-text-muted);
stroke-width: 1.5;
}
.m01-sldg__edge.is-strong .m01-sldg__wire {
stroke: var(--color-accent);
stroke-width: 3;
}
.m01-sldg__edge.is-dim .m01-sldg__wire {
opacity: 0.3;
}
.m01-sldg__drag {
fill: none;
stroke: var(--sldg-out);
stroke-width: 2;
stroke-dasharray: 5 4;
}
@media (prefers-reduced-motion: reduce) {
.m01-sldg.is-linking .m01-sldg__pill.is-target {
animation: none;
}
}
+513
View File
@@ -0,0 +1,513 @@
/* =============================================================================
* M01_SL_Diagram.ts
* 시트 로직 — 위 가로 다이어그램(정함 8 · 제안서 「화면 배치」 · 「상자의 입출력도 알약으로」)
* 단계 다섯 칸(입력값 · 표 · 계산 · 항목 · 결과) · 단계마다 상자를 세로로 쌓음(자리는 저절로) ·
* 상자 왼쪽 입력 알약(파랑 · 빈 자리는 회색 「? 이름」) · 오른쪽 출력 알약(주황) · 선 = SVG.
* 출력 알약을 끌어 입력 알약에 놓으면 on.connect · 선을 골라 Delete / 「끊기」 → on.disconnect ·
* 「+」 → 종류 → on.add · 상자를 골라 Delete → 물음 → on.remove. 키보드: Tab · Enter · 「잇기」.
* 그리기만 — 시트 로직을 바꾸지 않음(바꾸는 것은 부른 쪽이 Model 로).
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements";
import type { SheetLogic, SlEdge, SlEnd, SlKind, SlNode } from "./M01_SL_Model";
import "./M01_SL_Diagram.css";
const TEXT = {
Stage0: ["입력값", "Inputs"],
Stage1: ["표", "Tables"],
Stage2: ["계산", "Calculations"],
Stage3: ["항목", "Items"],
Stage4: ["결과", "Result"],
K_input: ["입력값", "Input"],
K_const: ["고정값", "Fixed value"],
K_table: ["표", "Table"],
K_calc: ["계산", "Calculation"],
K_item: ["항목", "Item"],
K_logic: ["다른 로직", "Other logic"],
K_result: ["결과", "Result"],
Add: ["+ 상자", "+ Box"],
AddTitle: ["더할 상자 종류", "Kind of box to add"],
Cancel: ["취소", "Cancel"],
Connect: ["잇기", "Connect"],
Disconnect: ["끊기", "Disconnect"],
Remove: ["상자 지우기", "Remove box"],
AskRemove: [
"「{v}」 상자를 지울까요? 이어진 선도 함께 끊어짐",
"Remove box “{v}”? Its lines go too",
],
PickTarget: ["「{v}」 을 받을 입력 알약을 누르세요", "Tap the input pill that receives “{v}”"],
Empty: ["상자가 없음 — 「+ 상자」 로 더함", "No boxes — add one with “+ Box”"],
NoOut: ["내보내는 알약이 없음", "No output pills"],
NoIn: ["받는 알약이 없음", "No input pills"],
SameBox: ["같은 상자 안에서는 이을 수 없음", "Can't connect inside the same box"],
NotIn: ["입력 알약에 놓아야 이어짐", "Drop it on an input pill"],
NoMatch: [
"이름이 같은 입력 알약이 없음 — 받을 알약에 바로 놓으세요",
"No input pill with the same name — drop on the pill itself",
],
Taken: [
"이미 이어진 입력 알약 — 먼저 그 선을 끊으세요",
"That input already has a line — disconnect it first",
],
From: ["어디서", "From"],
To: ["어디로", "To"],
Value: ["지금 값", "Now"],
LineOf: ["선 · {a} → {b}", "Line · {a} → {b}"],
} as const;
const td = (key: keyof typeof TEXT, fill: Record<string, string> = {}): string =>
TEXT[key][currentLanguageIndex as 0 | 1].replace(/\{(\w)\}/g, (_m, k: string) => fill[k] ?? "");
/** 종류 → 기본 단계(고정값 · 다른 로직은 앞 칸) */
export const STAGE_OF: Record<SlKind, number> = {
input: 0,
const: 0,
logic: 0,
table: 1,
calc: 2,
item: 3,
result: 4,
};
const KINDS: SlKind[] = ["input", "const", "table", "calc", "item", "logic", "result"];
const kindLabel = (k: SlKind): string => td(`K_${k}` as keyof typeof TEXT);
export interface DiagramOn {
select(id: string | null): void;
/** 잇기 — 막히면 까닭(쉬운 말)을 돌려주면 띠에 한 줄로 보임 */
connect(from: SlEnd, to: SlEnd): string | null | void;
disconnect(edgeId: string): void;
add(kind: SlKind, stage: number): void;
remove(nodeId: string): void;
}
export interface DiagramOpts {
/** 상자 id → 알약 이름 → 지금 값 */
values?: Record<string, Record<string, unknown>>;
/** 고른 상자 id · 선 id */
selected?: string | null;
}
/* ── 자리 정하기(DOM 없음) ─────────────────────────────────────────── */
/** 단계 칸마다 상자 차례 — node.stage(없으면 종류 기본) · 0~4 로 자름 · 같은 칸은 sl 차례 */
export function layout(nodes: SlNode[]): SlNode[][] {
const cols: SlNode[][] = [[], [], [], [], []];
for (const n of nodes) {
const s = Number.isInteger(n.stage) ? n.stage : STAGE_OF[n.kind];
cols[Math.min(4, Math.max(0, s ?? 0))].push(n);
}
return cols;
}
/** 떨군 자리 → 받을 끝 · 상자에 떨구면 이름 같은 입력 알약 · 못 정하면 까닭 */
export function resolveDrop(
sl: SheetLogic,
from: SlEnd,
target: { node: string; pill: string | null; dir: "in" | "out" | null } | null,
): { to: SlEnd } | { why: string } {
if (!target) return { why: td("NotIn") };
if (target.node === from.node) return { why: td("SameBox") };
if (target.dir === "out") return { why: td("NotIn") };
const node = sl.nodes.find((n) => n.id === target.node);
if (!node?.pillsIn.length) return { why: td("NoIn") };
const name = target.pill ?? node.pillsIn.find((p) => p.name === from.pill)?.name;
if (!name) return { why: td("NoMatch") };
if (sl.edges.some((e) => e.to.node === node.id && e.to.pill === name))
return { why: td("Taken") };
return { to: { node: node.id, pill: name } };
}
/* ── 그리기 ─────────────────────────────────────────────────────── */
interface HostState {
selected: string | null;
/** 잇기 중 — 출력 알약 */
linking: SlEnd | null;
/** 고른 알약(정보 줄) */
pill: { node: string; pill: string; dir: "in" | "out" } | null;
note: string;
scroll: number;
/** 선 다시 그리기 — 띠 크기 바뀜 */
watch?: ResizeObserver;
}
const states = new WeakMap<HTMLElement, HostState>();
const showValue = (v: unknown): string => {
if (v === null || v === undefined || v === "") return "";
if (typeof v === "number")
return Number.isInteger(v) ? v.toLocaleString() : String(+v.toFixed(4));
return String(v).slice(0, 12);
};
const edgeTouches = (e: SlEdge, node: string): boolean =>
e.from.node === node || e.to.node === node;
export function renderDiagram(
host: HTMLElement,
sl: SheetLogic,
on: DiagramOn,
opts: DiagramOpts = {},
): void {
const old = states.get(host);
const st: HostState = old ?? { selected: null, linking: null, pill: null, note: "", scroll: 0 };
if (opts.selected !== undefined) st.selected = opts.selected;
states.set(host, st);
const values = opts.values ?? {};
const nodeName = (id: string): string => sl.nodes.find((n) => n.id === id)?.name ?? "";
const redraw = (): void => renderDiagram(host, sl, on, { ...opts, selected: st.selected });
const select = (id: string | null): void => {
st.selected = id;
st.pill = null;
st.note = "";
on.select(id);
redraw();
};
const tryConnect = (from: SlEnd, target: Parameters<typeof resolveDrop>[2]): void => {
const got = resolveDrop(sl, from, target);
st.linking = null;
if ("why" in got) st.note = got.why;
else st.note = on.connect(from, got.to) || "";
redraw();
};
/* 상자 */
const pillEl = (n: SlNode, name: string, dir: "in" | "out", unit?: string): HTMLElement => {
const wired = dir === "out" || sl.edges.some((e) => e.to.node === n.id && e.to.pill === name);
const linking = st.linking;
const chosen = st.pill?.node === n.id && st.pill.pill === name && st.pill.dir === dir;
const b = el("button", {
className: [
"m01-sldg__pill",
`is-${dir}`,
wired ? "" : "is-empty",
chosen ? "is-chosen" : "",
linking && dir === "in" && n.id !== linking.node ? "is-target" : "",
].join(" "),
attrs: {
type: "button",
"data-node": n.id,
"data-pill": name,
"data-dir": dir,
title: unit ? `${name} (${unit})` : name,
},
children: [
el("span", { className: "m01-sldg__pill-name", text: wired ? name : `? ${name}` }),
el("span", { className: "m01-sldg__pill-value", text: showValue(values[n.id]?.[name]) }),
],
});
b.addEventListener("click", (ev) => {
ev.stopPropagation();
if (st.linking && dir === "in")
return tryConnect(st.linking, { node: n.id, pill: name, dir });
st.pill = chosen ? null : { node: n.id, pill: name, dir };
st.linking = null;
st.note = "";
redraw();
});
if (dir === "out")
b.addEventListener("pointerdown", (ev) => startDrag(ev, { node: n.id, pill: name }));
return b;
};
const boxEl = (n: SlNode): HTMLElement => {
const box = el("div", {
className: ["m01-sldg__box", `is-${n.kind}`, st.selected === n.id ? "is-selected" : ""].join(
" ",
),
attrs: {
"data-node": n.id,
tabindex: "0",
role: "button",
"aria-label": `${kindLabel(n.kind)} · ${n.name}`,
},
children: [
el("div", {
className: "m01-sldg__box-head",
children: [
el("span", { className: "m01-sldg__kind", text: kindLabel(n.kind) }),
el("strong", { className: "m01-sldg__name", text: n.name }),
],
}),
el("div", {
className: "m01-sldg__pills",
children: [
el("div", {
className: "m01-sldg__ins",
children: n.pillsIn.map((p) => pillEl(n, p.name, "in", p.unit)),
}),
el("div", {
className: "m01-sldg__outs",
children: n.pillsOut.map((p) => pillEl(n, p.name, "out", p.unit)),
}),
],
}),
],
});
box.addEventListener("click", () => {
if (st.linking) return tryConnect(st.linking, { node: n.id, pill: null, dir: null });
select(st.selected === n.id ? null : n.id);
});
box.addEventListener("keydown", (ev) => {
if (ev.target !== box) return;
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
box.click();
}
});
return box;
};
const cols = layout(sl.nodes);
const inner = el("div", {
className: "m01-sldg__inner",
children: cols.map((list, i) =>
el("div", {
className: "m01-sldg__col",
children: [
el("div", {
className: "m01-sldg__col-head",
text: td(`Stage${i}` as keyof typeof TEXT),
}),
...list.map(boxEl),
],
}),
),
});
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("m01-sldg__lines");
inner.prepend(svg);
inner.addEventListener("click", (ev) => {
if (ev.target === inner || (ev.target as HTMLElement).classList.contains("m01-sldg__col")) {
st.linking = null;
select(null);
}
});
/* 위 줄 — 더하기 · 고른 것 · 할 일 */
const addMenu = el("div", { className: "m01-sldg__menu", attrs: { hidden: "", role: "menu" } });
addMenu.append(
el("span", { className: "m01-sldg__muted", text: td("AddTitle") }),
...KINDS.map((k) =>
createButton({
label: kindLabel(k),
size: "sm",
variant: "ghost",
onClick: () => {
addMenu.hidden = true;
on.add(k, STAGE_OF[k]);
},
}),
),
);
const addBtn = createButton({
label: td("Add"),
size: "sm",
onClick: () => (addMenu.hidden = !addMenu.hidden),
});
const selEdge = sl.edges.find((e) => e.id === st.selected) ?? null;
const selNode = sl.nodes.find((n) => n.id === st.selected) ?? null;
const tools: HTMLElement[] = [];
if (st.linking) {
tools.push(
el("span", { text: td("PickTarget", { v: st.linking.pill }) }),
createButton({
label: td("Cancel"),
size: "sm",
variant: "ghost",
onClick: () => ((st.linking = null), redraw()),
}),
);
} else if (st.pill) {
const p = st.pill;
const linked = sl.edges.filter((e) =>
p.dir === "in"
? e.to.node === p.node && e.to.pill === p.pill
: e.from.node === p.node && e.from.pill === p.pill,
);
const where = linked
.map((e) =>
p.dir === "in"
? `${nodeName(e.from.node)} · ${e.from.pill}`
: `${nodeName(e.to.node)} · ${e.to.pill}`,
)
.join(", ");
tools.push(
el("span", {
text: [
`${p.pill}`,
`${td("Value")} ${showValue(values[p.node]?.[p.pill]) || "—"}`,
where ? `${td(p.dir === "in" ? "From" : "To")} ${where}` : "",
]
.filter(Boolean)
.join(" · "),
}),
);
if (p.dir === "out")
tools.push(
createButton({
label: td("Connect"),
size: "sm",
onClick: () => {
st.linking = { node: p.node, pill: p.pill };
st.pill = null;
redraw();
},
}),
);
} else if (selEdge) {
tools.push(
el("span", {
text: td("LineOf", {
a: `${nodeName(selEdge.from.node)} · ${selEdge.from.pill}`,
b: `${nodeName(selEdge.to.node)} · ${selEdge.to.pill}`,
}),
}),
createButton({
label: td("Disconnect"),
size: "sm",
variant: "danger",
onClick: () => on.disconnect(selEdge.id),
}),
);
} else if (selNode) {
tools.push(
createButton({
label: td("Remove"),
size: "sm",
variant: "danger",
onClick: () => void askRemove(selNode),
}),
);
}
const askRemove = async (n: SlNode): Promise<void> => {
if (await showConfirmDialog(td("AskRemove", { v: n.name }), td("Remove"))) on.remove(n.id);
};
const band = el("div", { className: "m01-sldg__band", children: [inner] });
const root = el("div", {
className: `m01-sldg${st.linking ? " is-linking" : ""}`,
attrs: { tabindex: "-1" },
children: [
el("div", {
className: "m01-sldg__bar",
children: [addBtn, ...tools, el("span", { className: "m01-sldg__note", text: st.note })],
}),
addMenu,
sl.nodes.length ? band : el("p", { className: "m01-sldg__muted", text: td("Empty") }),
],
});
root.addEventListener("keydown", (ev) => {
if (ev.key === "Escape" && (st.linking || st.pill)) {
st.linking = null;
st.pill = null;
redraw();
}
if (ev.key !== "Delete" || (ev.target as HTMLElement).closest("input, textarea")) return;
if (selEdge) on.disconnect(selEdge.id);
else if (selNode) void askRemove(selNode);
});
band.addEventListener("scroll", () => (st.scroll = band.scrollLeft));
host.replaceChildren(root);
band.scrollLeft = st.scroll;
/* 선 — 붙인 뒤 알약 자리를 재서 그림 */
const drawLines = (): void => {
const box = inner.getBoundingClientRect();
svg.setAttribute("width", String(inner.scrollWidth));
svg.setAttribute("height", String(inner.scrollHeight));
svg.replaceChildren();
const at = (end: SlEnd, dir: "in" | "out"): { x: number; y: number } | null => {
const pill = inner.querySelector<HTMLElement>(
`.m01-sldg__pill[data-node="${CSS.escape(end.node)}"][data-pill="${CSS.escape(end.pill)}"][data-dir="${dir}"]`,
);
if (!pill) return null;
const r = pill.getBoundingClientRect();
return {
x: (dir === "out" ? r.right : r.left) - box.left,
y: r.top + r.height / 2 - box.top,
};
};
for (const e of sl.edges) {
const a = at(e.from, "out");
const b = at(e.to, "in");
if (!a || !b) continue;
const dx = Math.max(30, Math.abs(b.x - a.x) / 2);
const d = `M${a.x},${a.y} C${a.x + dx},${a.y} ${b.x - dx},${b.y} ${b.x},${b.y}`;
const strong = st.selected === e.id || (selNode !== null && edgeTouches(e, selNode.id));
const dim = st.selected !== null && !strong;
const g = document.createElementNS("http://www.w3.org/2000/svg", "g");
g.setAttribute("class", `m01-sldg__edge${strong ? " is-strong" : ""}${dim ? " is-dim" : ""}`);
g.setAttribute("data-edge", e.id);
g.setAttribute("data-x1", a.x.toFixed(1));
g.setAttribute("data-y1", a.y.toFixed(1));
g.setAttribute("data-x2", b.x.toFixed(1));
g.setAttribute("data-y2", b.y.toFixed(1));
for (const cls of ["m01-sldg__hit", "m01-sldg__wire"]) {
const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
p.setAttribute("d", d);
p.setAttribute("class", cls);
g.append(p);
}
g.addEventListener("click", (ev) => {
ev.stopPropagation();
select(st.selected === e.id ? null : e.id);
});
svg.append(g);
}
};
// 붙어 있으면 바로 · 나중에 붙거나 크기가 바뀌면 다시(띠 밀기 · 창 크기)
st.watch?.disconnect();
st.watch = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(drawLines);
st.watch?.observe(inner);
if (inner.isConnected) drawLines();
/* 끌어 잇기 */
function startDrag(ev: PointerEvent, from: SlEnd): void {
if (ev.button !== 0) return;
const pill = ev.currentTarget as HTMLElement;
const box = inner.getBoundingClientRect();
const r = pill.getBoundingClientRect();
const x0 = r.right - box.left;
const y0 = r.top + r.height / 2 - box.top;
let line: SVGPathElement | null = null;
const move = (m: PointerEvent): void => {
const x = m.clientX - box.left;
const y = m.clientY - box.top;
if (!line && Math.hypot(x - x0, y - y0) < 6) return;
if (!line) {
line = document.createElementNS("http://www.w3.org/2000/svg", "path");
line.setAttribute("class", "m01-sldg__drag");
svg.append(line);
}
line.setAttribute("d", `M${x0},${y0} L${x},${y}`);
};
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (!line) return; // 끌지 않음 = 누름(click 이 처리)
line.remove();
const hit = document.elementFromPoint(u.clientX, u.clientY) as HTMLElement | null;
const pillHit = hit?.closest<HTMLElement>(".m01-sldg__pill");
const boxHit = hit?.closest<HTMLElement>(".m01-sldg__box");
const target = pillHit
? {
node: pillHit.dataset.node ?? "",
pill: pillHit.dataset.pill ?? null,
dir: (pillHit.dataset.dir as "in" | "out") ?? null,
}
: boxHit
? { node: boxHit.dataset.node ?? "", pill: null, dir: null }
: null;
// 끈 뒤 바로 따라오는 click 만 삼킴 — 안 오면 곧 풀어 다음 누름은 그대로
const swallow = (c: MouseEvent): void => c.stopPropagation();
window.addEventListener("click", swallow, { capture: true, once: true });
setTimeout(() => window.removeEventListener("click", swallow, { capture: true }), 0);
tryConnect(from, target);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
}
}