diff --git a/A00_Common/spreadsheet/spreadsheet_actions.ts b/A00_Common/spreadsheet/spreadsheet_actions.ts new file mode 100644 index 00000000..4c8fa631 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_actions.ts @@ -0,0 +1,139 @@ +/* ============================================================================= + * spreadsheet_actions.ts (주인 D) + * 단축키 표(sub1 `spreadsheet_shortcuts.ts`) 「안됨」 잇기 + 3차 부품 메뉴 자리([데이터 ▾] 에 붙임). + * Ctrl+Shift++ · Ctrl+- 칸 넣기 · 지우기(행 · 열 통째면 바로 · 아니면 sub1 창) · F5 · Ctrl+G 이동 · + * Ctrl+9/0 숨김 · Ctrl+Shift+9/0 풀기 · Ctrl+B/I/U/5 글꼴 뒤집기 · Ctrl+Shift+& 바깥 테두리 · + * Ctrl+Shift+_ 테두리 없앰 · Ctrl+; 오늘 날짜 · Ctrl+PageDown/PageUp 시트 · Shift+F11 새 시트 · + * Shift+Alt+→/← 행 · 열 묶기 · 풀기(C 윤곽 +/- 가 접음). + * 여기는 키 · 메뉴 → 이름만 · 몸통은 `spreadsheet_actions_run.ts`(늦게 — 크기 예산). + * ========================================================================== */ + +import type { MapContextMenuItem } from "@ui/ui_template_context_menu"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +const TEXT = { + goTo: "이동…(F5)", + freeze: "틀 고정", + freezeRow: "첫 행 고정", + freezeCol: "첫 열 고정", + freezeAt: "지금 칸 기준 고정", + unfreeze: "틀 고정 풀기", + insert: "칸 넣기…(Ctrl+Shift++)", + remove: "칸 지우기…(Ctrl+-)", + split: "텍스트 나누기…", + dedupe: "중복된 항목 제거…", + series: "연속 데이터 채우기…", + group: "묶기(Shift+Alt+→)", + ungroup: "묶음 풀기(Shift+Alt+←)", + outline: "윤곽", +}; + +export type Act = + | "goTo" + | "insert" + | "remove" + | "hideRows" + | "hideCols" + | "showRows" + | "showCols" + | "group" + | "ungroup" + | "outline" + | "clearBorders" + | "today" + | "next" + | "prev" + | "newSheet" + | "freezeRow" + | "freezeCol" + | "freezeAt" + | "unfreeze" + | "split" + | "dedupe" + | "series" + | `flip:${"굵게" | "기울임" | "밑줄" | "취소선"}`; + +/** Ctrl+글자(e.code) → 이름 */ +const CTRL: Record = { + KeyB: "flip:굵게", + KeyI: "flip:기울임", + KeyU: "flip:밑줄", + Digit5: "flip:취소선", + KeyG: "goTo", + Minus: "remove", + NumpadSubtract: "remove", + NumpadAdd: "insert", + Digit9: "hideRows", + Digit0: "hideCols", + Semicolon: "today", // ponytail: 시각(Ctrl+Shift+;)은 numfmt 시간 형식이 오면 + PageDown: "next", + PageUp: "prev", +}; +/** Ctrl+Shift+글자 → 이름 */ +const CTRL_SHIFT: Record = { + Equal: "insert", + NumpadAdd: "insert", + Minus: "clearBorders", + Digit9: "showRows", + Digit0: "showCols", + Digit7: "outline", + PageDown: "next", + PageUp: "prev", +}; + +/** body 에 뜬 창(.ui-confirm)이 다 닫히면 초점을 격자로 — 창을 연 바로 뒤에 부름 */ +export function watchClose(back: () => void): void { + const w = new MutationObserver(() => { + if (document.querySelector("body > .ui-confirm, body > .ui-confirm__panel")) return; + w.disconnect(); + back(); + }); + w.observe(document.body, { childList: true }); +} + +function pick(e: KeyboardEvent): Act | undefined { + const ctrl = e.ctrlKey || e.metaKey; + if (e.altKey) + return e.shiftKey && !ctrl + ? ({ ArrowRight: "group", ArrowLeft: "ungroup" } as Record)[e.key] + : undefined; + if (ctrl) return (e.shiftKey ? CTRL_SHIFT : CTRL)[e.code]; + if (e.key === "F5" && !e.shiftKey) return "goTo"; + if (e.key === "F11" && e.shiftKey) return "newSheet"; +} + +export interface Actions { + key(e: KeyboardEvent): boolean; + items: MapContextMenuItem[]; +} + +export function mountActions(ctx: SpreadsheetContext, back: () => void): Actions { + const go = (act: Act): void => + void import("./spreadsheet_actions_run").then((m) => m.run(ctx, back, act)); + const item = (label: string, act: Act): [string, () => void] => [label, () => go(act)]; + return { + key(e) { + const act = pick(e); + if (act) go(act); + return !!act; + }, + items: [ + item(TEXT.goTo, "goTo"), + { + label: TEXT.freeze, + children: [ + item(TEXT.freezeRow, "freezeRow"), + item(TEXT.freezeCol, "freezeCol"), + item(TEXT.freezeAt, "freezeAt"), + item(TEXT.unfreeze, "unfreeze"), + ], + }, + item(TEXT.insert, "insert"), + item(TEXT.remove, "remove"), + item(TEXT.split, "split"), + item(TEXT.dedupe, "dedupe"), + item(TEXT.series, "series"), + { label: TEXT.outline, children: [item(TEXT.group, "group"), item(TEXT.ungroup, "ungroup")] }, + ], + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_actions_run.ts b/A00_Common/spreadsheet/spreadsheet_actions_run.ts new file mode 100644 index 00000000..44d002e6 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_actions_run.ts @@ -0,0 +1,170 @@ +/* ============================================================================= + * spreadsheet_actions_run.ts (주인 D) + * `spreadsheet_actions.ts` 단축키 · 메뉴가 부르는 몸통 — 처음 누를 때 늦게 불러옴(크기 예산). + * ========================================================================== */ + +import { toA1 } from "./spreadsheet_address"; +import { watchClose, type Act } from "./spreadsheet_actions"; +import { cellStyleAt } from "./spreadsheet_cellstyle"; +import { LAST_C, LAST_R } from "./spreadsheet_selection"; +import type { CellRange, CellStyle, Command } from "./spreadsheet_types"; +import type { SpreadsheetContext } from "./spreadsheet_view_types"; + +/** 엑셀 날짜 일련번호 0 = 1899-12-30 */ +const EPOCH = Date.UTC(1899, 11, 30); + +export function run(ctx: SpreadsheetContext, back: () => void, act: Act): void { + const insert = () => import("./spreadsheet_insert_dialog"); + const view = () => import("./spreadsheet_view_menu"); + const tools = () => import("./spreadsheet_data_tools"); + /** 늦게 불러 창을 열고 닫히면 격자로 */ + const dialog = (load: () => Promise, open: (m: M) => void) => + void load().then((m) => (open(m), watchClose(back))); + const freezeBy = (pick: (m: Awaited>) => void) => + void view().then((m) => (pick(m), back())); + + const range = (): CellRange => ctx.selection.범위[0]; + const fullRows = (g: CellRange) => g.c0 === 0 && g.c1 === LAST_C; + const fullCols = (g: CellRange) => g.r0 === 0 && g.r1 === LAST_R; + const span = (a: number, b: number) => Array.from({ length: b - a + 1 }, (_, i) => a + i); + const id = () => ctx.selection.시트; + + /** 칸 넣기 · 지우기 — 행 · 열 통째 고름이면 엑셀처럼 창 없이 바로 */ + function cells(add: boolean): void { + const g = range(); + if (fullRows(g) || fullCols(g)) { + const rows = fullRows(g); + ctx.dispatch({ + 종류: rows ? (add ? "행넣기" : "행지우기") : add ? "열넣기" : "열지우기", + 시트: id(), + at: rows ? g.r0 : g.c0, + 수: rows ? g.r1 - g.r0 + 1 : g.c1 - g.c0 + 1, + }); + } else + dialog(insert, (m) => (add ? m.openInsertCellsDialog(ctx) : m.openDeleteCellsDialog(ctx))); + } + + function hide(rows: boolean, 숨김: boolean): void { + const g = range(); + const 번호 = rows ? span(g.r0, g.r1) : span(g.c0, g.c1); + ctx.dispatch({ 종류: "숨김", 시트: id(), 축: rows ? "행" : "열", 번호, 숨김 }); + } + + function group(on: boolean): void { + const g = range(); + const cols = fullCols(g) && !fullRows(g); + const 번호 = cols ? span(g.c0, g.c1) : span(g.r0, g.r1); + const 묶음 = on ? { 층: 1 } : null; + ctx.dispatch({ 종류: "묶음설정", 시트: id(), 축: cols ? "열" : "행", 번호, 묶음 }); + } + + function flip(key: keyof CellStyle): void { + const { r, c } = ctx.selection.활성; + const on = !!cellStyleAt(ctx.book, ctx.sheet(), r, c)[key]; + ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { [key]: !on } }); + } + + /** 바깥 테두리(가는 선) — 가장자리 칸마다 지금 테두리에 변을 더함 */ + function outline(): void { + const g = range(); + const n = (g.r1 - g.r0 + 1) * (g.c1 - g.c0 + 1); + // ponytail: 가장자리 칸만 셈 · 행 · 열 통째(백만 칸)는 안 함 — 쓴 범위로 자르면 풀림 + if (fullRows(g) || fullCols(g) || n > 1e5) return; + const cmds: Command[] = []; + for (let r = g.r0; r <= g.r1; r++) + for (let c = g.c0; c <= g.c1; c++) { + const sides = [ + r === g.r0 && "위", + r === g.r1 && "아래", + c === g.c0 && "왼", + c === g.c1 && "오른", + ].filter(Boolean) as ("위" | "아래" | "왼" | "오른")[]; + if (!sides.length) continue; + const 테두리 = { ...cellStyleAt(ctx.book, ctx.sheet(), r, c).테두리 }; + for (const s of sides) 테두리[s] = { 선: "thin" }; + cmds.push({ + 종류: "서식", + 시트: id(), + 범위: [{ r0: r, c0: c, r1: r, c1: c }], + 바꿀: { 테두리 }, + }); + } + ctx.dispatch({ 종류: "묶음", 명령: cmds }); + } + + function today(): void { + const d = new Date(); + const 값 = Math.round((Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) - EPOCH) / 864e5); + const { r, c } = ctx.selection.활성; + const key = toA1(r, c); + const 서식 = ctx.sheet().칸[key]?.서식; + ctx.dispatch({ + 종류: "묶음", + 명령: [ + { 종류: "칸", 시트: id(), 칸: { [key]: 서식 === undefined ? { 값 } : { 값, 서식 } } }, + { + 종류: "서식", + 시트: id(), + 범위: [{ r0: r, c0: c, r1: r, c1: c }], + 바꿀: { 형식: "yyyy-mm-dd" }, + }, + ], + }); + } + + /** 다음 · 이전 보이는 시트 */ + function sheetStep(d: number): void { + const list = ctx.book.시트.filter((s) => !s.숨김); + const i = list.findIndex((s) => s.id === id()); + const to = list[i + d]; + if (to) ctx.showSheet(to.id); + } + + const goTo = () => dialog(view, (m) => m.openGoToDialog(ctx)); + + function clearBorders(): void { + ctx.dispatch({ 종류: "서식", 시트: id(), 범위: ctx.selection.범위, 바꿀: { 테두리: null } }); + } + + if (act.startsWith("flip:")) return flip(act.slice(5) as keyof CellStyle); + switch (act) { + case "goTo": + return goTo(); + case "insert": + case "remove": + return cells(act === "insert"); + case "hideRows": + case "hideCols": + case "showRows": + case "showCols": + return hide(act.endsWith("Rows"), act.startsWith("hide")); + case "group": + case "ungroup": + return group(act === "group"); + case "outline": + return outline(); + case "clearBorders": + return clearBorders(); + case "today": + return today(); + case "next": + case "prev": + return sheetStep(act === "next" ? 1 : -1); + case "newSheet": + return ctx.root.querySelector(".ss-tabs__add")?.click(); + case "freezeRow": + return freezeBy((m) => m.freezeFirstRow(ctx)); + case "freezeCol": + return freezeBy((m) => m.freezeFirstColumn(ctx)); + case "freezeAt": + return freezeBy((m) => m.freezeAtActiveCell(ctx)); + case "unfreeze": + return freezeBy((m) => m.unfreezePanes(ctx)); + case "split": + return dialog(tools, (m) => m.openTextToColumnsDialog(ctx)); + case "dedupe": + return dialog(tools, (m) => m.openRemoveDuplicatesDialog(ctx)); + case "series": + return dialog(tools, (m) => m.openFillSeriesDialog(ctx)); + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_data_menu.ts b/A00_Common/spreadsheet/spreadsheet_data_menu.ts index 431cd309..a5d55e61 100644 --- a/A00_Common/spreadsheet/spreadsheet_data_menu.ts +++ b/A00_Common/spreadsheet/spreadsheet_data_menu.ts @@ -3,7 +3,8 @@ * 데이터 단추 · 메뉴 잇기 — 수식 입력줄 앞 [↑] [↓] [필터] [데이터 ▾]. * 정렬 · 자동 필터(sub3 `spreadsheet_sort_dialog` · `spreadsheet_filter`) · * 조건부 서식 · 데이터 유효성 · 이름 관리 · 시트 숨김/탭 색(sub4 패널 — 부품 오른위 창에 띄움) · 눈금선 · - * 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=). + * 수식 분석(sub3 — 선행 · 종속 추적 화살표 · 화살표 지우기 · 수식 계산 · 오류 ⚠ 풍선) · 자동 합계(Σ · Alt+=) · + * 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽(`spreadsheet_actions.ts`). * 유효성 목록 칸은 고르면 칸 옆 ▼ · Alt+↓ 로 값 목록(필터 머리 칸이면 필터 목록이 먼저). * 누를 때만 쓰는 창(정렬 · 조건부 서식 · 시트)은 `import()` 로 늦게 — 크기 예산(열 때 받는 조각). * ========================================================================== */ @@ -13,11 +14,13 @@ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_c import { el, showToast } from "@ui/ui_template_elements"; import { parseRange, rangeToA1, toA1 } from "./spreadsheet_address"; import { autoSumRange } from "./spreadsheet_autosum"; -import { attachErrorTip } from "./spreadsheet_errcheck"; -import { openEvalSteps } from "./spreadsheet_eval_steps"; -import { attachFilter, toggleFilter } from "./spreadsheet_filter"; +import { isError } from "./spreadsheet_eval"; +import type { FilterHandle } from "./spreadsheet_filter"; import { createNameManagerPanel } from "./spreadsheet_names"; -import { attachTrace } from "./spreadsheet_trace"; +import { createSheetExtraPanel } from "./spreadsheet_sheet_extra"; +import { mountActions } from "./spreadsheet_actions"; +import type { ErrorTipHandle } from "./spreadsheet_errcheck"; +import type { TraceHandle } from "./spreadsheet_trace"; import { createValidationDropdown, createValidationPanel, @@ -52,19 +55,48 @@ const TEXT = { export interface DataMenu { /** 수식 입력줄 앞에 둘 단추들 */ buttons: HTMLElement[]; - keys: Pick; + keys: Pick; part: PartHandle; } export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMenu { - const filter = attachFilter(ctx); - const trace = attachTrace(ctx); - const tip = attachErrorTip(ctx, trace); + // 자동 필터는 시트에 필터가 있거나 켤 때 불러옴(크기 예산) + let filter: FilterHandle | null = null; + let filterMod: Promise | null = null; + const loadFilter = () => + (filterMod ??= import("./spreadsheet_filter").then((m) => { + filter = m.attachFilter(ctx); + filter.refresh(); + return m; + })); + const toggleFilter = (): void => void loadFilter().then((m) => m.toggleFilter(ctx)); + // 수식 분석(추적 · ⚠ 풍선 · 수식 계산)은 처음 쓸 때 — 메뉴 · 오류 칸 · 글 숫자 칸을 고르면 + type Audit = { trace: TraceHandle; tip: ErrorTipHandle }; + let audit: Audit | null = null; + let loading: Promise | null = null; + const loadAudit = () => + (loading ??= Promise.all([ + import("./spreadsheet_trace"), + import("./spreadsheet_errcheck"), + ]).then(([t, e]) => { + const trace = t.attachTrace(ctx); + audit = { trace, tip: e.attachErrorTip(ctx, trace) }; + audit.tip.refresh(); + return audit; + })); + const warnAt = (): boolean => { + const { r, c } = ctx.selection.활성; + const cell = ctx.sheet().칸[toA1(r, c)]; + if (!cell) return false; + if (cell.식 !== undefined) return isError(ctx.engine.value(ctx.selection.시트, r, c)); + return typeof cell.값 === "string" && /\d/.test(cell.값); + }; let closeSteps: (() => void) | null = null; const traced = (more: boolean): void => { if (!more) showToast(TEXT.noMore, "info"); back(); }; + const act = mountActions(ctx, back); const menu = createMapContextMenu("ss"); ctx.root.append(menu.element); let closeSort: (() => void) | null = null; @@ -128,16 +160,17 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe closeSort?.(); closeSort = m.openSortDialog(ctx); } - function evalSteps(): void { + async function evalSteps(): Promise { + const m = await import("./spreadsheet_eval_steps"); closeSteps?.(); - closeSteps = openEvalSteps(ctx); + closeSteps = m.openEvalSteps(ctx); } function openMenu(anchor: HTMLElement): void { const r = anchor.getBoundingClientRect(); const items: MapContextMenuItem[] = [ [TEXT.sort, () => void sortDialog()], - [TEXT.filterItem, () => (toggleFilter(ctx), back())], + [TEXT.filterItem, () => (toggleFilter(), back())], [ TEXT.condfmt, () => @@ -147,21 +180,16 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe ], [TEXT.validation, () => void openPanel(TEXT.validation, async () => createValidationPanel)], [TEXT.names, () => void openPanel(TEXT.names, async () => createNameManagerPanel)], - [ - TEXT.sheets, - () => - void openPanel(TEXT.sheets, () => - import("./spreadsheet_sheet_extra").then((m) => m.createSheetExtraPanel), - ), - ], + [TEXT.sheets, () => void openPanel(TEXT.sheets, async () => createSheetExtraPanel)], [TEXT.grid, () => (toggleGridlines(), back())], + ...act.items, { label: TEXT.audit, children: [ - [TEXT.prec, () => traced(trace.precedents())], - [TEXT.dep, () => traced(trace.dependents())], - [TEXT.clear, () => (trace.clear(), back())], - [TEXT.steps, evalSteps], + [TEXT.prec, () => void loadAudit().then((a) => traced(a.trace.precedents()))], + [TEXT.dep, () => void loadAudit().then((a) => traced(a.trace.dependents()))], + [TEXT.clear, () => (audit?.trace.clear(), back())], + [TEXT.steps, () => void evalSteps()], ], }, ]; @@ -232,7 +260,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe : [ button("↑", TEXT.asc, () => void sortQuick(false), true), button("↓", TEXT.desc, () => void sortQuick(true), true), - button("필터", TEXT.filter, () => toggleFilter(ctx)), + button("필터", TEXT.filter, toggleFilter), button("Σ", TEXT.sum, autosum, true), button(TEXT.menu, TEXT.menu, openMenu, true), ]; @@ -240,22 +268,25 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe return { buttons, keys: { - filter: () => toggleFilter(ctx), + filter: toggleFilter, autosum, + more: act.key, dropdown() { const f = ctx.sheet().필터; const g = f && parseRange(f.범위); const { r, c } = ctx.selection.활성; - if (g && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); + if (g && filter && r === g.r0 && c >= g.c0 && c <= g.c1) return (filter.open(c), true); return openList(); }, }, part: { root: null, refresh() { - filter.refresh(); - trace.refresh(); - tip.refresh(); + filter?.refresh(); + if (!filter && ctx.sheet().필터) void loadFilter(); + audit?.trace.refresh(); + audit?.tip.refresh(); + if (!audit && warnAt()) void loadAudit(); panel?.refresh(); closeDrop(); const { r, c } = ctx.selection.활성; @@ -272,10 +303,10 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe closeSort?.(); closePanel(); closeDrop(); - filter.destroy(); + filter?.destroy(); closeSteps?.(); - trace.destroy(); - tip.destroy(); + audit?.trace.destroy(); + audit?.tip.destroy(); menu.element.remove(); dialog.remove(); arrow.remove(); diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 5d5736b8..fc4de019 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -110,6 +110,31 @@ export function insertableAt(text: string, pos: number): boolean { return i >= 0 && "=(,+-*/^&<>:;".includes(text[i]); } +/** F4 — caret 에 걸친 참조의 $ 를 A1 → $A$1 → A$1 → $A1 → A1 로(범위면 두 끝 같이) · caret 은 참조 끝 */ +export function cycleAbs(text: string, pos: number): { text: string; caret: number } | null { + if (text[0] !== "=") return null; + const t = tokenize(text.slice(1)).find( + (t) => t.kind === "ref" && t.start < pos && pos <= t.end + 1, + ); + if (!t) return null; + const bang = t.text.lastIndexOf("!") + 1; + const part = /(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g; + const first = part.exec(t.text.slice(bang)); + if (!first) return null; + const state = (first[1] ? 1 : 0) + (first[3] ? 2 : 0); // 0 없음 · 3 둘 다 · 2 행만 · 1 열만 + const next = { 0: 3, 3: 2, 2: 1, 1: 0 }[state]!; + const ref = + t.text.slice(0, bang) + + t.text + .slice(bang) + .replace( + part, + (_m, _a, col, _b, row) => (next & 1 ? "$" : "") + col + (next & 2 ? "$" : "") + row, + ); + const end = t.start + 1 + ref.length; + return { text: text.slice(0, t.start + 1) + ref + text.slice(t.end + 1), caret: end }; +} + /** 식 글 참조 → 색 테두리 목록(같은 글이면 같은 색) */ export function refHighlights( text: string, diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts index 2c78275d..1b89db2e 100644 --- a/A00_Common/spreadsheet/spreadsheet_extras.ts +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -7,11 +7,12 @@ * ========================================================================== */ import { el } from "@ui/ui_template_elements"; +import { watchClose } from "./spreadsheet_actions"; import { parseClipboard } from "./spreadsheet_clipboard"; import { attachComments, getComment, setComment } from "./spreadsheet_comments"; import { mountDataMenu } from "./spreadsheet_data_menu"; import type { Editor } from "./spreadsheet_editor"; -import { mountFindPanel } from "./spreadsheet_find_panel"; +import type { FindPanelHandle } from "./spreadsheet_find_panel"; import { createFormatPainter } from "./spreadsheet_format_painter"; import type { KeyHooks } from "./spreadsheet_keys"; import type { MouseHooks } from "./spreadsheet_mouse"; @@ -51,8 +52,12 @@ export function attachExtras( node.style.top = `${p.y}px`; }; - // ── 찾기 ─────────────────────────────────────────────────────────────── - const find = mountFindPanel(ctx, back); + // ── 찾기(처음 열 때 불러옴 — 크기 예산) ───────────────────────────────────── + let find: FindPanelHandle | null = null; + async function openFind(replace: boolean): Promise { + find ??= (await import("./spreadsheet_find_panel")).mountFindPanel(ctx, back); + find.open(replace); + } // ── 메모 — 풍선 · 편집 ───────────────────────────────────────────────── const comments = attachComments(ctx); @@ -118,12 +123,7 @@ export function attachExtras( async function openFormat(): Promise { if (ctx.readOnly) return; (await import("./spreadsheet_format_dialog")).openFormatDialog(ctx); // 늦게 — 크기 예산 - const watch = new MutationObserver(() => { - if (document.querySelector(".ss-fmtdlg")) return; - watch.disconnect(); - back(); - }); - watch.observe(document.body, { childList: true }); + watchClose(back); } fmt.addEventListener("click", () => void openFormat()); if (!ctx.readOnly) ed.bar.prepend(fmt, brush); @@ -194,6 +194,7 @@ export function attachExtras( ctx.root.removeEventListener("paste", onPaste, true); toolbarRoot?.removeEventListener("click", onToolbarClick); toolbarRoot?.removeEventListener("change", back); + find?.destroy(); memo.remove(); picker.remove(); brush.remove(); @@ -203,7 +204,7 @@ export function attachExtras( return { keys: { - find: (replace) => find.open(replace), + find: (replace) => void openFind(replace), pasteSpecial: () => (armed = true), comment: editMemo, formatDialog: () => void openFormat(), @@ -230,6 +231,6 @@ export function attachExtras( put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침 }, }, - parts: [find, comments, data.part, self], + parts: [comments, data.part, self], }; } diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index ed815923..829e128f 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -4,11 +4,13 @@ * 편집 중: Enter/Tab 확정 이동 · Alt+Enter 줄바꿈 · Ctrl+Enter 고름 전부 · Esc · F2 방식 바꿈 · * enter 방식 방향키 = 확정 이동(식 참조 자리면 가리키기). * 고름 중: 방향(Shift 늘림 · Ctrl 끝) · Home · Ctrl+Home/End · PgUp/PgDn · Enter/Tab(고름 안 돌기) · - * F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R. + * F2 · Delete · Backspace · Ctrl+A · Shift/Ctrl+Space · Ctrl+Z/Y · Ctrl+D/R · 그 밖은 hooks.more. + * 식 편집 중 F4 = 참조 $ 돌리기(A1 → $A$1 → A$1 → $A1). * 글자 키는 막지 않음 — textarea 의 input · compositionstart 가 편집을 엶(한글 첫 글자). * 복사 · 붙여넣기(Ctrl+C/X/V)는 막지 않음 — E 클립보드가 copy · paste 사건으로 받음. * ========================================================================== */ +import { cycleAbs } from "./spreadsheet_editor"; import { fillCells } from "./spreadsheet_fill"; import { cycle, @@ -49,6 +51,8 @@ export interface KeyHooks { filter?(): void; /** Alt+↓ — 필터 머리 칸이면 값 목록을 열고 true */ dropdown?(): boolean; + /** 고름 중 그 밖 단축키(`spreadsheet_actions.ts`) — 받았으면 true */ + more?(e: KeyboardEvent): boolean; } export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void { @@ -88,6 +92,15 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks case "F2": ed.toggleMode(); return true; + case "F4": { + const node = e.target as HTMLTextAreaElement; + const got = cycleAbs(node.value, node.selectionStart); + if (!got) return false; + node.value = got.text; + node.setSelectionRange(got.caret, got.caret); + node.dispatchEvent(new Event("input")); + return true; + } } const arrow = ARROWS[e.key]; if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true; @@ -108,6 +121,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks const sel = ctx.selection; const arrow = ARROWS[e.key]; if (e.altKey && e.key === "ArrowDown" && hooks.dropdown?.()) return true; + if (hooks.more?.(e)) return true; if (e.altKey && e.key === "=" && hooks.autosum && !ctx.readOnly) return (hooks.autosum(), true); if (arrow && !e.altKey) { go(move(sheet, sel, arrow[0], arrow[1], { extend: e.shiftKey, jump: ctrl })); diff --git a/A00_Common/spreadsheet/spreadsheet_selection.ts b/A00_Common/spreadsheet/spreadsheet_selection.ts index 310846e5..46c0c3e8 100644 --- a/A00_Common/spreadsheet/spreadsheet_selection.ts +++ b/A00_Common/spreadsheet/spreadsheet_selection.ts @@ -51,16 +51,17 @@ export function mergeAt(sheet: Sheet, r: number, c: number): CellRange { return merges(sheet).find((m) => inRange(m, r, c)) ?? { r0: r, c0: c, r1: r, c1: c }; } -/** 숨긴 행 · 필터로 걸러진 행(방향키 · Enter 가 건너뜀 — 엑셀) */ +/** 숨긴 행 · 필터로 걸러진 행 · 접힌 묶음(방향키 · Enter 가 건너뜀 — 엑셀) */ export function rowHidden(sheet: Sheet, r: number): boolean { const row = sheet.행?.[String(r + 1)]; - return !!(row?.숨김 || row?.걸러짐); + return !!(row?.숨김 || row?.걸러짐 || row?.묶음?.접힘); } export function colHidden(sheet: Sheet, c: number): boolean { const cols = sheet.열; if (!cols) return false; - return !!cols[colName(c)]?.숨김; + const col = cols[colName(c)]; + return !!(col?.숨김 || col?.묶음?.접힘); } export function hasValue(sheet: Sheet, r: number, c: number): boolean { diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index a0461a2d..16b3be28 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -102,5 +102,26 @@ const out = { const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p); return [addr.rangeToA1(t.target), t.clear && addr.rangeToA1(t.clear)]; }), + // F4 참조 $ 돌리기 — 네 번이면 제자리 · 범위 두 끝 · 시트 이름 · 참조 밖이면 null + abs: (() => { + const seq = []; + let t = { text: "=A1+B2", caret: 3 }; + for (let i = 0; i < 4; i++) seq.push((t = ed.cycleAbs(t.text, t.caret)).text); + const range = ed.cycleAbs("=SUM(Sheet2!A1:B3)", 10); + return [ + ...seq, + `${range.text}|${range.caret}`, + ed.cycleAbs("=SUM(1)", 5), + ed.cycleAbs("A1", 1), + ]; + })(), + // 접힌 묶음 행 건너뜀(2 → 5) + foldSkip: (() => { + const s = { + ...s1, + 행: { ...s1.행, 3: { 묶음: { 층: 1, 접힘: true } }, 4: { 묶음: { 층: 1, 접힘: true } } }, + }; + return addr.toA1(sel.move(s, sel.selectCell(s, 1, 11), 1, 0).활성.r, 11); + })(), }; console.log(JSON.stringify(out)); diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index 6b8a4af9..3a1d008f 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -93,3 +93,16 @@ def test_formula_hint(r): {"func": "IF", "arg": 1}, None, ] + + +def test_abs_cycle_and_fold(r): + assert r["abs"] == [ + "=$A$1+B2", + "=A$1+B2", + "=$A1+B2", + "=A1+B2", + "=SUM(Sheet2!$A$1:$B$3)|21", + None, + None, + ] + assert r["foldSkip"] == "L5"