/* ============================================================================= * spreadsheet_links.ts (주인 D) * 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) · * 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 * 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산). * ========================================================================== */ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { el, showToast } from "@ui/ui_template_elements"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; 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; } export interface LinkOptions { 항목: LinkItem[]; /** 입력 = 항목이 알약 · 자재 = 연결된 자재 + [자재 더하기] 로 고른 자재 */ 모드: "입력" | "자재"; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; } export interface Links extends PartHandle { /** `applyCommand` + 연결 따라가기 */ apply(book: Workbook, command: Command): CommandEffect; } const TEXT = { bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기", free: "칸을 고른 채 누르면 이 칸에 연결", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", add: "+ 자재 더하기", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", none: "맞는 자재 없음", fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", }; type RowCol = Extract; 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] } }; } 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 } }); if (opts) ctx.root.prepend(bar); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); /** 이번에 고른 자재(아직 칸 없음 — 연결하면 문서에 들어감) */ const picked = new Map(); let dots: HTMLElement[] = []; let pick: HTMLElement | null = null; /** 키 → 연결(시트 · 줄) */ function found(): Map { const out = new Map(); 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 }); } function link(item: LinkItem, 자재?: LinkMaterial): void { if (ctx.readOnly) return; const sheet = ctx.sheet(); const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c); const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; if (item.단위) one.단위 = item.단위; if (자재) one.자재 = 자재; put(sheet, [...rest, one]); 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(); } function pill( item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined, 자재?: LinkMaterial, ) { const where = hit ? hit.sheet.id === ctx.selection.시트 ? hit.link.칸 : `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}` : ""; const b = el("button", { className: hit ? "ss-link is-linked" : "ss-link", attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 }, children: [ el("span", { text: item.이름 }), ...(item.단위 ? [el("small", { text: item.단위 })] : []), ...(where ? [el("code", { text: where })] : []), ], }); b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠 b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item, 자재))); b.addEventListener("contextmenu", (e) => { e.preventDefault(); if (!hit || ctx.readOnly) return; menu.open(e.clientX, e.clientY, [ [ TEXT.unlink, () => { put( hit.sheet, (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), ); back(); }, ], ]); }); return b; } function renderBar(): void { if (!opts) return; const map = found(); const items: [LinkItem, LinkMaterial?][] = []; const seen = new Set(); const add = (item: LinkItem, 자재?: LinkMaterial) => { if (seen.has(item.키)) return; seen.add(item.키); items.push([item, 자재]); }; const ofMaterial = (m: LinkMaterial): LinkItem => ({ 키: m.코드, 이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름, 단위: m.단위, }); if (opts.모드 === "입력") for (const item of opts.항목) add(item); // 문서에 있는 연결(항목에서 빠진 것 · 자재) — 풀 수 있게 늘 보임 for (const { link } of map.values()) add( link.자재 ? ofMaterial(link.자재) : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, link.자재, ); for (const m of picked.values()) add(ofMaterial(m), m); const pills = items.map(([item, 자재]) => pill(item, map.get(item.키), 자재)); 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); } /** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */ function placeDots(): void { for (const d of dots) d.remove(); dots = []; for (const l of ctx.sheet().연결 ?? []) { const p = parseA1(l.칸); if (!p) continue; const { layer, box } = slot(ctx, p.r, p.c); if (!box.w || !box.h) continue; // 숨긴 행열 const dot = el("div", { className: "ss-link-dot" }); dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; layer.append(dot); dots.push(dot); } } // ── 자재 고르기 창 ────────────────────────────────────────────────────── 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.top = `${a.bottom - o.top + 4}px`; ctx.root.append(pick); document.addEventListener("mousedown", onOutside, true); let seq = 0; let timer = 0; const choose = (m: LinkMaterial) => { picked.set(m.코드, m); closePick(); renderBar(); showToast(TEXT.picked, "info"); back(); }; const search = async (): Promise => { 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(".ss-links__row")?.click(); }); input.focus(); void search(); } const onZoom = (): void => placeDots(); ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom); return { root: opts ? bar : null, apply: applyLinked, refresh() { renderBar(); placeDots(); }, destroy() { ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom); closePick(); for (const d of dots) d.remove(); menu.element.remove(); bar.remove(); }, }; }