From 990e8e673371b67b8ddf465d024d310ea6f0a910 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:28:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(M02):=20=EC=A2=8C=EC=B8=A1=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=91=9C=EC=8B=9C=EA=B0=80=20=EB=91=90=20=EC=A4=84?= =?UTF-8?q?=20=EB=8F=99=EC=8B=9C=EC=97=90=20=EB=82=A8=EB=8D=98=20=EA=B2=83?= =?UTF-8?q?=20=E2=80=94=20=EC=97=B4=EB=A6=B0=20=EA=B2=83=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=EB=A7=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 원인: 좌측 목록 줄은 목록만 · 구조물 열 줄은 열 줄만 다시 그려 이전 컨테이너 고름이 남음 · 카드 [도면 수정] 등은 좌측에 안 알림 - 고침: 편집 칸이 무엇을 열든(좌측 · 카드 · 다시 읽기) `onOpened` 로 좌측 `paint` 한 곳을 부름 — 목록 · 열 줄 · 단추를 함께 그림 · 따로 든 고름 상태(`picked` · 안 쓰던 `select`) 지움 - 확인: ORCA 5180 에서 카드 [도면 · 면적산출 · 재료산출 수정] → 다른 컨테이너 · 같은 컨테이너 다른 줄 · 집계표로 돌아옴 17 걸음 모두 고름 줄 1 개(고치기 전 2 개 재현) · 만든 것 없음 · 탭 닫음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013zGnt85s95baQ72rhKBCJ4 --- .../M02_MasterTemplete_UI_Main.ts | 8 ++++- .../M02_MasterTemplete_UI_Page.ts | 6 +++- .../M02_MasterTemplete_UI_Side.ts | 33 +++++++------------ resources/tester/test_m02_structure_panel.py | 12 +++++++ 4 files changed, 36 insertions(+), 23 deletions(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 207d4218..ae7727bd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -93,7 +93,12 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean { return layer === "project" || layer === "personal" || (layer === "system" && isAdmin); } -export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { +/** `onOpened` — 무엇을 열든(좌측 · 카드 [도면 수정] · 다시 읽기) 연 바로 뒤 부름(좌측 고름 표시) */ +export function createMain( + isAdmin: boolean, + onSaved?: () => void, + onOpened?: () => void, +): MainHandle { const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); @@ -357,6 +362,7 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { drop(); showNotice([]); sel = next; + onOpened?.(); bar(); if (!next) { title.textContent = L("M02_PickTemplate"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index 89350db2..8d20be4c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -17,7 +17,11 @@ import "./M02_MasterTemplete_UI_Style.css"; export async function renderM02MasterTemplate(root: HTMLElement): Promise { const user = await fetchSessionUser().catch(() => null); const isAdmin = user?.role === "SYSTEM_ADMIN"; - const main = createMain(isAdmin, () => void side.refresh()); + const main = createMain( + isAdmin, + () => void side.refresh(), + () => side.paint(), + ); const side = buildSide({ isAdmin, onOpen: (sel) => void main.open(sel), diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 9eec9861..9eb2d162 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -80,8 +80,8 @@ export interface SideOptions { export interface SideHandle { root: HTMLElement; refresh: () => Promise; - /** 좌측 고름 표시만 옮김 — 화면은 열지 않음 */ - select: (kind: Kind, name: string) => void; + /** 고름 표시를 지금 열린 것 한 줄로 — 편집 칸이 무엇을 열든(카드 [도면 수정] 등) 부름 */ + paint: () => void; } /** 서버 이름 규칙과 같게 — 화면에서 먼저 막음 */ @@ -102,8 +102,6 @@ export function buildSide(opt: SideOptions): SideHandle { let numbers: Record = {}; let seq = 0; - let picked: { kind: Kind; name: string } | null = null; - const section = (title: string, body: HTMLElement, ...more: HTMLElement[]): HTMLElement => el("section", { className: "m02-side__group ui-collapsible ui-sidebar-section ui-sidebar-card is-collapsed", @@ -140,9 +138,8 @@ export function buildSide(opt: SideOptions): SideHandle { const root = el("div", { className: "m02-side ui-sidebar-panel", children: [listHost] }); - /** 고름 표시 — `picked`(방금 누름) 이 있으면 그것 · 없으면 지금 열린 것과 비교 */ + /** 고름 표시 — 편집 칸에 지금 열린 것 하나뿐 */ const isPicked = (kind: Kind, name: string): boolean => { - if (picked) return picked.kind === kind && picked.name === name; const cur = opt.getOpen(); return !!cur && cur.layer === layer && cur.kind === kind && cur.name === name; }; @@ -199,21 +196,23 @@ export function buildSide(opt: SideOptions): SideHandle { for (const [kind, b] of editButtons) b.copy.disabled = b.del.disabled = cur?.종류 !== kind; } + /** 고름 표시는 여기서만 — 목록 · 열 줄을 함께 그려야 두 줄이 동시에 안 남음 */ + function paint(): void { + paintList(); + paintColumns(); + paintButtons(); + } + + // 여는 것은 편집 칸(`onOpen`) — 편집 칸이 열 때마다 `paint` 를 부름(페이지가 이음) async function select(info: TemplateInfo): Promise { if (!isOpen(info) && !(await opt.confirmLeave())) return; - picked = null; opt.onOpen({ layer, projectId: null, kind: info.종류, name: info.이름 }); - paintList(); - paintButtons(); } async function selectColumn(col: StructureColumn, kind: ColumnKind): Promise { if (!isPicked(kind, col.id) && !(await opt.confirmLeave())) return; - picked = null; // 제목은 편집 화면이 문서 번호를 앞에 붙임(「SD010101 돌기슭막이 …」) opt.onOpen({ layer, projectId: null, kind, name: col.id, label: columnHead(col) }); - paintColumns(); - paintButtons(); } async function refreshStructure(): Promise { @@ -328,13 +327,5 @@ export function buildSide(opt: SideOptions): SideHandle { } void refresh(); - return { - root, - refresh, - select: (kind, name) => { - picked = { kind, name }; - paintList(); - paintColumns(); - }, - }; + return { root, refresh, paint }; } diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index ac370442..6e832ca0 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -148,6 +148,18 @@ def test_좌측_새로_본떠_지우기는_표_도면_컨테이너_안에_하나 assert "양식: name" in side.split("function copyTemplate")[1] # 복사본 머리 이름 = 새 이름 +def test_좌측_고름_표시는_열린_것_한_줄뿐(): + """카드 [도면 수정] 등 무엇으로 열든 편집 칸이 좌측 `paint` 를 부름 — 목록 · 열 줄을 함께 그림.""" + side = _text(SIDE) + assert "picked" not in side # 따로 든 고름 상태 없음 — 편집 칸의 지금 열린 것만 봄 + assert re.search(r"function paint\(\): void \{\s*paintList\(\);\s*paintColumns\(\);", side) + for fn in ("async function select(", "async function selectColumn("): + body = side.split(fn)[1].split("\n }\n")[0] + assert "paint" not in body, fn # 여기서 한쪽만 그리면 두 줄이 남음 + assert re.search(r"sel = next;\s*onOpened\?\.\(\);", _text(MAIN)) + assert "() => side.paint()" in _text(M02 / "M02_MasterTemplete_UI_Page.ts") + + def test_리본_접힘_모드에서_탭으로_편_것은_밖을_누르면_다시_접힘(): ribbon = _text(ROOT / "B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx") assert "if (collapsed) setPeek(true);" in ribbon # 탭 = 잠깐 펼침(접힘 모드 유지) From 60246df689bd2741903057a0a69b664894874654 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:31:10 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(M02):=20=EB=96=A0=EB=82=98=EA=B8=B0=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EC=B0=BD=20=EC=B7=A8=EC=86=8C=20=EB=92=A4?= =?UTF-8?q?=20=EC=B4=88=EC=A0=90=EC=9D=84=20=EC=8B=9C=ED=8A=B8=20=C2=B7=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=C2=B7=20=EB=8F=84=EB=A9=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「저장 안 한 고침이 있음」 창에서 취소하면 고른 칸 입력 자리(시트) · 집계표 · 도면 iframe 으로 초점 복귀 — 키보드 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index 207d4218..3da28021 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -134,7 +134,13 @@ export function createMain(isAdmin: boolean, onSaved?: () => void): MainHandle { window.addEventListener("beforeunload", guard); const confirmLeave = async (): Promise => { if (!dirty) return true; - if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) return false; + if (!(await showConfirmDialog(L("M02_DiscardConfirm")))) { + // 취소 — 초점을 시트(고른 칸 입력 자리) · 집계표 · 도면으로 돌려 키보드 그대로 + host + .querySelector(".ss-input, .ui-sheet, iframe") + ?.focus({ preventScroll: true }); + return false; + } dirty = false; return true; }; From cd2690450e39be9114604013faa9d1a2707f413d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:33:55 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(spreadsheet):=20=EA=B8=B4=20=EA=B8=80?= =?UTF-8?q?=EC=9D=B4=20=EC=98=86=20=EB=B9=88=20=EC=B9=B8=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EB=84=98=EC=B3=90=20=EB=B3=B4=EC=9D=B4=EA=B2=8C(=EC=97=91?= =?UTF-8?q?=EC=85=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 왼쪽 · 일반 맞춤 글은 오른쪽으로 · 오른쪽 맞춤은 왼쪽으로 · 가운데는 양쪽으로 — 옆 칸이 빌 때만 - 비지 않은 칸 · 병합 칸 · 틀 고정 경계에서 멈춤 · 가운데 맞춤이 한쪽만 막히면 그쪽을 잘라 냄 - 줄 바꿈 켠 칸 · 숫자(####) · 오류는 안 넘침 · 덮은 칸 사이 눈금선 감춤 · 고치는 중에는 활성 칸 넘침 감춤 - 폭 계산은 새 파일 spreadsheet_spill.ts(격자 파일이 이미 700 줄 넘어 따로) · 정확한 글 폭은 글 요소 scrollWidth 를 판마다 한 번에 읽음 - ORCA(M02 면적산출 편집 창) 수치 확인 — 왼 · 오른 · 가운데 넘침 글 잘림 0 · 막힘 · 병합 · 틀 고정 멈춤 · 스크롤 · 확대 뒤 칸에 붙어 있음 · 비지 않은 칸과 겹침 0 · 실제 문서 새 그림 겹침 0 · 크기 시험 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt --- A00_Common/spreadsheet/spreadsheet_grid.css | 15 +++++ A00_Common/spreadsheet/spreadsheet_grid.ts | 48 +++++++++++++++ A00_Common/spreadsheet/spreadsheet_spill.ts | 61 +++++++++++++++++++ .../spreadsheet/helper_spreadsheet_d.cjs | 16 +++++ .../spreadsheet/test_spreadsheet_d_input.py | 11 ++++ 5 files changed, 151 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_spill.ts diff --git a/A00_Common/spreadsheet/spreadsheet_grid.css b/A00_Common/spreadsheet/spreadsheet_grid.css index 3dca48a5..03c72697 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.css +++ b/A00_Common/spreadsheet/spreadsheet_grid.css @@ -248,3 +248,18 @@ .aislo-grid__overlay > * { pointer-events: auto; } + +/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) · + * 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */ +.aislo-cell.is-spill { + overflow: visible; + z-index: 1; +} + +.aislo-grid--gridlines .aislo-cell.is-spill-r { + border-right-color: transparent; +} + +.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text { + visibility: hidden; +} diff --git a/A00_Common/spreadsheet/spreadsheet_grid.ts b/A00_Common/spreadsheet/spreadsheet_grid.ts index 22c28a0c..07eff56e 100644 --- a/A00_Common/spreadsheet/spreadsheet_grid.ts +++ b/A00_Common/spreadsheet/spreadsheet_grid.ts @@ -37,6 +37,7 @@ import { } from "./spreadsheet_headers"; import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt"; import { formatValue } from "./spreadsheet_numfmt"; +import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellRange, @@ -139,6 +140,19 @@ function buildMergeMap(sheet: Sheet): Map { let measureRuler: CanvasRenderingContext2D | null = null; /** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */ +const SPILL_ALIGN: Partial> = { + general: "left", + left: "left", + right: "right", + center: "center", +}; + +function resetSpill(cell: HTMLElement): void { + cell.classList.remove("is-spill", "is-spill-r"); + const t = cell.firstElementChild as HTMLElement | null; + if (t) t.style.width = t.style.marginLeft = t.style.clipPath = ""; +} + function measureText(text: string, font: string): number { measureRuler ??= document.createElement("canvas").getContext("2d"); if (!measureRuler) return text.length * 7; @@ -417,6 +431,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { const sheet = ctx.sheet(); const book = ctx.book; const keep = new Set(); + // 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서) + const slots = new Map>(); + const longs: { + r: number; + c: number; + el: HTMLElement; + own: number; + need: number; + align: SpillAlign; + }[] = []; for (const r of rowsVisible) { for (const c of colsVisible) { const merge = merges.get(`${r},${c}`); @@ -475,6 +499,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { } } setCellText(cellEl, text, display.title || comment || undefined); + resetSpill(cellEl); + if (!merge) { + (slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" }); + const align = SPILL_ALIGN[display.hAlign]; + if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) { + // 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로 + const need = measureText(text, cellFont(style, book, zoom)) + 4; + if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align }); + } + } cellEl.classList.toggle("is-error", display.isError); cellEl.classList.toggle("has-comment", !!comment); cellEl.classList.toggle( @@ -483,6 +517,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle { ); } } + // 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산 + for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5; + for (const l of longs) { + const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align); + if (!sp) continue; + l.el.classList.add("is-spill"); + const t = l.el.firstElementChild as HTMLElement; + t.style.width = `calc(100% + ${sp.left + sp.right}px)`; + t.style.marginLeft = `${-sp.left}px`; + if (sp.clipLeft || sp.clipRight) + t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`; + for (let k = sp.from; k < sp.to; k++) + pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r"); + } poolSweep(pool, keep); } diff --git a/A00_Common/spreadsheet/spreadsheet_spill.ts b/A00_Common/spreadsheet/spreadsheet_spill.ts new file mode 100644 index 00000000..bda33667 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_spill.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * spreadsheet_spill.ts + * 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은 + * 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤. + * 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기. + * ========================================================================== */ + +/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */ +export interface SpillSlot { + w: number; + empty: boolean; +} + +/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */ +export interface Spill { + left: number; + right: number; + from: number; + to: number; + /** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */ + clipLeft: number; + clipRight: number; +} + +export type SpillAlign = "left" | "right" | "center"; + +/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */ +export function spillOf( + slots: Map, + c: number, + own: number, + need: number, + align: SpillAlign, +): Spill | null { + if (need <= own) return null; + let from = c; + let to = c; + const grow = (dir: -1 | 1, want: number): number => { + let got = 0; + for (let k = c + dir; got < want; k += dir) { + const s = slots.get(k); + if (!s || !s.empty) break; + got += s.w; + if (dir < 0) from = k; + else to = k; + } + return got; + }; + if (align === "center") { + const half = (need - own) / 2; + const l = Math.min(grow(-1, half), half); + const r = Math.min(grow(1, half), half); + if (!l && !r) return null; + return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r }; + } + const got = grow(align === "right" ? -1 : 1, need - own); + if (!got) return null; + return align === "right" + ? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 } + : { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 }; +} diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index 505a7af1..e9326499 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -7,6 +7,7 @@ const sel = load(path.join(SS, "spreadsheet_selection.ts")); const ed = load(path.join(SS, "spreadsheet_editor.ts")); const mouse = load(path.join(SS, "spreadsheet_mouse.ts")); const addr = load(path.join(SS, "spreadsheet_address.ts")); +const spill = load(path.join(SS, "spreadsheet_spill.ts")); const sample = require(path.join(SS, "spreadsheet_sample.json")); const s1 = sample.시트[0]; @@ -69,6 +70,21 @@ const out = { (t) => ed.textToCell(t, { 서식: 4 }), ), textToCellNoStyle: ed.textToCell("", {}), + // 긴 글 넘침 — 칸 0..5 폭 50 · 3 은 값 있음 · 칸 2 에서 폭 130 글 + spill: (() => { + const row = (full) => + new Map([0, 1, 2, 3, 4, 5].map((c) => [c, { w: 50, empty: !full.includes(c) }])); + const f = (sp) => sp && [sp.left, sp.right, sp.from, sp.to, sp.clipLeft, sp.clipRight]; + return { + left: f(spill.spillOf(row([2, 3]), 2, 50, 130, "left")), + leftFree: f(spill.spillOf(row([2]), 2, 50, 130, "left")), + right: f(spill.spillOf(row([2]), 2, 50, 130, "right")), + center: f(spill.spillOf(row([2]), 2, 50, 130, "center")), + centerBlocked: f(spill.spillOf(row([2, 3]), 2, 50, 130, "center")), + fits: f(spill.spillOf(row([2]), 2, 50, 40, "left")), + edge: f(spill.spillOf(new Map([[2, { w: 50, empty: false }]]), 2, 50, 130, "left")), + }; + })(), // 친 모양대로 숫자 형식 · 식 대문자 · 닫는 괄호 채움(엑셀) typedFormat: ["50%", "12.5%", "1,234.5", "1,000", "7", "abc", "1e3%"].map(ed.typedFormat), upperFormula: ["sum(a1:b2)+round(c3,0)", "'시트 a'!a1*true", '"abc"&a1', "내이름*2"].map( diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index 74c1c7db..71a8495e 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -128,3 +128,14 @@ def test_upper_formula(r): def test_close_parens(r): assert r["closeParens"] == ["=(1+2)", "=SUM(1,ROUND(2,0))", None, None, '=")"&(1)'] + + +def test_spill(r): + sp = r["spill"] + assert sp["left"] is None # 오른 칸(3)에 값 → 못 넘침 + assert sp["leftFree"] == [0, 100, 2, 4, 0, 0] # 빈 칸 둘 덮음 + assert sp["right"] == [100, 0, 0, 2, 0, 0] + assert sp["center"] == [40, 40, 1, 3, 0, 0] + assert sp["centerBlocked"] == [40, 40, 1, 2, 0, 40] # 오른쪽 막힘 → 오른쪽 반 잘라 냄 + assert sp["fits"] is None + assert sp["edge"] is None # 판 끝(그린 칸 없음) From 96b74094118c5cbdfea6e86d714b52724aa79aea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:35:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(ui):=20=EC=9E=85=EB=A0=A5=EC=B9=B8=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=EC=B9=B8=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=ED=81=AC=EA=B8=B0=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=EA=B0=92=20=E2=80=94=20=EA=B0=99=EC=9D=80=20=EC=A4=84=20?= =?UTF-8?q?=EC=9C=84=EC=95=84=EB=9E=98=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD?= =?UTF-8?q?=EC=9D=B4=20=EC=9E=90=EA=B8=B0=20=EB=AA=A8=EC=96=91=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=A7=81=EC=A0=91=20=EB=B6=88=EB=9F=AC=EC=98=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 높이 변수(보통 42 · 작은 30) · 입력칸 작은 크기(compact) 더함 - M01 조합 미리 보기 입력칸 작은 크기 · M01 로직 계산 칸 작은 높이 - 서식 창 글꼴 이름 칸 28 로(속성 없는 칸이 규칙에서 빠졌음) - M02 가 대신 불러오던 M01 로직 고르기 창 모양 걷어냄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RoyUwuTFiDJfvCfHpvKKzp --- A00_Common/spreadsheet/spreadsheet_format_dialog.css | 1 + M01_MasterData/M01_MasterData_UI_Combo_Pick.ts | 1 + M01_MasterData/M01_MasterData_UI_Combo_Preview.ts | 2 +- M01_MasterData/M01_MasterData_UI_Logic_Style.css | 5 +++++ M02_MasterTemplete/M02_MasterTemplete_Combo.ts | 1 - ui_template/ui_template_elements.ts | 4 +++- ui_template/ui_template_elements_styles.ts | 4 ++++ ui_template/ui_template_theme.css | 4 ++++ 8 files changed, 19 insertions(+), 3 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_format_dialog.css b/A00_Common/spreadsheet/spreadsheet_format_dialog.css index 9d48ee59..c6773afb 100644 --- a/A00_Common/spreadsheet/spreadsheet_format_dialog.css +++ b/A00_Common/spreadsheet/spreadsheet_format_dialog.css @@ -72,6 +72,7 @@ .ss-fmtdlg__row select, .ss-fmtdlg__row input[type="text"], +.ss-fmtdlg__row input:not([type]), .ss-fmtdlg__row input[type="number"], .ss-fmtdlg__code { height: 28px; diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts b/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts index a5e019bb..7567a4d7 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Pick.ts @@ -18,6 +18,7 @@ import { type SubBrief, } from "./M01_MasterData_UI_Logic_Api"; import { tc } from "./M01_MasterData_UI_Combo_Text"; +import "./M01_MasterData_UI_Logic_Style.css"; // 창 겉틀 — M02 에서 열 때도 이 모듈이 불러옴 import "./M01_MasterData_UI_Combo.css"; const opts = (values: string[]): { value: string; text: string }[] => diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 581db717..c5df4e17 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -37,7 +37,7 @@ function inputCell( const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); const field = choices ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) - : createInputField({ type: "text", label: input.이름 }); + : createInputField({ type: "text", label: input.이름, compact: true }); const control = "select" in field ? field.select : field.input; control.value = value; control.setAttribute("data-input", `${key}|${input.이름}`); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Style.css b/M01_MasterData/M01_MasterData_UI_Logic_Style.css index ee37c395..b6cd54c8 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Style.css +++ b/M01_MasterData/M01_MasterData_UI_Logic_Style.css @@ -121,6 +121,11 @@ font-size: var(--text-body-sm); } +/* 한 줄 칸은 작은 고르기칸과 같은 높이 */ +input.m01-logic__input { + height: var(--control-height-sm); +} + .m01-logic__note { min-height: 48px; resize: vertical; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts index 80dddf90..d40ac736 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Combo.ts @@ -18,7 +18,6 @@ import { fetchCombo, type ComboLine } from "../M01_MasterData/M01_MasterData_UI_ import { openLogicPicker } from "../M01_MasterData/M01_MasterData_UI_Combo_Pick"; import { branchCombo, type BranchedCombo } from "./M02_MasterTemplete_Api_Fetch"; import { readColumnBase, reasonOf } from "./M02_MasterTemplete_Combo_Api"; -import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; // [로직 더하기] 창(M01 고르기) 겉틀 — M02 에서 열 때도 import "./M02_MasterTemplete_Combo.css"; const TEXT = { diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 4bd498b4..017b17f1 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -72,6 +72,8 @@ export interface InputFieldOptions { min?: number; max?: number; onInput?: (value: string) => void; + /** 좁은 자리(표 칸·좌측 패널)용 작은 크기 — 작은 고르기칸(compact)과 높이가 같음 */ + compact?: boolean; } export interface InputFieldHandle { @@ -90,7 +92,7 @@ export function createInputField(opts: InputFieldOptions): InputFieldHandle { } const input = el("input", { - className: "ui-input", + className: opts.compact ? "ui-input ui-input--sm" : "ui-input", attrs: { type: opts.type ?? "text", ...(opts.placeholder ? { placeholder: opts.placeholder } : {}), diff --git a/ui_template/ui_template_elements_styles.ts b/ui_template/ui_template_elements_styles.ts index 5a1dd3d1..0eb57923 100644 --- a/ui_template/ui_template_elements_styles.ts +++ b/ui_template/ui_template_elements_styles.ts @@ -99,6 +99,10 @@ const BASE_CSS = ` transition: border-color var(--transition-fast), box-shadow var(--transition-fast); } .ui-input::placeholder { color: var(--color-text-muted); } +/* 입력칸 · 고르기칸 높이는 크기마다 한 값 — 같은 줄에 놓여도 위아래 · 글자 줄이 맞음(date 칸 포함) */ +.ui-input:not(textarea), .ui-select { height: var(--control-height); line-height: 20px; } +.ui-input--sm:not(textarea), .ui-select--sm { height: var(--control-height-sm); line-height: 20px; } +.ui-input--sm { padding: 4px 8px; } .ui-input:focus { outline: none; border-color: var(--color-focus-ring); diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 6ff260f1..bda8dae0 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -154,6 +154,10 @@ --radius-icons: var(--radius-lg); --radius-large: var(--radius-3xl); + /* 입력칸 · 고르기칸 높이 — 크기마다 한 값(같은 줄이면 위아래가 맞음) */ + --control-height: 42px; + --control-height-sm: 30px; + /* --------------------------------------------------------------------------- * 6. 그림자 (Shadows) — 블루틴트, 서브틀 (최대 32px blur / 6% opacity) * ------------------------------------------------------------------------ */ From ee0475d0a49e863f03090b328e0f3b64c9012e8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 22:38:37 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix(ui):=20=EA=B3=B5=EC=9A=A9=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EC=B0=BD=20=EB=8B=AB=ED=9E=90=20=EB=95=8C=20?= =?UTF-8?q?=EC=B4=88=EC=A0=90=EC=9D=84=20=EC=97=B0=20=EC=9E=90=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=BC=20=C2=B7=20Esc=20?= =?UTF-8?q?=EB=A1=9C=20=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - showConfirmDialog: 열기 전 초점을 기억해 취소 · 확인 · Esc 로 닫힐 때 그 요소가 아직 화면에 있으면 되돌림 - 열리면 [취소]에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 · Esc 는 확인 창만 닫음(뒤 창 · 페이지로 안 넘김) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019Kyw9qnkJ94pC1A4PfpDtA --- ui_template/ui_template_elements.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 4bd498b4..40285e34 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -318,17 +318,26 @@ export function showConfirmDialog(message: string, confirmLabel = "확인"): Pro const panel = el("div", { className: "ui-confirm__panel" }); const text = el("p", { className: "ui-confirm__message", text: message }); const actions = el("div", { className: "ui-confirm__actions" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 const done = (result: boolean): void => { overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); resolve(result); }; + const cancel = createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }); actions.append( - createButton({ label: "취소", variant: "ghost", onClick: () => done(false) }), + cancel, createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }), ); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape") return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done(false); + }); panel.append(text, actions); overlay.append(panel); document.body.append(overlay); + cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 }); }