From 854531cfd80a76709189b340e22d1540eda872ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 12:09:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=A7=89=EB=8C=80=20=E2=80=94=20createSpreadsheet({links})=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=EC=97=B0=EA=B2=B0=20=C2=B7=20=ED=92=80?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=9E=90=EC=9E=AC=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸을 고른 채 알약을 누르면 연결(흐림 + 주소 · 칸 왼아래 세모) · 흐린 알약은 연결 칸으로 · 우클릭 연결 풀기 - 자재 모드 [자재 더하기] → 자재찾기 창 → 새 알약 - 행열 넣기 · 지우기에 연결 칸 따라감(지운 칸이면 빠짐) · 되돌리기 짝 · getLinkedValues() - 늦게 불러옴(links 선택 또는 연결 있는 문서만) · 화면 28,518 B(예산 28,672) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- A00_Common/spreadsheet/spreadsheet.ts | 38 +- A00_Common/spreadsheet/spreadsheet_links.css | 110 ++++++ A00_Common/spreadsheet/spreadsheet_links.ts | 343 +++++++++++++++++++ 3 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_links.css create mode 100644 A00_Common/spreadsheet/spreadsheet_links.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 38951196..6a60d745 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -15,12 +15,14 @@ import { el } from "@ui/ui_template_elements"; import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address"; import { attachClipboard } from "./spreadsheet_clipboard"; import { applyCommand, CommandError } from "./spreadsheet_commands"; -import { createEditor } from "./spreadsheet_editor"; +import { createEditor, slotOf } from "./spreadsheet_editor"; +import { toCalcValue } from "./spreadsheet_eval"; import { attachExtras } from "./spreadsheet_extras"; import { createCalcEngine } from "./spreadsheet_graph"; import { createGrid } from "./spreadsheet_grid"; import { createHistory } from "./spreadsheet_history"; import { attachKeys } from "./spreadsheet_keys"; +import type { LinkOptions, Links } from "./spreadsheet_links"; import { attachMenu } from "./spreadsheet_menu"; import { attachMouse } from "./spreadsheet_mouse"; import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection"; @@ -28,6 +30,7 @@ import { createStatusBar } from "./spreadsheet_statusbar"; import { mountTabs } from "./spreadsheet_tabs"; import { mountToolbar } from "./spreadsheet_toolbar"; import type { + CalcValue, CalcValues, Command, CommandEffect, @@ -42,7 +45,8 @@ import type { SpreadsheetContext, } from "./spreadsheet_view_types"; -export type { Workbook, CalcValues } from "./spreadsheet_types"; +export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types"; +export type { LinkItem, LinkOptions } from "./spreadsheet_links"; export interface SpreadsheetOptions { /** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */ @@ -53,6 +57,8 @@ export interface SpreadsheetOptions { onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void; /** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */ tabs?: boolean; + /** 위 알약 막대 — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */ + links?: LinkOptions; } export interface SpreadsheetHandle { @@ -62,6 +68,8 @@ export interface SpreadsheetHandle { setDoc(book: Workbook): void; /** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */ recalc(): CalcValues; + /** 알약 키 → 연결 칸 값(빈 칸 0) */ + getLinkedValues(): Record; destroy(): void; } @@ -136,9 +144,23 @@ export function createSpreadsheet( const detachMouse = attachMouse(ctx, ed, extras.mouse); const toast = el("div", { className: "ss-toast" }); let toastTimer = 0; + /** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */ + let links: Links | null = null; + let linking = false; + function loadLinks(): void { + if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return; + linking = true; + void import("./spreadsheet_links").then((m) => { + if (!root.isConnected) return; // 받기 전에 닫힘 + links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf); + others.push(links); + links.refresh(); + }); + } grid.render(); select(ctx.selection); + loadLinks(); function notify(message: string): void { toast.textContent = message; @@ -149,7 +171,7 @@ export function createSpreadsheet( function apply(command: Command): CommandEffect | null { try { - return applyCommand(ctx.book, command); + return (links?.apply ?? applyCommand)(ctx.book, command); } catch (e) { if (e instanceof CommandError) return (notify(e.message), null); throw e; @@ -238,6 +260,7 @@ export function createSpreadsheet( ctx.selection = selectCell(firstSheet(next), 0, 0); grid.render(); select(ctx.selection); + loadLinks(); }, recalc() { ctx.engine.rebuild(ctx.book); @@ -245,6 +268,15 @@ export function createSpreadsheet( for (const p of others) p.refresh(); return ctx.engine.snapshot(); }, + getLinkedValues() { + const out: Record = {}; + for (const s of ctx.book.시트) + for (const l of s.연결 ?? []) { + const p = parseA1(l.칸); + if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c)); + } + return out; + }, destroy() { clearTimeout(toastTimer); detachKeys(); diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css new file mode 100644 index 00000000..e6402728 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -0,0 +1,110 @@ +/* spreadsheet_links.css (주인 D) — 위 알약 막대 · 연결 칸 세모 · 자재 고르기 창 */ + +.ss-links { + display: flex; + flex: none; + flex-wrap: wrap; + gap: var(--spacing-4); + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + background: var(--color-surface); +} + +.ss-link { + display: inline-flex; + align-items: baseline; + gap: var(--spacing-4); + padding: 2px 10px; + border: 1px solid var(--color-accent); + border-radius: 999px; + background: var(--color-surface-raised); + color: var(--color-text-body); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.ss-link small { + color: var(--color-text-secondary); +} + +.ss-link code { + font-size: 11px; + color: var(--color-accent); +} + +/* 연결됨 — 흐림(연결 현황) */ +.ss-link.is-linked { + opacity: 0.5; + border-style: dashed; +} + +.ss-link.is-linked:hover { + opacity: 0.8; +} + +.ss-link--add { + border-style: dashed; + color: var(--color-text-secondary); +} + +/* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */ +.ss-link-dot { + position: absolute; + z-index: 3; + width: 0; + height: 0; + border-top: 6px solid transparent; + border-left: 6px solid var(--color-accent); + pointer-events: none; +} + +.ss-links__pick { + position: absolute; + z-index: var(--z-dropdown); + display: flex; + flex-direction: column; + gap: var(--spacing-4); + width: 320px; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ss-links__search { + padding: 4px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font: inherit; +} + +.ss-links__list { + display: flex; + flex-direction: column; + max-height: 240px; + overflow: auto; +} + +.ss-links__row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: var(--spacing-8); + padding: 4px 6px; + border: none; + background: none; + color: var(--color-text-body); + font: inherit; + text-align: left; + cursor: pointer; +} + +.ss-links__row:hover { + background: var(--color-surface-sunken); +} + +.ss-links__note { + padding: 4px 6px; + color: var(--color-text-muted); +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts new file mode 100644 index 00000000..44c0d774 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -0,0 +1,343 @@ +/* ============================================================================= + * 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(); + }, + }; +}