feat(spreadsheet): 자동 필터 화면 — 머리 행 단추 · 값 목록 체크 · 이 열 정렬 · 필터 켜기 · 끄기
- spreadsheet_filter.ts — attachFilter(ctx) 단추 층(격자 overlay) · 값 목록 창(모두 선택 · 빈 칸 · 이 열 필터 지움 · 오름 · 내림) · toggleFilter · open(열) · 걸러진 행은 엔진이 행.걸러짐 으로 적음(사용자 숨김과 따로) · 잇는 법(D · C)은 머리 주석 - 시험 틀 harness_sortfilter.html · .ts · _bundle.cjs — 진짜 부품에 정렬 · 필터를 얹어 ORCA 로 봄 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
This commit is contained in:
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user