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