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:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user