From 77c132adb5bf7ced90f2c7546afce4490d032e4d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 27 Sep 2026 20:56:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=ED=96=89=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=EA=B0=92?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B2=B4=ED=81=AC=20=C2=B7=20=EC=9D=B4?= =?UTF-8?q?=20=EC=97=B4=20=EC=A0=95=EB=A0=AC=20=C2=B7=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=20=EC=BC=9C=EA=B8=B0=20=C2=B7=20=EB=81=84=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spreadsheet_filter.ts — attachFilter(ctx) 단추 층(격자 overlay) · 값 목록 창(모두 선택 · 빈 칸 · 이 열 필터 지움 · 오름 · 내림) · toggleFilter · open(열) · 걸러진 행은 엔진이 행.걸러짐 으로 적음(사용자 숨김과 따로) · 잇는 법(D · C)은 머리 주석 - 시험 틀 harness_sortfilter.html · .ts · _bundle.cjs — 진짜 부품에 정렬 · 필터를 얹어 ORCA 로 봄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF --- A00_Common/spreadsheet/spreadsheet_filter.ts | 232 ++++++++++++++++++ .../spreadsheet/harness_sortfilter.html | 28 +++ .../tester/spreadsheet/harness_sortfilter.ts | 85 +++++++ .../spreadsheet/harness_sortfilter_bundle.cjs | 29 +++ 4 files changed, 374 insertions(+) create mode 100644 A00_Common/spreadsheet/spreadsheet_filter.ts create mode 100644 resources/tester/spreadsheet/harness_sortfilter.html create mode 100644 resources/tester/spreadsheet/harness_sortfilter.ts create mode 100644 resources/tester/spreadsheet/harness_sortfilter_bundle.cjs diff --git a/A00_Common/spreadsheet/spreadsheet_filter.ts b/A00_Common/spreadsheet/spreadsheet_filter.ts new file mode 100644 index 00000000..328267d2 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_filter.ts @@ -0,0 +1,232 @@ +/* ============================================================================= + * spreadsheet_filter.ts (주인 A · 화면) + * 자동 필터 — 머리 행 칸마다 ▼ 단추 · 값 목록 창(모두 선택 · 값마다 체크 · 이 열 정렬 · 이 열 필터 지움). + * 걸러진 행은 엔진이 `행.걸러짐` 으로 적음(사용자 숨김과 따로) · 명령은 `필터`(spreadsheet_sortfilter.ts). + * + * 잇는 법(D · sub7 · C · sub6): + * · 부품 붙이기: `const filter = attachFilter(ctx)` — 단추 층을 격자 overlay(editorSlot 층)에 얹음. + * grid.render() 뒤마다 `filter.refresh()`(단추 자리 · 켜짐 표시) · 스크롤은 스스로 따라감. + * · 도구 단추 「필터」 · Ctrl+Shift+L → `toggleFilter(ctx)`(켜기 = 고른 범위 · 칸 하나면 현재 영역 / 끄기). + * · Alt+↓(활성 칸이 머리 행이면) → `filter.open(활성 열)`. + * · 격자(C): 행 `걸러짐` 을 `숨김` 처럼 높이 0 으로 — 머리의 숨김 표시(두 줄)는 사용자 `숨김` 만. + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { colName, parseRange, rangeToA1 } from "./spreadsheet_address"; +import { CommandError } from "./spreadsheet_commands"; +import { currentRegion, filterCommand, filterItems, sortCommand } from "./spreadsheet_sortfilter"; +import type { AutoFilter, Command } from "./spreadsheet_types"; +import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types"; +import "./spreadsheet_sortfilter.css"; + +const TEXT = { + Button: ["필터", "Filter"], + All: ["(모두 선택)", "(Select all)"], + Blank: ["(빈 칸)", "(Blanks)"], + SortAsc: ["오름차순 정렬", "Sort ascending"], + SortDesc: ["내림차순 정렬", "Sort descending"], + Clear: ["이 열 필터 지우기", "Clear filter from column"], + Ok: ["확인", "OK"], + Cancel: ["취소", "Cancel"], +}; +const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0]; + +function send(ctx: SpreadsheetContext, make: () => Command): void { + try { + ctx.dispatch(make()); + } catch (e) { + if (!(e instanceof CommandError)) throw e; + showToast(e.message, "error"); + } +} + +/** 필터 켜기 · 끄기 */ +export function toggleFilter(ctx: SpreadsheetContext): void { + if (ctx.readOnly) return; + const s = ctx.sheet(); + if (s.필터) return send(ctx, () => filterCommand(ctx.book, ctx.engine, s.id, null)); + const g = ctx.selection.범위[0]; + const rg = g.r0 === g.r1 && g.c0 === g.c1 ? currentRegion(ctx.book, s.id, g.r0, g.c0) : g; + send(ctx, () => ({ 종류: "필터", 시트: s.id, 필터: { 범위: rangeToA1(rg) }, 걸러짐: [] })); +} + +export interface FilterHandle extends PartHandle { + /** 열 `c` 값 목록 창 열기(필터 범위 안일 때) */ + open(c: number): void; +} + +export function attachFilter(ctx: SpreadsheetContext): FilterHandle { + const layer = el("div", { className: "ss-filter__layer" }); + let panel: HTMLElement | null = null; + + const filterOf = () => { + const f = ctx.sheet().필터; + const rg = f && parseRange(f.범위); + return f && rg ? { f, rg } : null; + }; + + /** 칸 자리 → 단추 층 안 자리(editorSlot 상자는 격자 뿌리 기준 — 층 원점과 다를 수 있어 실제 자리로 맞춤) */ + function boxIn(r: number, c: number) { + const { layer: host, box } = ctx.grid.editorSlot(r, c); + const a = host.getBoundingClientRect(); + const g = ctx.grid.root.getBoundingClientRect(); + return { ...box, x: box.x - (a.left - g.left), y: box.y - (a.top - g.top) }; + } + + /** 머리 칸이 지금 보이나(틀고정 판이면 늘 보임) */ + function shown(r: number, c: number): boolean { + const v = ctx.grid.visibleRange(); + const fz = ctx.sheet().틀고정 ?? {}; + const rowOk = r < (fz.행 ?? 0) || (r >= v.r0 && r <= v.r1); + const colOk = c < (fz.열 ?? 0) || (c >= v.c0 && c <= v.c1); + return rowOk && colOk; + } + + function place(): void { + const { layer: host } = ctx.grid.editorSlot(0, 0); + if (layer.parentElement !== host) host.append(layer); + const cur = filterOf(); + const btns: HTMLElement[] = []; + if (cur) + for (let c = cur.rg.c0; c <= cur.rg.c1; c++) { + const box = boxIn(cur.rg.r0, c); + if (!box.w || !box.h || !shown(cur.rg.r0, c)) continue; + const on = !!cur.f.조건?.[colName(c)]; + const b = el("button", { + className: on ? "ss-filter__btn is-on" : "ss-filter__btn", + text: "▼", + attrs: { + type: "button", + "aria-label": `${t("Button")} ${colName(c)}`, + "data-col": String(c), + }, + }); + b.style.left = `${box.x + box.w - 17}px`; + b.style.top = `${box.y + box.h - 17}px`; + b.addEventListener("mousedown", (ev) => ev.stopPropagation()); + b.addEventListener("click", () => open(c)); + btns.push(b); + } + layer.replaceChildren(...btns, ...(panel ? [panel] : [])); + } + + function close(): void { + panel?.remove(); + panel = null; + document.removeEventListener("mousedown", onOutside, true); + } + + const onOutside = (ev: MouseEvent) => { + if (panel && !panel.contains(ev.target as Node)) close(); + }; + + function apply(next: AutoFilter): void { + close(); + send(ctx, () => filterCommand(ctx.book, ctx.engine, ctx.sheet().id, next)); + } + + function open(c: number): void { + const cur = filterOf(); + if (!cur || c < cur.rg.c0 || c > cur.rg.c1 || ctx.readOnly) return; + close(); + const sheetId = ctx.sheet().id; + const items = filterItems(ctx.book, ctx.engine, sheetId, c); + const boxes = items.map((it) => { + const cb = el("input", { attrs: { type: "checkbox" } }); + cb.checked = it.켬; + return cb; + }); + const all = el("input", { attrs: { type: "checkbox" } }); + const ok = createButton({ + label: t("Ok"), + onClick: () => { + const conds = { ...cur.f.조건 }; + if (boxes.every((b) => b.checked)) delete conds[colName(c)]; + else conds[colName(c)] = items.filter((_, i) => boxes[i].checked).map((it) => it.글); + apply({ ...cur.f, 조건: conds }); + }, + }); + const sync = () => { + all.checked = boxes.every((b) => b.checked); + all.indeterminate = !all.checked && boxes.some((b) => b.checked); + ok.disabled = !boxes.some((b) => b.checked); + }; + all.addEventListener("change", () => { + for (const b of boxes) b.checked = all.checked; + sync(); + }); + for (const b of boxes) b.addEventListener("change", sync); + const link = (label: string, run: () => void) => { + const a = el("button", { + className: "ss-filter__link", + text: label, + attrs: { type: "button" }, + }); + a.addEventListener("click", run); + return a; + }; + const sortBy = (desc: boolean) => () => { + close(); + send(ctx, () => + sortCommand(ctx.book, ctx.engine, sheetId, cur.rg, { + 머리: true, + 기준: [{ 열: c, 내림: desc }], + }), + ); + }; + const rest = { ...cur.f.조건 }; + delete rest[colName(c)]; + + panel = el("div", { + className: "ss-filter__panel", + attrs: { role: "dialog", "aria-label": `${t("Button")} ${colName(c)}` }, + children: [ + link(t("SortAsc"), sortBy(false)), + link(t("SortDesc"), sortBy(true)), + link(t("Clear"), () => apply({ ...cur.f, 조건: rest })), + el("div", { + className: "ss-filter__list", + children: [ + el("label", { children: [all, t("All")] }), + ...items.map((it, i) => + el("label", { children: [boxes[i], it.글 === "" ? t("Blank") : it.글] }), + ), + ], + }), + el("div", { + className: "ss-filter__foot", + children: [createButton({ label: t("Cancel"), variant: "ghost", onClick: close }), ok], + }), + ], + }); + panel.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") close(); + ev.stopPropagation(); + }); + const box = boxIn(cur.rg.r0, c); + panel.style.left = `${box.x}px`; + panel.style.top = `${box.y + box.h}px`; + sync(); + layer.append(panel); + document.addEventListener("mousedown", onOutside, true); + } + + const onScroll = () => place(); + ctx.grid.root.addEventListener("scroll", onScroll, true); + place(); + + return { + root: layer, + refresh() { + close(); + place(); + }, + open, + destroy() { + close(); + ctx.grid.root.removeEventListener("scroll", onScroll, true); + layer.remove(); + }, + }; +} diff --git a/resources/tester/spreadsheet/harness_sortfilter.html b/resources/tester/spreadsheet/harness_sortfilter.html new file mode 100644 index 00000000..7eaf596d --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter.html @@ -0,0 +1,28 @@ + + + + + 정렬 · 필터 시험 틀 + + + + +
+ + + + + +
+
+ + + + diff --git a/resources/tester/spreadsheet/harness_sortfilter.ts b/resources/tester/spreadsheet/harness_sortfilter.ts new file mode 100644 index 00000000..6703ba73 --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter.ts @@ -0,0 +1,85 @@ +/* 정렬 · 자동 필터 화면 시험 틀 입구 — 진짜 부품(createSpreadsheet)에 정렬 대화 상자 · 필터 단추를 얹음. + * sub7 잇기 전이라 `parts.mountTabs` 를 감싸 문맥(ctx)을 꺼내고, grid.render 뒤 filter.refresh 를 부름. + * 번들: node harness_sortfilter_bundle.cjs → tmp/spreadsheet_sortfilter/harness.js (git 밖) */ +import { createSpreadsheet, parts } from "../../../A00_Common/spreadsheet/spreadsheet"; +import { + openSortDialog, + sortSelection, +} from "../../../A00_Common/spreadsheet/spreadsheet_sort_dialog"; +import { attachFilter, toggleFilter } from "../../../A00_Common/spreadsheet/spreadsheet_filter"; +import type { SpreadsheetContext } from "../../../A00_Common/spreadsheet/spreadsheet_view_types"; +import type { Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; + +const w = window as unknown as Record; +let ctx: SpreadsheetContext | null = null; +const origTabs = parts.mountTabs; +parts.mountTabs = (c: SpreadsheetContext) => { + ctx = c; + return origTabs(c); +}; + +const rows: [string, string, number][] = [ + ["터파기", "토사", 12.5], + ["되메우기", "토사", 3], + ["잡석", "", 7.25], + ["터파기", "풍화암", 4], + ["콘크리트", "18-21-8", 9.9], + ["되메우기", "토사", 0], + ["거푸집", "합판 3회", 21], + ["터파기", "토사", 1.5], +]; +const 칸: Workbook["시트"][number]["칸"] = { + A1: { 값: "공종" }, + B1: { 값: "규격" }, + C1: { 값: "수량" }, + D1: { 값: "두 배" }, + C12: { 식: "SUM(C2:C9)" }, + B12: { 값: "합계" }, +}; +rows.forEach(([a, b, c], i) => { + const r = i + 2; + 칸[`A${r}`] = { 값: a }; + if (b) 칸[`B${r}`] = { 값: b }; + 칸[`C${r}`] = { 값: c, 서식: 1 }; + 칸[`D${r}`] = { 식: `C${r}*2`, 서식: 1 }; +}); +const book: Workbook = { + 종류: "통합문서", + 판: 1, + 열: "harness", + 서식: [{}, { 형식: "0.00" }], + 시트: [{ id: "s1", 이름: "정렬필터", 칸, 행: { 11: { 숨김: true } } }], + 활성: "s1", +}; + +const host = document.getElementById("host")!; +w.ss = createSpreadsheet(host, book, { + onChange: () => (w.ssChanges = Number(w.ssChanges ?? 0) + 1), +}); +const c = ctx as unknown as SpreadsheetContext; +const filter = attachFilter(c); +const render = c.grid.render.bind(c.grid); +c.grid.render = () => { + render(); + filter.refresh(); +}; +w.ssCtx = c; +w.ssFilter = filter; +w.ssSortDialog = () => openSortDialog(c); +w.ssSortQuick = (desc: boolean) => sortSelection(c, desc); +w.ssToggleFilter = () => toggleFilter(c); +/** 열 값(보이는 칸 글) — 판정용 */ +w.ssCol = (letter: string, r0: number, r1: number) => { + const out: string[] = []; + for (let r = r0; r <= r1; r++) + out.push(document.querySelector(`[data-a1="${letter}${r}"]`)?.textContent ?? ""); + return out; +}; +for (const [id, run] of [ + ["btn-sort", () => openSortDialog(c)], + ["btn-asc", () => sortSelection(c, false)], + ["btn-desc", () => sortSelection(c, true)], + ["btn-filter", () => toggleFilter(c)], + ["btn-undo", () => c.undo()], +] as const) + document.getElementById(id)!.addEventListener("click", run); diff --git a/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs b/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs new file mode 100644 index 00000000..632917a1 --- /dev/null +++ b/resources/tester/spreadsheet/harness_sortfilter_bundle.cjs @@ -0,0 +1,29 @@ +/* 정렬 · 필터 시험 틀 번들 — rolldown(이미 설치) 한 파일 · css 는