- 도구 모음: 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 · 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식 · 되돌리기 - 시트 탭: 더하기 · 이름 바꾸기 · 지우기 · 복사 · 끌어 옮기기 - 우클릭 메뉴: 칸 · 행 머리 · 열 머리별 명령(공용 `ui_template_context_menu` 재사용) - 클립보드: 안 → 안 서식 · 병합 · 수식 상대 이동 왕복 · 밖 → 안(엑셀 값 + 「수식 표시」 · 구글 R1C1) · 안 → 밖(TSV + 인라인 서식 HTML) - E 화면 시험 틀(A · B 몫 가짜 대역) · TSV 파싱 Node 시험 추가
278 lines
8.5 KiB
TypeScript
278 lines
8.5 KiB
TypeScript
/* =============================================================================
|
|
* e_harness.ts — E(도구 모음 · 탭 · 메뉴 · 클립보드) 화면 시험 틀.
|
|
* A(주소 · 명령 · 참조 옮김) · B(숫자 형식) 는 아직 0 계약 몸만 있어 `fakes/`(진짜 대신 최소
|
|
* 흉내)를 `vite.harness.config.ts` 별칭으로 끼움. dispatch 는 이 파일이 직접 문서에 적용해
|
|
* 화면에 곧바로 비침 — 진짜 A `applyCommand` 가 아님(시험 전용).
|
|
* ========================================================================== */
|
|
|
|
import { mountToolbar } from "../../../A00_Common/spreadsheet/spreadsheet_toolbar";
|
|
import { mountTabs } from "../../../A00_Common/spreadsheet/spreadsheet_tabs";
|
|
import { attachMenu } from "../../../A00_Common/spreadsheet/spreadsheet_menu";
|
|
import {
|
|
attachClipboard,
|
|
parseClipboard,
|
|
} from "../../../A00_Common/spreadsheet/spreadsheet_clipboard";
|
|
import type {
|
|
Cell,
|
|
CellRange,
|
|
Command,
|
|
Sheet,
|
|
Workbook,
|
|
} from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
|
import type {
|
|
GridHandle,
|
|
Selection,
|
|
SpreadsheetContext,
|
|
} from "../../../A00_Common/spreadsheet/spreadsheet_view_types";
|
|
import { colName, rangeToA1, toA1 } from "./fakes/fake_address";
|
|
|
|
const log: string[] = [];
|
|
const logEl = document.querySelector<HTMLPreElement>("#log")!;
|
|
function say(msg: string): void {
|
|
log.push(msg);
|
|
logEl.textContent = log.join("\n");
|
|
}
|
|
|
|
const book: Workbook = {
|
|
종류: "통합문서",
|
|
판: 1,
|
|
열: "test",
|
|
서식: [{}, { 굵게: true }],
|
|
시트: [
|
|
{
|
|
id: "s1",
|
|
이름: "측구수로400",
|
|
칸: {
|
|
A1: { 값: "터파기 계산서", 서식: 1 },
|
|
B1: { 값: 12.5 },
|
|
C1: { 식: "B1*2" },
|
|
},
|
|
병합: [],
|
|
},
|
|
],
|
|
활성: "s1",
|
|
};
|
|
|
|
function forEachCell(range: CellRange, fn: (r: number, c: number) => void): void {
|
|
for (let r = range.r0; r <= range.r1; r++) for (let c = range.c0; c <= range.c1; c++) fn(r, c);
|
|
}
|
|
|
|
function styleIndexAt(sheet: Sheet, r: number, c: number): number {
|
|
return (
|
|
sheet.칸[toA1(r, c)]?.서식 ??
|
|
sheet.열?.[colName(c)]?.서식 ??
|
|
sheet.행?.[String(r + 1)]?.서식 ??
|
|
0
|
|
);
|
|
}
|
|
|
|
function internStyle(book: Workbook, style: Record<string, unknown>): number {
|
|
const json = JSON.stringify(style);
|
|
let idx = book.서식.findIndex((s) => JSON.stringify(s) === json);
|
|
if (idx === -1) {
|
|
book.서식.push(style as never);
|
|
idx = book.서식.length - 1;
|
|
}
|
|
return idx;
|
|
}
|
|
|
|
/** dispatch 가 흉내로 적용 — 진짜 A `applyCommand` 아님(시험 전용, 모양만 맞춤). */
|
|
function applyMock(cmd: Command): void {
|
|
switch (cmd.종류) {
|
|
case "묶음":
|
|
cmd.명령.forEach(applyMock);
|
|
return;
|
|
case "서식": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
cmd.범위.forEach((range) =>
|
|
forEachCell(range, (r, c) => {
|
|
const cur = { ...(book.서식[styleIndexAt(sheet, r, c)] ?? {}) };
|
|
for (const [k, v] of Object.entries(cmd.바꿀)) {
|
|
if (v === null) delete (cur as Record<string, unknown>)[k];
|
|
else (cur as Record<string, unknown>)[k] = v;
|
|
}
|
|
const key = toA1(r, c);
|
|
sheet.칸[key] = { ...sheet.칸[key], 서식: internStyle(book, cur) };
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
case "서식지움": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
cmd.범위.forEach((range) =>
|
|
forEachCell(range, (r, c) => {
|
|
const key = toA1(r, c);
|
|
if (sheet.칸[key]) delete sheet.칸[key].서식;
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
case "내용지움": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
cmd.범위.forEach((range) =>
|
|
forEachCell(range, (r, c) => {
|
|
const key = toA1(r, c);
|
|
const cell = sheet.칸[key];
|
|
if (!cell) return;
|
|
delete cell.값;
|
|
delete cell.식;
|
|
if (!Object.keys(cell).length) delete sheet.칸[key];
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
case "칸": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
for (const [key, val] of Object.entries(cmd.칸)) {
|
|
if (val === null) delete sheet.칸[key];
|
|
else sheet.칸[key] = val as Cell;
|
|
}
|
|
return;
|
|
}
|
|
case "병합": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
sheet.병합 = [...(sheet.병합 ?? []), rangeToA1(cmd.범위)];
|
|
return;
|
|
}
|
|
case "병합풀기": {
|
|
const sheet = book.시트.find((s) => s.id === cmd.시트)!;
|
|
const a1 = rangeToA1(cmd.범위);
|
|
sheet.병합 = (sheet.병합 ?? []).filter((m) => m !== a1);
|
|
return;
|
|
}
|
|
case "시트더하기":
|
|
book.시트.splice(cmd.자리, 0, cmd.시트);
|
|
return;
|
|
case "시트지우기":
|
|
book.시트 = book.시트.filter((s) => s.id !== cmd.시트);
|
|
if (book.활성 === cmd.시트) book.활성 = book.시트[0]?.id;
|
|
return;
|
|
case "시트이름":
|
|
book.시트.find((s) => s.id === cmd.시트)!.이름 = cmd.이름;
|
|
return;
|
|
case "시트옮기기": {
|
|
const idx = book.시트.findIndex((s) => s.id === cmd.시트);
|
|
const [sheet] = book.시트.splice(idx, 1);
|
|
book.시트.splice(cmd.자리, 0, sheet);
|
|
return;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
const selection: Selection = {
|
|
시트: "s1",
|
|
범위: [{ r0: 0, c0: 0, r1: 0, c1: 0 }],
|
|
활성: { r: 0, c: 0 },
|
|
기준: { r: 0, c: 0 },
|
|
};
|
|
|
|
const root = document.querySelector<HTMLDivElement>("#ss-root")!;
|
|
|
|
const grid: GridHandle = {
|
|
root,
|
|
render() {},
|
|
invalidate() {},
|
|
renderSelection() {},
|
|
setRefHighlights() {},
|
|
cellBox: () => ({ x: 0, y: 0, w: 60, h: 20 }),
|
|
hitTest: (clientX) => {
|
|
const box = root.getBoundingClientRect();
|
|
const x = clientX - box.left;
|
|
if (x < 30) return { kind: "rowHead", r: 0, c: 0 };
|
|
if (x < 60) return { kind: "colHead", r: 0, c: 0 };
|
|
return { kind: "cell", r: selection.활성.r, c: selection.활성.c };
|
|
},
|
|
reveal() {},
|
|
visibleRange: () => ({ r0: 0, c0: 0, r1: 10, c1: 10 }),
|
|
editorSlot: () => ({ layer: root, box: { x: 0, y: 0, w: 60, h: 20 } }),
|
|
destroy() {},
|
|
};
|
|
|
|
const ctx: SpreadsheetContext = {
|
|
root,
|
|
book,
|
|
readOnly: false,
|
|
engine: {
|
|
update: () => [],
|
|
rebuild() {},
|
|
value: (sheetId, r, c) => {
|
|
const sheet = book.시트.find((s) => s.id === sheetId);
|
|
const cell = sheet?.칸[toA1(r, c)];
|
|
if (!cell) return null;
|
|
if (cell.식 !== undefined) return { n: 999n, d: 1n };
|
|
if (typeof cell.값 === "number") return { n: BigInt(Math.round(cell.값 * 100)), d: 100n };
|
|
if (cell.값 === undefined) return null;
|
|
return cell.값;
|
|
},
|
|
precedents: () => [],
|
|
snapshot: () => ({}),
|
|
},
|
|
history: {
|
|
push() {},
|
|
undo: () => null,
|
|
redo: () => null,
|
|
canUndo: () => false,
|
|
canRedo: () => false,
|
|
},
|
|
selection,
|
|
sheet: () => book.시트.find((s) => s.id === selection.시트)!,
|
|
dispatch(command) {
|
|
say(`dispatch: ${JSON.stringify(command)}`);
|
|
applyMock(command);
|
|
toolbar.refresh();
|
|
tabs.refresh();
|
|
},
|
|
undo() {
|
|
say("undo");
|
|
},
|
|
redo() {
|
|
say("redo");
|
|
},
|
|
select(sel) {
|
|
Object.assign(selection, sel);
|
|
say(`select: ${JSON.stringify(sel)}`);
|
|
toolbar.refresh();
|
|
},
|
|
showSheet(sheetId) {
|
|
book.활성 = sheetId;
|
|
selection.시트 = sheetId;
|
|
say(`showSheet: ${sheetId}`);
|
|
tabs.refresh();
|
|
toolbar.refresh();
|
|
},
|
|
grid,
|
|
editor: {
|
|
editing: () => false,
|
|
begin() {},
|
|
commit() {},
|
|
cancel() {},
|
|
sync() {},
|
|
},
|
|
};
|
|
|
|
const toolbar = mountToolbar(ctx);
|
|
const tabs = mountTabs(ctx);
|
|
attachMenu(ctx);
|
|
attachClipboard(ctx);
|
|
|
|
document.querySelector("#toolbar-slot")!.append(toolbar.root!);
|
|
document.querySelector("#tabs-slot")!.append(tabs.root!);
|
|
|
|
say("mounted — 도구 모음 · 탭 · 메뉴 · 클립보드 붙음");
|
|
|
|
(window as unknown as { __ctx: unknown; __book: unknown }).__ctx = ctx;
|
|
(window as unknown as { __ctx: unknown; __book: unknown }).__book = book;
|
|
|
|
// 클립보드 밖 → 안 시험 — 자리 표시자에 실제로 붙여넣기(Ctrl+V)를 받아 결과를 보임.
|
|
const pasteZone = document.querySelector<HTMLDivElement>("#paste-zone")!;
|
|
const pasteOut = document.querySelector<HTMLPreElement>("#paste-out")!;
|
|
pasteZone.addEventListener("paste", (ev) => {
|
|
ev.preventDefault();
|
|
const html = ev.clipboardData?.getData("text/html") ?? "";
|
|
const text = ev.clipboardData?.getData("text/plain") ?? "";
|
|
const block = parseClipboard(html, text, { r: 0, c: 0 });
|
|
pasteOut.textContent = `html:\n${html}\n\ntext:\n${text}\n\nblock:\n${JSON.stringify(block, null, 2)}`;
|
|
});
|