fix(sheet): 머리 칸 / 로 층 나눔 · 줄 머리 칸 고정 · 줄 머리 칸 폭 자동 (M02 재검증)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tjoit7rxvpLMM7cafeVTo1
This commit is contained in:
@@ -79,3 +79,36 @@ def test_page_rows_from_doc_and_master_single_page():
|
||||
def test_empty_doc_still_one_page_with_totals():
|
||||
plan = _plan({"양식": "시험", "종류": "표", "판": 1, "열": [], "줄": []})["project"]
|
||||
assert plan == [{"ids": [], "first": 1, "totals": True}]
|
||||
|
||||
|
||||
HEAD_SCRIPT = """
|
||||
const { setHeadLabel } = await import(process.argv[1]);
|
||||
const out = JSON.parse(process.argv[2]).map(([head, level, text, depth]) => {
|
||||
setHeadLabel(head, level, text, depth);
|
||||
return head;
|
||||
});
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def test_head_label_slash_splits_layers():
|
||||
cases = [
|
||||
[["새 열", None, None], 0, "관공/Φ800/관매설", 3],
|
||||
[["새 열", None, None], 0, "관공/Φ800", 3], # 적게 → 남은 층 합침
|
||||
[["새 열", None, None], 0, "a/b/c/d", 3], # 많으면 끝 층에 이어 붙임
|
||||
[["종류", "공법", "규격"], 1, "이름", 3], # / 없음 → 그 층만
|
||||
[["새 열"], 0, "가/나", 3], # 모자란 층은 채움
|
||||
]
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e",
|
||||
HEAD_SCRIPT, OPS.as_uri(), json.dumps(cases, ensure_ascii=False)],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == [
|
||||
["관공", "Φ800", "관매설"],
|
||||
["관공", "Φ800", None],
|
||||
["a", "b", "c/d"],
|
||||
["종류", "이름", "규격"],
|
||||
["가", "나", None],
|
||||
]
|
||||
|
||||
@@ -74,10 +74,10 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-sheet__gutter {
|
||||
.ui-sheet__table .ui-sheet__gutter {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
z-index: 4;
|
||||
background: var(--color-surface) !important;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 500;
|
||||
@@ -176,6 +176,17 @@
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.ui-sheet__headhint {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.ui-sheet__resize {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -20,7 +20,9 @@ import {
|
||||
type SheetMode,
|
||||
setCell,
|
||||
setColumnWidth,
|
||||
setHeadLabel,
|
||||
} from "./ui_template_sheet_ops";
|
||||
import { headDepth } from "./ui_template_sheet_header";
|
||||
import { recalcSheet } from "./ui_template_sheet_recalc";
|
||||
import {
|
||||
KEY_UNIT,
|
||||
@@ -123,6 +125,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
|
||||
// ── 그리기 · 고름 ─────────────────────────────────────────────────
|
||||
const render = (): void => {
|
||||
root.querySelector(".ui-sheet__headhint")?.remove();
|
||||
state.result = recalcSheet(state.doc);
|
||||
const { scrollLeft, scrollTop } = scroll;
|
||||
scroll.replaceChildren(renderSheet(state));
|
||||
@@ -200,13 +203,17 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio
|
||||
const level = Number(th.dataset.level);
|
||||
if (start < 0 || Number.isNaN(level)) return;
|
||||
const span = state.doc.열.slice(start, start + (th as HTMLTableCellElement).colSpan);
|
||||
const depth = headDepth(state.doc.열, state.doc.층);
|
||||
const tip = el("div", { className: "ui-sheet__headhint", text: st("Head_Hint") });
|
||||
const at = th.getBoundingClientRect();
|
||||
tip.style.left = `${at.left}px`;
|
||||
tip.style.top = `${at.bottom + 2}px`;
|
||||
root.append(tip);
|
||||
startEdit(th, th.textContent ?? "", (value) => {
|
||||
for (const col of span) {
|
||||
while (col.머리.length <= level) col.머리.push(null);
|
||||
col.머리[level] = value.trim();
|
||||
}
|
||||
for (const col of span) setHeadLabel(col.머리, level, value, depth);
|
||||
changed();
|
||||
});
|
||||
th.querySelector("input")?.addEventListener("blur", () => tip.remove());
|
||||
};
|
||||
|
||||
// ── 사건 ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -76,7 +76,7 @@ export function formulaLabel(
|
||||
|
||||
let ruler: CanvasRenderingContext2D | null = null;
|
||||
|
||||
function textWidth(text: string, font: string): number {
|
||||
export function textWidth(text: string, font: string): number {
|
||||
ruler ??= document.createElement("canvas").getContext("2d");
|
||||
if (!ruler) return text.length * 13;
|
||||
ruler.font = font;
|
||||
|
||||
@@ -131,6 +131,22 @@ export function setCell(doc: SheetDoc, rowId: string, col: SheetColumn, text: st
|
||||
row.값[col.id] = typeof value === "number" && String(value) !== plain ? plain : value;
|
||||
}
|
||||
|
||||
/** 머리 칸 고치기 — 글에 `/` 가 있으면 `level` 층부터 아래로 나눠 넣음(층보다 많으면 끝 층에 이어 붙임) ·
|
||||
* 층보다 적게 쓰면 남은 층은 `null`(위 칸과 합침). `/` 가 없으면 그 층 글만 바꿈. */
|
||||
export function setHeadLabel(head: (string | null)[], level: number, text: string, depth: number) {
|
||||
while (head.length <= level) head.push(null);
|
||||
const parts = text.split("/").map((part) => part.trim());
|
||||
if (parts.length < 2) {
|
||||
head[level] = text.trim();
|
||||
return;
|
||||
}
|
||||
const room = Math.max(1, depth - level);
|
||||
const fit =
|
||||
parts.length > room ? [...parts.slice(0, room - 1), parts.slice(room - 1).join("/")] : parts;
|
||||
while (head.length < depth) head.push(null);
|
||||
for (let k = level; k < depth; k += 1) head[k] = fit[k - level] ?? null;
|
||||
}
|
||||
|
||||
export function setColumnWidth(doc: SheetDoc, id: string, width: number): void {
|
||||
doc.보기 ??= {};
|
||||
(doc.보기.열너비 ??= {})[id] = Math.round(width);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { headDepth, layoutHead } from "./ui_template_sheet_header";
|
||||
import { columnNames, formulaLabel, headMinWidths } from "./ui_template_sheet_labels";
|
||||
import { columnNames, formulaLabel, headMinWidths, textWidth } from "./ui_template_sheet_labels";
|
||||
import {
|
||||
cellEditable,
|
||||
isBoundColumn,
|
||||
@@ -24,7 +24,8 @@ import type { SheetColumn, SheetDoc, SheetResult, SheetRow } from "./ui_template
|
||||
|
||||
export const KEY_UNIT = "s:unit";
|
||||
const MASTER_ROWS = ["s:formula", "s:desc", "s:price"] as const;
|
||||
const GUTTER = 64;
|
||||
const GUTTER_MIN = 64;
|
||||
const GUTTER_MAX = 140;
|
||||
const DEFAULT_WIDTH = { 수: 72, 글: 96 };
|
||||
|
||||
export interface RenderState {
|
||||
@@ -85,7 +86,23 @@ export function renderSheet(state: RenderState): HTMLElement {
|
||||
const depth = headDepth(cols, doc.층);
|
||||
const head = layoutHead(cols, depth);
|
||||
const names = columnNames(cols);
|
||||
const mins = headMinWidths(cols, depth, headFont());
|
||||
const font = headFont();
|
||||
const mins = headMinWidths(cols, depth, font);
|
||||
// 줄 머리 칸 폭 — 층 이름 · 단위 · 식 · 들어갈 것 · 일위대가 · 합계 이름이 안 잘리게
|
||||
const GUTTER = Math.min(
|
||||
GUTTER_MAX,
|
||||
Math.max(
|
||||
GUTTER_MIN,
|
||||
...[
|
||||
...(doc.층 ?? []),
|
||||
st("Row_Unit"),
|
||||
st("Row_Formula"),
|
||||
st("Row_Desc"),
|
||||
st("Row_Unit_Price"),
|
||||
...(doc.합계줄 ?? []).map((t) => t.이름),
|
||||
].map((label) => Math.ceil(textWidth(label, font)) + 16),
|
||||
),
|
||||
);
|
||||
const errors = new Map(state.result.오류.map((e) => [`d:${e.줄}|${e.열}`, e.까닭]));
|
||||
for (const e of state.result.오류) errors.set(`t:${e.줄}|${e.열}`, e.까닭);
|
||||
const frozen = Math.min(doc.보기?.틀고정?.열 ?? 0, cols.length);
|
||||
|
||||
@@ -21,6 +21,10 @@ const TEXT = {
|
||||
Kind_Hand: ["손 입력", "Manual"],
|
||||
Kind_Spread: ["값마다 열", "Per value"],
|
||||
Page: ["쪽", "page"],
|
||||
Head_Hint: [
|
||||
"/ 로 이으면 층으로 나뉨 (예 관공/Φ800/관매설) · 적게 쓰면 남은 층은 위 칸과 합침",
|
||||
"Use / to split into layers (e.g. A/B/C) · fewer parts merge the rest with the cell above",
|
||||
],
|
||||
Error: ["#오류", "#ERR"],
|
||||
Hint: [
|
||||
"칸을 누르고 바로 치거나 Enter · F2 로 고침 · = 로 시작하면 그 칸만 식 · 화살표 · Tab 으로 옮김 · Delete 로 비움",
|
||||
|
||||
Reference in New Issue
Block a user