/* ============================================================================= * 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 { parseA1, 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; } 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: "이미 목록에 있는 자재", }; 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] } }; } /** 자재 → 알약(키 = 자재 코드) */ 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; /** 키 → 연결(시트 · 줄) */ 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 }); } /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */ async function link(item: LinkItem, at?: string): Promise { 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, ) { 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 })] : []), ], }); if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); 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))); const 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(); 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(); 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 pills = shown.map((item) => pill(item, map.get(item.키), hues.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 = []; const hues = linkHues(found().keys()); // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); 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 (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); } } /** 칸에 마우스를 올리면 이은 알약 이름들(격자 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 (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 => { 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 => { 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); 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(); }, }; }