From a6765f84697c7b4ee890c7f63aa3458e5ac10725 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:33:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix(spreadsheet):=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=CE=A6=20=EC=95=9E=EB=B6=99=EC=9D=B4=EA=B8=B0=20=E2=80=94=20?= =?UTF-8?q?=EA=B0=95=EC=A0=9C=20=EA=B8=80=20=EB=82=AB=ED=91=9C=20=EB=92=A4?= =?UTF-8?q?=EC=97=90=20=EB=B6=99=EC=9E=84=20=C2=B7=20=EC=8B=9D=EC=9D=80=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C(81-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- A00_Common/spreadsheet/spreadsheet_toolbar.ts | 32 ++++++++++++------- .../spreadsheet_toolbar_unit_entry.ts | 9 ++++++ .../tester/test_spreadsheet_toolbar_unit.py | 2 +- .../tester/test_spreadsheet_unit_prefix.py | 24 +++++++++++--- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index 3f4ad3eda..5c450eade 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -103,6 +103,15 @@ export function attachUnitToFormat(code: string, unit: string): string { return `"${unit}"` + plain; } +/** 앞 단위를 글 앞에 붙임(「300」 → 「Φ300」) — 글 낫표(강제 글 `'300`)는 맨 앞에 두고 그 뒤에 + * 붙임(`'Φ300` → 칸 값 「Φ300」) · 이미 붙어 있거나 식(`=`)이면 그대로 */ +export function prefixUnit(text: string, unit: string): string { + if (text.startsWith("=")) return text; + const lead = text.startsWith("'") ? "'" : ""; + const body = text.slice(lead.length); + return body.startsWith(unit) ? text : lead + unit + body; +} + /** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 시트 기본(`Sheet.기본.서식`) · 그다음 0. */ function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle { const sheet = ctx.sheet(); @@ -405,13 +414,18 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { if (ctx.editor.editing()) { const active = document.activeElement; if (active instanceof HTMLTextAreaElement && ctx.root.contains(active)) { - const front = UNIT_PREFIX.includes(unit); - const s = front ? 0 : (active.selectionStart ?? active.value.length); - const e = front ? 0 : (active.selectionEnd ?? active.value.length); const was = active.selectionStart ?? active.value.length; - active.value = active.value.slice(0, s) + unit + active.value.slice(e); - const at = front ? was + unit.length : s + unit.length; - active.setSelectionRange(at, at); + if (UNIT_PREFIX.includes(unit)) { + const next = prefixUnit(active.value, unit); + const at = was + next.length - active.value.length; + active.value = next; + active.setSelectionRange(at, at); + } else { + const s = was; + const e = active.selectionEnd ?? active.value.length; + active.value = active.value.slice(0, s) + unit + active.value.slice(e); + active.setSelectionRange(s + unit.length, s + unit.length); + } active.dispatchEvent(new Event("input", { bubbles: true })); } } else if (unitAsFormat.checked) { @@ -422,11 +436,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { const sheet = ctx.sheet(); const key = toA1(r, c); const now = cellToText(sheet.칸[key]); - const text = UNIT_PREFIX.includes(unit) - ? now.startsWith(unit) - ? now - : unit + now // 앞 단위 — 「300」 → 「Φ300」 - : now + unit; + const text = UNIT_PREFIX.includes(unit) ? prefixUnit(now, unit) : now + unit; ctx.dispatch({ 종류: "칸", 시트: sheet.id, 칸: { [key]: textToCell(text, sheet.칸[key]) } }); } renderRecent(pushRecentUnit(unit)); diff --git a/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts b/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts index ef70a469e..eda0ed3e0 100644 --- a/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_toolbar_unit_entry.ts @@ -3,6 +3,7 @@ * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_toolbar_unit.py` 가 읽음) */ import { attachUnitToFormat, + prefixUnit, UNIT_GROUPS, UNIT_PREFIX, } from "../../../A00_Common/spreadsheet/spreadsheet_toolbar"; @@ -36,4 +37,12 @@ eq("Φ 서식 — 빈 코드도 0 기준", attachUnitToFormat("", PHI), '"Φ"0') eq("Φ 서식 — 옛 Φ 바꿈(겹쳐 안 붙음)", attachUnitToFormat('"Φ"0', PHI), '"Φ"0'); eq("Φ 서식 — 뒤 단위와 공존", attachUnitToFormat('0"㎡"', PHI), '"Φ"0"㎡"'); +// 앞 단위 글 — 낫표(강제 글)는 맨 앞에 두고 그 뒤에 붙임 · 이미 붙었으면 그대로(81-5 흠) +eq("Φ 글 — 숫자 앞", prefixUnit("300", PHI), "Φ300"); +eq("Φ 글 — 낫표 뒤", prefixUnit("'300", PHI), "'Φ300"); +eq("Φ 글 — 두 번 안 붙음", prefixUnit("Φ300", PHI), "Φ300"); +eq("Φ 글 — 낫표 뒤 두 번 안 붙음", prefixUnit("'Φ300", PHI), "'Φ300"); +eq("Φ 글 — 빈 칸", prefixUnit("", PHI), "Φ"); +eq("Φ 글 — 식은 그대로", prefixUnit("=A1", PHI), "=A1"); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_toolbar_unit.py b/resources/tester/test_spreadsheet_toolbar_unit.py index 2df38cb7c..5fa31707e 100644 --- a/resources/tester/test_spreadsheet_toolbar_unit.py +++ b/resources/tester/test_spreadsheet_toolbar_unit.py @@ -54,4 +54,4 @@ def test_단위_파이_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 9 + assert result["pass"] == 15 diff --git a/resources/tester/test_spreadsheet_unit_prefix.py b/resources/tester/test_spreadsheet_unit_prefix.py index 9710e049d..17bcae407 100644 --- a/resources/tester/test_spreadsheet_unit_prefix.py +++ b/resources/tester/test_spreadsheet_unit_prefix.py @@ -66,7 +66,23 @@ def test_Φ_길이_묶음_끝_칸_글_편집기(): assert '["UnitGroup_area", ["㎡", "㎥", "m", "㎝", "㎜", "㎞", "Φ"]]' in src body = src.split("function insertUnit")[1].split("function renderRecent")[0] assert "UNIT_PREFIX.includes(unit)" in body - assert ( - "now.startsWith(unit)" in body and "unit + now" in body - ) # 「300」 → 「Φ300」 · 두 번 안 붙음 - assert "const s = front ? 0" in body # 편집 중에도 글 맨 앞 + assert "prefixUnit(now, unit)" in body # 칸 글 — 「300」 → 「Φ300」 · 두 번 안 붙음 + assert "prefixUnit(active.value, unit)" in body # 편집 중에도 같은 규칙 + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_앞_단위_글_낫표_뒤에_붙음(): + """강제 글 낫표(`'300`)는 맨 앞에 두고 그 뒤에 붙임 — 「Φ'300」 처럼 낫표가 가운데 남지 않음.""" + func = re.search(r"^export function prefixUnit[\s\S]*?^}", _text(), re.M) + assert func + script = ( + func.group(0).replace("export ", "").replace(": string", "") + + """ +const f = prefixUnit; +console.log(JSON.stringify([f("300", "Φ"), f("'300", "Φ"), f("Φ300", "Φ"), f("'Φ300", "Φ"), f("=A1", "Φ"), f("", "Φ")])); +""" + ) + out = subprocess.run( + ["node", "-e", script], capture_output=True, text=True, encoding="utf-8", check=True + ) + assert json.loads(out.stdout) == ["Φ300", "'Φ300", "Φ300", "'Φ300", "=A1", "Φ"] From d3a1d1b9b38a5ea9e7221bb6ca523586a2df7a43 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:34:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(B07):=20=EB=8D=A7=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EA=B8=B0=EB=8C=80=EA=B0=92=20=E2=80=94=20?= =?UTF-8?q?85.05=20=EC=9C=A0=EC=9E=85=20=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=81=9D=20=EC=A0=88=ED=86=A0=20=EB=B3=B5=ED=95=A9=EA=B2=BD?= =?UTF-8?q?=EC=82=AC(91-4)=20=EB=94=B0=EB=9D=BC=20x=5Fmin=20=E2=88=9211.62?= =?UTF-8?q?39=20=C2=B7=20=EB=86=92=EC=9D=B4=20=ED=8F=AD=2017.7502?= 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_01EYJJRCfHfdCGNhmXTjQKV7 --- resources/tester/test_b07_cross_overlay_extent.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/tester/test_b07_cross_overlay_extent.py b/resources/tester/test_b07_cross_overlay_extent.py index 86924d51a..c829dbf35 100644 --- a/resources/tester/test_b07_cross_overlay_extent.py +++ b/resources/tester/test_b07_cross_overlay_extent.py @@ -85,7 +85,8 @@ def test_node_bundle_measures_screen_overlay() -> None: assert set(extents) == {0, 85.05} # 측점 키는 0.01 m 로 끊는다(cross_station) x_min, x_max, y_min, y_max = extents[85.05] assert x_max == pytest.approx(13.8216, abs=1e-3) - assert x_min == pytest.approx(-10.4371, abs=1e-3) - assert y_max - y_min == pytest.approx(16.9505, abs=1e-3) + # 유입 집수정 끝 절토 = 암 1:0.4 → 토사 1:1.0 복합경사(91-4 · 옛 한 줄 −10.4371 · 16.9505) + assert x_min == pytest.approx(-11.6239, abs=1e-3) + assert y_max - y_min == pytest.approx(17.7502, abs=1e-3) # 0 m: 노폭 글자(2 mm, 세로 가운데 정렬)가 맨 위 — 글자 반높이 0.1 m 까지 assert extents[0][3] == pytest.approx(879.8831, abs=1e-3) From 9c510b57b1663c334242eb0a2e1337a5a6ce4361 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:36:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(spreadsheet):=20=EB=A9=94=EB=89=B4=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EB=92=A4=20=EC=B4=88=EC=A0=90=20=EA=B2=A9?= =?UTF-8?q?=EC=9E=90=EB=A1=9C=20=C2=B7=20=ED=8E=BC=EC=B9=9C=20=ED=95=98?= =?UTF-8?q?=EC=9C=84=20=EB=A9=94=EB=89=B4=EA=B0=80=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=B0=96=EC=9D=B4=EB=A9=B4=20=EC=98=AC=EB=A6=BC(81-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - keepGridFocus — 항목을 누른 뒤 초점이 문서로 빠지면 격자 입력칸으로(Ctrl+Z 등 키가 바로 먹음) - ui_template_context_menu — 하위(▸)를 펼칠 때도 화면 안으로 당김(fit 한 곳) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- A00_Common/spreadsheet/spreadsheet_menu.ts | 2 ++ .../spreadsheet/spreadsheet_menu_items.ts | 12 +++++++++++ ui_template/ui_template_context_menu.ts | 21 ++++++++++++------- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_menu.ts b/A00_Common/spreadsheet/spreadsheet_menu.ts index a2af5138f..c47a50612 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu.ts @@ -23,6 +23,7 @@ import { colMoreItems, copyCut, copyOnlyItems, + keepGridFocus, pasteItems, rowInsertItems, rowMoreItems, @@ -44,6 +45,7 @@ export function attachMenu(ctx: SpreadsheetContext): PartHandle { const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); + keepGridFocus(ctx, menu.element); // 메뉴 동작 뒤 Ctrl+Z 등 키가 격자로 ctx.cellMenuExtra ??= () => []; // 칸 메뉴 있음 — 조건 만들기가 이 끝에 붙음 function isMerged(range: CellRange): boolean { diff --git a/A00_Common/spreadsheet/spreadsheet_menu_items.ts b/A00_Common/spreadsheet/spreadsheet_menu_items.ts index 649f8ed01..cfb14d7ce 100644 --- a/A00_Common/spreadsheet/spreadsheet_menu_items.ts +++ b/A00_Common/spreadsheet/spreadsheet_menu_items.ts @@ -83,6 +83,18 @@ function back(ctx: SpreadsheetContext): void { (ctx.editor as Editor).focus?.(); } +/** 항목을 누른 뒤 초점이 문서(body)에 떨어졌으면 격자로 — 창을 띄운 동작은 그 창이 초점을 가짐 */ +export function keepGridFocus(ctx: SpreadsheetContext, menuRoot: HTMLElement): void { + menuRoot.addEventListener( + "click", + () => + setTimeout(() => { + if (document.activeElement === document.body) back(ctx); + }), + true, + ); +} + /** 잘라내기 · 복사 — 초점을 격자로 되돌린 뒤 불러야 부품의 copy · cut 듣기가 받음(빈 복사 막음) */ export function copyCut(ctx: SpreadsheetContext, kind: "cut" | "copy"): void { back(ctx); diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 3b787d4d3..4edc8545b 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -113,6 +113,18 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { return entry; } + /** 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝) — 열 때 · 하위 메뉴를 펼칠 때 */ + function fit(): void { + const x = parseFloat(element.style.left); + const y = parseFloat(element.style.top); + const box = element.getBoundingClientRect(); + const view = element.ownerDocument.defaultView ?? window; // 새 창에 옮겨진 패널(54-35) + const dx = Math.max(0, box.right - view.innerWidth + 4); + const dy = Math.max(0, box.bottom - view.innerHeight + 4); + if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`; + if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`; + } + return { element, open(x, y, items) { @@ -152,6 +164,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { .forEach((other) => (other.textContent = other.textContent!.replace("▾", "▸"))); body.hidden = !willOpen; head.textContent = `${item.label} ${willOpen ? "▾" : "▸"}`; + fit(); // 펼친 하위가 화면 아래로 넘치면 메뉴를 올림 }); element.append(head, body); }); @@ -160,13 +173,7 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { element.style.left = `${x}px`; element.style.top = `${y}px`; element.hidden = false; - // 화면 밖으로 나가면 안쪽으로 당김(오른쪽 · 아래 끝에서 연 메뉴) - const box = element.getBoundingClientRect(); - const view = element.ownerDocument.defaultView ?? window; // 새 창에 옮겨진 패널(54-35) - const dx = Math.max(0, box.right - view.innerWidth + 4); - const dy = Math.max(0, box.bottom - view.innerHeight + 4); - if (dx) element.style.left = `${Math.max(x - dx, x - box.left)}px`; - if (dy) element.style.top = `${Math.max(y - dy, y - box.top)}px`; + fit(); }, close, contains: (target) => target instanceof Node && element.contains(target),