feat(spreadsheet): D 잇기 마무리 — 진짜 부품으로 createSpreadsheet · 2단계 찾기 · 메모 · 서식 붓 · 골라 붙여넣기 · 폭 맞춤 · 함수 도움말

- spreadsheet.ts — 가짜 부품 자리(parts) 걷고 A · B · C · E 직접 잇기 · 2단계는 spreadsheet_extras.ts 로
- spreadsheet_extras.ts(새) — 메모 풍선(마우스 올림) · Shift+F2 메모 편집 · 서식 붓 단추 · Ctrl+Shift+V 값만 · 서식만 · 수식만 · 도구 모음 뒤 초점 되돌림
- spreadsheet_find_panel.ts(새) — Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 패널
- spreadsheet_editor.ts — C editorSlot(격자 뿌리 기준) → 층 안 좌표 맞춤(slotOf) · 함수 자동 완성(Tab) · 인자 도움말
- spreadsheet_keys.ts · spreadsheet_mouse.ts — 2단계 잇기 자리 · 열 경계 두 번 누르면 폭 자동 맞춤
- 시험 — 함수 도움말 자리 판정 추가(8 묶음)

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 20:57:25 +09:00
co-authored by Claude Opus 5.5
parent 88866fb4a5
commit ce3df39206
10 changed files with 700 additions and 37 deletions
+148
View File
@@ -109,3 +109,151 @@
background: #24292f;
color: #fff;
}
/* 2단계 잇기 — 서식 붓 · 찾기 패널 · 메모 · 골라 붙여넣기 */
.ss-brush {
flex: none;
padding: 0 8px;
border: none;
border-right: 1px solid #d0d7de;
background: none;
font: inherit;
cursor: pointer;
}
.ss-brush.is-on {
background: #dafbe1;
box-shadow: inset 0 0 0 2px #217346;
}
.ss--painting .ss-grid-host {
cursor: copy;
}
.ss-find {
position: absolute;
top: 4px;
right: 16px;
z-index: 20;
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
border: 1px solid #d0d7de;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-find[hidden],
.ss-find__row[hidden],
.ss-memo[hidden],
.ss-pick[hidden],
.spreadsheet-comment-balloon[hidden] {
display: none;
}
.ss-find__row {
display: flex;
align-items: center;
gap: 4px;
}
.ss-find__input {
width: 180px;
padding: 2px 6px;
border: 1px solid #d0d7de;
font: inherit;
}
.ss-find__btn {
padding: 2px 8px;
border: 1px solid #d0d7de;
border-radius: 4px;
background: #f6f8fa;
font: inherit;
cursor: pointer;
}
.ss-find__opt {
display: flex;
align-items: center;
gap: 2px;
font-size: 12px;
}
.ss-find__status {
font-size: 12px;
color: #57606a;
}
.ss-memo,
.spreadsheet-comment-balloon {
position: absolute;
z-index: 15;
min-width: 140px;
max-width: 260px;
padding: 4px 6px;
border: 1px solid #bf8700;
background: #fff8c5;
font: inherit;
white-space: pre-wrap;
box-shadow: 0 2px 6px rgb(0 0 0 / 15%);
}
.ss-memo {
min-height: 60px;
resize: both;
outline: none;
}
.ss-pick {
position: absolute;
z-index: 20;
display: flex;
gap: 2px;
padding: 4px;
border: 1px solid #d0d7de;
background: #fff;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-pick > button {
padding: 2px 8px;
border: 1px solid #d0d7de;
background: #f6f8fa;
font: inherit;
cursor: pointer;
}
/* 함수 자동 완성 · 인자 도움말 */
.ss-hint {
position: absolute;
z-index: 6;
min-width: 200px;
max-width: 360px;
border: 1px solid #d0d7de;
background: #fff;
font-size: 12px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
}
.ss-hint[hidden] {
display: none;
}
.ss-hint__item,
.ss-hint__help {
padding: 2px 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ss-hint__item {
cursor: pointer;
}
.ss-hint__item.is-on {
background: #ddf4ff;
}
+14 -22
View File
@@ -7,6 +7,7 @@
* ⚠ M02 만 `import()` 로 불러옴 — 정적 import 금지(다른 페이지 번들에 안 섞이게).
* 저장은 부른 쪽 몫(`onChange` 로 문서 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂).
* 화면 차례: 도구 모음(E) · 주소 상자 + 수식 입력줄(D) · 격자(C) · 시트 탭(E).
* 2단계 부품(찾기 · 메모 · 서식 붓 · 골라 붙여넣기)은 `spreadsheet_extras.ts` 로 이음.
* ========================================================================== */
import "./spreadsheet.css";
@@ -15,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor } from "./spreadsheet_editor";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
import { createGrid } from "./spreadsheet_grid";
import { createHistory } from "./spreadsheet_history";
@@ -70,18 +72,6 @@ export interface SpreadsheetHandle {
destroy(): void;
}
/** 이어 붙이는 남의 부품 — 시험 틀이 빈 몸 부품을 가짜로 바꿔 끼우는 자리(화면 코드는 그대로 씀) */
export const parts = {
createCalcEngine,
createHistory,
applyCommand,
createGrid,
mountToolbar,
mountTabs,
attachMenu,
attachClipboard,
};
/** 움직이는 끝(닻에서 먼 쪽) — 행열 전체면 활성 칸 쪽 */
function farCorner(sel: Selection): { r: number; c: number } {
const g = sel.범위[0];
@@ -117,8 +107,8 @@ export function createSpreadsheet(
root,
book,
readOnly,
engine: parts.createCalcEngine(book),
history: parts.createHistory(),
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
sheet: () => ctx.book.시트.find((s) => s.id === ctx.selection.시트) ?? ctx.book.시트[0],
dispatch,
@@ -130,20 +120,22 @@ export function createSpreadsheet(
editor: null as unknown as EditorHandle,
};
const grid = parts.createGrid(ctx);
const grid = createGrid(ctx);
ctx.grid = grid;
const ed = createEditor(ctx);
ctx.editor = ed;
const toolbar: PartHandle | null = readOnly ? null : parts.mountToolbar(ctx);
const tabs = parts.mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : parts.attachMenu(ctx);
const clip = parts.attachClipboard(ctx);
const toolbar: PartHandle | null = readOnly ? null : mountToolbar(ctx);
const tabs = mountTabs(ctx);
const menu: PartHandle | null = readOnly ? null : attachMenu(ctx);
const clip = attachClipboard(ctx);
const others = [toolbar, tabs, menu, clip].filter((p): p is PartHandle => !!p);
grid.root.classList.add("ss-grid-host");
for (const node of [toolbar?.root, ed.bar, grid.root, tabs.root]) if (node) root.append(node);
const detachKeys = attachKeys(ctx, ed);
const detachMouse = attachMouse(ctx, ed);
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
const detachMouse = attachMouse(ctx, ed, extras.mouse);
const toast = el("div", { className: "ss-toast" });
let toastTimer = 0;
@@ -160,7 +152,7 @@ export function createSpreadsheet(
function apply(command: Command): CommandEffect | null {
try {
return parts.applyCommand(ctx.book, command);
return applyCommand(ctx.book, command);
} catch (e) {
if (e instanceof CommandError) return (notify(e.message), null);
throw e;
+104 -2
View File
@@ -11,6 +11,7 @@
import { el } from "@ui/ui_template_elements";
import { moveFormula } from "./spreadsheet_refshift";
import { colName, parseA1, parseRange, rangeToA1, toA1 } from "./spreadsheet_address";
import { funcHelp, suggestFunctions, type FuncHelp } from "./spreadsheet_func_help";
import { tokenize } from "./spreadsheet_parser";
import { cellsOf, move, selectCell } from "./spreadsheet_selection";
import type { Cell, CellAddress, CellStyle, Sheet } from "./spreadsheet_types";
@@ -36,6 +37,10 @@ export interface Editor extends EditorHandle {
point(sel: Selection): void;
/** 방향키 가리키기 — 지금 가리킨 곳(없으면 편집 칸)에서 */
pointMove(dr: number, dc: number, extend: boolean, jump: boolean): void;
/** 함수 자동 완성 목록이 떠 있으면 고른 이름을 넣음(Tab) */
accept(): boolean;
/** 자동 완성 목록 안 위아래(목록이 없으면 false) */
suggestMove(d: number): boolean;
/** 고름 칸 전부에 같은 입력(Ctrl+Enter) — 식은 활성 칸 기준 상대 이동 */
commitAll(): void;
/** 초점 되찾기(스크롤 없이) */
@@ -140,7 +145,45 @@ function styleAt(ctx: SpreadsheetContext, sheet: Sheet, r: number, c: number): C
return ctx.book.서식[idx] ?? {};
}
/** editorSlot 칸 자리 → 층 안 자리 */
/** 식 글 caret 앞 → 함수 이름 치는 중(prefix) · 함수 인자 안(func · 몇 번째 인자 0 부터) · 아님 null */
export function formulaHint(
pre: string,
): { prefix: string } | { func: string; arg: number } | null {
if (pre[0] !== "=") return null;
const m = /(?:^=|[(,+\-*/^&<>:;= ])([A-Za-z][A-Za-z0-9.]*)$/.exec(pre);
if (m && !/^\$?[A-Za-z]{1,3}\$?\d+$/.test(m[1])) return { prefix: m[1] };
let depth = 0;
let arg = 0;
let inStr = false;
for (let i = pre.length - 1; i > 0; i--) {
const ch = pre[i];
if (ch === '"') inStr = !inStr;
if (inStr) continue;
if (ch === ")") depth++;
else if (ch === "," && depth === 0) arg++;
else if (ch === "(") {
if (depth-- > 0) continue;
const n = /([A-Za-z][A-Za-z0-9.]*)$/.exec(pre.slice(0, i));
return n ? { func: n[1].toUpperCase(), arg } : null;
}
}
return null;
}
/** 편집기 · 미리보기 자리 — C `editorSlot` 의 box 는 격자 뿌리 기준이라 층(layer)이 뿌리 안에서
* 비켜 놓인 만큼 빼서 층 안 좌표로 맞춤. */
export function slotOf(
ctx: SpreadsheetContext,
r: number,
c: number,
): { layer: HTMLElement; box: CellBox } {
const { layer, box } = ctx.grid.editorSlot(r, c);
const a = layer.getBoundingClientRect();
const g = ctx.grid.root.getBoundingClientRect();
return { layer, box: { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) } };
}
/** 칸 자리 → 층 안 자리 */
export function boxStyle(node: HTMLElement, box: CellBox): void {
node.style.left = `${box.x}px`;
node.style.top = `${box.y}px`;
@@ -178,7 +221,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
function place(): void {
const at = st?.at ?? ctx.selection.활성;
const { layer, box } = ctx.grid.editorSlot(at.r, at.c);
const { layer, box } = slotOf(ctx, at.r, at.c);
if (ta.parentElement !== layer) {
const had = document.activeElement === ta;
layer.append(ta);
@@ -206,6 +249,56 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
function highlight(): void {
const t = st ? text() : "";
ctx.grid.setRefHighlights(st ? refHighlights(t, st.sheet, sheetId) : []);
showHint();
}
// ── 함수 자동 완성 · 인자 도움말 (spreadsheet_func_help · sub1) ────────────────
const hint = el("div", { className: "ss-hint", attrs: { hidden: "" } });
let sugg: FuncHelp[] = [];
let pick = 0;
let typed = "";
function showHint(): void {
const node = src();
const h = st ? formulaHint(node.value.slice(0, node.selectionEnd)) : null;
sugg = h && "prefix" in h ? suggestFunctions(h.prefix).slice(0, 8) : [];
typed = h && "prefix" in h ? h.prefix : "";
const help = h && "func" in h ? funcHelp(h.func) : null;
if (!sugg.length && !help) return void hint.setAttribute("hidden", "");
pick = Math.min(pick, Math.max(0, sugg.length - 1));
hint.replaceChildren(
...(sugg.length
? sugg.map((f, i) => {
const row = el("div", {
className: i === pick ? "ss-hint__item is-on" : "ss-hint__item",
children: [el("b", { text: f.이름 }), ` ${f.뜻}`],
});
row.addEventListener("mousedown", (e) => {
e.preventDefault();
pick = i;
accept();
});
return row;
})
: [el("div", { className: "ss-hint__help", text: `${help!.꼴} — ${help!.뜻}` })]),
);
if (hint.parentElement !== ta.parentElement) ta.parentElement?.append(hint);
hint.style.left = ta.style.left;
hint.style.top = `${parseFloat(ta.style.top || "0") + ta.offsetHeight}px`;
hint.removeAttribute("hidden");
}
function accept(): boolean {
if (!st || !sugg.length) return false;
const node = src();
const pos = node.selectionEnd;
const name = `${sugg[pick].이름}(`;
node.setRangeText(name, pos - typed.length, pos, "end");
(node === ta ? formula : ta).value = node.value;
pick = 0;
place();
highlight();
return true;
}
/** 편집 시작 — `value` 없으면 textarea 에 이미 든 글(조합 · 바로 친 글자) 그대로 */
@@ -231,6 +324,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.removeAttribute("style");
ctx.root.classList.remove("ss--editing");
ctx.grid.setRefHighlights([]);
hint.setAttribute("hidden", "");
sync();
focus();
}
@@ -389,11 +483,19 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
pointable,
point,
pointMove,
accept,
suggestMove(d: number) {
if (!st || !sugg.length) return false;
pick = (pick + d + sugg.length) % sugg.length;
showHint();
return true;
},
focus,
place() {
if (st) place();
},
destroy() {
hint.remove();
ta.remove();
bar.remove();
},
@@ -0,0 +1,208 @@
/* =============================================================================
* spreadsheet_extras.ts (주인 D)
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
* 도구 모음을 누른 뒤 초점을 격자로 되돌림.
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { parseClipboard } from "./spreadsheet_clipboard";
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
import type { Editor } from "./spreadsheet_editor";
import { mountFindPanel } from "./spreadsheet_find_panel";
import { createFormatPainter } from "./spreadsheet_format_painter";
import type { KeyHooks } from "./spreadsheet_keys";
import type { MouseHooks } from "./spreadsheet_mouse";
import { pasteSpecial, type PasteSpecialMode } from "./spreadsheet_paste_special";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
pasteTitle: "골라 붙여넣기",
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
};
export interface Extras {
keys: KeyHooks;
mouse: MouseHooks;
parts: PartHandle[];
}
export function attachExtras(
ctx: SpreadsheetContext,
ed: Editor,
toolbarRoot: HTMLElement | null,
): Extras {
const back = (): void => ed.focus();
/** 칸 오른쪽 자리(부품 뿌리 기준 px) — C editorSlot box 는 격자 뿌리 기준 */
const beside = (r: number, c: number): { x: number; y: number } => {
const { box } = ctx.grid.editorSlot(r, c);
const g = ctx.grid.root.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
return { x: box.x + box.w + g.left - o.left + 4, y: box.y + g.top - o.top };
};
const put = (node: HTMLElement, r: number, c: number): void => {
const p = beside(r, c);
node.style.left = `${p.x}px`;
node.style.top = `${p.y}px`;
};
// ── 찾기 ───────────────────────────────────────────────────────────────
const find = mountFindPanel(ctx, back);
// ── 메모 — 풍선 · 편집 ─────────────────────────────────────────────────
const comments = attachComments(ctx);
let hovered = "";
const memo = el("textarea", { className: "ss-memo", attrs: { hidden: "", title: TEXT.memo } });
ctx.root.append(memo);
let memoAt: { r: number; c: number } | null = null;
const closeMemo = (save: boolean): void => {
if (!memoAt) return;
const at = memoAt;
memoAt = null;
memo.setAttribute("hidden", "");
if (save && memo.value !== (getComment(ctx.sheet(), at.r, at.c) ?? ""))
setComment(ctx, at.r, at.c, memo.value.trim());
back();
};
memo.addEventListener("keydown", (e) => {
if (e.key === "Escape" || (e.key === "Enter" && e.ctrlKey)) {
e.preventDefault();
e.stopPropagation();
closeMemo(e.key === "Enter");
}
});
memo.addEventListener("blur", () => closeMemo(true));
function editMemo(): void {
if (ctx.readOnly) return;
const { r, c } = ctx.selection.활성;
memoAt = { r, c };
comments.hideBalloon();
memo.value = getComment(ctx.sheet(), r, c) ?? "";
memo.removeAttribute("hidden");
put(memo, r, c);
memo.focus();
}
// ── 서식 붓 ─────────────────────────────────────────────────────────────
const painter = createFormatPainter(ctx);
let sticky = false;
const brush = el("button", {
className: "ss-brush",
text: "붓",
attrs: { type: "button", title: TEXT.painter },
});
const showBrush = (): void => {
brush.classList.toggle("is-on", painter.active());
ctx.root.classList.toggle("ss--painting", painter.active());
};
brush.addEventListener("click", (e) => {
if (e.detail === 2) sticky = true;
else if (painter.active()) (painter.clear(), (sticky = false));
else (painter.pick(), (sticky = false));
showBrush();
back();
});
if (!ctx.readOnly) ed.bar.prepend(brush);
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
let armed = false;
const picker = el("div", { className: "ss-pick", attrs: { hidden: "", title: TEXT.pasteTitle } });
ctx.root.append(picker);
const onPaste = (e: ClipboardEvent): void => {
if (!armed || ed.editing()) return;
armed = false;
e.preventDefault();
e.stopPropagation(); // E 클립보드의 보통 붙여넣기를 막음
const at = { ...ctx.selection.활성 };
const data = e.clipboardData;
const block = parseClipboard(
data?.getData("text/html") ?? "",
data?.getData("text/plain") ?? "",
at,
);
if (!block) return;
// ponytail: 우리 복사면 원본을 같은 시트로 봄(ClipBlock 에 시트가 없음) — 값만 때 식 칸 계산값용
const source =
block.출처 === "aislo" && block.원점
? { 시트: ctx.selection.시트, ...block.원점 }
: undefined;
picker.replaceChildren(
...(Object.keys(TEXT.modes) as PasteSpecialMode[]).map((mode) => {
const b = el("button", {
text: TEXT.modes[mode],
attrs: { type: "button", "data-mode": mode },
});
b.addEventListener("click", () => {
picker.setAttribute("hidden", "");
pasteSpecial(ctx, block, at, mode, source);
back();
});
return b;
}),
);
picker.removeAttribute("hidden");
put(picker, at.r, at.c);
(picker.firstElementChild as HTMLElement).focus();
};
picker.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
e.preventDefault();
picker.setAttribute("hidden", "");
back();
});
ctx.root.addEventListener("paste", onPaste, true);
// ── 도구 모음 뒤 초점 ───────────────────────────────────────────────────
const onToolbarClick = (e: Event): void => {
if ((e.target as HTMLElement).closest("button")) back();
};
toolbarRoot?.addEventListener("click", onToolbarClick);
toolbarRoot?.addEventListener("change", back);
const self: PartHandle = {
root: null,
refresh: () => showBrush(),
destroy() {
ctx.root.removeEventListener("paste", onPaste, true);
toolbarRoot?.removeEventListener("click", onToolbarClick);
toolbarRoot?.removeEventListener("change", back);
memo.remove();
picker.remove();
brush.remove();
},
};
return {
keys: {
find: (replace) => find.open(replace),
pasteSpecial: () => (armed = true),
comment: editMemo,
escape() {
painter.clear();
sticky = false;
showBrush();
},
},
mouse: {
selected() {
if (!painter.active()) return;
painter.paint(ctx.selection.범위[0], sticky);
showBrush();
},
hover(hit) {
const key = hit?.kind === "cell" ? `${hit.r},${hit.c}` : "";
if (key === hovered) return;
hovered = key;
if (!hit || !key || memoAt || !getComment(ctx.sheet(), hit.r, hit.c))
return comments.hideBalloon();
comments.showBalloon(hit.r, hit.c);
put(comments.root!, hit.r, hit.c); // showBalloon 의 cellBox 자리 → 화면 자리로 고침
},
},
parts: [find, comments, self],
};
}
@@ -0,0 +1,131 @@
/* =============================================================================
* spreadsheet_find_panel.ts (주인 D)
* 찾기(Ctrl+F) · 바꾸기(Ctrl+H) 작은 패널 — 부품 오른위에 뜸. 찾기 규칙은 `spreadsheet_find.ts`(sub1).
* Enter = 다음 · Esc = 닫고 격자로. 바꾸기 = 활성 칸이 맞으면 바꾸고 다음으로(엑셀).
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { findAll, findNext, replaceAll, replaceOne, type FindOptions } from "./spreadsheet_find";
import { selectCell } from "./spreadsheet_selection";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
const TEXT = {
find: "찾을 내용",
replace: "바꿀 내용",
next: "다음",
one: "바꾸기",
all: "모두 바꾸기",
close: "닫기",
case: "대소문자",
whole: "전체 칸",
formula: "수식 안",
none: "찾을 수 없음",
replaced: "{n} 개 바꿈",
};
export interface FindPanelHandle extends PartHandle {
open(replace: boolean): void;
close(): void;
}
export function mountFindPanel(ctx: SpreadsheetContext, back: () => void): FindPanelHandle {
const query = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.find } });
const repl = el("input", { className: "ss-find__input", attrs: { placeholder: TEXT.replace } });
const status = el("span", { className: "ss-find__status" });
const button = (text: string, onClick: () => void): HTMLButtonElement => {
const b = el("button", { className: "ss-find__btn", text, attrs: { type: "button" } });
b.addEventListener("click", onClick);
return b;
};
const check = (text: string): HTMLInputElement => {
const box = el("input", { attrs: { type: "checkbox" } });
options.append(el("label", { className: "ss-find__opt", children: [box, text] }));
return box;
};
const closeBtn = button("×", close);
closeBtn.title = TEXT.close;
const options = el("div", { className: "ss-find__row" });
const caseBox = check(TEXT.case);
const wholeBox = check(TEXT.whole);
const formulaBox = check(TEXT.formula);
const replaceRow = el("div", {
className: "ss-find__row",
children: [repl, button(TEXT.one, one), button(TEXT.all, all)],
});
const root = el("div", {
className: "ss-find",
attrs: { hidden: "" },
children: [
el("div", {
className: "ss-find__row",
children: [query, button(TEXT.next, next), closeBtn],
}),
replaceRow,
options,
status,
],
});
ctx.root.append(root);
const opts = (): FindOptions => ({
대소문자: caseBox.checked,
전체칸: wholeBox.checked,
수식안: formulaBox.checked,
});
const here = () => ({ 시트: ctx.selection.시트, ...ctx.selection.활성 });
function next(): void {
status.textContent = "";
if (!query.value) return;
const hit = findNext(ctx.book, here(), query.value, opts());
if (!hit) return void (status.textContent = TEXT.none);
if (hit.시트 !== ctx.selection.시트) ctx.showSheet(hit.시트);
ctx.select(selectCell(ctx.sheet(), hit.r, hit.c), true);
}
function one(): void {
if (ctx.readOnly || !query.value) return;
const at = here();
const onIt = findAll(ctx.book, query.value, opts(), [at.시트]).some(
(h) => h.r === at.r && h.c === at.c,
);
if (onIt) replaceOne(ctx, at, query.value, repl.value, opts());
next();
}
function all(): void {
if (ctx.readOnly || !query.value) return;
const n = replaceAll(ctx, query.value, repl.value, opts());
status.textContent = n ? TEXT.replaced.replace("{n}", String(n)) : TEXT.none;
}
function open(replace: boolean): void {
replaceRow.hidden = !replace || ctx.readOnly;
root.removeAttribute("hidden");
status.textContent = "";
query.focus();
query.select();
}
function close(): void {
root.setAttribute("hidden", "");
back();
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === "Escape") close();
else if (e.key === "Enter") e.target === repl ? one() : next();
else return;
e.preventDefault();
e.stopPropagation();
};
root.addEventListener("keydown", onKey);
return {
root,
open,
close,
refresh() {},
destroy: () => root.remove(),
};
}
+33 -3
View File
@@ -31,7 +31,19 @@ const ARROWS: Record<string, [number, number]> = {
ArrowRight: [0, 1],
};
export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface KeyHooks {
/** Ctrl+F · Shift+F5 찾기 · Ctrl+H 바꾸기 */
find?(replace: boolean): void;
/** Ctrl+Shift+V — 곧 올 paste 사건을 골라 붙여넣기로 */
pasteSpecial?(): void;
/** Shift+F2 메모 편집 */
comment?(): void;
/** 고름 중 Esc(서식 붓 끄기 등) */
escape?(): void;
}
export function attachKeys(ctx: SpreadsheetContext, ed: Editor, hooks: KeyHooks = {}): () => void {
const go = (sel: Selection): void => ctx.select(sel, true);
/** 확정 뒤 한 칸 — 고름이 여러 칸이면 그 안에서 돎 */
@@ -56,6 +68,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
return true;
}
case "Tab":
if (!e.shiftKey && ed.accept()) return true; // 함수 자동 완성
ed.commit();
next(0, e.shiftKey ? -1 : 1);
return true;
@@ -67,6 +80,7 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
return true;
}
const arrow = ARROWS[e.key];
if (arrow && !arrow[1] && !e.shiftKey && ed.suggestMove(arrow[0])) return true;
if (!arrow || inBar || ed.mode() === "edit") return false;
if (ed.pointable()) {
ed.pointMove(arrow[0], arrow[1], e.shiftKey, ctrl);
@@ -116,6 +130,14 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
case "r":
fill(k === "d");
return true;
case "f":
case "h":
if (!hooks.find) return false;
hooks.find(k === "h");
return true;
case "v":
if (e.shiftKey) hooks.pasteSpecial?.();
return false; // paste 사건은 그대로 — E 클립보드 · 골라 붙여넣기가 받음
}
return false;
}
@@ -126,9 +148,17 @@ export function attachKeys(ctx: SpreadsheetContext, ed: Editor): () => void {
case "Tab":
next(0, e.shiftKey ? -1 : 1);
return true;
case "F2":
ed.begin();
case "F5": // Shift+F5 = 찾기(엑셀 · Ctrl+F 를 브라우저가 가로챌 때)
if (!e.shiftKey || !hooks.find) return false;
hooks.find(false);
return true;
case "F2":
if (e.shiftKey) hooks.comment?.();
else ed.begin();
return true;
case "Escape":
hooks.escape?.();
return !!hooks.escape;
case "Delete":
if (!ctx.readOnly) ctx.dispatch({ 종류: "내용지움", 시트: sheet.id, 범위: sel.범위 });
return true;
+41 -9
View File
@@ -6,6 +6,7 @@
* 머리 끝(폭 · 높이 끌기)은 C 몫 — 여기서 건드리지 않음.
* ========================================================================== */
import { autofitColumn } from "./spreadsheet_autofit";
import { fillCells } from "./spreadsheet_fill";
import {
hasValue,
@@ -18,7 +19,7 @@ import {
selectRows,
selectSpan,
} from "./spreadsheet_selection";
import { boxStyle, type Editor } from "./spreadsheet_editor";
import { boxStyle, slotOf, type Editor } from "./spreadsheet_editor";
import type { CellAddress, CellRange } from "./spreadsheet_types";
import type { HitResult, Selection, SpreadsheetContext } from "./spreadsheet_view_types";
@@ -45,7 +46,19 @@ export function fillTarget(
return { target: { ...src, c0: p.c }, clear: null };
}
export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
/** 2단계 잇기 자리 — 부른 쪽(spreadsheet.ts)이 채움 */
export interface MouseHooks {
/** 칸 끌어 고르기가 끝남(서식 붓 바르기) */
selected?(): void;
/** 누르지 않고 움직일 때 가리킨 것(메모 풍선) — 격자 밖이면 null */
hover?(hit: HitResult | null): void;
}
export function attachMouse(
ctx: SpreadsheetContext,
ed: Editor,
hooks: MouseHooks = {},
): () => void {
const root = ctx.grid.root;
const preview = document.createElement("div");
preview.className = "ss-fill-preview";
@@ -80,8 +93,8 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
function showPreview(g: CellRange | null): void {
if (!g) return preview.remove();
const a = ctx.grid.editorSlot(g.r0, g.c0);
const b = ctx.grid.editorSlot(Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
const a = slotOf(ctx, g.r0, g.c0);
const b = slotOf(ctx, Math.min(g.r1, LAST_R), Math.min(g.c1, LAST_C));
if (preview.parentElement !== a.layer) a.layer.append(preview);
boxStyle(preview, {
x: a.box.x,
@@ -198,11 +211,14 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
ctx.select(first);
const anchor = first.기준;
let last = `${hit.r},${hit.c}`;
return drag((p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
});
return drag(
(p) => {
if (`${p.r},${p.c}` === last) return;
last = `${p.r},${p.c}`;
ctx.select(selectSpan(sheet, anchor, p));
},
() => hooks.selected?.(),
);
}
}
}
@@ -212,12 +228,28 @@ export function attachMouse(ctx: SpreadsheetContext, ed: Editor): () => void {
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
const g = ctx.selection.범위[0];
const whole = g.r0 === 0 && g.r1 === LAST_R && inRange(g, 0, hit.c);
for (let c = whole ? g.c0 : hit.c; c <= (whole ? g.c1 : hit.c); c++) autofitColumn(ctx, c);
return;
}
if (hit.kind === "cell" && !ed.editing()) ed.begin();
}
const onHover = (e: MouseEvent): void => {
if (!stopDrag && !e.buttons) hooks.hover?.(ctx.grid.hitTest(e.clientX, e.clientY));
};
const onLeave = (): void => hooks.hover?.(null);
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
return () => {
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
preview.remove();
root.removeEventListener("mousedown", onDown);
@@ -93,6 +93,10 @@ const out = {
highlights: ed
.refHighlights("=A1+B2:C3*A1+치수!B1", "s1", (n) => (n === "치수" ? "s2" : null))
.map((h) => `${h.시트}:${addr.rangeToA1(h.범위)}:${h.색번호}`),
// 함수 자동 완성 · 인자 도움말 자리
hint: ["=su", "=A1+ro", "=A1", "=ROUND(B5,", "=SUM(A1,MAX(1,2),", '=IF(A1=",",', "abc"].map((t) =>
ed.formulaHint(t),
),
// 채우기 끌기 목표
fill: [[{ r: 9, c: 1 }], [{ r: 2, c: 6 }], [{ r: 3, c: 1 }], [{ r: 2, c: 1 }]].map(([p]) => {
const t = mouse.fillTarget({ r0: 1, c0: 1, r1: 3, c1: 2 }, p);
@@ -438,7 +438,11 @@ function createGrid(ctx) {
const r0 = Math.floor(root.scrollTop / H);
return { r0, r1: r0 + Math.floor(root.clientHeight / H) - 2, c0: 0, c1: COLS - 1 };
},
editorSlot: (r, c) => ({ layer: content, box: cellBox(r, c) }),
// C 와 같은 꼴 — box 는 격자 뿌리 기준(스크롤 뺌)
editorSlot: (r, c) => {
const b = cellBox(r, c);
return { layer: content, box: { ...b, x: b.x - root.scrollLeft, y: b.y - root.scrollTop } };
},
destroy: () => root.remove(),
};
}
@@ -81,3 +81,15 @@ def test_fill_target(r):
["B2:B4", "C2:C4"],
["B2:C3", "B4:C4"],
]
def test_formula_hint(r):
assert r["hint"] == [
{"prefix": "su"},
{"prefix": "ro"},
None,
{"func": "ROUND", "arg": 1},
{"func": "SUM", "arg": 2},
{"func": "IF", "arg": 1},
None,
]