Files
Aislo/A00_Common/spreadsheet/spreadsheet_sort_dialog.ts
T
eomsangdonandClaude Opus 5.5 a4ad54571c feat(spreadsheet): 정렬 — 엑셀 차례 · 여러 기준 · 머리 행 · 식 복사식 옮김 · 대화 상자 — 계약 더함(정렬 · 필터 명령 · Sheet.필터 · RowInfo.걸러짐)
- 계약 더함: Command 「정렬」(차례를 박음 → 다시 하기가 같음) · 「필터」(걸러짐 행을 박음) · AutoFilter · Sheet.필터 · RowInfo.걸러짐 · 파일 목록
- spreadsheet_sortfilter.ts(A 엔진) — 수 < 글 < 참거짓 < 오류 · 빈 칸 늘 맨 뒤 · 같은 값 원래 차례 · 대소문자 옵션 · 현재 영역
  · 정렬 옮김 = 범위 열만 · 식은 상대 참조만 복사처럼 · 범위 밖 식 그대로 · 서식 · 메모 따라감 · 걸러진 행 제자리 · 병합 걸치면 막음
  · 자동 필터 엔진(값 목록 · 걸러짐 셈 · 적용 · 행열 넣기 · 지우기 때 필터 따라감) · 되돌림 짝 = 시트통째
- spreadsheet_sort_dialog.ts — 정렬 대화 상자(범위 · 머리 행 짐작 · 기준 더하기 · 오름 · 내림) · 빠른 정렬 sortSelection
- spreadsheet_commands_sheet.ts — 시트 명령을 떼어 냄(commands 700 줄 안쪽)
- 시험 test_spreadsheet_sortfilter.py 엑셀 규칙표 4 묶음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TULoa94ZFL26KU6ZqVpjkF
2026-09-27 20:56:28 +09:00

181 lines
6.9 KiB
TypeScript

