From ce3df392065e8d448798fe72cd6d94b500fe4958 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:57:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20D=20=EC=9E=87=EA=B8=B0=20?= =?UTF-8?q?=EB=A7=88=EB=AC=B4=EB=A6=AC=20=E2=80=94=20=EC=A7=84=EC=A7=9C=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=EC=9C=BC=EB=A1=9C=20createSpreadsheet=20?= =?UTF-8?q?=C2=B7=202=EB=8B=A8=EA=B3=84=20=EC=B0=BE=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=A9=94=EB=AA=A8=20=C2=B7=20=EC=84=9C=EC=8B=9D=20=EB=B6=93=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A8=EB=9D=BC=20=EB=B6=99=EC=97=AC=EB=84=A3?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=ED=8F=AD=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EB=8F=84=EC=9B=80=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY --- A00_Common/spreadsheet/spreadsheet.css | 148 +++++++++++++ A00_Common/spreadsheet/spreadsheet.ts | 36 ++- A00_Common/spreadsheet/spreadsheet_editor.ts | 106 ++++++++- A00_Common/spreadsheet/spreadsheet_extras.ts | 208 ++++++++++++++++++ .../spreadsheet/spreadsheet_find_panel.ts | 131 +++++++++++ A00_Common/spreadsheet/spreadsheet_keys.ts | 36 ++- A00_Common/spreadsheet/spreadsheet_mouse.ts | 50 ++++- .../spreadsheet/helper_spreadsheet_d.cjs | 4 + .../spreadsheet/spreadsheet_d_fakes.cjs | 6 +- .../spreadsheet/test_spreadsheet_d_input.py | 12 + 10 files changed, 700 insertions(+), 37 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_extras.ts create mode 100644 A00_Common/spreadsheet/spreadsheet_find_panel.ts diff --git a/A00_Common/spreadsheet/spreadsheet.css b/A00_Common/spreadsheet/spreadsheet.css index 1266725d..9f068cd6 100644 --- a/A00_Common/spreadsheet/spreadsheet.css +++ b/A00_Common/spreadsheet/spreadsheet.css @@ -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; +} diff --git a/A00_Common/spreadsheet/spreadsheet.ts b/A00_Common/spreadsheet/spreadsheet.ts index 25f43b0e..1597924a 100644 --- a/A00_Common/spreadsheet/spreadsheet.ts +++ b/A00_Common/spreadsheet/spreadsheet.ts @@ -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; diff --git a/A00_Common/spreadsheet/spreadsheet_editor.ts b/A00_Common/spreadsheet/spreadsheet_editor.ts index 08411ba7..ed3b9e90 100644 --- a/A00_Common/spreadsheet/spreadsheet_editor.ts +++ b/A00_Common/spreadsheet/spreadsheet_editor.ts @@ -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(); }, diff --git a/A00_Common/spreadsheet/spreadsheet_extras.ts b/A00_Common/spreadsheet/spreadsheet_extras.ts new file mode 100644 index 00000000..bcb24ea3 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_extras.ts @@ -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, + 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], + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_find_panel.ts b/A00_Common/spreadsheet/spreadsheet_find_panel.ts new file mode 100644 index 00000000..985ffd1b --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_find_panel.ts @@ -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(), + }; +} diff --git a/A00_Common/spreadsheet/spreadsheet_keys.ts b/A00_Common/spreadsheet/spreadsheet_keys.ts index 2e5cdfcb..22315c3a 100644 --- a/A00_Common/spreadsheet/spreadsheet_keys.ts +++ b/A00_Common/spreadsheet/spreadsheet_keys.ts @@ -31,7 +31,19 @@ const ARROWS: Record = { 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; diff --git a/A00_Common/spreadsheet/spreadsheet_mouse.ts b/A00_Common/spreadsheet/spreadsheet_mouse.ts index 5d0e706b..9100497f 100644 --- a/A00_Common/spreadsheet/spreadsheet_mouse.ts +++ b/A00_Common/spreadsheet/spreadsheet_mouse.ts @@ -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); diff --git a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs index cc09a435..a0461a2d 100644 --- a/resources/tester/spreadsheet/helper_spreadsheet_d.cjs +++ b/resources/tester/spreadsheet/helper_spreadsheet_d.cjs @@ -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); diff --git a/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs index 9dbb9582..6f164b3e 100644 --- a/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs +++ b/resources/tester/spreadsheet/spreadsheet_d_fakes.cjs @@ -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(), }; } diff --git a/resources/tester/spreadsheet/test_spreadsheet_d_input.py b/resources/tester/spreadsheet/test_spreadsheet_d_input.py index c4a5f38d..6b8a4af9 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_d_input.py +++ b/resources/tester/spreadsheet/test_spreadsheet_d_input.py @@ -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, + ]