- spreadsheet_extras.ts — 수식 입력줄 앞 데이터 단추(↑ ↓ 정렬… 필터) · attachFilter 부품 붙임 · Alt+↓ 머리 칸 값 목록 - spreadsheet_keys.ts — Ctrl+Shift+L 필터 · Alt+↓ 자리 - spreadsheet_selection.ts — 걸러진 행도 방향키 · Enter 가 건너뜀 - spreadsheet.ts — recalc 뒤 부품 refresh Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6KXAabDTenEU7hrDQmCKY
254 lines
10 KiB
TypeScript
254 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_extras.ts (주인 D)
|
|
* 2단계 부품 잇기(sub1 파일들의 「잇는 법」) — 찾기 패널 · 메모 풍선(마우스 올림) · 메모 편집(Shift+F2) ·
|
|
* 서식 붓(수식 입력줄 앞 단추 · 두 번 누르면 계속 · Esc 끔) · 골라 붙여넣기(Ctrl+Shift+V → 값만 · 서식만 · 수식만) ·
|
|
* 도구 모음을 누른 뒤 초점을 격자로 되돌림 · 정렬(↑ ↓ 정렬…) · 자동 필터(단추 · Ctrl+Shift+L · Alt+↓).
|
|
* 빨간 세모는 C 격자 · 우클릭 [메모 …] 는 E 메뉴 몫(브레인 배정).
|
|
* ========================================================================== */
|
|
|
|
import { el } from "@ui/ui_template_elements";
|
|
import { parseRange } from "./spreadsheet_address";
|
|
import { parseClipboard } from "./spreadsheet_clipboard";
|
|
import { attachComments, getComment, setComment } from "./spreadsheet_comments";
|
|
import type { Editor } from "./spreadsheet_editor";
|
|
import { attachFilter, toggleFilter } from "./spreadsheet_filter";
|
|
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 { openSortDialog, sortSelection } from "./spreadsheet_sort_dialog";
|
|
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
|
|
|
const TEXT = {
|
|
painter: "서식 붓 — 한 번 누르면 한 번 · 두 번 누르면 Esc 까지 계속",
|
|
pasteTitle: "골라 붙여넣기",
|
|
modes: { 값: "값만", 서식: "서식만", 수식: "수식만" } as Record<PasteSpecialMode, string>,
|
|
memo: "메모 — Ctrl+Enter 또는 밖을 누르면 적용 · Esc 취소",
|
|
sortAsc: "오름차순 정렬(활성 칸 열 기준)",
|
|
sortDesc: "내림차순 정렬(활성 칸 열 기준)",
|
|
sortDialog: "정렬 — 기준 여럿 · 머리 행",
|
|
filter: "자동 필터 켜기 · 끄기(Ctrl+Shift+L · 머리 칸에서 Alt+↓ 값 목록)",
|
|
};
|
|
|
|
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);
|
|
|
|
// ── 정렬 · 필터(sub3) — 수식 입력줄 앞 데이터 단추 ───────────────────────
|
|
const filter = attachFilter(ctx);
|
|
let closeSort: (() => void) | null = null;
|
|
const dataButton = (text: string, title: string, run: () => void, dialog = false) => {
|
|
const b = el("button", { className: "ss-brush", text, attrs: { type: "button", title } });
|
|
b.addEventListener("click", () => {
|
|
run();
|
|
if (!dialog) back();
|
|
});
|
|
return b;
|
|
};
|
|
const data = [
|
|
dataButton("↑", TEXT.sortAsc, () => sortSelection(ctx, false)),
|
|
dataButton("↓", TEXT.sortDesc, () => sortSelection(ctx, true)),
|
|
dataButton(
|
|
"정렬…",
|
|
TEXT.sortDialog,
|
|
() => {
|
|
closeSort?.();
|
|
closeSort = openSortDialog(ctx);
|
|
},
|
|
true,
|
|
),
|
|
dataButton("필터", TEXT.filter, () => toggleFilter(ctx)),
|
|
];
|
|
if (!ctx.readOnly) brush.after(...data);
|
|
|
|
// ── 골라 붙여넣기 ─────────────────────────────────────────────────────────
|
|
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() {
|
|
closeSort?.();
|
|
data.forEach((b) => b.remove());
|
|
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,
|
|
filter: () => toggleFilter(ctx),
|
|
dropdown() {
|
|
const f = ctx.sheet().필터;
|
|
const g = f && parseRange(f.범위);
|
|
const { r, c } = ctx.selection.활성;
|
|
if (!g || r !== g.r0 || c < g.c0 || c > g.c1) return false;
|
|
filter.open(c);
|
|
return true;
|
|
},
|
|
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, filter, self],
|
|
};
|
|
}
|