M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 · 이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 · 수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 · 취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠. - 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이 빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림 - 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 · 테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글 대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91) - 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge) - 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때 초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 · Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이 - 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약 우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리 메뉴에 잘라내기 · 붙여넣기(안내) 더함 - 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면 위로 뒤집음 - Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다 바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells) - 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음 - 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신 시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침 - 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임 - 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지 - 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 → 화면 안쪽 칸에서만(안내) ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 · 어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum (여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry 197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은 저장 안 하고 「다시 열기」 로 버림. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
207 lines
7.8 KiB
TypeScript
207 lines
7.8 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_view_menu.ts (2단계 · 주인 sub_laptop_1)
|
|
* 이동 대화상자(F5 · 주소 · 정의된 이름) · 틀 고정 메뉴(첫 행 · 첫 열 · 현재 칸 기준 · 풀기).
|
|
*
|
|
* 정의된 이름 목록은 `Sheet.이름정의`(3단계 · sub_laptop_4 계약 · 안 이어짐) 가 있으면
|
|
* 그대로 보여 줌 — 없으면 빈 목록(주소 입력만 됨). 이름으로 다른 시트를 못 가리키는
|
|
* 지금 계약대로 활성 시트 안에서만 찾음.
|
|
*
|
|
* 잇는 법(D · sub7) — F5 → `openGoToDialog(ctx)` 를 `spreadsheet_keys.ts` navKey 에 더함.
|
|
* 틀 고정 단추(E 도구 모음)는 눌렀을 때 `openFreezePanesMenu(ctx, button)` 를 부르면 그
|
|
* 단추 아래 작은 메뉴가 뜸.
|
|
* ========================================================================== */
|
|
|
|
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
|
|
import { dialogKeys } from "./spreadsheet_actions";
|
|
import { parseRange, rangeToA1 } from "./spreadsheet_address";
|
|
import type { CellRange } from "./spreadsheet_types";
|
|
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
|
|
|
const TEXT = {
|
|
goToTitle: "이동",
|
|
addressLabel: "참조",
|
|
namedRanges: "정의된 이름",
|
|
ok: "확인",
|
|
cancel: "취소",
|
|
notFound: "주소를 찾을 수 없음",
|
|
freezeFirstRow: "첫 행 고정",
|
|
freezeFirstColumn: "첫 열 고정",
|
|
freezeAtActive: "틀 고정(현재 칸 기준)",
|
|
unfreeze: "틀 고정 풀기",
|
|
};
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// 이동(F5)
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
/** 범위(또는 칸) 글을 읽어 그 범위로 고름을 옮기고 보이게 스크롤함. */
|
|
export function goTo(ctx: SpreadsheetContext, target: CellRange): void {
|
|
const top = { r: target.r0, c: target.c0 };
|
|
ctx.select({ 시트: ctx.selection.시트, 범위: [target], 활성: top, 기준: top }, true);
|
|
}
|
|
|
|
export function openGoToDialog(ctx: SpreadsheetContext): void {
|
|
const overlay = el("div", { className: "ui-confirm" });
|
|
const panel = el("div", { className: "ui-confirm__panel" });
|
|
const close = (): void => overlay.remove();
|
|
overlay.addEventListener("click", (ev) => {
|
|
if (ev.target === overlay) close();
|
|
});
|
|
|
|
const errorMsg = el("p", { text: "" });
|
|
errorMsg.style.cssText = "color:var(--color-danger, red);font-size:0.8rem;min-height:1em;";
|
|
|
|
const addressField = createInputField({
|
|
label: TEXT.addressLabel,
|
|
value: rangeToA1(
|
|
ctx.selection.범위[0] ?? {
|
|
r0: ctx.selection.활성.r,
|
|
c0: ctx.selection.활성.c,
|
|
r1: ctx.selection.활성.r,
|
|
c1: ctx.selection.활성.c,
|
|
},
|
|
),
|
|
});
|
|
|
|
const goToAddress = (): void => {
|
|
const target = parseRange(addressField.input.value);
|
|
if (!target) {
|
|
errorMsg.textContent = TEXT.notFound;
|
|
return;
|
|
}
|
|
goTo(ctx, target);
|
|
close();
|
|
};
|
|
addressField.input.addEventListener("keydown", (ev) => {
|
|
if (ev.key === "Enter") goToAddress();
|
|
});
|
|
|
|
const names = ctx.sheet().이름정의 ?? [];
|
|
const namesList = el("div", {
|
|
children: names.map((n) =>
|
|
createButton({
|
|
label: `${n.이름} (${rangeToA1(n.범위)})`,
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
goTo(ctx, n.범위);
|
|
close();
|
|
},
|
|
}),
|
|
),
|
|
});
|
|
namesList.style.cssText =
|
|
"display:flex;flex-direction:column;gap:2px;max-height:160px;overflow:auto;";
|
|
|
|
panel.append(
|
|
el("h3", { text: TEXT.goToTitle }),
|
|
addressField.root,
|
|
errorMsg,
|
|
...(names.length > 0 ? [el("p", { text: TEXT.namedRanges }), namesList] : []),
|
|
el("div", {
|
|
className: "ui-confirm__actions",
|
|
children: [
|
|
createButton({ label: TEXT.cancel, variant: "ghost", onClick: close }),
|
|
createButton({ label: TEXT.ok, variant: "filled", onClick: goToAddress }),
|
|
],
|
|
}),
|
|
);
|
|
overlay.append(panel);
|
|
document.body.append(overlay);
|
|
dialogKeys(overlay, close);
|
|
addressField.input.focus();
|
|
addressField.input.select();
|
|
}
|
|
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
// 틀 고정
|
|
// ═════════════════════════════════════════════════════════════════════════════
|
|
|
|
export function freezeFirstRow(ctx: SpreadsheetContext): void {
|
|
ctx.dispatch({
|
|
종류: "틀고정",
|
|
시트: ctx.selection.시트,
|
|
행: 1,
|
|
열: ctx.sheet().틀고정?.열 ?? 0,
|
|
});
|
|
}
|
|
|
|
export function freezeFirstColumn(ctx: SpreadsheetContext): void {
|
|
ctx.dispatch({
|
|
종류: "틀고정",
|
|
시트: ctx.selection.시트,
|
|
행: ctx.sheet().틀고정?.행 ?? 0,
|
|
열: 1,
|
|
});
|
|
}
|
|
|
|
/** 활성 칸의 왼위 자리를 기준으로 그 위 · 왼쪽을 고정(활성 칸이 A1 이면 사실상 풀기와 같음). */
|
|
export function freezeAtActiveCell(ctx: SpreadsheetContext): void {
|
|
const at = ctx.selection.활성;
|
|
// 고정 판이 격자를 거의 다 덮으면 나머지가 안 움직임(시트 머리부터 고정) — 화면 안쪽 칸만
|
|
const box = ctx.grid.cellBox(at.r, at.c);
|
|
const g = ctx.grid.root;
|
|
if (box.y > g.clientHeight * 0.8 || box.x > g.clientWidth * 0.8) {
|
|
showToast("고정할 칸이 너무 아래 · 오른쪽 — 화면 안쪽 칸에서 고정할 것", "warning");
|
|
return;
|
|
}
|
|
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: at.r, 열: at.c });
|
|
}
|
|
|
|
export function unfreezePanes(ctx: SpreadsheetContext): void {
|
|
ctx.dispatch({ 종류: "틀고정", 시트: ctx.selection.시트, 행: 0, 열: 0 });
|
|
}
|
|
|
|
/** 도구 모음 단추 아래(또는 화면 가운데, `anchor` 없으면) 뜨는 작은 메뉴 — 누르면 바로 적용하고 닫힘. */
|
|
export function openFreezePanesMenu(ctx: SpreadsheetContext, anchor?: HTMLElement): void {
|
|
const menu = el("div", { className: "ui-confirm__panel" });
|
|
menu.style.cssText =
|
|
"position:fixed;display:flex;flex-direction:column;gap:2px;min-width:180px;z-index:1000;padding:6px;";
|
|
|
|
const items: [string, () => void][] = [
|
|
[TEXT.freezeFirstRow, () => freezeFirstRow(ctx)],
|
|
[TEXT.freezeFirstColumn, () => freezeFirstColumn(ctx)],
|
|
[TEXT.freezeAtActive, () => freezeAtActiveCell(ctx)],
|
|
[TEXT.unfreeze, () => unfreezePanes(ctx)],
|
|
];
|
|
|
|
const close = (): void => {
|
|
menu.remove();
|
|
document.removeEventListener("mousedown", onOutside, true);
|
|
document.removeEventListener("keydown", onEscape);
|
|
};
|
|
const onOutside = (ev: MouseEvent): void => {
|
|
if (!menu.contains(ev.target as Node)) close();
|
|
};
|
|
const onEscape = (ev: KeyboardEvent): void => {
|
|
if (ev.key === "Escape") close();
|
|
};
|
|
|
|
menu.append(
|
|
...items.map(([label, action]) =>
|
|
createButton({
|
|
label,
|
|
variant: "ghost",
|
|
onClick: () => {
|
|
action();
|
|
close();
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
document.body.append(menu);
|
|
if (anchor) {
|
|
const box = anchor.getBoundingClientRect();
|
|
menu.style.top = `${box.bottom + 4}px`;
|
|
menu.style.left = `${box.left}px`;
|
|
} else {
|
|
menu.style.top = "40%";
|
|
menu.style.left = "50%";
|
|
menu.style.transform = "translate(-50%, -50%)";
|
|
}
|
|
setTimeout(() => {
|
|
document.addEventListener("mousedown", onOutside, true);
|
|
document.addEventListener("keydown", onEscape);
|
|
}, 0);
|
|
}
|