feat(spreadsheet): D 잇기 3 — 칸 넣기 · 이동 · 틀 고정 · 데이터 도구 · 묶기 · 단축키 표 「안됨」 잇기

- spreadsheet_actions(키 · 메뉴 → 이름) + actions_run(몸통 · 늦게): Ctrl+Shift++ · Ctrl+- (행 · 열 통째면 바로) · 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+→/←
- [데이터 ▾] 에 이동 · 틀 고정 ▸ · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 윤곽 ▸
- 식 편집 중 F4 참조 $ 돌리기(cycleAbs) · 접힌 묶음 행 · 열은 방향키가 건너뜀
- 크기: 수식 분석 · 자동 필터 · 찾기 패널을 처음 쓸 때 불러옴 — 화면 27.0 KB · 합 60.9 KB
- M02 [스프레드시트 시험] ORCA 수치 확인 · D 규칙 시험 9 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
This commit is contained in:
2026-09-27 21:51:06 +09:00
co-authored by Claude Opus 5.5
parent 117ea76ec2
commit fca4463768
9 changed files with 462 additions and 47 deletions
@@ -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<string, Act> = {
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<string, Act> = {
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<string, Act>)[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")] },
],
};
}
@@ -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 = <M>(load: () => Promise<M>, open: (m: M) => void) =>
void load().then((m) => (open(m), watchClose(back)));
const freezeBy = (pick: (m: Awaited<ReturnType<typeof view>>) => 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<HTMLElement>(".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));
}
}
+63 -32
View File
@@ -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<KeyHooks, "filter" | "dropdown" | "autosum">;
keys: Pick<KeyHooks, "filter" | "dropdown" | "autosum" | "more">;
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<typeof import("./spreadsheet_filter")> | 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<Audit> | 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<void> {
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();
@@ -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,
+12 -11
View File
@@ -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<void> {
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<void> {
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],
};
}
+15 -1
View File
@@ -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 }));
@@ -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 {
@@ -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));
@@ -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"