Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into sub_laptop_4

This commit is contained in:
2026-10-08 13:38:07 +09:00
8 changed files with 82 additions and 25 deletions
@@ -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 {
@@ -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);
+21 -11
View File
@@ -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));
@@ -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 }));
@@ -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)
@@ -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
@@ -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", "Φ"]
+14 -7
View File
@@ -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),