diff --git a/A00_Common/spreadsheet/spreadsheet_link_set.ts b/A00_Common/spreadsheet/spreadsheet_link_set.ts index 0a2db64b1..faea14751 100644 --- a/A00_Common/spreadsheet/spreadsheet_link_set.ts +++ b/A00_Common/spreadsheet/spreadsheet_link_set.ts @@ -70,6 +70,20 @@ export function linkCommand( return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; } +/** 알약 연결을 전부 풀 명령(81-4) — 연결 있는 시트마다 연결을 뺀 `시트통째` · 시트가 둘 이상이면 `묶음` + * (되돌리기 한 번) · 풀 연결이 없으면 null */ +export function clearLinksCommand(book: Workbook): Command | null { + const commands: Command[] = book.시트 + .filter((s) => s.연결?.length) + .map((s) => { + const copy = structuredClone(s); + delete copy.연결; + return { 종류: "시트통째", 시트: copy }; + }); + if (!commands.length) return null; + return commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands }; +} + /** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 · * 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */ export function linkAreaCell( diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 30694cd8e..6c3c131a7 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -89,6 +89,13 @@ color: var(--color-text-secondary); } +/* 알약 줄 끝 [연결 모두 해제] — 점선 알약과 같은 결 · 오른쪽 끝으로 밀어 알약과 떨어뜨림 */ +.ss-link--clear { + margin-left: auto; + border-style: dashed; + color: var(--color-text-secondary); +} + /* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */ .ss-link-dot { position: absolute; diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index e2d31e23a..8643d1b3f 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -14,12 +14,12 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, 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 { clearLinksCommand, linkCommand, linksAt } from "./spreadsheet_link_set"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -73,6 +73,9 @@ const TEXT = { remove: "목록에서 빼기", removed: "목록에서 뺌 — 연결도 풀림", add: "+ 자재 추가", + clearAll: "연결 모두 해제", + clearTip: "이은 알약 연결을 한꺼번에 풂 — 알약 목록은 그대로 · 되돌리기 한 번", + clearAsk: "알약 연결 {n}개를 모두 풀까?", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", none: "맞는 자재 없음", @@ -190,6 +193,16 @@ export function mountLinks( back(); } + /** 알약 줄 끝 [연결 모두 해제](81-4) — 물음 한 번 · 연결만 빠짐(목록 그대로) · 시트 여럿이어도 되돌리기 한 번 */ + async function clearAll(): Promise { + const n = ctx.book.시트.reduce((sum, s) => sum + (s.연결?.length ?? 0), 0); + const command = clearLinksCommand(ctx.book); + if (ctx.readOnly || !command) return; + if (await showConfirmDialog(TEXT.clearAsk.replace("{n}", String(n)), TEXT.clearAll)) + ctx.dispatch(command); + back(); + } + /** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */ function goTo(sheet: Sheet, a1: string): void { const p = parseA1(a1); @@ -353,6 +366,15 @@ export function mountLinks( more.addEventListener("click", () => openPick(more)); pills.push(more); } + if (!ctx.readOnly && map.size) { + const clear = el("button", { + className: "ss-link ss-link--clear", + text: TEXT.clearAll, + attrs: { type: "button", title: TEXT.clearTip }, + }); + clear.addEventListener("click", () => void clearAll()); + pills.push(clear); + } bar.replaceChildren(...pills); if (fresh) bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index 45acbf3be..8a74f2d55 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -3,6 +3,7 @@ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; import { + clearLinksCommand, linkAreaCell, linkCommand, linksAt, @@ -33,6 +34,11 @@ const book = (): Workbook => ], 활성: "s1", }) as unknown as Workbook; +const b0 = (): Workbook => { + const x = book(); + x.시트.forEach((t) => delete t.연결); + return x; +}; const links = (b: Workbook) => b.시트.map((s) => s.연결 ?? null); const run = (b: Workbook, c: Command | null | false) => { if (!c) return null; @@ -129,6 +135,22 @@ eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); +// 연결 모두 해제(81-4) — 연결 있는 시트마다 시트통째 · 둘 넘으면 묶음 · 되돌리기 한 번 · 없으면 null +b = book(); +const wholeBefore = JSON.stringify(b); +const clearAll = clearLinksCommand(b); +eq("모두 해제 = 묶음", clearAll && clearAll.종류, "묶음"); +undo = run(b, clearAll); +eq("모두 해제 뒤 연결 0", links(b), [null, null]); +eq("칸 값은 그대로", b.시트[0].칸, { B1: { 값: 12.5 } }); +if (undo) applyCommand(b, undo); +eq("되돌리기 한 번에 전부 돌아옴", JSON.stringify(b), wholeBefore); +eq("연결 없으면 null", clearLinksCommand(b0()), null); +b = book(); +b.시트[1].연결 = undefined; +const one = clearLinksCommand(b); +eq("시트 하나만 있으면 시트통째 하나", one && one.종류, "시트통째"); + // linkHues — 키로 정해짐 · 차례 무관 · 24 개까지 서로 다름 · 12 개까지 30° 바퀴 · 15° 눈금 const keys = Array.from({ length: 24 }, (_, i) => `알약${i}|㎡`); const h1 = linkHues(keys); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index fbb65186c..45766837a 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 29 + assert result["pass"] == 35 def test_손잡이_setLink_몸(): @@ -90,3 +90,13 @@ def test_알약_끌어_놓기_색(): "--ss-link-l: 31%;" in css and css.count("--ss-link-l: 66%;") == 2 ) # 밝음 · 어두움(설정 · 시스템) assert "ss-link-flash" in css # 반짝임 그대로 + + +def test_연결_모두_해제_단추는_알약_줄_끝_편집_가능일_때만(): + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") + assert "if (!ctx.readOnly && map.size) {" in src # 읽기 전용 · 연결 0 = 단추 없음 + assert 'className: "ss-link ss-link--clear"' in src + body = src.split("async function clearAll")[1].split("function goTo")[0] + assert "showConfirmDialog(TEXT.clearAsk.replace" in body # 물음 한 번 + assert "ctx.dispatch(command)" in body # 묶음 명령 한 번 = 되돌리기 한 번 + assert 'clearAsk: "알약 연결 {n}개를 모두 풀까?"' in src