From 3d6f59e494598e39117ddc7b64c6421f3d390ab1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:48:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=B9=B8=20=ED=95=98?= =?UTF-8?q?=EB=82=98=EC=97=90=20=EC=95=8C=EC=95=BD=20=EC=97=AC=EB=9F=BF=20?= =?UTF-8?q?=E2=80=94=20=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=B4=EB=A9=B4=20[=ED=95=A8=EA=BB=98=20=EC=9E=87=EA=B8=B0]?= =?UTF-8?q?=20=C2=B7=20[=EB=B0=94=EA=BE=B8=EA=B8=B0]=20=C2=B7=20[=EC=B7=A8?= =?UTF-8?q?=EC=86=8C]=20=EB=AC=BC=EC=9D=8C(=EC=95=8C=EC=95=BD=20=EB=88=84?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EB=81=8C=EC=96=B4=20=EB=86=93=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=B9=B8=20=EC=A7=80=EC=A0=95=20=C2=B7=20=EC=97=91?= =?UTF-8?q?=EC=85=80=20=EA=B3=A0=EB=A5=B4=EA=B8=B0)=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=20=EC=83=89=20=3D=20=EB=A8=BC=EC=A0=80=20=EC=9D=B4=EC=9D=80=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=9C=84=20?= =?UTF-8?q?=EC=88=98=20=C2=B7=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20=EC=98=AC?= =?UTF-8?q?=EB=A6=AC=EB=A9=B4=20=EC=9D=B4=EB=A6=84=EB=93=A4=20=C2=B7=20lin?= =?UTF-8?q?kAreaCell=20=ED=95=A8=EA=BB=98=20=EA=B8=B0=EB=B3=B8=20(PLAN=204?= =?UTF-8?q?0=20=EB=A7=88=EB=AC=B4=EB=A6=AC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/spreadsheet/spreadsheet.ts | 8 +- .../spreadsheet/spreadsheet_link_ask.ts | 65 ++++++++++++++ .../spreadsheet/spreadsheet_link_set.ts | 31 +++++-- A00_Common/spreadsheet/spreadsheet_links.css | 18 ++++ A00_Common/spreadsheet/spreadsheet_links.ts | 87 ++++++++++++------- .../M02_MasterTemplete_AreaCellPick.ts | 35 ++++++-- .../M02_MasterTemplete_AreaLink.ts | 24 +++-- .../spreadsheet/spreadsheet_link_set_entry.ts | 34 +++++++- resources/tester/test_m02_area_panel.py | 11 ++- resources/tester/test_spreadsheet_set_link.py | 18 ++-- 10 files changed, 264 insertions(+), 67 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_ask.ts diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 54cd49c06..dfb65ccc5 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -79,8 +79,8 @@ export interface SpreadsheetHandle { /** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */ getLinkedValues(): Record; /** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 · - * 주소가 틀리거나 읽기만이면 false */ - setLink(key: string, cell: string | null, info?: LinkInfo): boolean; + * 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */ + setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean; destroy(): void; } @@ -295,9 +295,9 @@ export function createSpreadsheet( } return out; }, - setLink(key, cell, info) { + setLink(key, cell, info, replace) { if (readOnly) return false; - const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info); + const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace); if (command === false) return false; if (command) dispatch(command); loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴 diff --git a/A00_Common/spreadsheet/spreadsheet_link_ask.ts b/A00_Common/spreadsheet/spreadsheet_link_ask.ts new file mode 100644 index 000000000..ab83067d1 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_ask.ts @@ -0,0 +1,65 @@ +/* ============================================================================= + * spreadsheet_link_ask.ts (주인 D) + * 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리). + * 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 · + * 모양은 공용 확인창(`ui-confirm`) 그대로. + * ========================================================================== */ + +import { createButton, el } from "@ui/ui_template_elements"; + +const TEXT = { + ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?", + together: "함께 잇기", + replace: "바꾸기", + cancel: "취소", +}; + +/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */ +export type ShareChoice = "함께" | "바꾸기" | null; + +export function askShare(cell: string, names: string[]): Promise { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (choice: ShareChoice): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve(choice); + }; + const together = createButton({ + label: TEXT.together, + variant: "filled", + onClick: () => done("함께"), + }); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done(null); + }); + const message = TEXT.ask + .replace("{cell}", cell) + .replace("{names}", names.map((n) => `「${n}」`).join(" · ")); + overlay.append( + el("div", { + className: "ui-confirm__panel", + children: [ + el("p", { className: "ui-confirm__message", text: message }), + el("div", { + className: "ui-confirm__actions", + children: [ + createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }), + createButton({ + label: TEXT.replace, + variant: "ghost", + onClick: () => done("바꾸기"), + }), + together, + ], + }), + ], + }), + ); + document.body.append(overlay); + together.focus(); // Enter = 함께 잇기(기본) + }); +} diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 65822f7be..0a2db64b1 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -1,8 +1,8 @@ /* ============================================================================= * spreadsheet_link_set.ts (주인 D) * 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`). - * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 · - * 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). + * 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러 + * 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름). * ========================================================================== */ import { parseA1, toA1 } from "./spreadsheet_address"; @@ -15,13 +15,28 @@ export interface LinkInfo { 자재?: LinkMaterial; } -/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */ +/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */ +export function normA1(cell: string): string | null { + const at = parseA1(cell.trim().toUpperCase()); + return at ? toA1(at.r, at.c) : null; +} + +/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */ +export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] { + const a1 = normA1(cell); + const sheet = book.시트.find((s) => s.id === sheetId); + return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key); +} + +/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false · + * `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */ export function linkCommand( book: Workbook, sheetId: string, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): Command | null | false { const at = cell === null ? null : parseA1(cell.trim().toUpperCase()); if (cell !== null && !at) return false; @@ -29,7 +44,8 @@ export function linkCommand( const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key); const home = book.시트.find((s) => s.id === sheetId); if (a1 && !home) return false; - if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 + const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0; + if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸 const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null; if (one) { const 이름 = info.이름 ?? old?.이름; @@ -42,7 +58,7 @@ export function linkCommand( const commands: Command[] = []; for (const s of book.시트) { const was = s.연결 ?? []; - const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1)); + const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1)); const next = s === home && one ? [...rest, one] : rest; if (next.length === was.length && next.every((l, i) => l === was[i])) continue; const copy = structuredClone(s); @@ -55,16 +71,17 @@ export function linkCommand( } /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · - * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ + * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( book: T, key: string, cell: string | null, info: LinkInfo = {}, + replace = false, ): T | null { const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0]; if (!sheet) return null; - const command = linkCommand(book, sheet.id, key, cell, info); + const command = linkCommand(book, sheet.id, key, cell, info, replace); if (command === false) return null; const next = structuredClone(book); const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command]; diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index edecb144f..01a82b2c9 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -177,3 +177,21 @@ opacity: 0; } } + +/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */ +.ss-link-count { + position: absolute; + z-index: 3; + min-width: 14px; + height: 14px; + padding: 0 3px; + border-radius: 7px; + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); + color: var(--color-canvas); + font-size: 10px; + font-weight: 700; + line-height: 14px; + text-align: center; + transform: translate(-100%, 0); + pointer-events: none; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 45e9a1572..978a9c8b4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -1,9 +1,11 @@ /* ============================================================================= * spreadsheet_links.ts (주인 D) * 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). - * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면 - * 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 + - * 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 — + * 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는 + * 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 · + * 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · + * 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 @@ -12,10 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +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"; @@ -59,8 +63,6 @@ export interface Links extends PartHandle { const TEXT = { bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기", free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결", - swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?", - swapOk: "바꿈", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", remove: "목록에서 빼기", @@ -160,16 +162,22 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */ - function link(item: LinkItem, at?: string): void { + /** 알약을 칸에 이음 — `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 rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); - const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; - if (item.단위) one.단위 = item.단위; - if (item.자재) one.자재 = item.자재; - put(sheet, [...rest, one]); + 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(); } @@ -224,7 +232,7 @@ export function mountLinks( }); // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); - b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); + b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); const unlink = (): void => { if (hit) put( @@ -317,22 +325,46 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); - for (const l of ctx.sheet().연결 ?? []) { - const p = parseA1(l.칸); + // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 + 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 tint = el("div", { className: "ss-link-cell", attrs: { "data-key": l.키 } }); + 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`; - for (const d of [tint, dot]) d.style.setProperty("--ss-link-h", String(hues.get(l.키) ?? 0)); - layer.append(tint, dot); - dots.push(tint, dot); + 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 { @@ -358,19 +390,8 @@ export function mountLinks( dragging = null; if (!item || !at) return; e.preventDefault(); - const 칸 = toA1(at.r, at.c); - const other = (ctx.sheet().연결 ?? []).find((l) => l.칸 === 칸 && l.키 !== item.키); - const name = other?.이름 ?? other?.키 ?? ""; - if ( - other && - !(await showConfirmDialog( - TEXT.swap.replace("{cell}", 칸).replace("{name}", name), - TEXT.swapOk, - )) - ) - return back(); ctx.select(selectCell(ctx.sheet(), at.r, at.c), true); - link(item, 칸); + await link(item, toA1(at.r, at.c)); }; // ── 자재 고르기 창 ────────────────────────────────────────────────────── @@ -457,6 +478,7 @@ export function mountLinks( 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, @@ -470,6 +492,7 @@ export function mountLinks( 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(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts index 8012ac0e6..712cdba99 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaCellPick.ts @@ -5,10 +5,13 @@ * 일위대가 [저장] 때 함께 저장(여기는 저장 안 함). * · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에 * · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null) + * · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로) * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import "./M02_MasterTemplete_AreaPanel.css"; @@ -40,8 +43,9 @@ export interface AreaCellPickOptions { label: string; } -/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */ -export type AreaCellPick = { 칸: string | null } | null; +/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) · + * `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */ +export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null; export function pickAreaCell(opts: AreaCellPickOptions): Promise { return new Promise((resolve) => { @@ -76,10 +80,24 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { el("p", { className: "m02-cellpick__hint", text: TEXT.hint }), host, ); - const choose = (): void => (finish({ 칸: picked }), close()); + let book: Workbook | null = null; + const choose = async (): Promise => { + const at = picked; + const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id; + const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : []; + const choice = others.length + ? await askShare( + at, + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice || done) return; + finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at }); + close(); + }; void (async () => { try { - const [{ createSpreadsheet }, book] = await Promise.all([ + const [{ createSpreadsheet }, got] = await Promise.all([ import("../A00_Common/spreadsheet/spreadsheet"), opts.doc ? Promise.resolve(opts.doc) @@ -88,10 +106,11 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { ), ]); if (done) return; - const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; + book = got; + const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸; head.textContent = at ? fill(TEXT.now, at) : TEXT.none; unlink.hidden = !at; - handle = createSpreadsheet(host, structuredClone(book), { + handle = createSpreadsheet(host, structuredClone(got), { readOnly: true, tabs: false, onSelect: (s) => { @@ -103,9 +122,9 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise { const grid = host.querySelector(".ss-grid-host"); grid?.addEventListener("mousedown", () => (moved = false), true); grid?.addEventListener("mouseup", (e) => { - if ((e as MouseEvent).button === 0 && moved) choose(); + if ((e as MouseEvent).button === 0 && moved) void choose(); }); - host.addEventListener("keydown", (e) => e.key === "Enter" && choose()); + host.addEventListener("keydown", (e) => e.key === "Enter" && void choose()); } catch (error) { if (done) return; head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : ""); diff --git a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts index 6ba71c3c2..7219678a7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_AreaLink.ts @@ -8,6 +8,8 @@ import { createButton, createInputField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask"; +import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set"; import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; const TEXT = { @@ -68,8 +70,21 @@ export function openAreaLink(opts: AreaLinkOptions): void { now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`; unlink.disabled = opts.readOnly || !text; }; - const done = (cell: string | null): boolean => { - if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false; + /** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */ + const done = async (cell: string | null): Promise => { + const a1 = cell === null ? null : normA1(cell); + if (cell !== null && !a1) return false; + const book = opts.sheet.getDoc(); + const sheetId = (book.시트.find((s) => s.id === book.활성) ?? book.시트[0])?.id ?? ""; + const others = a1 ? linksAt(book, sheetId, a1, opts.key) : []; + const choice = others.length + ? await askShare( + a1 ?? "", + others.map((l) => l.이름 ?? l.키), + ) + : "함께"; + if (!choice) return true; + if (!opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")) return false; close(); return true; }; @@ -85,9 +100,8 @@ export function openAreaLink(opts: AreaLinkOptions): void { label: TEXT.link, variant: "ghost", size: "sm", - onClick: () => { - if (!done(address.input.value)) address.setError(TEXT.bad); - }, + onClick: () => + void done(address.input.value).then((ok) => ok || address.setError(TEXT.bad)), }); const unlink = createButton({ label: TEXT.unlink, diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index a2ba19fb1..45acbf3be 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -2,7 +2,11 @@ * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; -import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; +import { + linkAreaCell, + linkCommand, + linksAt, +} from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; let pass = 0; @@ -58,10 +62,34 @@ b = book(); run(b, linkCommand(b, "s1", "깔기|㎡", "B1")); eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]); -// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나) +// 같은 칸에 다른 키 — 기본 = 함께(칸 하나에 알약 여럿 · 40 마무리) · 바꾸기 = 그 칸 옛 키 빠짐 b = book(); run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" })); -eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +eq("칸 하나 알약 여럿", b.시트[0].연결, [ + { 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" }, + { 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }, +]); +eq( + "그 칸 다른 알약", + linksAt(b, "s1", " b6 ", "새|m").map((l) => l.키), + ["깔기|㎡"], +); +b = book(); +const undoSwap = run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }, true)); +eq("바꾸기", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]); +run(b, undoSwap); +eq("바꾸기 되돌림", links(b), links(book())); +b = book(); +run(b, linkCommand(b, "s1", "새|m", "B6")); +eq("함께 둔 칸에 바꾸기 = 명령 있음", !!linkCommand(b, "s1", "새|m", "B6", {}, true), true); +eq( + "면적 문서 함께 · 바꾸기", + [ + linkAreaCell(book(), "새|m", "B6")!.시트[0].연결!.length, + linkAreaCell(book(), "새|m", "B6", {}, true)!.시트[0].연결!.length, + ], + [2, 1], +); // 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다 b = book(); diff --git a/resources/tester/test_m02_area_panel.py b/resources/tester/test_m02_area_panel.py index d9a79db7c..e53925a02 100644 --- a/resources/tester/test_m02_area_panel.py +++ b/resources/tester/test_m02_area_panel.py @@ -42,7 +42,10 @@ def test_패널_몸은_보기_갈래_줄_누름은_칸_지정(): def test_칸_지정_모달은_setLink_만(): link = _text("M02_MasterTemplete_AreaLink.ts") - assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link + assert 'opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")' in link + assert ( + "linksAt(book, sheetId, a1, opts.key)" in link and "askShare(" in link + ) # 칸 하나 알약 여럿 assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로 assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link @@ -95,7 +98,9 @@ def test_엑셀_칸_고르기_모달_부품(): pick = _text("M02_MasterTemplete_AreaCellPick.ts") assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick - assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick - assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick + assert "finish({ 칸: null })" in pick + assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick + assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick + assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick assert "handle?.destroy();" in pick assert "saveTemplate" not in pick diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 554ca3aa9..fbb65186c 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,15 +55,18 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 24 + assert result["pass"] == 29 def test_손잡이_setLink_몸(): src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8") - assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src - body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0] + assert ( + "setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;" + in src + ) + body = src.split(" setLink(key, cell, info, replace) {")[1].split(" destroy() {")[0] assert "if (readOnly) return false;" in body - assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body + assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace)" in body assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) assert "loadLinks();" in body @@ -72,7 +75,12 @@ def test_알약_끌어_놓기_색(): """40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기.""" src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src - assert "showConfirmDialog(" in src and "link(item, 칸);" in src + assert "await link(item, toA1(at.r, at.c));" in src + # 칸 하나에 알약 여럿 — 다른 알약 칸이면 함께 · 바꾸기 · 취소 물음(알약 누름 · 끌어 놓기 한 길) + body = src.split("async function link(")[1].split("\n }\n")[0] + assert "linksAt(ctx.book, sheet.id, 칸, item.키)" in body and "askShare(" in body + assert 'choice === "바꾸기"' in body + assert 'className: "ss-link-count"' in src and 'ctx.grid.root.title = names.join(" · ")' in src assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음 assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text(