/* =============================================================================
* spreadsheet_sort_dialog.ts (주인 A · 화면)
* 정렬 대화 상자 · 빠른 정렬(오름 · 내림). 엔진은 `spreadsheet_sortfilter.ts`(명령 `정렬`).
* 범위 = 고른 범위 · 칸 하나만 골랐으면 이어진 값 덩어리(엑셀 「현재 영역」) · 머리 행은 짐작해 켬.
*
* 잇는 법(D · sub7):
* · 도구 모음 · 메뉴 「정렬…」 → `openSortDialog(ctx)` — 창을 `ctx.root` 에 띄우고 닫는 함수를 돌려줌.
* · 「오름차순」 「내림차순」 단추 → `sortSelection(ctx, 내림)` — 활성 칸 열을 기준으로 바로 정렬.
* · 막히면(병합 걸침 등) 토스트로 까닭을 보임 · 되돌리기는 `ctx.dispatch` 가 쌓음.
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { colName, rangeToA1 } from "./spreadsheet_address";
import { CommandError } from "./spreadsheet_commands";
import { currentRegion, displayText, sortCommand } from "./spreadsheet_sortfilter";
import type { SortKey, SortOptions } from "./spreadsheet_sortfilter";
import type { CellRange } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
import "./spreadsheet_sortfilter.css";
const TEXT = {
Title: ["정렬", "Sort"],
Range: ["범위", "Range"],
Header: ["머리 행 있음", "My data has headers"],
Case: ["대소문자 구분", "Case sensitive"],
Column: ["열", "Column"],
Asc: ["오름차순", "Ascending"],
Desc: ["내림차순", "Descending"],
AddKey: ["기준 더하기", "Add level"],
RemoveKey: ["빼기", "Remove"],
Cancel: ["취소", "Cancel"],
Ok: ["정렬", "Sort"],
};
const t = (key: keyof typeof TEXT) => TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
/** 정렬 범위 — 칸 하나면 현재 영역 */
function targetRange(ctx: SpreadsheetContext): CellRange {
const g = ctx.selection.범위[0];
if (g.r0 === g.r1 && g.c0 === g.c1)
return currentRegion(ctx.book, ctx.selection.시트, g.r0, g.c0);
return g;
}
/** 머리 행 짐작 — 첫 행이 모두 글이고 둘째 행에 글 아닌 값이 있으면 머리 */
function guessHeader(ctx: SpreadsheetContext, rg: CellRange): boolean {
if (rg.r0 === rg.r1) return false;
const v = (r: number, c: number) => ctx.engine.value(ctx.selection.시트, r, c);
let allText = true;
let secondOther = false;
for (let c = rg.c0; c <= rg.c1; c++) {
const a = v(rg.r0, c);
const b = v(rg.r0 + 1, c);
if (a !== null && typeof a !== "string") allText = false;
if (b !== null && typeof b !== "string") secondOther = true;
}
return allText && secondOther;
}
function run(ctx: SpreadsheetContext, rg: CellRange, opts: SortOptions): boolean {
try {
ctx.dispatch(sortCommand(ctx.book, ctx.engine, ctx.selection.시트, rg, opts));
return true;
} catch (e) {
if (!(e instanceof CommandError)) throw e;
showToast(e.message, "error");
return false;
}
}
/** 활성 칸 열 기준 바로 정렬 */
export function sortSelection(ctx: SpreadsheetContext, desc: boolean): void {
if (ctx.readOnly) return;
const rg = targetRange(ctx);
const c = Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1);
run(ctx, rg, { 머리: guessHeader(ctx, rg), 기준: [{ 열: c, 내림: desc }] });
}
/** 정렬 대화 상자 — 닫는 함수를 돌려줌 */
export function openSortDialog(ctx: SpreadsheetContext): () => void {
const rg = targetRange(ctx);
const sheet = ctx.selection.시트;
const keys: SortKey[] = [{ 열: Math.min(Math.max(ctx.selection.활성.c, rg.c0), rg.c1) }];
const header = el("input", { attrs: { type: "checkbox" } });
header.checked = guessHeader(ctx, rg);
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
const columnLabel = (c: number) => {
const head = header.checked ? displayText(ctx.book, ctx.engine, sheet, rg.r0, c) : "";
return head ? `${colName(c)}: ${head}` : `${t("Column")} ${colName(c)}`;
};
function drawKeys() {
list.replaceChildren(
...keys.map((k, i) => {
const col = el("select", { attrs: { "aria-label": t("Column") } });
for (let c = rg.c0; c <= rg.c1; c++)
col.append(el("option", { text: columnLabel(c), attrs: { value: String(c) } }));
col.value = String(k.열);
col.addEventListener("change", () => (k.열 = Number(col.value)));
const dir = el("select", {
children: [
el("option", { text: t("Asc"), attrs: { value: "asc" } }),
el("option", { text: t("Desc"), attrs: { value: "desc" } }),
],
});
dir.value = k.내림 ? "desc" : "asc";
dir.addEventListener("change", () => (k.내림 = dir.value === "desc"));
const remove = createButton({
label: t("RemoveKey"),
variant: "ghost",
disabled: keys.length === 1,
onClick: () => {
keys.splice(i, 1);
drawKeys();
},
});
return el("div", { className: "ss-sort__row", children: [col, dir, remove] });
}),
);
}
const close = () => {
shade.remove();
document.removeEventListener("keydown", onKey, true);
};
const ok = () => {
if (run(ctx, rg, { 머리: header.checked, 기준: keys, 대소문자: caseBox.checked })) close();
};
const onKey = (ev: KeyboardEvent) => {
if (ev.key === "Escape") close();
else if (ev.key === "Enter" && !(ev.target instanceof HTMLSelectElement)) ok();
else return;
ev.preventDefault();
ev.stopPropagation();
};
header.addEventListener("change", drawKeys);
box.append(
el("div", { className: "ss-sort__title", text: t("Title") }),
el("div", { className: "ss-sort__meta", text: `${t("Range")} ${rangeToA1(rg)}` }),
el("label", { className: "ss-sort__meta", children: [header, t("Header")] }),
el("label", { className: "ss-sort__meta", children: [caseBox, t("Case")] }),
list,
el("div", {
className: "ss-sort__row",
children: [
createButton({
label: t("AddKey"),
variant: "ghost",
onClick: () => {
keys.push({ 열: rg.c0 });
drawKeys();
},
}),
],
}),
el("div", {
className: "ss-sort__foot",
children: [
createButton({ label: t("Cancel"), variant: "ghost", onClick: close }),
createButton({ label: t("Ok"), onClick: ok }),
],
}),
);
shade.addEventListener("mousedown", (ev) => {
if (ev.target === shade) close();
});
document.addEventListener("keydown", onKey, true);
drawKeys();
ctx.root.append(shade);
return close;
}