Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
566 lines
24 KiB
TypeScript
566 lines
24 KiB
TypeScript
/* =============================================================================
|
||
* spreadsheet_links.ts (주인 D)
|
||
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
|
||
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
|
||
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
|
||
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
|
||
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
|
||
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
|
||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
|
||
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
|
||
* ========================================================================== */
|
||
|
||
import "./spreadsheet_links.css";
|
||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||
import { el, showToast } from "@ui/ui_template_elements";
|
||
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
|
||
import { applyCommand } from "./spreadsheet_commands";
|
||
import { askShare } from "./spreadsheet_link_ask";
|
||
import { linkHues } from "./spreadsheet_link_color";
|
||
import { linkCommand, linksAt } from "./spreadsheet_link_set";
|
||
import type { slotOf } from "./spreadsheet_editor";
|
||
import { shiftSpan } from "./spreadsheet_refshift";
|
||
import { selectCell } from "./spreadsheet_selection";
|
||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||
import type {
|
||
CellLink,
|
||
Command,
|
||
CommandEffect,
|
||
LinkMaterial,
|
||
Sheet,
|
||
Workbook,
|
||
} from "./spreadsheet_types";
|
||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||
|
||
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
|
||
export interface LinkItem {
|
||
키: string;
|
||
이름: string;
|
||
단위?: string;
|
||
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
|
||
자재?: LinkMaterial;
|
||
/** 호스트가 정한 알약 — 자재 모드에서도 [목록에서 빼기]가 안 뜸(예: 선택 입력 카드가 자리를 고른 입력) */
|
||
고정?: boolean;
|
||
}
|
||
|
||
export interface LinkOptions {
|
||
항목: LinkItem[];
|
||
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
|
||
모드: "입력" | "자재";
|
||
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
|
||
onItemsChange?: (항목: LinkItem[]) => void;
|
||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
|
||
* 목록에 없는 연결 알약에 ⚠ + [×] */
|
||
확정?: boolean;
|
||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
|
||
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
|
||
}
|
||
|
||
export interface Links extends PartHandle {
|
||
/** `applyCommand` + 연결 따라가기 */
|
||
apply(book: Workbook, command: Command): CommandEffect;
|
||
}
|
||
|
||
const TEXT = {
|
||
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
|
||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||
unlink: "연결 풀기",
|
||
remove: "목록에서 빼기",
|
||
removed: "목록에서 뺌 — 연결도 풀림",
|
||
add: "+ 자재 추가",
|
||
search: "자재 이름 · 규격 · 코드",
|
||
searching: "찾는 중…",
|
||
none: "맞는 자재 없음",
|
||
fail: "자재 목록을 못 받음",
|
||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||
dup: "이미 목록에 있는 자재",
|
||
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
|
||
orphanX: "연결 풀기",
|
||
};
|
||
|
||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||
|
||
const isRowCol = (c: Command): c is RowCol =>
|
||
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
|
||
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
|
||
|
||
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
|
||
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
|
||
const rows = cmd.종류.startsWith("행");
|
||
const insert = cmd.종류.endsWith("넣기");
|
||
return links.flatMap((link) => {
|
||
const p = parseA1(link.칸);
|
||
if (!p) return [link];
|
||
const i = rows ? p.r : p.c;
|
||
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
|
||
if (to === undefined) return [];
|
||
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
|
||
});
|
||
}
|
||
|
||
const setLinks = (s: Sheet, list: CellLink[]): void => {
|
||
if (list.length) s.연결 = list;
|
||
else delete s.연결;
|
||
};
|
||
|
||
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
|
||
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
|
||
export function applyLinked(book: Workbook, command: Command): CommandEffect {
|
||
const ops = flat(command).filter(isRowCol);
|
||
const hit = new Set(ops.map((o) => o.시트));
|
||
const before = book.시트
|
||
.filter((s) => hit.has(s.id) && s.연결?.length)
|
||
.map((s) => structuredClone(s));
|
||
const eff = applyCommand(book, command);
|
||
if (!before.length) return eff;
|
||
for (const op of ops) {
|
||
const s = book.시트.find((x) => x.id === op.시트);
|
||
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
|
||
}
|
||
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
|
||
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
|
||
}
|
||
|
||
/** 자재 → 알약(키 = 자재 코드) */
|
||
const ofMaterial = (m: LinkMaterial): LinkItem => ({
|
||
키: m.코드,
|
||
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
|
||
단위: m.단위,
|
||
자재: m,
|
||
});
|
||
|
||
export function mountLinks(
|
||
ctx: SpreadsheetContext,
|
||
opts: LinkOptions | undefined,
|
||
back: () => void,
|
||
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
|
||
slot: typeof slotOf,
|
||
): Links {
|
||
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
|
||
// 상태줄(합계 줄) 아래 맨 끝(표는 flex 라 막대 높이만큼 줄어듦 — 여러 줄이어도 페이지 스크롤 없음)
|
||
if (opts) ctx.root.querySelector(":scope > .ss-statusbar")?.after(bar);
|
||
const menu = createMapContextMenu("ss");
|
||
ctx.root.append(menu.element);
|
||
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
|
||
const items: LinkItem[] = opts ? [...opts.항목] : [];
|
||
let dots: HTMLElement[] = [];
|
||
/** 끄는 중인 알약 · 놓을 칸 표시 */
|
||
let dragging: LinkItem | null = null;
|
||
const dropMark = el("div", { className: "ss-link-drop" });
|
||
let pick: HTMLElement | null = null;
|
||
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
|
||
let lastAt = "";
|
||
|
||
/** 키 → 연결(시트 · 줄) */
|
||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
|
||
for (const sheet of ctx.book.시트)
|
||
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
|
||
return out;
|
||
}
|
||
|
||
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
|
||
function put(sheet: Sheet, list: CellLink[]): void {
|
||
const next = structuredClone(sheet);
|
||
setLinks(next, list);
|
||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||
}
|
||
|
||
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
|
||
async function link(item: LinkItem, at?: string): Promise<void> {
|
||
if (ctx.readOnly) return;
|
||
const sheet = ctx.sheet();
|
||
const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c);
|
||
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
|
||
const choice = others.length
|
||
? await askShare(
|
||
칸,
|
||
others.map((l) => l.이름 ?? l.키),
|
||
)
|
||
: "함께";
|
||
if (!choice) return back();
|
||
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
|
||
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
|
||
if (command) ctx.dispatch(command);
|
||
back();
|
||
}
|
||
|
||
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
|
||
function goTo(sheet: Sheet, a1: string): void {
|
||
const p = parseA1(a1);
|
||
if (!p) return;
|
||
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
|
||
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
|
||
back();
|
||
requestAnimationFrame(() => {
|
||
const { layer, box } = slot(ctx, p.r, p.c);
|
||
if (!box.w || !box.h) return;
|
||
const f = el("div", { className: "ss-link-flash" });
|
||
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||
f.addEventListener("animationend", () => f.remove());
|
||
layer.append(f);
|
||
});
|
||
}
|
||
|
||
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
|
||
|
||
function pill(
|
||
item: LinkItem,
|
||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||
hue: number | undefined,
|
||
orphan: boolean,
|
||
here: string,
|
||
) {
|
||
const where = hit
|
||
? hit.sheet.id === ctx.selection.시트
|
||
? hit.link.칸
|
||
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
|
||
: "";
|
||
const b = el("button", {
|
||
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
|
||
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
|
||
}`,
|
||
attrs: {
|
||
type: "button",
|
||
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
|
||
"data-key": item.키,
|
||
},
|
||
children: [
|
||
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
|
||
...(item.단위 ? [el("small", { text: item.단위 })] : []),
|
||
...(where ? [el("code", { text: where })] : []),
|
||
],
|
||
});
|
||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||
if (orphan && !ctx.readOnly) {
|
||
const x = el("span", {
|
||
className: "ss-link__x",
|
||
text: "×",
|
||
attrs: { role: "button", title: TEXT.orphanX },
|
||
});
|
||
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
|
||
b.append(x);
|
||
}
|
||
b.draggable = !ctx.readOnly;
|
||
b.addEventListener("dragstart", (e) => {
|
||
dragging = item;
|
||
e.dataTransfer?.setData("text/plain", item.키);
|
||
if (e.dataTransfer) e.dataTransfer.effectAllowed = "link";
|
||
});
|
||
b.addEventListener("dragend", () => {
|
||
dragging = null;
|
||
dropMark.remove();
|
||
});
|
||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||
function unlink(): void {
|
||
if (hit)
|
||
put(
|
||
hit.sheet,
|
||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||
);
|
||
}
|
||
b.addEventListener("contextmenu", (e) => {
|
||
e.preventDefault();
|
||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||
if (ctx.readOnly || !(hit || inList)) return;
|
||
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
|
||
menu.open(e.clientX - o.left, e.clientY - o.top, [
|
||
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
|
||
...(inList
|
||
? [
|
||
[
|
||
TEXT.remove,
|
||
() => {
|
||
unlink();
|
||
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
|
||
commit();
|
||
renderBar();
|
||
if (hit) showToast(TEXT.removed, "info");
|
||
back();
|
||
},
|
||
] as [string, () => void],
|
||
]
|
||
: []),
|
||
]);
|
||
});
|
||
return b;
|
||
}
|
||
|
||
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
|
||
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
|
||
function align(): void {
|
||
if (opts?.모드 !== "입력") return;
|
||
const keys = new Set(items.map((i) => i.키));
|
||
const taken = new Set<string>();
|
||
for (const sheet of ctx.book.시트)
|
||
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
|
||
for (const sheet of ctx.book.시트)
|
||
for (const l of sheet.연결 ?? []) {
|
||
if (keys.has(l.키)) continue;
|
||
const hit = items.find(
|
||
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
|
||
);
|
||
if (!hit) continue;
|
||
l.키 = hit.키;
|
||
taken.add(hit.키);
|
||
}
|
||
}
|
||
|
||
function renderBar(): void {
|
||
if (!opts) return;
|
||
align();
|
||
const map = found();
|
||
const shown: LinkItem[] = [];
|
||
const seen = new Set<string>();
|
||
const add = (item: LinkItem) => {
|
||
if (seen.has(item.키)) return;
|
||
seen.add(item.키);
|
||
shown.push(item);
|
||
};
|
||
for (const item of items) add(item);
|
||
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
|
||
for (const { link } of map.values())
|
||
add(
|
||
link.자재
|
||
? ofMaterial(link.자재)
|
||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||
);
|
||
const hues = linkHues(map.keys());
|
||
const listed = new Set(items.map((i) => i.키));
|
||
const sure = opts.모드 === "입력" && opts.확정 === true;
|
||
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
|
||
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
|
||
const fresh = at !== lastAt;
|
||
lastAt = at;
|
||
const pills = shown.map((item) => {
|
||
const hit = map.get(item.키);
|
||
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
|
||
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
|
||
});
|
||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||
const more = el("button", {
|
||
className: "ss-link ss-link--add",
|
||
text: TEXT.add,
|
||
attrs: { type: "button" },
|
||
});
|
||
more.addEventListener("click", () => openPick(more));
|
||
pills.push(more);
|
||
}
|
||
bar.replaceChildren(...pills);
|
||
if (fresh)
|
||
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||
}
|
||
|
||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||
function placeDots(): void {
|
||
for (const d of dots) d.remove();
|
||
dots = [];
|
||
const hues = linkHues(found().keys());
|
||
const listed = new Set(items.map((i) => i.키));
|
||
const sure = opts?.모드 === "입력" && opts.확정 === true;
|
||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||
const byCell = new Map<string, CellLink[]>();
|
||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||
for (const [a1, list] of byCell) {
|
||
const p = parseA1(a1);
|
||
if (!p) continue;
|
||
const { layer, box } = slot(ctx, p.r, p.c);
|
||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||
const first = list[0];
|
||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
|
||
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||
const dot = el("div", { className: "ss-link-dot" });
|
||
dot.style.left = `${box.x}px`;
|
||
dot.style.top = `${box.y + box.h - 7}px`;
|
||
const marks = [tint, dot];
|
||
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
|
||
if (list.length > 1) {
|
||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||
count.style.left = `${box.x + box.w}px`;
|
||
count.style.top = `${box.y}px`;
|
||
marks.push(count);
|
||
}
|
||
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
|
||
layer.append(...marks);
|
||
dots.push(...marks);
|
||
}
|
||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||
for (const mark of opts?.표시범위 ?? []) {
|
||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||
if (!g) continue;
|
||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||
const end = slot(ctx, g.r1, g.c1).box;
|
||
if (!box.w || !box.h) continue;
|
||
const frame = el("div", {
|
||
className: "ss-link-range",
|
||
attrs: { "data-range": mark.범위 },
|
||
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
|
||
});
|
||
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
|
||
layer.append(frame);
|
||
dots.push(frame);
|
||
}
|
||
}
|
||
|
||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||
let tipped = false;
|
||
const onHover = (e: MouseEvent): void => {
|
||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
|
||
const names = a1
|
||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||
: [];
|
||
if (hit && hit.r >= 0 && hit.c >= 0)
|
||
for (const m of opts?.표시범위 ?? []) {
|
||
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
|
||
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
|
||
}
|
||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||
tipped = names.length > 0;
|
||
};
|
||
|
||
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
|
||
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
|
||
function cellAt(e: DragEvent): { r: number; c: number } | null {
|
||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||
return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null;
|
||
}
|
||
const onDragOver = (e: DragEvent): void => {
|
||
const at = dragging && !ctx.readOnly ? cellAt(e) : null;
|
||
if (!at) return void dropMark.remove();
|
||
e.preventDefault(); // 놓을 수 있음
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = "link";
|
||
const { layer, box } = slot(ctx, at.r, at.c);
|
||
dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||
if (dropMark.parentElement !== layer) layer.append(dropMark);
|
||
};
|
||
const onDragLeave = (e: DragEvent): void => {
|
||
if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove();
|
||
};
|
||
const onDrop = async (e: DragEvent): Promise<void> => {
|
||
const item = dragging;
|
||
const at = item && !ctx.readOnly ? cellAt(e) : null;
|
||
dropMark.remove();
|
||
dragging = null;
|
||
if (!item || !at) return;
|
||
e.preventDefault();
|
||
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
|
||
await link(item, toA1(at.r, at.c));
|
||
};
|
||
|
||
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
|
||
function closePick(): void {
|
||
pick?.remove();
|
||
pick = null;
|
||
document.removeEventListener("mousedown", onOutside, true);
|
||
}
|
||
const onOutside = (e: MouseEvent): void => {
|
||
if (pick && !pick.contains(e.target as Node)) closePick();
|
||
};
|
||
|
||
function openPick(anchor: HTMLElement): void {
|
||
closePick();
|
||
const input = el("input", {
|
||
className: "ss-links__search",
|
||
attrs: { type: "search", placeholder: TEXT.search },
|
||
}) as HTMLInputElement;
|
||
const list = el("div", { className: "ss-links__list" });
|
||
const note = (text: string) => el("div", { className: "ss-links__note", text });
|
||
pick = el("div", { className: "ss-links__pick", children: [input, list] });
|
||
const a = anchor.getBoundingClientRect();
|
||
const o = ctx.root.getBoundingClientRect();
|
||
pick.style.left = `${a.left - o.left}px`;
|
||
pick.style.bottom = `${o.bottom - bar.getBoundingClientRect().top + 4}px`; // 막대 위로 엶
|
||
ctx.root.append(pick);
|
||
document.addEventListener("mousedown", onOutside, true);
|
||
|
||
let seq = 0;
|
||
let timer = 0;
|
||
const choose = (m: LinkMaterial) => {
|
||
closePick();
|
||
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
|
||
else {
|
||
items.push(ofMaterial(m));
|
||
commit();
|
||
renderBar();
|
||
showToast(TEXT.picked, "info");
|
||
}
|
||
back();
|
||
};
|
||
const search = async (): Promise<void> => {
|
||
const mine = ++seq;
|
||
list.replaceChildren(note(TEXT.searching));
|
||
try {
|
||
const got = await opts!.자재찾기!(input.value.trim());
|
||
if (mine !== seq) return;
|
||
list.replaceChildren(
|
||
...(got.length
|
||
? got.map((m) => {
|
||
const row = el("button", {
|
||
className: "ss-links__row",
|
||
attrs: { type: "button", title: m.코드 },
|
||
children: [
|
||
el("span", { text: m.이름 }),
|
||
el("span", { text: m.규격 }),
|
||
el("small", { text: m.단위 }),
|
||
],
|
||
});
|
||
row.addEventListener("click", () => choose(m));
|
||
return row;
|
||
})
|
||
: [note(TEXT.none)]),
|
||
);
|
||
} catch {
|
||
if (mine === seq) list.replaceChildren(note(TEXT.fail));
|
||
}
|
||
};
|
||
input.addEventListener("input", () => {
|
||
clearTimeout(timer);
|
||
timer = window.setTimeout(() => void search(), 250);
|
||
});
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
|
||
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
|
||
});
|
||
input.focus();
|
||
void search();
|
||
}
|
||
|
||
const onZoom = (): void => placeDots();
|
||
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
|
||
const onDropEvent = (e: DragEvent): void => void onDrop(e);
|
||
ctx.grid.root.addEventListener("dragover", onDragOver);
|
||
ctx.grid.root.addEventListener("dragleave", onDragLeave);
|
||
ctx.grid.root.addEventListener("drop", onDropEvent);
|
||
ctx.grid.root.addEventListener("mousemove", onHover);
|
||
|
||
return {
|
||
root: opts ? bar : null,
|
||
apply: applyLinked,
|
||
refresh() {
|
||
renderBar();
|
||
placeDots();
|
||
},
|
||
destroy() {
|
||
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
|
||
ctx.grid.root.removeEventListener("dragover", onDragOver);
|
||
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
|
||
ctx.grid.root.removeEventListener("drop", onDropEvent);
|
||
ctx.grid.root.removeEventListener("mousemove", onHover);
|
||
dropMark.remove();
|
||
closePick();
|
||
for (const d of dots) d.remove();
|
||
menu.element.remove();
|
||
bar.remove();
|
||
},
|
||
};
|
||
}
|