Merge remote-tracking branch 'origin/dev' into sub_laptop_5
This commit is contained in:
@@ -48,6 +48,16 @@ export interface SpreadsheetOptions {
|
||||
onChange?: (book: Workbook) => void;
|
||||
/** 고름이 바뀜 — 시트 id · 활성 칸 A1 · 범위 A1 */
|
||||
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
|
||||
/** ⚠ 임시 — 브레인 브라우저 검증용: 문맥을 `window.__aisloSheet` 에 걺(book · 고름 · 명령 읽기).
|
||||
* M02 [스프레드시트 시험] 단추만 켬 · 산출근거 카드는 안 켬 · 1단계 검증 뒤 지움. */
|
||||
inspect?: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/** ⚠ 임시 — `inspect` 로 연 표의 문맥(브레인 검증용) */
|
||||
__aisloSheet?: SpreadsheetContext;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -139,6 +149,7 @@ export function createSpreadsheet(
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
if (opts.inspect) window.__aisloSheet = ctx; // ⚠ 임시 — 브레인 검증용
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
@@ -244,6 +255,7 @@ export function createSpreadsheet(
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
destroy() {
|
||||
if (window.__aisloSheet === ctx) delete window.__aisloSheet;
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
detachMouse();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_cellstyle.ts (주인 C)
|
||||
* 서식 → CSS — 글꼴 · 정렬(균등 분할 · 선택 영역 가운데 · 세로 · 줄 바꿈) · 채움색 ·
|
||||
* 테두리(hair · thin · medium · thick · double · dotted · dashed · 색) · 겹친 테두리 고르기.
|
||||
* 칸 하나가 위 · 왼 이웃과 테두리를 나눠 가짐(표 `border-collapse` 방식) — 겹쳐 그리지 않게
|
||||
* 위 · 왼만 두 이웃 중 굵은 쪽으로 그리고 오른 · 아래는 시트 끝 칸만 그림(`resolveEdgeBorders`).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colName, toA1 } from "./spreadsheet_address";
|
||||
import type {
|
||||
BorderLine,
|
||||
BorderSide,
|
||||
CellStyle,
|
||||
HAlign,
|
||||
Sheet,
|
||||
VAlign,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
|
||||
/** 선 굵기 차례 — 겹치면 굵은 쪽이 이김(엑셀과 완전히 같지 않음 · 근사). */
|
||||
const BORDER_WEIGHT: Record<BorderLine, number> = {
|
||||
hair: 0,
|
||||
dotted: 1,
|
||||
dashed: 2,
|
||||
thin: 3,
|
||||
double: 4,
|
||||
medium: 5,
|
||||
thick: 6,
|
||||
};
|
||||
|
||||
/** 두 이웃 칸이 한 자리에 그리려는 테두리 중 굵은 쪽. 없으면 있는 쪽 · 둘 다 없으면 undefined. */
|
||||
export function strongerBorder(a?: BorderSide, b?: BorderSide): BorderSide | undefined {
|
||||
if (!a) return b;
|
||||
if (!b) return a;
|
||||
return BORDER_WEIGHT[b.선] > BORDER_WEIGHT[a.선] ? b : a;
|
||||
}
|
||||
|
||||
/** 칸 하나가 실제로 그릴 네 변 — 위 · 왼은 이웃과 겹쳐 고름 · 오른 · 아래는 시트 끝(그 다음 칸이 없음)일 때만. */
|
||||
export interface ResolvedBorders {
|
||||
위?: BorderSide;
|
||||
아래?: BorderSide;
|
||||
왼?: BorderSide;
|
||||
오른?: BorderSide;
|
||||
}
|
||||
|
||||
/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */
|
||||
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
|
||||
const cell = sheet.칸[key];
|
||||
if (cell?.서식 !== undefined) return cell.서식;
|
||||
const rowFmt = sheet.행?.[String(r + 1)]?.서식;
|
||||
if (rowFmt !== undefined) return rowFmt;
|
||||
const colFmt = sheet.열?.[colName(c)]?.서식;
|
||||
if (colFmt !== undefined) return colFmt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** (r, c) 칸이 쓸 서식 — 서식 표 밖 번호는 기본(빈 `{}`)으로. */
|
||||
export function cellStyleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const key = toA1(r, c);
|
||||
const idx = styleIndexAt(sheet, r, c, key);
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** 칸 하나 · 병합 칸 한 덩이(box)의 테두리 — 네 변마다 안쪽 이웃과 겹쳐 굵은 쪽으로 고름.
|
||||
* 병합이면 왼위(r0,c0) · 오른아래(r1,c1) 이 다른 칸이라 네 변을 따로 봄(테두리는 칸마다 있음). */
|
||||
export function resolveBoxBorders(
|
||||
book: Workbook,
|
||||
sheet: Sheet,
|
||||
r0: number,
|
||||
c0: number,
|
||||
r1: number,
|
||||
c1: number,
|
||||
lastRow: number,
|
||||
lastCol: number,
|
||||
): ResolvedBorders {
|
||||
const top = cellStyleAt(book, sheet, r0, c0).테두리?.위;
|
||||
const aboveOf = r0 > 0 ? cellStyleAt(book, sheet, r0 - 1, c0).테두리?.아래 : undefined;
|
||||
const left = cellStyleAt(book, sheet, r0, c0).테두리?.왼;
|
||||
const leftOf = c0 > 0 ? cellStyleAt(book, sheet, r0, c0 - 1).테두리?.오른 : undefined;
|
||||
const bottomOwn = cellStyleAt(book, sheet, r1, c0).테두리?.아래;
|
||||
const belowOf = r1 < lastRow ? cellStyleAt(book, sheet, r1 + 1, c0).테두리?.위 : undefined;
|
||||
const rightOwn = cellStyleAt(book, sheet, r0, c1).테두리?.오른;
|
||||
const rightOf = c1 < lastCol ? cellStyleAt(book, sheet, r0, c1 + 1).테두리?.왼 : undefined;
|
||||
return {
|
||||
위: strongerBorder(top, aboveOf),
|
||||
왼: strongerBorder(left, leftOf),
|
||||
아래: r1 >= lastRow ? bottomOwn : strongerBorder(bottomOwn, belowOf),
|
||||
오른: c1 >= lastCol ? rightOwn : strongerBorder(rightOwn, rightOf),
|
||||
};
|
||||
}
|
||||
|
||||
/** 빈 글 = 인라인 테두리를 안 그림(css `aislo-grid--gridlines` 눈금선이 비쳐 보임). */
|
||||
function borderCss(side?: BorderSide): string {
|
||||
if (!side) return "";
|
||||
const color = side.색 ?? "#000000";
|
||||
switch (side.선) {
|
||||
case "hair":
|
||||
return `1px dotted ${color}`;
|
||||
case "thin":
|
||||
return `1px solid ${color}`;
|
||||
case "medium":
|
||||
return `2px solid ${color}`;
|
||||
case "thick":
|
||||
return `3px solid ${color}`;
|
||||
case "double":
|
||||
return `3px double ${color}`;
|
||||
case "dotted":
|
||||
return `1px dotted ${color}`;
|
||||
case "dashed":
|
||||
return `1px dashed ${color}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
|
||||
const HALIGN_CSS: Record<HAlign, string> = {
|
||||
general: "left",
|
||||
left: "left",
|
||||
right: "right",
|
||||
center: "center",
|
||||
distributed: "justify",
|
||||
centerContinuous: "center",
|
||||
};
|
||||
|
||||
const VALIGN_CSS: Record<VAlign, string> = {
|
||||
top: "flex-start",
|
||||
center: "center",
|
||||
bottom: "flex-end",
|
||||
};
|
||||
|
||||
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`). */
|
||||
export function applyCellStyle(
|
||||
cell: HTMLElement,
|
||||
style: CellStyle,
|
||||
borders: ResolvedBorders,
|
||||
hAlign: HAlign,
|
||||
): void {
|
||||
const s = cell.style;
|
||||
s.fontFamily = style.글꼴 ?? "";
|
||||
s.fontSize = style.크기 ? `${style.크기}pt` : "";
|
||||
s.fontWeight = style.굵게 ? "700" : "400";
|
||||
s.fontStyle = style.기울임 ? "italic" : "normal";
|
||||
s.textDecorationLine = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
s.color = style.글자색 ?? "";
|
||||
s.backgroundColor = style.채움 ?? "";
|
||||
s.borderTop = borderCss(borders.위);
|
||||
s.borderLeft = borderCss(borders.왼);
|
||||
s.borderBottom = borderCss(borders.아래);
|
||||
s.borderRight = borderCss(borders.오른);
|
||||
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
|
||||
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
|
||||
|
||||
let text = cell.firstElementChild as HTMLElement | null;
|
||||
if (!text || !text.classList.contains("aislo-cell__text")) {
|
||||
text = document.createElement("div");
|
||||
text.className = "aislo-cell__text";
|
||||
cell.replaceChildren(text);
|
||||
}
|
||||
text.style.textAlign = HALIGN_CSS[hAlign];
|
||||
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
|
||||
}
|
||||
|
||||
/** 칸 글 — 서식 CSS 와 따로 두어 `invalidate()` 가 값만 바꿀 때 서식을 다시 잴 필요가 없게. */
|
||||
export function setCellText(cell: HTMLElement, text: string, title?: string): void {
|
||||
const node = cell.firstElementChild as HTMLElement | null;
|
||||
if (node && node.classList.contains("aislo-cell__text")) {
|
||||
node.textContent = text;
|
||||
} else {
|
||||
cell.textContent = text;
|
||||
}
|
||||
if (title) cell.title = title;
|
||||
else cell.removeAttribute("title");
|
||||
}
|
||||
@@ -2,32 +2,359 @@
|
||||
* spreadsheet_clipboard.ts (주인 E)
|
||||
* 복사 · 잘라내기 · 붙여넣기 — `copy` · `cut` · `paste` 사건만(허락 창 없음 · navigator.clipboard 안 씀).
|
||||
* 형 · 수식 살리기 규칙은 `spreadsheet_types.ts` `ClipBlock` 머리. 잘라 붙이기(안쪽) = `옮기기` 명령 ·
|
||||
* 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 병합 명령 묶음.
|
||||
* 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김. 0 계약 머리 — 몸은 E 가 채움.
|
||||
* 나머지 = `칸` 명령(식은 A `moveFormula` · 구글 R1C1 은 `r1c1ToA1`) + 서식 · 병합 명령 묶음.
|
||||
* 편집 중(`ctx.editor.editing()`)이면 사건을 편집기에 맡김.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellAddress, CellRange, ClipBlock, Workbook } from "./spreadsheet_types";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { colName, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type {
|
||||
BorderLine,
|
||||
Cell,
|
||||
CellAddress,
|
||||
CellInput,
|
||||
CellRange,
|
||||
CellStyle,
|
||||
ClipBlock,
|
||||
ClipCell,
|
||||
Command,
|
||||
Scalar,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_clipboard: 아직 없음(E)");
|
||||
/* ── 안 → 밖 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const BORDER_CSS: Record<BorderLine, string> = {
|
||||
hair: "dotted",
|
||||
thin: "solid",
|
||||
medium: "solid",
|
||||
thick: "solid",
|
||||
double: "double",
|
||||
dotted: "dotted",
|
||||
dashed: "dashed",
|
||||
};
|
||||
|
||||
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
|
||||
export function attachClipboard(_ctx: SpreadsheetContext): PartHandle {
|
||||
return todo();
|
||||
function styleAt(book: Workbook, sheet: Sheet, r: number, c: number): CellStyle {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0;
|
||||
return book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이) */
|
||||
export function parseClipboard(_html: string, _text: string, _at: CellAddress): ClipBlock | null {
|
||||
return todo();
|
||||
function styleToCss(style: CellStyle): string {
|
||||
const parts: string[] = [];
|
||||
if (style.글꼴) parts.push(`font-family:${style.글꼴}`);
|
||||
if (style.크기) parts.push(`font-size:${style.크기}pt`);
|
||||
if (style.굵게) parts.push("font-weight:bold");
|
||||
if (style.기울임) parts.push("font-style:italic");
|
||||
const deco = [style.밑줄 && "underline", style.취소선 && "line-through"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
if (deco) parts.push(`text-decoration:${deco}`);
|
||||
if (style.글자색) parts.push(`color:${style.글자색}`);
|
||||
if (style.채움) parts.push(`background-color:${style.채움}`);
|
||||
if (style.가로 && style.가로 !== "general") {
|
||||
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
|
||||
}
|
||||
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
|
||||
if (style.줄바꿈) parts.push("white-space:pre-wrap");
|
||||
if (style.테두리) {
|
||||
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
|
||||
const s = style.테두리?.[side];
|
||||
if (!s) return;
|
||||
const prop = {
|
||||
위: "border-top",
|
||||
아래: "border-bottom",
|
||||
왼: "border-left",
|
||||
오른: "border-right",
|
||||
}[side];
|
||||
parts.push(`${prop}:1px ${BORDER_CSS[s.선]} ${s.색 ?? "#000"}`);
|
||||
});
|
||||
}
|
||||
return parts.join(";");
|
||||
}
|
||||
|
||||
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON) */
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/** 안 → 안 왕복용 — 칸 원래 값 · 식 · 서식(인라인, 표 번호 아님) 그대로. */
|
||||
function buildAisloBlock(book: Workbook, sheet: Sheet, range: CellRange): ClipBlock {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
if (!cell) continue;
|
||||
const clip: ClipCell = {};
|
||||
if (cell.값 !== undefined) clip.값 = cell.값;
|
||||
if (cell.식 !== undefined) clip.식 = cell.식;
|
||||
if (cell.서식 !== undefined) clip.서식 = book.서식[cell.서식];
|
||||
if (Object.keys(clip).length) 칸[`${r - r0},${c - c0}`] = clip;
|
||||
}
|
||||
}
|
||||
const 병합 = (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.filter((m): m is CellRange => !!m && m.r0 >= r0 && m.c0 >= c0 && m.r1 <= r1 && m.c1 <= c1)
|
||||
.map((m) => ({ r0: m.r0 - r0, c0: m.c0 - c0, r1: m.r1 - r0, c1: m.c1 - c0 }));
|
||||
return { rows: r1 - r0 + 1, cols: c1 - c0 + 1, 칸, 병합, 원점: { r: r0, c: c0 }, 출처: "aislo" };
|
||||
}
|
||||
|
||||
/** 범위 → 클립보드 글 둘(TSV = 보이는 값 · HTML = 인라인 서식 표 + 안쪽 뭉치 JSON). */
|
||||
export function blockToClipboard(
|
||||
_book: Workbook,
|
||||
_sheet: string,
|
||||
_range: CellRange,
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
range: CellRange,
|
||||
valueAt: (r: number, c: number) => Scalar,
|
||||
): { html: string; text: string } {
|
||||
return todo();
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
if (!sheet) return { html: "", text: "" };
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const aisloBlock = buildAisloBlock(book, sheet, range);
|
||||
const rowsHtml: string[] = [];
|
||||
const tsvRows: string[] = [];
|
||||
const skip = new Set<string>();
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
const cellsHtml: string[] = [];
|
||||
const tsvCells: string[] = [];
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const key = `${r},${c}`;
|
||||
if (skip.has(key)) continue;
|
||||
const style = styleAt(book, sheet, r, c);
|
||||
const text = formatValue(valueAt(r, c), style.형식).글;
|
||||
tsvCells.push(text.replace(/\t/g, " "));
|
||||
let span = "";
|
||||
const merge = (sheet.병합 ?? [])
|
||||
.map((m) => parseRange(m))
|
||||
.find((m): m is CellRange => !!m && m.r0 === r && m.c0 === c && m.r1 <= r1 && m.c1 <= c1);
|
||||
if (merge) {
|
||||
const rs = merge.r1 - merge.r0 + 1;
|
||||
const cs = merge.c1 - merge.c0 + 1;
|
||||
if (rs > 1) span += ` rowspan="${rs}"`;
|
||||
if (cs > 1) span += ` colspan="${cs}"`;
|
||||
for (let dr = 0; dr < rs; dr++)
|
||||
for (let dc = 0; dc < cs; dc++) skip.add(`${r + dr},${c + dc}`);
|
||||
}
|
||||
cellsHtml.push(
|
||||
`<td${span} style="${escapeHtml(styleToCss(style))}">${escapeHtml(text)}</td>`,
|
||||
);
|
||||
}
|
||||
rowsHtml.push(`<tr>${cellsHtml.join("")}</tr>`);
|
||||
tsvRows.push(tsvCells.join("\t"));
|
||||
}
|
||||
const table = `<table>${rowsHtml.join("")}</table>`;
|
||||
const html = `<!--aislo:${encodeURIComponent(JSON.stringify(aisloBlock))}-->${table}`;
|
||||
return { html, text: tsvRows.join("\r\n") };
|
||||
}
|
||||
|
||||
/* ── 밖 → 안 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 「1,234.56」 · 「TRUE」 는 값으로 · 나머지는 글로. */
|
||||
function coerceValue(text: string): CellInput {
|
||||
const t = text.trim();
|
||||
if (/^[+-]?\d[\d,]*(\.\d+)?$/.test(t)) {
|
||||
const n = Number(t.replace(/,/g, ""));
|
||||
if (Number.isFinite(n)) return n;
|
||||
}
|
||||
if (/^(TRUE|FALSE)$/i.test(t)) return t.toUpperCase() === "TRUE";
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 「=」 뒤에 글이 있고 식으로 보이면 식 — 엑셀 「수식 표시」 복사 대응(「=」 한 글자는 글). */
|
||||
function cellFromText(text: string): ClipCell {
|
||||
if (text.startsWith("=") && text.length > 1) return { 식: text.slice(1) };
|
||||
return { 값: coerceValue(text) };
|
||||
}
|
||||
|
||||
interface TableWalk {
|
||||
rows: number;
|
||||
cols: number;
|
||||
owner: Map<string, HTMLTableCellElement>;
|
||||
merges: CellRange[];
|
||||
}
|
||||
|
||||
function walkTable(table: HTMLTableElement): TableWalk {
|
||||
const occupied = new Set<string>();
|
||||
const owner = new Map<string, HTMLTableCellElement>();
|
||||
const merges: CellRange[] = [];
|
||||
const rows = Array.from(table.rows);
|
||||
rows.forEach((row, r) => {
|
||||
let c = 0;
|
||||
Array.from(row.cells).forEach((cellEl) => {
|
||||
while (occupied.has(`${r},${c}`)) c++;
|
||||
const rowspan = cellEl.rowSpan || 1;
|
||||
const colspan = cellEl.colSpan || 1;
|
||||
owner.set(`${r},${c}`, cellEl);
|
||||
for (let dr = 0; dr < rowspan; dr++) {
|
||||
for (let dc = 0; dc < colspan; dc++) occupied.add(`${r + dr},${c + dc}`);
|
||||
}
|
||||
if (rowspan > 1 || colspan > 1) {
|
||||
merges.push({ r0: r, c0: c, r1: r + rowspan - 1, c1: c + colspan - 1 });
|
||||
}
|
||||
c += colspan;
|
||||
});
|
||||
});
|
||||
let cols = 0;
|
||||
for (const key of occupied) {
|
||||
const c = Number(key.split(",")[1]);
|
||||
if (c + 1 > cols) cols = c + 1;
|
||||
}
|
||||
return { rows: rows.length, cols, owner, merges };
|
||||
}
|
||||
|
||||
function parseHtmlTable(html: string, at: CellAddress): ClipBlock | null {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const table = doc.querySelector("table");
|
||||
if (!table) return null;
|
||||
const walk = walkTable(table);
|
||||
const sheetsMode = /data-sheets-formula/.test(html);
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
for (const [key, cellEl] of walk.owner) {
|
||||
const [r, c] = key.split(",").map(Number);
|
||||
const text = cellEl.textContent ?? "";
|
||||
const formula = sheetsMode ? cellEl.getAttribute("data-sheets-formula") : null;
|
||||
if (formula) {
|
||||
const target = { r: at.r + r, c: at.c + c };
|
||||
칸[key] = { 식: r1c1ToA1(formula.replace(/^=/, ""), target) };
|
||||
continue;
|
||||
}
|
||||
if (!text) continue;
|
||||
칸[key] = cellFromText(text);
|
||||
}
|
||||
return {
|
||||
rows: walk.rows,
|
||||
cols: walk.cols,
|
||||
칸,
|
||||
병합: walk.merges,
|
||||
원점: null,
|
||||
출처: sheetsMode ? "google" : "excel",
|
||||
};
|
||||
}
|
||||
|
||||
function parseTsv(text: string): ClipBlock {
|
||||
const lines = text.replace(/\r\n/g, "\n").split("\n");
|
||||
while (lines.length && lines[lines.length - 1] === "") lines.pop();
|
||||
const grid = lines.map((line) => line.split("\t"));
|
||||
let cols = 0;
|
||||
for (const row of grid) if (row.length > cols) cols = row.length;
|
||||
const 칸: Record<string, ClipCell> = {};
|
||||
grid.forEach((row, r) => {
|
||||
row.forEach((text, c) => {
|
||||
if (text === "") return;
|
||||
칸[`${r},${c}`] = cellFromText(text);
|
||||
});
|
||||
});
|
||||
return { rows: grid.length, cols, 칸, 병합: [], 원점: null, 출처: "text" };
|
||||
}
|
||||
|
||||
/** 클립보드 글 → 뭉치(HTML 먼저 · 없으면 TSV) · 읽을 것 없으면 null · `at` = 붙일 왼위(구글 R1C1 풀이). */
|
||||
export function parseClipboard(html: string, text: string, at: CellAddress): ClipBlock | null {
|
||||
const aislo = /<!--aislo:([^>]*)-->/.exec(html);
|
||||
if (aislo) {
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(aislo[1])) as ClipBlock;
|
||||
} catch {
|
||||
// 손상됐으면 아래 일반 경로로 폴백.
|
||||
}
|
||||
}
|
||||
if (/<table/i.test(html)) return parseHtmlTable(html, at);
|
||||
if (text) return parseTsv(text);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ── 화면 붙임 — copy · cut · paste 사건 ────────────────────────────────── */
|
||||
|
||||
function activeRange(ctx: SpreadsheetContext): CellRange {
|
||||
return (
|
||||
ctx.selection.범위[0] ?? {
|
||||
r0: ctx.selection.활성.r,
|
||||
c0: ctx.selection.활성.c,
|
||||
r1: ctx.selection.활성.r,
|
||||
c1: ctx.selection.활성.c,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function pasteBlock(ctx: SpreadsheetContext, block: ClipBlock, at: CellAddress): void {
|
||||
const shift = block.원점 ? { dr: at.r - block.원점.r, dc: at.c - block.원점.c } : null;
|
||||
const 칸: Record<string, Cell | null> = {};
|
||||
const styleCmds: Command[] = [];
|
||||
for (const [key, clip] of Object.entries(block.칸)) {
|
||||
const [lr, lc] = key.split(",").map(Number);
|
||||
const r = at.r + lr;
|
||||
const c = at.c + lc;
|
||||
const cell: Cell = {};
|
||||
if (clip.값 !== undefined) cell.값 = clip.값;
|
||||
if (clip.식 !== undefined) cell.식 = shift ? moveFormula(clip.식, shift.dr, shift.dc) : clip.식;
|
||||
칸[toA1(r, c)] = Object.keys(cell).length ? cell : null;
|
||||
if (clip.서식) {
|
||||
styleCmds.push({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: clip.서식,
|
||||
});
|
||||
}
|
||||
}
|
||||
const commands: Command[] = [{ 종류: "칸", 시트: ctx.selection.시트, 칸 }, ...styleCmds];
|
||||
for (const m of block.병합) {
|
||||
commands.push({
|
||||
종류: "병합",
|
||||
시트: ctx.selection.시트,
|
||||
범위: { r0: at.r + m.r0, c0: at.c + m.c0, r1: at.r + m.r1, c1: at.c + m.c1 },
|
||||
});
|
||||
}
|
||||
ctx.dispatch(commands.length === 1 ? commands[0] : { 종류: "묶음", 명령: commands });
|
||||
}
|
||||
|
||||
/** `ctx.root` 의 copy · cut · paste 를 받음(root null) */
|
||||
export function attachClipboard(ctx: SpreadsheetContext): PartHandle {
|
||||
function onCopy(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing()) return;
|
||||
const range = activeRange(ctx);
|
||||
const { html, text } = blockToClipboard(ctx.book, ctx.selection.시트, range, (r, c) =>
|
||||
ctx.engine.value(ctx.selection.시트, r, c),
|
||||
);
|
||||
ev.clipboardData?.setData("text/html", html);
|
||||
ev.clipboardData?.setData("text/plain", text);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function onCut(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing() || ctx.readOnly) return;
|
||||
onCopy(ev);
|
||||
ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [activeRange(ctx)] });
|
||||
}
|
||||
|
||||
function onPaste(ev: ClipboardEvent): void {
|
||||
if (ctx.editor.editing() || ctx.readOnly) return;
|
||||
const html = ev.clipboardData?.getData("text/html") ?? "";
|
||||
const text = ev.clipboardData?.getData("text/plain") ?? "";
|
||||
const at = ctx.selection.활성;
|
||||
const block = parseClipboard(html, text, at);
|
||||
if (!block) {
|
||||
showToast(st("ClipboardPasteFailed"), "error");
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
pasteBlock(ctx, block, at);
|
||||
}
|
||||
|
||||
ctx.root.addEventListener("copy", onCopy);
|
||||
ctx.root.addEventListener("cut", onCut);
|
||||
ctx.root.addEventListener("paste", onPaste);
|
||||
|
||||
return {
|
||||
root: null,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("copy", onCopy);
|
||||
ctx.root.removeEventListener("cut", onCut);
|
||||
ctx.root.removeEventListener("paste", onPaste);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_grid.css (주인 C)
|
||||
* 격자 4 판 · 머리 · 칸 기본 모양. 색 · 간격은 여기 변수만(다른 파일은 이 클래스만 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
.aislo-grid {
|
||||
--aislo-grid-line: #d9d9d9;
|
||||
--aislo-grid-head-bg: #f3f3f3;
|
||||
--aislo-grid-head-fg: #444;
|
||||
--aislo-grid-head-border: #cfcfcf;
|
||||
--aislo-grid-freeze-shadow: rgba(0, 0, 0, 0.12);
|
||||
--aislo-grid-selected: #1a73e8;
|
||||
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font:
|
||||
10pt "굴림",
|
||||
sans-serif;
|
||||
background: #fff;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.aislo-grid__corner,
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll,
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll,
|
||||
.aislo-grid__pane {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__corner {
|
||||
z-index: 5;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__colhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead-frozen,
|
||||
.aislo-grid__rowhead-scroll {
|
||||
z-index: 4;
|
||||
background: var(--aislo-grid-head-bg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-frozen,
|
||||
.aislo-grid__rowhead-frozen {
|
||||
z-index: 6;
|
||||
box-shadow: 2px 2px 4px var(--aislo-grid-freeze-shadow);
|
||||
}
|
||||
|
||||
.aislo-grid__colhead-inner,
|
||||
.aislo-grid__rowhead-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead,
|
||||
.aislo-grid__rowhead {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.82em;
|
||||
font-weight: 600;
|
||||
color: var(--aislo-grid-head-fg);
|
||||
border-right: 1px solid var(--aislo-grid-head-border);
|
||||
border-bottom: 1px solid var(--aislo-grid-head-border);
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.aislo-grid__rowhead {
|
||||
left: 0;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.aislo-grid__colhead--hidden-before,
|
||||
.aislo-grid__rowhead--hidden-before {
|
||||
border-left: 3px double #888;
|
||||
}
|
||||
|
||||
.aislo-grid__head-label {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.aislo-grid__resize {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--col {
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__resize--row {
|
||||
left: 0;
|
||||
bottom: -3px;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
cursor: row-resize;
|
||||
}
|
||||
|
||||
.aislo-grid__pane-inner {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.aislo-grid__pane--br .aislo-grid__spacer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.aislo-cell {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 2px;
|
||||
background: #fff;
|
||||
cursor: cell;
|
||||
}
|
||||
|
||||
.aislo-grid--gridlines .aislo-cell {
|
||||
border-right: 1px solid var(--aislo-grid-line);
|
||||
border-bottom: 1px solid var(--aislo-grid-line);
|
||||
}
|
||||
|
||||
.aislo-cell__text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.aislo-cell.is-error {
|
||||
color: #c00;
|
||||
}
|
||||
|
||||
.aislo-cell.is-selected {
|
||||
outline: 2px solid var(--aislo-grid-selected);
|
||||
outline-offset: -1px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.aislo-grid__ref-highlight {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
border: 1.5px solid;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__fill-handle {
|
||||
position: absolute;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--aislo-grid-selected);
|
||||
border: 1px solid #fff;
|
||||
cursor: crosshair;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.aislo-grid__overlay > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
@@ -3,16 +3,705 @@
|
||||
* 격자 — DOM(칸 = 절대 위치 div) · 틀고정 4 판(왼위 · 위 · 왼 · 본문) · 판마다 보이는 행열만 그림 ·
|
||||
* 병합 칸 = div 하나 · 숨긴 행열 건너뜀 · 눈금선 · 고름 · 참조 색 테두리 · 채우기 손잡이.
|
||||
* 칸 글은 엔진 값 → B `formatValue` → 서식 CSS(`spreadsheet_cellstyle.ts`). 머리 · 폭 끌기는 `spreadsheet_headers.ts`.
|
||||
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`. 0 계약 머리 — 몸은 C 가 채움.
|
||||
* 폭 · 높이 끌기는 `ctx.dispatch({종류:"열폭"|"행높이"})`.
|
||||
*
|
||||
* 4 판 — 오른아래(`br`) 만 실제 스크롤바를 가짐(overflow:auto · 안에 전체 크기 스페이서) ·
|
||||
* 나머지 세 판은 overflow:hidden 이고 `br` 의 scroll 을 그대로 옮겨 받음(틀고정 방향만).
|
||||
* 보이는 칸만 = 판 크기 안에 들어오는 행열만 실제 div 로 그림(칸 재사용 — `CellPool`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { GridHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import "./spreadsheet_grid.css";
|
||||
import { toFrac } from "@ui/sheet/ui_template_sheet_frac";
|
||||
import { colIndex, parseA1, parseRange, toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
applyCellStyle,
|
||||
cellStyleAt,
|
||||
resolveBoxBorders,
|
||||
setCellText,
|
||||
type ResolvedBorders,
|
||||
} from "./spreadsheet_cellstyle";
|
||||
import {
|
||||
buildColGeometry,
|
||||
buildRowGeometry,
|
||||
commitColWidth,
|
||||
commitRowHeight,
|
||||
makeColHeaderCell,
|
||||
makeRowHeaderCell,
|
||||
startDragResize,
|
||||
visibleIndices,
|
||||
type LineGeometry,
|
||||
} from "./spreadsheet_headers";
|
||||
import { formatValue } from "./spreadsheet_numfmt";
|
||||
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
|
||||
import type {
|
||||
CellRange,
|
||||
ErrorValue,
|
||||
HAlign,
|
||||
Scalar,
|
||||
Sheet,
|
||||
SheetCellAddress,
|
||||
} from "./spreadsheet_types";
|
||||
import type {
|
||||
CellBox,
|
||||
GridHandle,
|
||||
HitResult,
|
||||
RefHighlight,
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_grid: 아직 없음(C)");
|
||||
};
|
||||
const HEAD_ROW_H = 24;
|
||||
const HEAD_COL_W = 46;
|
||||
const RESIZE_HOTZONE = 4;
|
||||
const REF_COLORS = ["#1a73e8", "#e8710a", "#0f9d58", "#a142f4", "#d93025"];
|
||||
|
||||
/** `ctx.grid` 는 D 가 이 결과로 채움 — 만들 때 `ctx.grid` · `ctx.editor` 를 부르지 말 것 */
|
||||
export function createGrid(_ctx: SpreadsheetContext): GridHandle {
|
||||
return todo();
|
||||
/** 칸 div 재사용 — 새 보이는 칸 집합에 없는 건 떼어 다음 판에 다시 씀. */
|
||||
interface CellPool {
|
||||
used: Map<string, HTMLElement>;
|
||||
free: HTMLElement[];
|
||||
}
|
||||
const createPool = (): CellPool => ({ used: new Map(), free: [] });
|
||||
|
||||
function poolGet(
|
||||
pool: CellPool,
|
||||
key: string,
|
||||
container: HTMLElement,
|
||||
className: string,
|
||||
): HTMLElement {
|
||||
const found = pool.used.get(key);
|
||||
if (found) return found;
|
||||
const node = pool.free.pop() ?? document.createElement("div");
|
||||
node.className = className;
|
||||
pool.used.set(key, node);
|
||||
container.append(node);
|
||||
return node;
|
||||
}
|
||||
|
||||
function poolSweep(pool: CellPool, keep: Set<string>): void {
|
||||
for (const [key, node] of pool.used) {
|
||||
if (keep.has(key)) continue;
|
||||
pool.used.delete(key);
|
||||
node.remove();
|
||||
pool.free.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
function toScalar(value: number | string | boolean): Scalar {
|
||||
return typeof value === "number" ? toFrac(value) : value;
|
||||
}
|
||||
|
||||
/** 시트에 실제 쓰인 자리 + 여유(엑셀 최대 자리까지 다 쓰지 않음 — 스크롤판 스페이서를 적당히 둠). */
|
||||
function computeExtent(sheet: Sheet): { rows: number; cols: number } {
|
||||
let maxR = 0;
|
||||
let maxC = 0;
|
||||
for (const key of Object.keys(sheet.칸)) {
|
||||
const addr = parseA1(key);
|
||||
if (!addr) continue;
|
||||
maxR = Math.max(maxR, addr.r);
|
||||
maxC = Math.max(maxC, addr.c);
|
||||
}
|
||||
for (const key of Object.keys(sheet.행 ?? {})) maxR = Math.max(maxR, Number(key) - 1);
|
||||
for (const key of Object.keys(sheet.열 ?? {})) maxC = Math.max(maxC, colIndex(key));
|
||||
for (const m of sheet.병합 ?? []) {
|
||||
const range = parseRange(m);
|
||||
if (!range) continue;
|
||||
maxR = Math.max(maxR, range.r1);
|
||||
maxC = Math.max(maxC, range.c1);
|
||||
}
|
||||
return {
|
||||
rows: Math.min(MAX_ROWS, Math.max(maxR + 100, 200)),
|
||||
cols: Math.min(MAX_COLS, Math.max(maxC + 20, 60)),
|
||||
};
|
||||
}
|
||||
|
||||
function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
|
||||
const map = new Map<string, CellRange>();
|
||||
for (const m of sheet.병합 ?? []) {
|
||||
const range = parseRange(m);
|
||||
if (!range) continue;
|
||||
for (let r = range.r0; r <= range.r1; r += 1) {
|
||||
for (let c = range.c0; c <= range.c1; c += 1) map.set(`${r},${c}`, range);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
interface CellDisplay {
|
||||
text: string;
|
||||
hAlign: HAlign;
|
||||
title: string;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
function cellDisplay(
|
||||
ctx: SpreadsheetContext,
|
||||
sheet: Sheet,
|
||||
r: number,
|
||||
c: number,
|
||||
hAlign0: HAlign,
|
||||
code?: string,
|
||||
): CellDisplay {
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const scalar: Scalar =
|
||||
cell?.식 !== undefined
|
||||
? ctx.engine.value(sheet.id, r, c)
|
||||
: cell?.값 !== undefined
|
||||
? toScalar(cell.값)
|
||||
: null;
|
||||
const formatted = formatValue(scalar, code);
|
||||
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
|
||||
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
|
||||
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
|
||||
return { text: formatted.글, hAlign, title, isError };
|
||||
}
|
||||
|
||||
export function createGrid(ctx: SpreadsheetContext): GridHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "aislo-grid";
|
||||
|
||||
const corner = document.createElement("div");
|
||||
corner.className = "aislo-grid__corner";
|
||||
const colHeadFrozen = paneDiv("aislo-grid__colhead-frozen");
|
||||
const colHeadScroll = paneDiv("aislo-grid__colhead-scroll");
|
||||
const rowHeadFrozen = paneDiv("aislo-grid__rowhead-frozen");
|
||||
const rowHeadScroll = paneDiv("aislo-grid__rowhead-scroll");
|
||||
const paneTL = paneDiv("aislo-grid__pane aislo-grid__pane--tl");
|
||||
const paneTR = paneDiv("aislo-grid__pane aislo-grid__pane--tr");
|
||||
const paneBL = paneDiv("aislo-grid__pane aislo-grid__pane--bl");
|
||||
const paneBR = paneDiv("aislo-grid__pane aislo-grid__pane--br");
|
||||
const overlay = paneDiv("aislo-grid__overlay");
|
||||
|
||||
const colHeadInnerFrozen = innerDiv("aislo-grid__colhead-inner");
|
||||
const colHeadInnerScroll = innerDiv("aislo-grid__colhead-inner");
|
||||
const rowHeadInnerFrozen = innerDiv("aislo-grid__rowhead-inner");
|
||||
const rowHeadInnerScroll = innerDiv("aislo-grid__rowhead-inner");
|
||||
const paneTLInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneTRInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneBLInner = innerDiv("aislo-grid__pane-inner");
|
||||
const paneBRInner = innerDiv("aislo-grid__pane-inner");
|
||||
const spacer = document.createElement("div");
|
||||
spacer.className = "aislo-grid__spacer";
|
||||
|
||||
colHeadFrozen.append(colHeadInnerFrozen);
|
||||
colHeadScroll.append(colHeadInnerScroll);
|
||||
rowHeadFrozen.append(rowHeadInnerFrozen);
|
||||
rowHeadScroll.append(rowHeadInnerScroll);
|
||||
paneTL.append(paneTLInner);
|
||||
paneTR.append(paneTRInner);
|
||||
paneBL.append(paneBLInner);
|
||||
paneBR.append(paneBRInner, spacer);
|
||||
root.append(
|
||||
corner,
|
||||
colHeadFrozen,
|
||||
colHeadScroll,
|
||||
rowHeadFrozen,
|
||||
rowHeadScroll,
|
||||
paneTL,
|
||||
paneTR,
|
||||
paneBL,
|
||||
paneBR,
|
||||
overlay,
|
||||
);
|
||||
|
||||
const pools = { tl: createPool(), tr: createPool(), bl: createPool(), br: createPool() };
|
||||
const colHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
const rowHeadPools = { frozen: createPool(), scroll: createPool() };
|
||||
|
||||
let colGeo: LineGeometry = buildColGeometry(ctx.book, ctx.sheet());
|
||||
let rowGeo: LineGeometry = buildRowGeometry(ctx.book, ctx.sheet());
|
||||
let extent = computeExtent(ctx.sheet());
|
||||
let merges = buildMergeMap(ctx.sheet());
|
||||
let frozenRows = 0;
|
||||
let frozenCols = 0;
|
||||
let frozenW = 0;
|
||||
let frozenH = 0;
|
||||
|
||||
const resize = new ResizeObserver(() => layoutPanes());
|
||||
resize.observe(root);
|
||||
|
||||
/** 판 크기 · 자리 다시 재기(창 크기 · 틀고정 수가 바뀔 때). */
|
||||
function layoutPanes(): void {
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
frozenW = colGeo.total(frozenCols);
|
||||
frozenH = rowGeo.total(frozenRows);
|
||||
corner.style.width = `${HEAD_COL_W}px`;
|
||||
corner.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadFrozen.style.left = `${HEAD_COL_W}px`;
|
||||
colHeadFrozen.style.top = "0";
|
||||
colHeadFrozen.style.width = `${frozenW}px`;
|
||||
colHeadFrozen.style.height = `${HEAD_ROW_H}px`;
|
||||
colHeadScroll.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
colHeadScroll.style.top = "0";
|
||||
colHeadScroll.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
colHeadScroll.style.height = `${HEAD_ROW_H}px`;
|
||||
rowHeadFrozen.style.left = "0";
|
||||
rowHeadFrozen.style.top = `${HEAD_ROW_H}px`;
|
||||
rowHeadFrozen.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadFrozen.style.height = `${frozenH}px`;
|
||||
rowHeadScroll.style.left = "0";
|
||||
rowHeadScroll.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
rowHeadScroll.style.width = `${HEAD_COL_W}px`;
|
||||
rowHeadScroll.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneTL.style.left = `${HEAD_COL_W}px`;
|
||||
paneTL.style.top = `${HEAD_ROW_H}px`;
|
||||
paneTL.style.width = `${frozenW}px`;
|
||||
paneTL.style.height = `${frozenH}px`;
|
||||
paneTR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneTR.style.top = `${HEAD_ROW_H}px`;
|
||||
paneTR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneTR.style.height = `${frozenH}px`;
|
||||
paneBL.style.left = `${HEAD_COL_W}px`;
|
||||
paneBL.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBL.style.width = `${frozenW}px`;
|
||||
paneBL.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
paneBR.style.left = `${HEAD_COL_W + frozenW}px`;
|
||||
paneBR.style.top = `${HEAD_ROW_H + frozenH}px`;
|
||||
paneBR.style.width = `${Math.max(0, w - HEAD_COL_W - frozenW)}px`;
|
||||
paneBR.style.height = `${Math.max(0, h - HEAD_ROW_H - frozenH)}px`;
|
||||
spacer.style.transform = `translate(${colGeo.total(extent.cols) - frozenW}px, ${rowGeo.total(extent.rows) - frozenH}px)`;
|
||||
overlay.style.left = `${HEAD_COL_W}px`;
|
||||
overlay.style.top = `${HEAD_ROW_H}px`;
|
||||
overlay.style.right = "0";
|
||||
overlay.style.bottom = "0";
|
||||
renderVisible();
|
||||
}
|
||||
|
||||
/** `br` 스크롤 위치 → 세 판(머리 둘 · tr · bl)에 옮겨 그리고 보이는 칸을 다시. */
|
||||
function onScroll(): void {
|
||||
const left = paneBR.scrollLeft;
|
||||
const top = paneBR.scrollTop;
|
||||
colHeadInnerScroll.style.transform = `translateX(${-left}px)`;
|
||||
paneTRInner.style.transform = `translateX(${-left}px)`;
|
||||
rowHeadInnerScroll.style.transform = `translateY(${-top}px)`;
|
||||
paneBLInner.style.transform = `translateY(${-top}px)`;
|
||||
renderVisible();
|
||||
}
|
||||
paneBR.addEventListener("scroll", onScroll);
|
||||
|
||||
const gridlines = (): boolean => ctx.sheet().보기?.눈금선 !== false;
|
||||
|
||||
function renderRowOfCells(
|
||||
rowsVisible: number[],
|
||||
colsVisible: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
baseX: number,
|
||||
baseY: number,
|
||||
): void {
|
||||
const sheet = ctx.sheet();
|
||||
const book = ctx.book;
|
||||
const keep = new Set<string>();
|
||||
for (const r of rowsVisible) {
|
||||
for (const c of colsVisible) {
|
||||
const merge = merges.get(`${r},${c}`);
|
||||
if (merge && (merge.r0 !== r || merge.c0 !== c)) continue;
|
||||
const r0 = merge ? merge.r0 : r;
|
||||
const c0 = merge ? merge.c0 : c;
|
||||
const r1 = merge ? merge.r1 : r;
|
||||
const c1 = merge ? merge.c1 : c;
|
||||
const key = `${r0},${c0}`;
|
||||
if (keep.has(key)) continue;
|
||||
keep.add(key);
|
||||
const cellEl = poolGet(pool, key, container, "aislo-cell");
|
||||
const x = colGeo.offset(c0) - baseX;
|
||||
const y = rowGeo.offset(r0) - baseY;
|
||||
const w = colGeo.offset(c1 + 1) - colGeo.offset(c0);
|
||||
const h = rowGeo.offset(r1 + 1) - rowGeo.offset(r0);
|
||||
cellEl.style.left = `${x}px`;
|
||||
cellEl.style.top = `${y}px`;
|
||||
cellEl.style.width = `${w}px`;
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.r = String(r0);
|
||||
cellEl.dataset.c = String(c0);
|
||||
const style = cellStyleAt(book, sheet, r0, c0);
|
||||
const borders: ResolvedBorders = resolveBoxBorders(
|
||||
book,
|
||||
sheet,
|
||||
r0,
|
||||
c0,
|
||||
r1,
|
||||
c1,
|
||||
extent.rows - 1,
|
||||
extent.cols - 1,
|
||||
);
|
||||
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
|
||||
applyCellStyle(cellEl, style, borders, display.hAlign);
|
||||
setCellText(cellEl, display.text, display.title || undefined);
|
||||
cellEl.classList.toggle("is-error", display.isError);
|
||||
cellEl.classList.toggle(
|
||||
"is-selected",
|
||||
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
|
||||
);
|
||||
}
|
||||
}
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
|
||||
function layoutColHeaders(
|
||||
cols: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
base: number,
|
||||
): void {
|
||||
const keep = new Set<string>();
|
||||
cols.forEach((c, i) => {
|
||||
const key = String(c);
|
||||
keep.add(key);
|
||||
const prev = i > 0 ? cols[i - 1] : c - 1;
|
||||
const hiddenBefore = c - prev > 1;
|
||||
let cellEl = pool.used.get(key);
|
||||
if (!cellEl) {
|
||||
const built = makeColHeaderCell(c, 0, 0, hiddenBefore);
|
||||
cellEl = built.el;
|
||||
pool.used.set(key, cellEl);
|
||||
container.append(cellEl);
|
||||
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--col")!;
|
||||
startDragResize({
|
||||
handle,
|
||||
axis: "col",
|
||||
currentPx: () => colGeo.size(c),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.width = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitColWidth(ctx, c, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.left = `${colGeo.offset(c) - base}px`;
|
||||
cellEl.style.width = `${colGeo.size(c)}px`;
|
||||
cellEl.classList.toggle("aislo-grid__colhead--hidden-before", hiddenBefore);
|
||||
});
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
|
||||
function layoutRowHeaders(
|
||||
rows: number[],
|
||||
pool: CellPool,
|
||||
container: HTMLElement,
|
||||
base: number,
|
||||
): void {
|
||||
const keep = new Set<string>();
|
||||
rows.forEach((r, i) => {
|
||||
const key = String(r);
|
||||
keep.add(key);
|
||||
const prev = i > 0 ? rows[i - 1] : r - 1;
|
||||
const hiddenBefore = r - prev > 1;
|
||||
let cellEl = pool.used.get(key);
|
||||
if (!cellEl) {
|
||||
const built = makeRowHeaderCell(r, 0, 0, hiddenBefore);
|
||||
cellEl = built.el;
|
||||
pool.used.set(key, cellEl);
|
||||
container.append(cellEl);
|
||||
const handle = cellEl.querySelector<HTMLElement>(".aislo-grid__resize--row")!;
|
||||
startDragResize({
|
||||
handle,
|
||||
axis: "row",
|
||||
currentPx: () => rowGeo.size(r),
|
||||
onPreview: (px) => {
|
||||
cellEl!.style.height = `${px}px`;
|
||||
},
|
||||
onCommit: (px) => commitRowHeight(ctx, r, px),
|
||||
});
|
||||
}
|
||||
cellEl.style.top = `${rowGeo.offset(r) - base}px`;
|
||||
cellEl.style.height = `${rowGeo.size(r)}px`;
|
||||
cellEl.classList.toggle("aislo-grid__rowhead--hidden-before", hiddenBefore);
|
||||
});
|
||||
poolSweep(pool, keep);
|
||||
}
|
||||
|
||||
function renderVisible(): void {
|
||||
root.classList.toggle("aislo-grid--gridlines", gridlines());
|
||||
const w = root.clientWidth;
|
||||
const h = root.clientHeight;
|
||||
const scrollW = Math.max(0, w - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, h - HEAD_ROW_H - frozenH);
|
||||
const frozenColsVisible = visibleIndices(colGeo, 0, Math.max(0, frozenCols - 1));
|
||||
const scrollColFrom = Math.max(frozenCols, colGeo.indexAt(paneBR.scrollLeft));
|
||||
const scrollColTo = Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW) + 1);
|
||||
const scrollColsVisible = visibleIndices(
|
||||
colGeo,
|
||||
scrollColFrom,
|
||||
Math.max(scrollColFrom, scrollColTo),
|
||||
);
|
||||
const frozenRowsVisible = visibleIndices(rowGeo, 0, Math.max(0, frozenRows - 1));
|
||||
const scrollRowFrom = Math.max(frozenRows, rowGeo.indexAt(paneBR.scrollTop));
|
||||
const scrollRowTo = Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH) + 1);
|
||||
const scrollRowsVisible = visibleIndices(
|
||||
rowGeo,
|
||||
scrollRowFrom,
|
||||
Math.max(scrollRowFrom, scrollRowTo),
|
||||
);
|
||||
|
||||
layoutColHeaders(frozenColsVisible, colHeadPools.frozen, colHeadInnerFrozen, 0);
|
||||
layoutColHeaders(
|
||||
scrollColsVisible,
|
||||
colHeadPools.scroll,
|
||||
colHeadInnerScroll,
|
||||
colGeo.offset(frozenCols),
|
||||
);
|
||||
layoutRowHeaders(frozenRowsVisible, rowHeadPools.frozen, rowHeadInnerFrozen, 0);
|
||||
layoutRowHeaders(
|
||||
scrollRowsVisible,
|
||||
rowHeadPools.scroll,
|
||||
rowHeadInnerScroll,
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
|
||||
renderRowOfCells(frozenRowsVisible, frozenColsVisible, pools.tl, paneTLInner, 0, 0);
|
||||
renderRowOfCells(
|
||||
frozenRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.tr,
|
||||
paneTRInner,
|
||||
colGeo.offset(frozenCols),
|
||||
0,
|
||||
);
|
||||
renderRowOfCells(
|
||||
scrollRowsVisible,
|
||||
frozenColsVisible,
|
||||
pools.bl,
|
||||
paneBLInner,
|
||||
0,
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
renderRowOfCells(
|
||||
scrollRowsVisible,
|
||||
scrollColsVisible,
|
||||
pools.br,
|
||||
paneBRInner,
|
||||
colGeo.offset(frozenCols),
|
||||
rowGeo.offset(frozenRows),
|
||||
);
|
||||
|
||||
renderSelection();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const sheet = ctx.sheet();
|
||||
colGeo = buildColGeometry(ctx.book, sheet);
|
||||
rowGeo = buildRowGeometry(ctx.book, sheet);
|
||||
extent = computeExtent(sheet);
|
||||
merges = buildMergeMap(sheet);
|
||||
frozenRows = Math.min(sheet.틀고정?.행 ?? 0, extent.rows);
|
||||
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
|
||||
spacer.style.width = "1px";
|
||||
spacer.style.height = "1px";
|
||||
paneBR.scrollLeft = 0;
|
||||
paneBR.scrollTop = 0;
|
||||
layoutPanes();
|
||||
}
|
||||
|
||||
/** 값 · 서식이 바뀐 칸만 — 지금 그려진 칸이면 다시(구조는 안 바꿈 · `render()` 는 구조 바뀔 때만). */
|
||||
function invalidate(cells: SheetCellAddress[]): void {
|
||||
const sheet = ctx.sheet();
|
||||
const dirty = new Set(cells.filter((c) => c.시트 === sheet.id).map((c) => `${c.r},${c.c}`));
|
||||
if (dirty.size === 0) return;
|
||||
const anyDirty = (pool: CellPool): boolean => {
|
||||
for (const key of pool.used.keys()) if (dirty.has(key)) return true;
|
||||
return false;
|
||||
};
|
||||
if (anyDirty(pools.tl) || anyDirty(pools.tr) || anyDirty(pools.bl) || anyDirty(pools.br))
|
||||
renderVisible();
|
||||
}
|
||||
|
||||
function boxOf(r: number, c: number): CellBox {
|
||||
const merge = merges.get(`${r},${c}`);
|
||||
const r0 = merge ? merge.r0 : r;
|
||||
const c0 = merge ? merge.c0 : c;
|
||||
const r1 = merge ? merge.r1 : r;
|
||||
const c1 = merge ? merge.c1 : c;
|
||||
return {
|
||||
x: colGeo.offset(c0),
|
||||
y: rowGeo.offset(r0),
|
||||
w: colGeo.offset(c1 + 1) - colGeo.offset(c0),
|
||||
h: rowGeo.offset(r1 + 1) - rowGeo.offset(r0),
|
||||
};
|
||||
}
|
||||
|
||||
function screenBoxOf(r: number, c: number): CellBox {
|
||||
const box = boxOf(r, c);
|
||||
const inFrozenCol = c < frozenCols;
|
||||
const inFrozenRow = r < frozenRows;
|
||||
const x = HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft);
|
||||
const y = HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop);
|
||||
return { x, y, w: box.w, h: box.h };
|
||||
}
|
||||
|
||||
function renderSelection(): void {
|
||||
let selLayer = overlay.querySelector<HTMLElement>(".aislo-grid__sel-layer");
|
||||
if (!selLayer) {
|
||||
selLayer = document.createElement("div");
|
||||
selLayer.className = "aislo-grid__sel-layer";
|
||||
overlay.append(selLayer);
|
||||
}
|
||||
selLayer.replaceChildren();
|
||||
const sel = ctx.selection;
|
||||
if (!sel || sel.시트 !== ctx.sheet().id) return;
|
||||
for (const [i, range] of sel.범위.entries()) {
|
||||
const box: CellBox = {
|
||||
x: colGeo.offset(range.c0),
|
||||
y: rowGeo.offset(range.r0),
|
||||
w: colGeo.offset(range.c1 + 1) - colGeo.offset(range.c0),
|
||||
h: rowGeo.offset(range.r1 + 1) - rowGeo.offset(range.r0),
|
||||
};
|
||||
const inFrozenCol = range.c0 < frozenCols;
|
||||
const inFrozenRow = range.r0 < frozenRows;
|
||||
const screen: CellBox = {
|
||||
x: HEAD_COL_W + (inFrozenCol ? box.x : box.x - paneBR.scrollLeft),
|
||||
y: HEAD_ROW_H + (inFrozenRow ? box.y : box.y - paneBR.scrollTop),
|
||||
w: box.w,
|
||||
h: box.h,
|
||||
};
|
||||
const node = document.createElement("div");
|
||||
node.className = "aislo-grid__ref-highlight";
|
||||
node.style.left = `${screen.x}px`;
|
||||
node.style.top = `${screen.y}px`;
|
||||
node.style.width = `${screen.w}px`;
|
||||
node.style.height = `${screen.h}px`;
|
||||
node.style.borderColor = "#1a73e8";
|
||||
node.style.borderWidth = i === 0 ? "2px" : "1px";
|
||||
selLayer!.append(node);
|
||||
}
|
||||
const active = boxOf(sel.활성.r, sel.활성.c);
|
||||
const inFrozenCol = sel.활성.c < frozenCols;
|
||||
const inFrozenRow = sel.활성.r < frozenRows;
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__fill-handle";
|
||||
handle.style.left = `${HEAD_COL_W + (inFrozenCol ? active.x : active.x - paneBR.scrollLeft) + active.w - 4}px`;
|
||||
handle.style.top = `${HEAD_ROW_H + (inFrozenRow ? active.y : active.y - paneBR.scrollTop) + active.h - 4}px`;
|
||||
selLayer!.append(handle);
|
||||
}
|
||||
|
||||
function setRefHighlights(list: RefHighlight[]): void {
|
||||
let refLayer = overlay.querySelector<HTMLElement>(".aislo-grid__ref-layer");
|
||||
if (!refLayer) {
|
||||
refLayer = document.createElement("div");
|
||||
refLayer.className = "aislo-grid__ref-layer";
|
||||
overlay.append(refLayer);
|
||||
}
|
||||
refLayer.replaceChildren();
|
||||
for (const ref of list) {
|
||||
if (ref.시트 !== ctx.sheet().id) continue;
|
||||
const box = boxOf(ref.범위.r0, ref.범위.c0);
|
||||
const box2 = boxOf(ref.범위.r1, ref.범위.c1);
|
||||
const merged: CellBox = {
|
||||
x: box.x,
|
||||
y: box.y,
|
||||
w: box2.x + box2.w - box.x,
|
||||
h: box2.y + box2.h - box.y,
|
||||
};
|
||||
const inFrozenCol = ref.범위.c0 < frozenCols;
|
||||
const inFrozenRow = ref.범위.r0 < frozenRows;
|
||||
const node = document.createElement("div");
|
||||
node.className = "aislo-grid__ref-highlight";
|
||||
node.style.left = `${HEAD_COL_W + (inFrozenCol ? merged.x : merged.x - paneBR.scrollLeft)}px`;
|
||||
node.style.top = `${HEAD_ROW_H + (inFrozenRow ? merged.y : merged.y - paneBR.scrollTop)}px`;
|
||||
node.style.width = `${merged.w}px`;
|
||||
node.style.height = `${merged.h}px`;
|
||||
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
|
||||
refLayer!.append(node);
|
||||
}
|
||||
}
|
||||
|
||||
function hitTest(clientX: number, clientY: number): HitResult | null {
|
||||
const bounds = root.getBoundingClientRect();
|
||||
const x = clientX - bounds.left;
|
||||
const y = clientY - bounds.top;
|
||||
if (x < 0 || y < 0 || x > root.clientWidth || y > root.clientHeight) return null;
|
||||
if (x < HEAD_COL_W && y < HEAD_ROW_H) return { kind: "corner", r: 0, c: 0 };
|
||||
const contentX =
|
||||
x < HEAD_COL_W ? -1 : x - HEAD_COL_W + (x - HEAD_COL_W > frozenW ? paneBR.scrollLeft : 0);
|
||||
const contentY =
|
||||
y < HEAD_ROW_H ? -1 : y - HEAD_ROW_H + (y - HEAD_ROW_H > frozenH ? paneBR.scrollTop : 0);
|
||||
if (y < HEAD_ROW_H) {
|
||||
const c = colGeo.indexAt(contentX);
|
||||
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
|
||||
}
|
||||
if (x < HEAD_COL_W) {
|
||||
const r = rowGeo.indexAt(contentY);
|
||||
const edge = Math.abs(rowGeo.offset(r + 1) - contentY) <= RESIZE_HOTZONE;
|
||||
return { kind: edge ? "rowEdge" : "rowHead", r, c: 0 };
|
||||
}
|
||||
const c = colGeo.indexAt(contentX);
|
||||
const r = rowGeo.indexAt(contentY);
|
||||
const sel = ctx.selection;
|
||||
if (sel && sel.시트 === ctx.sheet().id) {
|
||||
const active = boxOf(sel.활성.r, sel.활성.c);
|
||||
const screen = screenBoxOf(sel.활성.r, sel.활성.c);
|
||||
void active;
|
||||
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
|
||||
return { kind: "fillHandle", r: sel.활성.r, c: sel.활성.c };
|
||||
}
|
||||
}
|
||||
return { kind: "cell", r, c };
|
||||
}
|
||||
|
||||
function reveal(r: number, c: number): void {
|
||||
if (r >= frozenRows) {
|
||||
const y0 = rowGeo.offset(r) - rowGeo.offset(frozenRows);
|
||||
const y1 = y0 + rowGeo.size(r);
|
||||
const viewTop = paneBR.scrollTop;
|
||||
const viewBottom = viewTop + paneBR.clientHeight;
|
||||
if (y0 < viewTop) paneBR.scrollTop = y0;
|
||||
else if (y1 > viewBottom) paneBR.scrollTop = y1 - paneBR.clientHeight;
|
||||
}
|
||||
if (c >= frozenCols) {
|
||||
const x0 = colGeo.offset(c) - colGeo.offset(frozenCols);
|
||||
const x1 = x0 + colGeo.size(c);
|
||||
const viewLeft = paneBR.scrollLeft;
|
||||
const viewRight = viewLeft + paneBR.clientWidth;
|
||||
if (x0 < viewLeft) paneBR.scrollLeft = x0;
|
||||
else if (x1 > viewRight) paneBR.scrollLeft = x1 - paneBR.clientWidth;
|
||||
}
|
||||
}
|
||||
|
||||
function visibleRange(): CellRange {
|
||||
const scrollW = Math.max(0, root.clientWidth - HEAD_COL_W - frozenW);
|
||||
const scrollH = Math.max(0, root.clientHeight - HEAD_ROW_H - frozenH);
|
||||
return {
|
||||
r0: rowGeo.indexAt(paneBR.scrollTop),
|
||||
c0: colGeo.indexAt(paneBR.scrollLeft),
|
||||
r1: Math.min(extent.rows - 1, rowGeo.indexAt(paneBR.scrollTop + scrollH)),
|
||||
c1: Math.min(extent.cols - 1, colGeo.indexAt(paneBR.scrollLeft + scrollW)),
|
||||
};
|
||||
}
|
||||
|
||||
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
|
||||
return { layer: overlay, box: screenBoxOf(r, c) };
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
resize.disconnect();
|
||||
paneBR.removeEventListener("scroll", onScroll);
|
||||
root.remove();
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
render,
|
||||
invalidate,
|
||||
renderSelection,
|
||||
setRefHighlights,
|
||||
cellBox: boxOf,
|
||||
hitTest,
|
||||
reveal,
|
||||
visibleRange,
|
||||
editorSlot,
|
||||
destroy,
|
||||
};
|
||||
}
|
||||
|
||||
function paneDiv(className: string): HTMLElement {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
function innerDiv(className: string): HTMLElement {
|
||||
const node = document.createElement("div");
|
||||
node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_headers.ts (주인 C)
|
||||
* 열 글자 · 행 번호 머리 · 폭 · 높이 재기(숨김 = 0) · 끌기 · 숨김 표시.
|
||||
* `LineGeometry` = 자리 하나 재기 — 자리 대부분 기본값이라(성긴 `열`·`행` 표) 겹친 만큼만
|
||||
* 어긋난 표로 쥐고 이분 탐색으로 자리 ↔ 픽셀을 오간다(1만 행이어도 가벼움).
|
||||
* 0 계약 머리 — 몸은 C 가 채움. 계약은 `spreadsheet_types.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colIndex, colName } from "./spreadsheet_address";
|
||||
import type { RowInfo, Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
export const DEFAULT_COL_CHARS = 8.43;
|
||||
export const DEFAULT_ROW_PT = 15;
|
||||
/** 엑셀 글자 폭 → px 근사(굴림 10pt 기준 · Calibri 공식과 같은 꼴). */
|
||||
const CHAR_PX = 7;
|
||||
const CHAR_PAD_PX = 5;
|
||||
const PT_PX = 4 / 3;
|
||||
|
||||
export const colCharsToPx = (chars: number): number => Math.round(chars * CHAR_PX + CHAR_PAD_PX);
|
||||
export const pxToColChars = (px: number): number => Math.max(0, (px - CHAR_PAD_PX) / CHAR_PX);
|
||||
export const ptToPx = (pt: number): number => Math.round(pt * PT_PX);
|
||||
export const pxToPt = (px: number): number => px / PT_PX;
|
||||
|
||||
export const colLabel = (c: number): string => colName(c);
|
||||
export const rowLabel = (r: number): string => String(r + 1);
|
||||
|
||||
/** 자리 하나 재기 — 성긴 기본값 어긋남 표(정렬된 색인)로 자리 ↔ 픽셀 변환. */
|
||||
export interface LineGeometry {
|
||||
/** 자리 `i` 폭(px) — 숨겼으면 0. */
|
||||
size(i: number): number;
|
||||
/** 자리 `i` 가 0 부터 시작하는 픽셀 자리(내용 좌표). */
|
||||
offset(i: number): number;
|
||||
/** 자리 `count` 개(0..count-1)를 합친 픽셀 길이. */
|
||||
total(count: number): number;
|
||||
/** 픽셀 자리 `px` 가 든 자리 번호(0 부터). */
|
||||
indexAt(px: number): number;
|
||||
}
|
||||
|
||||
class SparseGeometry implements LineGeometry {
|
||||
private readonly idx: number[] = [];
|
||||
private readonly prefix: number[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly base: number,
|
||||
overrides: Map<number, number>,
|
||||
) {
|
||||
const keys = [...overrides.keys()].sort((a, b) => a - b);
|
||||
let acc = 0;
|
||||
for (const k of keys) {
|
||||
acc += overrides.get(k)! - base;
|
||||
this.idx.push(k);
|
||||
this.prefix.push(acc);
|
||||
}
|
||||
}
|
||||
|
||||
/** 어긋난 표에서 `i` 가 들어갈 자리(첫 idx[k] >= i). */
|
||||
private lowerBound(i: number): number {
|
||||
let lo = 0;
|
||||
let hi = this.idx.length;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (this.idx[mid] < i) lo = mid + 1;
|
||||
else hi = mid;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
|
||||
size(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
if (this.idx[at] === i) {
|
||||
const before = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return this.base + (this.prefix[at] - before);
|
||||
}
|
||||
return this.base;
|
||||
}
|
||||
|
||||
offset(i: number): number {
|
||||
const at = this.lowerBound(i);
|
||||
const acc = at > 0 ? this.prefix[at - 1] : 0;
|
||||
return i * this.base + acc;
|
||||
}
|
||||
|
||||
total(count: number): number {
|
||||
return this.offset(count);
|
||||
}
|
||||
|
||||
indexAt(px: number): number {
|
||||
if (px <= 0) return 0;
|
||||
let hi = Math.max(1, Math.ceil(px / Math.max(this.base, 1)) + this.idx.length + 2);
|
||||
while (this.offset(hi) <= px) hi *= 2;
|
||||
let lo = 0;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (this.offset(mid) <= px) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
return lo;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
|
||||
const c = colIndex(key);
|
||||
if (c < 0) continue;
|
||||
if (info.숨김) overrides.set(c, 0);
|
||||
else if (info.폭 !== undefined) overrides.set(c, colCharsToPx(info.폭));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
|
||||
const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT);
|
||||
const overrides = new Map<number, number>();
|
||||
for (const [key, info] of Object.entries(sheet.행 ?? {}) as [string, RowInfo][]) {
|
||||
const r = Number(key) - 1;
|
||||
if (!Number.isInteger(r) || r < 0) continue;
|
||||
if (info.숨김) overrides.set(r, 0);
|
||||
else if (info.높이 !== undefined) overrides.set(r, ptToPx(info.높이));
|
||||
}
|
||||
return new SparseGeometry(base, overrides);
|
||||
}
|
||||
|
||||
/** `from..to` 안에서 숨기지 않은 자리만. */
|
||||
export function visibleIndices(geo: LineGeometry, from: number, to: number): number[] {
|
||||
const list: number[] = [];
|
||||
for (let i = from; i <= to; i += 1) if (geo.size(i) > 0) list.push(i);
|
||||
return list;
|
||||
}
|
||||
|
||||
export interface HeaderCell {
|
||||
el: HTMLElement;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** 열 머리 칸 — 글자 · 폭 끌기 손잡이 · 숨김 표시(왼쪽에 건너뛴 열이 있으면). */
|
||||
export function makeColHeaderCell(
|
||||
c: number,
|
||||
x: number,
|
||||
w: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__colhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__colhead--hidden-before");
|
||||
cellEl.style.left = `${x}px`;
|
||||
cellEl.style.width = `${w}px`;
|
||||
cellEl.dataset.col = String(c);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = colLabel(c);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--col";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: c };
|
||||
}
|
||||
|
||||
/** 행 머리 칸 — 번호 · 높이 끌기 손잡이 · 숨김 표시(위에 건너뛴 행이 있으면). */
|
||||
export function makeRowHeaderCell(
|
||||
r: number,
|
||||
y: number,
|
||||
h: number,
|
||||
hiddenBefore: boolean,
|
||||
): HeaderCell {
|
||||
const cellEl = document.createElement("div");
|
||||
cellEl.className = "aislo-grid__rowhead";
|
||||
if (hiddenBefore) cellEl.classList.add("aislo-grid__rowhead--hidden-before");
|
||||
cellEl.style.top = `${y}px`;
|
||||
cellEl.style.height = `${h}px`;
|
||||
cellEl.dataset.row = String(r);
|
||||
const label = document.createElement("span");
|
||||
label.className = "aislo-grid__head-label";
|
||||
label.textContent = rowLabel(r);
|
||||
cellEl.append(label);
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "aislo-grid__resize aislo-grid__resize--row";
|
||||
cellEl.append(handle);
|
||||
return { el: cellEl, index: r };
|
||||
}
|
||||
|
||||
export interface ResizeDragOptions {
|
||||
handle: HTMLElement;
|
||||
axis: "col" | "row";
|
||||
currentPx: () => number;
|
||||
onPreview: (px: number) => void;
|
||||
onCommit: (px: number) => void;
|
||||
}
|
||||
|
||||
/** 폭 · 높이 끌기 — 누르는 순간 시작 픽셀을 쥐고 포인터가 옮긴 만큼 더함. */
|
||||
export function startDragResize(opts: ResizeDragOptions): void {
|
||||
opts.handle.addEventListener("pointerdown", (ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
const startClient = opts.axis === "col" ? ev.clientX : ev.clientY;
|
||||
const startPx = opts.currentPx();
|
||||
opts.handle.setPointerCapture(ev.pointerId);
|
||||
const next = (e: PointerEvent): number => {
|
||||
const cur = opts.axis === "col" ? e.clientX : e.clientY;
|
||||
return Math.max(4, startPx + (cur - startClient));
|
||||
};
|
||||
const move = (e: PointerEvent): void => opts.onPreview(next(e));
|
||||
const up = (e: PointerEvent): void => {
|
||||
opts.handle.removeEventListener("pointermove", move);
|
||||
opts.handle.removeEventListener("pointerup", up);
|
||||
opts.onCommit(next(e));
|
||||
};
|
||||
opts.handle.addEventListener("pointermove", move);
|
||||
opts.handle.addEventListener("pointerup", up);
|
||||
});
|
||||
}
|
||||
|
||||
/** 폭 끌기 다 됨 → `열폭` 명령(글자 단위로 되돌려 보냄). */
|
||||
export function commitColWidth(ctx: SpreadsheetContext, c: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "열폭", 시트: ctx.sheet().id, 열: [c], 폭: pxToColChars(px) });
|
||||
}
|
||||
|
||||
/** 높이 끌기 다 됨 → `행높이` 명령(pt 단위로 되돌려 보냄). */
|
||||
export function commitRowHeight(ctx: SpreadsheetContext, r: number, px: number): void {
|
||||
ctx.dispatch({ 종류: "행높이", 시트: ctx.sheet().id, 행: [r], 높이: pxToPt(px) });
|
||||
}
|
||||
@@ -2,16 +2,210 @@
|
||||
* spreadsheet_menu.ts (주인 E)
|
||||
* 우클릭 메뉴 — 칸 · 행 머리 · 열 머리마다: 잘라내기 · 복사 · (붙여넣기 = 「Ctrl+V 를 쓸 것」 안내) ·
|
||||
* 행열 넣기 · 지우기 · 숨기기 · 보이기 · 내용 지우기 · 서식 지우기 · 병합 · 풀기.
|
||||
* 0 계약 머리 — 몸은 E 가 채움.
|
||||
* `ui_template_context_menu.ts` 재사용 — 우클릭 밖 자리는 그냥 눌러 선택부터 옮김.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { rangeToA1 } from "./spreadsheet_address";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import { MAX_COLS, MAX_ROWS, type CellRange } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_menu: 아직 없음(E)");
|
||||
};
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
function within(range: CellRange, r: number, c: number): boolean {
|
||||
return r >= range.r0 && r <= range.r1 && c >= range.c0 && c <= range.c1;
|
||||
}
|
||||
|
||||
/** `ctx.root` 의 contextmenu 를 받음(root null) */
|
||||
export function attachMenu(_ctx: SpreadsheetContext): PartHandle {
|
||||
return todo();
|
||||
export function attachMenu(ctx: SpreadsheetContext): PartHandle {
|
||||
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
|
||||
|
||||
const menu = createMapContextMenu("ss");
|
||||
ctx.root.append(menu.element);
|
||||
|
||||
function isMerged(range: CellRange): boolean {
|
||||
return (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
}
|
||||
|
||||
function cutCopy(kind: "cut" | "copy"): void {
|
||||
document.execCommand(kind);
|
||||
}
|
||||
|
||||
function cellMenuItems(range: CellRange): MapContextMenuItem[] {
|
||||
return [
|
||||
[st("MenuCut"), () => cutCopy("cut")],
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[st("MenuPasteHint"), () => showToast(st("MenuPasteHint"), "info")],
|
||||
[
|
||||
st("MenuClearContent"),
|
||||
() => ctx.dispatch({ 종류: "내용지움", 시트: ctx.selection.시트, 범위: [range] }),
|
||||
],
|
||||
[
|
||||
st("MenuClearFormat"),
|
||||
() => ctx.dispatch({ 종류: "서식지움", 시트: ctx.selection.시트, 범위: [range] }),
|
||||
],
|
||||
[
|
||||
isMerged(range) ? st("MenuUnmerge") : st("MenuMerge"),
|
||||
() =>
|
||||
ctx.dispatch(
|
||||
isMerged(range)
|
||||
? { 종류: "병합풀기", 시트: ctx.selection.시트, 범위: range }
|
||||
: { 종류: "병합", 시트: ctx.selection.시트, 범위: range },
|
||||
),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function rowMenuItems(rows: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...rows);
|
||||
const 수 = rows.length;
|
||||
return [
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[
|
||||
st("MenuInsertRow"),
|
||||
() => ctx.dispatch({ 종류: "행넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuDeleteRow"),
|
||||
() => ctx.dispatch({ 종류: "행지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuHide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "행",
|
||||
번호: rows,
|
||||
숨김: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
st("MenuUnhide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "행",
|
||||
번호: rows,
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function colMenuItems(cols: number[]): MapContextMenuItem[] {
|
||||
const at = Math.min(...cols);
|
||||
const 수 = cols.length;
|
||||
return [
|
||||
[st("MenuCopy"), () => cutCopy("copy")],
|
||||
[
|
||||
st("MenuInsertCol"),
|
||||
() => ctx.dispatch({ 종류: "열넣기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuDeleteCol"),
|
||||
() => ctx.dispatch({ 종류: "열지우기", 시트: ctx.selection.시트, at, 수 }),
|
||||
],
|
||||
[
|
||||
st("MenuHide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "열",
|
||||
번호: cols,
|
||||
숨김: true,
|
||||
}),
|
||||
],
|
||||
[
|
||||
st("MenuUnhide"),
|
||||
() =>
|
||||
ctx.dispatch({
|
||||
종류: "숨김",
|
||||
시트: ctx.selection.시트,
|
||||
축: "열",
|
||||
번호: cols,
|
||||
숨김: false,
|
||||
}),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
function range(r0: number, c0: number, r1: number, c1: number): CellRange {
|
||||
return { r0, c0, r1, c1 };
|
||||
}
|
||||
|
||||
function onContextMenu(ev: MouseEvent): void {
|
||||
const hit = ctx.grid.hitTest(ev.clientX, ev.clientY);
|
||||
if (!hit) return;
|
||||
ev.preventDefault();
|
||||
const box = ctx.root.getBoundingClientRect();
|
||||
const x = ev.clientX - box.left;
|
||||
const y = ev.clientY - box.top;
|
||||
|
||||
if (hit.kind === "rowHead") {
|
||||
const covered = ctx.selection.범위.find(
|
||||
(rg) => rg.c0 === 0 && rg.c1 >= MAX_COLS - 1 && within(rg, hit.r, 0),
|
||||
);
|
||||
const rows = covered
|
||||
? Array.from({ length: covered.r1 - covered.r0 + 1 }, (_, i) => covered.r0 + i)
|
||||
: [hit.r];
|
||||
if (!covered)
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(hit.r, 0, hit.r, MAX_COLS - 1)],
|
||||
활성: { r: hit.r, c: 0 },
|
||||
기준: { r: hit.r, c: 0 },
|
||||
});
|
||||
menu.open(x, y, rowMenuItems(rows));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit.kind === "colHead") {
|
||||
const covered = ctx.selection.범위.find(
|
||||
(rg) => rg.r0 === 0 && rg.r1 >= MAX_ROWS - 1 && within(rg, 0, hit.c),
|
||||
);
|
||||
const cols = covered
|
||||
? Array.from({ length: covered.c1 - covered.c0 + 1 }, (_, i) => covered.c0 + i)
|
||||
: [hit.c];
|
||||
if (!covered)
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(0, hit.c, MAX_ROWS - 1, hit.c)],
|
||||
활성: { r: 0, c: hit.c },
|
||||
기준: { r: 0, c: hit.c },
|
||||
});
|
||||
menu.open(x, y, colMenuItems(cols));
|
||||
return;
|
||||
}
|
||||
|
||||
if (hit.kind === "cell") {
|
||||
const covered = ctx.selection.범위.some((rg) => within(rg, hit.r, hit.c));
|
||||
if (!covered) {
|
||||
ctx.select({
|
||||
시트: ctx.selection.시트,
|
||||
범위: [range(hit.r, hit.c, hit.r, hit.c)],
|
||||
활성: { r: hit.r, c: hit.c },
|
||||
기준: { r: hit.r, c: hit.c },
|
||||
});
|
||||
}
|
||||
const active = ctx.selection.범위[0] ?? range(hit.r, hit.c, hit.r, hit.c);
|
||||
menu.open(x, y, cellMenuItems(active));
|
||||
}
|
||||
}
|
||||
|
||||
ctx.root.addEventListener("contextmenu", onContextMenu);
|
||||
|
||||
return {
|
||||
root: menu.element,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContextMenu);
|
||||
menu.close();
|
||||
menu.element.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,16 +1,164 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_tabs.ts (주인 E)
|
||||
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 · 끌어 옮기기 · 복사.
|
||||
* 시트 탭 — 고르기(`ctx.showSheet`) · 더하기 · 이름 바꾸기(두 번 누르기) · 지우기 ·
|
||||
* 끌어 옮기기 · 복사. 우클릭 메뉴는 `ui_template_context_menu.ts` 재사용.
|
||||
* 이름 바꾸기 · 지우기 · 옮기기는 명령(`시트이름` · `시트지우기` · `시트옮기기` · `시트더하기`).
|
||||
* 0 계약 머리 — 몸은 E 가 채움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { emptySheet } from "./spreadsheet_commands";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { Sheet, Workbook } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_tabs: 아직 없음(E)");
|
||||
};
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
export function mountTabs(_ctx: SpreadsheetContext): PartHandle {
|
||||
return todo();
|
||||
/** 없는 `s숫자` 하나 고름. */
|
||||
function nextSheetId(book: Workbook): string {
|
||||
const used = new Set(book.시트.map((s) => s.id));
|
||||
let n = 1;
|
||||
while (used.has(`s${n}`)) n++;
|
||||
return `s${n}`;
|
||||
}
|
||||
|
||||
/** 「시트2」 · 「시트2 (2)」 처럼 안 겹치는 이름. */
|
||||
function nextSheetName(book: Workbook, base: string): string {
|
||||
const used = new Set(book.시트.map((s) => s.이름));
|
||||
if (!used.has(base)) return base;
|
||||
let n = 2;
|
||||
while (used.has(`${base} (${n})`)) n++;
|
||||
return `${base} (${n})`;
|
||||
}
|
||||
|
||||
export function mountTabs(ctx: SpreadsheetContext): PartHandle {
|
||||
const menu = createMapContextMenu("ss");
|
||||
const root = el("div", { className: "ss-tabs", children: [menu.element] });
|
||||
|
||||
let dragging: string | null = null;
|
||||
|
||||
function tabEl(id: string): HTMLElement | null {
|
||||
return root.querySelector<HTMLElement>(`[data-sheet-id="${CSS.escape(id)}"]`);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
root.replaceChildren(menu.element, ...ctx.book.시트.map(renderTab), addButton());
|
||||
}
|
||||
|
||||
function renderTab(sheet: Sheet): HTMLElement {
|
||||
const tab = el("div", {
|
||||
className: `ss-tabs__tab${sheet.id === ctx.book.활성 ? " is-active" : ""}`,
|
||||
text: sheet.이름,
|
||||
attrs: { "data-sheet-id": sheet.id },
|
||||
});
|
||||
tab.draggable = true;
|
||||
|
||||
tab.addEventListener("click", () => ctx.showSheet(sheet.id));
|
||||
tab.addEventListener("dblclick", () => beginRename(sheet));
|
||||
tab.addEventListener("contextmenu", (ev) => {
|
||||
ev.preventDefault();
|
||||
const box = root.getBoundingClientRect();
|
||||
openMenu(sheet, ev.clientX - box.left, ev.clientY - box.top);
|
||||
});
|
||||
|
||||
tab.addEventListener("dragstart", (ev) => {
|
||||
dragging = sheet.id;
|
||||
ev.dataTransfer?.setData("text/plain", sheet.id);
|
||||
});
|
||||
tab.addEventListener("dragover", (ev) => {
|
||||
if (!dragging || dragging === sheet.id) return;
|
||||
ev.preventDefault();
|
||||
tab.classList.add("is-dragover");
|
||||
});
|
||||
tab.addEventListener("dragleave", () => tab.classList.remove("is-dragover"));
|
||||
tab.addEventListener("drop", (ev) => {
|
||||
ev.preventDefault();
|
||||
tab.classList.remove("is-dragover");
|
||||
if (!dragging || dragging === sheet.id) return;
|
||||
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id);
|
||||
ctx.dispatch({ 종류: "시트옮기기", 시트: dragging, 자리 });
|
||||
dragging = null;
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
function beginRename(sheet: Sheet): void {
|
||||
const tab = tabEl(sheet.id);
|
||||
if (!tab) return;
|
||||
const input = el("input", { attrs: { value: sheet.이름 } }) as HTMLInputElement;
|
||||
tab.replaceChildren(input);
|
||||
input.focus();
|
||||
input.select();
|
||||
const commit = (): void => {
|
||||
const name = input.value.trim();
|
||||
if (name && name !== sheet.이름)
|
||||
ctx.dispatch({ 종류: "시트이름", 시트: sheet.id, 이름: name });
|
||||
else render();
|
||||
};
|
||||
input.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter") input.blur();
|
||||
if (ev.key === "Escape") {
|
||||
input.removeEventListener("blur", commit);
|
||||
render();
|
||||
}
|
||||
});
|
||||
input.addEventListener("blur", commit);
|
||||
}
|
||||
|
||||
function openMenu(sheet: Sheet, x: number, y: number): void {
|
||||
const items: MapContextMenuItem[] = [
|
||||
[st("SheetRename"), () => beginRename(sheet)],
|
||||
[
|
||||
st("SheetDuplicate"),
|
||||
() => {
|
||||
const copy: Sheet = {
|
||||
...structuredClone(sheet),
|
||||
id: nextSheetId(ctx.book),
|
||||
이름: nextSheetName(ctx.book, sheet.이름),
|
||||
};
|
||||
const 자리 = ctx.book.시트.findIndex((s) => s.id === sheet.id) + 1;
|
||||
ctx.dispatch({ 종류: "시트더하기", 시트: copy, 자리 });
|
||||
},
|
||||
],
|
||||
[
|
||||
st("SheetDelete"),
|
||||
async () => {
|
||||
if (ctx.book.시트.length <= 1) {
|
||||
showToast(st("SheetDeleteLastError"), "error");
|
||||
return;
|
||||
}
|
||||
if (await showConfirmDialog(st("SheetDeleteConfirm"))) {
|
||||
ctx.dispatch({ 종류: "시트지우기", 시트: sheet.id });
|
||||
}
|
||||
},
|
||||
],
|
||||
];
|
||||
menu.open(x, y, items);
|
||||
}
|
||||
|
||||
function addButton(): HTMLElement {
|
||||
const btn = el("button", {
|
||||
className: "ss-tabs__add",
|
||||
text: "+",
|
||||
attrs: { type: "button", title: st("SheetAdd") },
|
||||
});
|
||||
btn.addEventListener("click", () => {
|
||||
const name = nextSheetName(ctx.book, "Sheet");
|
||||
const sheet = emptySheet(nextSheetId(ctx.book), name);
|
||||
ctx.dispatch({ 종류: "시트더하기", 시트: sheet, 자리: ctx.book.시트.length });
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh: render,
|
||||
destroy() {
|
||||
menu.close();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_text.ts (주인 E)
|
||||
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 글자 — [한국어, 영어].
|
||||
* `ui_template_sheet_text.ts` 와 같은 방식(`st(key)`). 0 계약 밖(E 만 씀).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
const TEXT = {
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Redo: ["다시", "Redo"],
|
||||
FontName: ["글꼴", "Font"],
|
||||
FontSize: ["크기", "Size"],
|
||||
Bold: ["굵게", "Bold"],
|
||||
Italic: ["기울임", "Italic"],
|
||||
Underline: ["밑줄", "Underline"],
|
||||
Strike: ["취소선", "Strikethrough"],
|
||||
TextColor: ["글자색", "Text color"],
|
||||
FillColor: ["채움색", "Fill color"],
|
||||
ColorClear: ["지움", "Clear"],
|
||||
HAlign: ["가로 정렬", "Horizontal align"],
|
||||
VAlign: ["세로 정렬", "Vertical align"],
|
||||
Align_general: ["일반", "General"],
|
||||
Align_left: ["왼쪽", "Left"],
|
||||
Align_center: ["가운데", "Center"],
|
||||
Align_right: ["오른쪽", "Right"],
|
||||
Align_distributed: ["균등 분할", "Distributed"],
|
||||
Align_centerContinuous: ["선택 영역 가운데", "Center across"],
|
||||
VAlign_top: ["위", "Top"],
|
||||
VAlign_center: ["가운데", "Middle"],
|
||||
VAlign_bottom: ["아래", "Bottom"],
|
||||
Wrap: ["줄 바꿈", "Wrap text"],
|
||||
Merge: ["병합", "Merge"],
|
||||
Unmerge: ["병합 해제", "Unmerge"],
|
||||
Border: ["테두리", "Border"],
|
||||
Border_none: ["없음", "None"],
|
||||
Border_all: ["모두", "All"],
|
||||
Border_outer: ["바깥", "Outer"],
|
||||
Border_top: ["위", "Top"],
|
||||
Border_bottom: ["아래", "Bottom"],
|
||||
Border_left: ["왼", "Left"],
|
||||
Border_right: ["오른", "Right"],
|
||||
BorderLine: ["선", "Line"],
|
||||
BorderColor: ["색", "Color"],
|
||||
NumberFormat: ["숫자 형식", "Number format"],
|
||||
NumberFormat_general: ["일반", "General"],
|
||||
NumberFormat_integer: ["정수 0", "Integer"],
|
||||
NumberFormat_decimal2: ["소수 0.00", "Decimal 0.00"],
|
||||
NumberFormat_thousands: ["천 단위 #,##0", "Thousands"],
|
||||
NumberFormat_thousands2: ["천 단위 #,##0.00", "Thousands 0.00"],
|
||||
NumberFormat_percent: ["백분율 0%", "Percent"],
|
||||
NumberFormat_accounting: ["회계", "Accounting"],
|
||||
NumberFormat_text: ["글자 @", "Text"],
|
||||
NumberFormat_hint: [
|
||||
"이름 골라 쓰거나 엑셀 형식 코드를 바로 침",
|
||||
"Pick a preset or type an Excel format code",
|
||||
],
|
||||
SheetAdd: ["시트 더하기", "Add sheet"],
|
||||
SheetRename: ["이름 바꾸기", "Rename"],
|
||||
SheetDelete: ["지우기", "Delete"],
|
||||
SheetDuplicate: ["복사", "Duplicate"],
|
||||
SheetRenamePrompt: ["새 시트 이름", "New sheet name"],
|
||||
SheetDeleteConfirm: [
|
||||
"이 시트를 지울까요? 되돌릴 수 없습니다.",
|
||||
"Delete this sheet? This cannot be undone.",
|
||||
],
|
||||
SheetDeleteLastError: ["마지막 시트는 지울 수 없음", "Cannot delete the last sheet"],
|
||||
MenuCut: ["잘라내기", "Cut"],
|
||||
MenuCopy: ["복사", "Copy"],
|
||||
MenuPasteHint: ["Ctrl+V 를 쓸 것", "Use Ctrl+V"],
|
||||
MenuInsertRow: ["행 넣기", "Insert row"],
|
||||
MenuDeleteRow: ["행 지우기", "Delete row"],
|
||||
MenuInsertCol: ["열 넣기", "Insert column"],
|
||||
MenuDeleteCol: ["열 지우기", "Delete column"],
|
||||
MenuHide: ["숨기기", "Hide"],
|
||||
MenuUnhide: ["숨김 취소", "Unhide"],
|
||||
MenuClearContent: ["내용 지우기", "Clear content"],
|
||||
MenuClearFormat: ["서식 지우기", "Clear format"],
|
||||
MenuMerge: ["병합", "Merge"],
|
||||
MenuUnmerge: ["병합 해제", "Unmerge"],
|
||||
ClipboardPasteFailed: ["붙여넣기를 읽지 못함", "Could not read paste content"],
|
||||
} as const;
|
||||
|
||||
export function st(key: keyof typeof TEXT): string {
|
||||
return TEXT[key][currentLanguageIndex] ?? TEXT[key][0];
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_toolbar.css (주인 E)
|
||||
* 도구 모음 · 시트 탭 · 우클릭 메뉴 · 클립보드 안내 토스트 — 화면 스타일 한 벌.
|
||||
* 색 · 간격은 테마 토큰만(`ui_template_theme.css`).
|
||||
* ========================================================================== */
|
||||
|
||||
.ss-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-toolbar__group {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding-right: var(--spacing-8);
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.ss-toolbar__group:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.ss-toolbar .ui-btn {
|
||||
padding: 4px 8px;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.ss-toolbar .ui-btn.is-on {
|
||||
background: var(--color-mist-violet);
|
||||
box-shadow: inset 0 0 0 1px var(--color-royal-amethyst);
|
||||
}
|
||||
|
||||
.ss-toolbar__select {
|
||||
height: 28px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ss-toolbar__input {
|
||||
height: 28px;
|
||||
width: 72px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--font {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--size {
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.ss-toolbar__input--numfmt {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.ss-toolbar__color {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__clear {
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.72rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-toolbar__popover {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 180px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-toolbar__popover[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss-toolbar__popover-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-toolbar__popover-presets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* ── 시트 탭 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.ss-tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow-x: auto;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-tabs__tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-bottom: 0;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
background: var(--color-paper);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.82rem;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-tabs__tab.is-active {
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-tabs__tab.is-dragover {
|
||||
box-shadow: inset 2px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.ss-tabs__tab input {
|
||||
width: 96px;
|
||||
border: 1px solid var(--color-focus-ring);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-tabs__add {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) ───────────── */
|
||||
|
||||
.ss__context-menu {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 160px;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss__context-menu[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ss__context-menu-item {
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-body);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss__context-menu-item:hover {
|
||||
background: var(--color-surface-sunken, var(--color-mist-violet));
|
||||
}
|
||||
|
||||
.ss__context-menu-item.is-danger {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss__context-menu-sep {
|
||||
margin: 2px 4px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -1,17 +1,372 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_toolbar.ts (주인 E)
|
||||
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 글자색 · 채움색 · 가로 · 세로 정렬 ·
|
||||
* 줄 바꿈 · 테두리(위치 × 선) · 숫자 형식 · 병합 · 틀고정 · 눈금선. 모두 `ctx.dispatch` 명령으로.
|
||||
* 서식 도구 모음 — 되돌리기 · 다시 · 글꼴 · 크기 · 굵게 · 기울임 · 밑줄 · 취소선 ·
|
||||
* 글자색 · 채움색 · 가로/세로 정렬 · 줄 바꿈 · 병합 · 테두리 · 숫자 형식.
|
||||
* 모두 `ctx.dispatch` 명령으로 — 이 파일은 문서를 직접 안 건드림.
|
||||
* 활성 칸 서식을 `refresh()` 때 단추 상태로. readOnly 면 안 붙임(root null).
|
||||
* 0 계약 머리 — 몸은 E 가 채움.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { st } from "./spreadsheet_text";
|
||||
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
|
||||
const todo = (): never => {
|
||||
throw new Error("spreadsheet_toolbar: 아직 없음(E)");
|
||||
};
|
||||
import "./spreadsheet_toolbar.css";
|
||||
|
||||
export function mountToolbar(_ctx: SpreadsheetContext): PartHandle {
|
||||
return todo();
|
||||
type Side = "위" | "아래" | "왼" | "오른";
|
||||
type BorderPreset = "none" | "all" | "outer" | "top" | "bottom" | "left" | "right";
|
||||
|
||||
const FONTS = [
|
||||
"굴림",
|
||||
"굴림체",
|
||||
"맑은 고딕",
|
||||
"나눔고딕",
|
||||
"돋움",
|
||||
"Arial",
|
||||
"Calibri",
|
||||
"Times New Roman",
|
||||
];
|
||||
|
||||
const NUMFMT_PRESETS: [label: string, code: string][] = [
|
||||
[st("NumberFormat_general"), ""],
|
||||
[st("NumberFormat_integer"), "0"],
|
||||
[st("NumberFormat_decimal2"), "0.00"],
|
||||
[st("NumberFormat_thousands"), "#,##0"],
|
||||
[st("NumberFormat_thousands2"), "#,##0.00"],
|
||||
[st("NumberFormat_percent"), "0%"],
|
||||
[st("NumberFormat_accounting"), '_-* #,##0.00_-;-* #,##0.00_-;_-* "-"_-;_-@_-'],
|
||||
[st("NumberFormat_text"), "@"],
|
||||
];
|
||||
|
||||
const BORDER_LINES: BorderLine[] = [
|
||||
"hair",
|
||||
"thin",
|
||||
"medium",
|
||||
"thick",
|
||||
"double",
|
||||
"dotted",
|
||||
"dashed",
|
||||
];
|
||||
|
||||
/** 활성 칸에 지금 걸린 서식 — 칸 자기 것 없으면 열 · 행 · 그다음 기본(0). */
|
||||
function styleAt(ctx: SpreadsheetContext, r: number, c: number): CellStyle {
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[toA1(r, c)];
|
||||
const idx = cell?.서식 ?? sheet.열?.[colName(c)]?.서식 ?? sheet.행?.[String(r + 1)]?.서식 ?? 0;
|
||||
return ctx.book.서식[idx] ?? {};
|
||||
}
|
||||
|
||||
export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
|
||||
if (ctx.readOnly) return { root: null, refresh() {}, destroy() {} };
|
||||
|
||||
const root = el("div", { className: "ss-toolbar" });
|
||||
|
||||
const group = (...children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "ss-toolbar__group", children });
|
||||
|
||||
const patch = (change: { [K in keyof CellStyle]?: CellStyle[K] | null }): void => {
|
||||
ctx.dispatch({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: ctx.selection.범위,
|
||||
바꿀: change,
|
||||
});
|
||||
};
|
||||
|
||||
// ── 되돌리기 · 다시 ────────────────────────────────────────────────────
|
||||
const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() });
|
||||
undoBtn.title = st("Undo");
|
||||
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
|
||||
redoBtn.title = st("Redo");
|
||||
|
||||
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
|
||||
const fontInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--font",
|
||||
attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") },
|
||||
}) as HTMLInputElement;
|
||||
const fontList = el("datalist", {
|
||||
attrs: { id: "ss-toolbar-fonts" },
|
||||
children: FONTS.map((name) => el("option", { attrs: { value: name } })),
|
||||
});
|
||||
fontInput.append(fontList);
|
||||
const commitFont = (): void => patch({ 글꼴: fontInput.value.trim() || null });
|
||||
fontInput.addEventListener("change", commitFont);
|
||||
|
||||
const sizeInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--size",
|
||||
attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") },
|
||||
}) as HTMLInputElement;
|
||||
sizeInput.addEventListener("change", () => {
|
||||
const n = Number(sizeInput.value);
|
||||
patch({ 크기: sizeInput.value && Number.isFinite(n) && n > 0 ? n : null });
|
||||
});
|
||||
|
||||
// ── 굵게 · 기울임 · 밑줄 · 취소선 ──────────────────────────────────────
|
||||
const boldBtn = createButton({ label: "B", variant: "ghost" });
|
||||
boldBtn.title = st("Bold");
|
||||
const italicBtn = createButton({ label: "I", variant: "ghost" });
|
||||
italicBtn.title = st("Italic");
|
||||
const underlineBtn = createButton({ label: "U", variant: "ghost" });
|
||||
underlineBtn.title = st("Underline");
|
||||
const strikeBtn = createButton({ label: "S", variant: "ghost" });
|
||||
strikeBtn.title = st("Strike");
|
||||
const toggles: [HTMLButtonElement, keyof CellStyle][] = [
|
||||
[boldBtn, "굵게"],
|
||||
[italicBtn, "기울임"],
|
||||
[underlineBtn, "밑줄"],
|
||||
[strikeBtn, "취소선"],
|
||||
];
|
||||
for (const [btn, key] of toggles) {
|
||||
btn.addEventListener("click", () => patch({ [key]: !btn.classList.contains("is-on") }));
|
||||
}
|
||||
|
||||
// ── 글자색 · 채움색 ────────────────────────────────────────────────────
|
||||
const textColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" },
|
||||
}) as HTMLInputElement;
|
||||
textColor.addEventListener("change", () => patch({ 글자색: textColor.value }));
|
||||
const textColorClear = el("button", {
|
||||
className: "ss-toolbar__clear",
|
||||
text: "✕",
|
||||
attrs: { type: "button", title: st("ColorClear") },
|
||||
});
|
||||
textColorClear.addEventListener("click", () => patch({ 글자색: null }));
|
||||
|
||||
const fillColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" },
|
||||
}) as HTMLInputElement;
|
||||
fillColor.addEventListener("change", () => patch({ 채움: fillColor.value }));
|
||||
const fillColorClear = el("button", {
|
||||
className: "ss-toolbar__clear",
|
||||
text: "✕",
|
||||
attrs: { type: "button", title: st("ColorClear") },
|
||||
});
|
||||
fillColorClear.addEventListener("click", () => patch({ 채움: null }));
|
||||
|
||||
// ── 가로 · 세로 정렬 · 줄 바꿈 ─────────────────────────────────────────
|
||||
const HALIGN_LABEL = {
|
||||
general: st("Align_general"),
|
||||
left: st("Align_left"),
|
||||
center: st("Align_center"),
|
||||
right: st("Align_right"),
|
||||
distributed: st("Align_distributed"),
|
||||
centerContinuous: st("Align_centerContinuous"),
|
||||
} as const;
|
||||
const VALIGN_LABEL = {
|
||||
top: st("VAlign_top"),
|
||||
center: st("VAlign_center"),
|
||||
bottom: st("VAlign_bottom"),
|
||||
} as const;
|
||||
|
||||
const hAlignSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
attrs: { "aria-label": st("HAlign") },
|
||||
children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) =>
|
||||
el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
hAlignSelect.addEventListener("change", () =>
|
||||
patch({ 가로: hAlignSelect.value as CellStyle["가로"] }),
|
||||
);
|
||||
|
||||
const vAlignSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
attrs: { "aria-label": st("VAlign") },
|
||||
children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) =>
|
||||
el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }),
|
||||
),
|
||||
}) as HTMLSelectElement;
|
||||
vAlignSelect.addEventListener("change", () =>
|
||||
patch({ 세로: vAlignSelect.value as CellStyle["세로"] }),
|
||||
);
|
||||
|
||||
const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" });
|
||||
wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") }));
|
||||
|
||||
// ── 병합 ───────────────────────────────────────────────────────────────
|
||||
const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" });
|
||||
mergeBtn.addEventListener("click", () => {
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
const merged = (ctx.sheet().병합 ?? []).includes(rangeToA1(range));
|
||||
ctx.dispatch({ 종류: merged ? "병합풀기" : "병합", 시트: ctx.selection.시트, 범위: range });
|
||||
});
|
||||
|
||||
// ── 테두리 ───────────────────────────────────────────────────────────
|
||||
const borderBtn = createButton({ label: st("Border"), variant: "ghost" });
|
||||
const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } });
|
||||
const lineSelect = el("select", {
|
||||
className: "ss-toolbar__select",
|
||||
children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })),
|
||||
}) as HTMLSelectElement;
|
||||
const lineColor = el("input", {
|
||||
className: "ss-toolbar__color",
|
||||
attrs: { type: "color", value: "#000000" },
|
||||
}) as HTMLInputElement;
|
||||
const presetsRow = el("div", { className: "ss-toolbar__popover-presets" });
|
||||
const presets: [BorderPreset, string][] = [
|
||||
["none", st("Border_none")],
|
||||
["all", st("Border_all")],
|
||||
["outer", st("Border_outer")],
|
||||
["top", st("Border_top")],
|
||||
["bottom", st("Border_bottom")],
|
||||
["left", st("Border_left")],
|
||||
["right", st("Border_right")],
|
||||
];
|
||||
for (const [preset, label] of presets) {
|
||||
const b = createButton({
|
||||
label,
|
||||
variant: "ghost",
|
||||
onClick: () => {
|
||||
applyBorderPreset(preset);
|
||||
borderPop.hidden = true;
|
||||
},
|
||||
});
|
||||
presetsRow.append(b);
|
||||
}
|
||||
borderPop.append(
|
||||
el("div", {
|
||||
className: "ss-toolbar__popover-row",
|
||||
children: [el("span", { text: st("BorderLine") }), lineSelect],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-toolbar__popover-row",
|
||||
children: [el("span", { text: st("BorderColor") }), lineColor],
|
||||
}),
|
||||
presetsRow,
|
||||
);
|
||||
borderBtn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
borderPop.hidden = !borderPop.hidden;
|
||||
});
|
||||
borderPop.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
const closeBorderPop = (): void => {
|
||||
borderPop.hidden = true;
|
||||
};
|
||||
document.addEventListener("click", closeBorderPop);
|
||||
|
||||
function cellsFor(
|
||||
range: CellRange,
|
||||
edge: BorderPreset,
|
||||
): { r: number; c: number; sides: Side[] }[] {
|
||||
const { r0, c0, r1, c1 } = range;
|
||||
const out: { r: number; c: number; sides: Side[] }[] = [];
|
||||
for (let r = r0; r <= r1; r++) {
|
||||
for (let c = c0; c <= c1; c++) {
|
||||
const sides: Side[] = [];
|
||||
if (edge === "all") sides.push("위", "아래", "왼", "오른");
|
||||
else {
|
||||
if ((edge === "outer" || edge === "top") && r === r0) sides.push("위");
|
||||
if ((edge === "outer" || edge === "bottom") && r === r1) sides.push("아래");
|
||||
if ((edge === "outer" || edge === "left") && c === c0) sides.push("왼");
|
||||
if ((edge === "outer" || edge === "right") && c === c1) sides.push("오른");
|
||||
}
|
||||
if (sides.length) out.push({ r, c, sides });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function applyBorderPreset(preset: BorderPreset): void {
|
||||
const range = ctx.selection.범위[0];
|
||||
if (!range) return;
|
||||
if (preset === "none") {
|
||||
ctx.dispatch({
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: ctx.selection.범위,
|
||||
바꿀: { 테두리: null },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const style: BorderSide = { 선: lineSelect.value as BorderLine, 색: lineColor.value };
|
||||
const cmds: Command[] = cellsFor(range, preset).map(({ r, c, sides }) => {
|
||||
const cur = { ...(styleAt(ctx, r, c).테두리 ?? {}) };
|
||||
for (const s of sides) cur[s] = style;
|
||||
return {
|
||||
종류: "서식",
|
||||
시트: ctx.selection.시트,
|
||||
범위: [{ r0: r, c0: c, r1: r, c1: c }],
|
||||
바꿀: { 테두리: cur },
|
||||
};
|
||||
});
|
||||
if (cmds.length === 1) ctx.dispatch(cmds[0]);
|
||||
else if (cmds.length > 1) ctx.dispatch({ 종류: "묶음", 명령: cmds });
|
||||
}
|
||||
|
||||
// ── 숫자 형식 ─────────────────────────────────────────────────────────
|
||||
const numfmtInput = el("input", {
|
||||
className: "ss-toolbar__input ss-toolbar__input--numfmt",
|
||||
attrs: {
|
||||
list: "ss-toolbar-numfmt",
|
||||
"aria-label": st("NumberFormat"),
|
||||
title: st("NumberFormat_hint"),
|
||||
},
|
||||
}) as HTMLInputElement;
|
||||
const numfmtList = el("datalist", {
|
||||
attrs: { id: "ss-toolbar-numfmt" },
|
||||
children: NUMFMT_PRESETS.map(([label, code]) =>
|
||||
el("option", {
|
||||
attrs: { value: code },
|
||||
text: `${label} (${code || st("NumberFormat_general")})`,
|
||||
}),
|
||||
),
|
||||
});
|
||||
numfmtInput.append(numfmtList);
|
||||
numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null }));
|
||||
|
||||
root.append(
|
||||
group(undoBtn, redoBtn),
|
||||
group(fontInput, sizeInput),
|
||||
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
|
||||
group(textColor, textColorClear, fillColor, fillColorClear),
|
||||
group(hAlignSelect, vAlignSelect, wrapBtn),
|
||||
group(mergeBtn),
|
||||
group(borderBtn, borderPop),
|
||||
group(numfmtInput),
|
||||
);
|
||||
|
||||
function refresh(): void {
|
||||
undoBtn.disabled = !ctx.history.canUndo();
|
||||
redoBtn.disabled = !ctx.history.canRedo();
|
||||
const { r, c } = ctx.selection.활성;
|
||||
const style = styleAt(ctx, r, c);
|
||||
fontInput.value = style.글꼴 ?? "";
|
||||
sizeInput.value = style.크기 != null ? String(style.크기) : "";
|
||||
boldBtn.classList.toggle("is-on", !!style.굵게);
|
||||
italicBtn.classList.toggle("is-on", !!style.기울임);
|
||||
underlineBtn.classList.toggle("is-on", !!style.밑줄);
|
||||
strikeBtn.classList.toggle("is-on", !!style.취소선);
|
||||
textColor.value = style.글자색 ?? "#000000";
|
||||
fillColor.value = style.채움 ?? "#ffffff";
|
||||
hAlignSelect.value = style.가로 ?? "general";
|
||||
vAlignSelect.value = style.세로 ?? "top";
|
||||
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
|
||||
numfmtInput.value = style.형식 ?? "";
|
||||
const range = ctx.selection.범위[0];
|
||||
mergeBtn.textContent = "";
|
||||
mergeBtn.append(
|
||||
el("span", {
|
||||
className: "ui-btn__label",
|
||||
text:
|
||||
range && (ctx.sheet().병합 ?? []).includes(rangeToA1(range))
|
||||
? st("Unmerge")
|
||||
: st("Merge"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
refresh();
|
||||
|
||||
return {
|
||||
root,
|
||||
refresh,
|
||||
destroy() {
|
||||
document.removeEventListener("click", closeBorderPop);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,7 +56,10 @@ export function mountSpreadsheetTrial(
|
||||
readSpreadsheetTrial(),
|
||||
]);
|
||||
if (!alive) return;
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, { onChange: () => onDirty(true) });
|
||||
sheet = createSpreadsheet(area, got.문서 as Workbook, {
|
||||
onChange: () => onDirty(true),
|
||||
inspect: true, // ⚠ 임시 — 브레인 검증용 문맥(window.__aisloSheet) · 산출근거 카드는 안 넘김
|
||||
});
|
||||
} catch (error) {
|
||||
if (!alive) return;
|
||||
area.replaceChildren(el("p", { className: "m02-trial__empty", text: why(error) }));
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/* =============================================================================
|
||||
* 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)}`;
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/* =============================================================================
|
||||
* fake_address.ts — E 시험 전용 가짜(A 몫 `spreadsheet_address.ts` 대역).
|
||||
* A 가 아직 없어(0 계약 직후) 도구 모음 · 탭 · 메뉴가 `toA1` 등을 부르면 던지므로
|
||||
* 화면 시험에서만 `vite.harness.config.ts` 별칭으로 이것을 끼움. 진짜 코드는 안 건드림.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CellAddress, CellRange } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export function colName(c: number): string {
|
||||
let n = c + 1;
|
||||
let s = "";
|
||||
while (n > 0) {
|
||||
const rem = (n - 1) % 26;
|
||||
s = String.fromCharCode(65 + rem) + s;
|
||||
n = Math.floor((n - 1) / 26);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function colIndex(letters: string): number {
|
||||
let n = 0;
|
||||
for (const ch of letters.toUpperCase()) {
|
||||
const d = ch.charCodeAt(0) - 64;
|
||||
if (d < 1 || d > 26) return -1;
|
||||
n = n * 26 + d;
|
||||
}
|
||||
return n - 1;
|
||||
}
|
||||
|
||||
export function toA1(r: number, c: number): string {
|
||||
return `${colName(c)}${r + 1}`;
|
||||
}
|
||||
|
||||
export function parseA1(text: string): CellAddress | null {
|
||||
const m = /^\$?([A-Za-z]+)\$?(\d+)$/.exec(text.trim());
|
||||
if (!m) return null;
|
||||
const c = colIndex(m[1]);
|
||||
const r = Number(m[2]) - 1;
|
||||
if (c < 0 || r < 0) return null;
|
||||
return { r, c };
|
||||
}
|
||||
|
||||
export function rangeToA1(range: CellRange): string {
|
||||
const a = toA1(range.r0, range.c0);
|
||||
const b = toA1(range.r1, range.c1);
|
||||
return a === b ? a : `${a}:${b}`;
|
||||
}
|
||||
|
||||
export function parseRange(text: string): CellRange | null {
|
||||
const t = text.trim();
|
||||
if (t.includes(":")) {
|
||||
const [a, b] = t.split(":");
|
||||
const pa = parseA1(a);
|
||||
const pb = parseA1(b);
|
||||
if (!pa || !pb) return null;
|
||||
return {
|
||||
r0: Math.min(pa.r, pb.r),
|
||||
c0: Math.min(pa.c, pb.c),
|
||||
r1: Math.max(pa.r, pb.r),
|
||||
c1: Math.max(pa.c, pb.c),
|
||||
};
|
||||
}
|
||||
const p = parseA1(t);
|
||||
return p ? { r0: p.r, c0: p.c, r1: p.r, c1: p.c } : null;
|
||||
}
|
||||
|
||||
export function quoteSheet(name: string): string {
|
||||
return /^[A-Za-z0-9_]+$/.test(name) ? name : `'${name.replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
const MAX_COLS = 16_384;
|
||||
export function cellId(r: number, c: number): number {
|
||||
return r * MAX_COLS + c;
|
||||
}
|
||||
|
||||
export function fromCellId(id: number): CellAddress {
|
||||
return { r: Math.floor(id / MAX_COLS), c: id % MAX_COLS };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/* =============================================================================
|
||||
* fake_commands.ts — E 시험 전용 가짜(A 몫 `spreadsheet_commands.ts` 대역).
|
||||
* 탭의 [+] 가 부르는 `emptySheet` 만 흉내(진짜 `applyCommand` 는 안 씀 · dispatch 는 harness 가 spy).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { Sheet } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export function emptySheet(id: string, name: string): Sheet {
|
||||
return { id, 이름: name, 칸: {} };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/* =============================================================================
|
||||
* fake_numfmt.ts — E 시험 전용 가짜(B 몫 `spreadsheet_numfmt.ts` 대역).
|
||||
* 클립보드 「안 → 밖」 시험이 보이는 글을 만들 수 있도록 아주 단순한 형식만 흉내.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FormattedText, Scalar } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
export function formatValue(value: Scalar, code: string | undefined): FormattedText {
|
||||
if (value === null) return { 글: "", 정렬: "left" };
|
||||
if (typeof value === "boolean") return { 글: value ? "TRUE" : "FALSE", 정렬: "center" };
|
||||
if (typeof value === "string") return { 글: value, 정렬: "left" };
|
||||
if (typeof value === "object" && "error" in value) return { 글: value.error, 정렬: "center" };
|
||||
const num = Number(value.n) / Number(value.d);
|
||||
const text =
|
||||
code === "0.00" ? num.toFixed(2) : code === "0" ? String(Math.round(num)) : String(num);
|
||||
return { 글: text, 정렬: "right" };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/* =============================================================================
|
||||
* fake_refshift.ts — E 시험 전용 가짜(A 몫 `spreadsheet_refshift.ts` 대역).
|
||||
* 클립보드 붙여넣기 시험이 상대 참조 옮김 · 구글 R1C1 을 확인할 수 있도록 아주 단순히 흉내.
|
||||
* ========================================================================== */
|
||||
|
||||
import { colIndex, colName } from "./fake_address";
|
||||
import type { CellAddress } from "../../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
/** 복사 · 붙여넣기 — `$` 없는 A1 참조만 (dr, dc) 옮김. */
|
||||
export function moveFormula(formula: string, dr: number, dc: number): string {
|
||||
return formula.replace(/(\$?)([A-Za-z]{1,3})(\$?)(\d+)/g, (_m, absCol, col, absRow, row) => {
|
||||
let c = colIndex(col);
|
||||
let r = Number(row) - 1;
|
||||
if (!absCol) c += dc;
|
||||
if (!absRow) r += dr;
|
||||
if (c < 0 || r < 0) return "#REF!";
|
||||
return `${absCol}${colName(c)}${absRow}${r + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** 구글 `R[0]C[-1]` 꼴 → 붙일 자리(`at`) 기준 A1 식. */
|
||||
export function r1c1ToA1(formula: string, at: CellAddress): string {
|
||||
return formula.replace(
|
||||
/R(\[-?\d+\]|\d+)?C(\[-?\d+\]|\d+)?/g,
|
||||
(_m, rp: string | undefined, cp: string | undefined) => {
|
||||
const row =
|
||||
rp === undefined
|
||||
? at.r + 1
|
||||
: rp.startsWith("[")
|
||||
? at.r + 1 + Number(rp.slice(1, -1))
|
||||
: Number(rp);
|
||||
const col =
|
||||
cp === undefined
|
||||
? at.c + 1
|
||||
: cp.startsWith("[")
|
||||
? at.c + 1 + Number(cp.slice(1, -1))
|
||||
: Number(cp);
|
||||
return `${colName(col - 1)}${row}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,116 @@ window.ssSample = JSON.parse(JSON.stringify(sample));
|
||||
window.ss = createSpreadsheet(host, JSON.parse(JSON.stringify(sample)), {
|
||||
onChange: () => window.ssChanges++,
|
||||
onSelect: (s) => (window.ssSelect = s),
|
||||
inspect: true,
|
||||
});
|
||||
window.ssCreate = createSpreadsheet;
|
||||
/** 칸 입력 값(A1) */
|
||||
window.ssCell = (a1, sheet = "s1") =>
|
||||
window.ss.getDoc().시트.find((s) => s.id === sheet).칸[a1] ?? null;
|
||||
|
||||
/* 한 번에 도는 시나리오 — 탭이 새로 읽혀도 끊기지 않게 페이지 안에서 합성 사건으로 조작하고 수치를 모음.
|
||||
* (믿음 입력 — 진짜 키 · 마우스 · 한글 — 은 ORCA keypress · type · mouse 로 따로 잼) */
|
||||
window.ssScenario = () => {
|
||||
const ta = () => document.querySelector(".ss-input");
|
||||
const at = (a1) => {
|
||||
const d = document.querySelector(`[data-a1="${a1}"]`).getBoundingClientRect();
|
||||
return {
|
||||
clientX: d.left + d.width / 2,
|
||||
clientY: d.top + d.height / 2,
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
};
|
||||
};
|
||||
const grid = () => document.querySelector(".ss-grid-host");
|
||||
const click = (a1, extra = {}) => {
|
||||
grid().dispatchEvent(new MouseEvent("mousedown", { ...at(a1), ...extra }));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", at(a1)));
|
||||
};
|
||||
const dragTo = (from, to) => {
|
||||
grid().dispatchEvent(new MouseEvent("mousedown", from));
|
||||
window.dispatchEvent(new MouseEvent("mousemove", to));
|
||||
window.dispatchEvent(new MouseEvent("mouseup", to));
|
||||
};
|
||||
const key = (k, o = {}) =>
|
||||
document.activeElement.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: k, bubbles: true, cancelable: true, ...o }),
|
||||
);
|
||||
const type = (text) => {
|
||||
const t = document.activeElement;
|
||||
t.setRangeText(text, t.selectionStart, t.selectionEnd, "end");
|
||||
t.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
const shown = (a1) => document.querySelector(`[data-a1="${a1}"]`).textContent;
|
||||
const sel = () => `${ssSelect.칸}|${ssSelect.범위}`;
|
||||
const out = {};
|
||||
// 한글 첫 글자 — compositionstart 로 편집 시작 · 같은 textarea 그대로
|
||||
click("L2");
|
||||
ta().dispatchEvent(new CompositionEvent("compositionstart", { data: "" }));
|
||||
out.imeEditing =
|
||||
document.querySelector(".ss").classList.contains("ss--editing") &&
|
||||
document.activeElement === ta();
|
||||
type("한");
|
||||
key("Enter");
|
||||
out.ime = [ssCell("L2"), sel()];
|
||||
// 수 · 식 · 참조 가리키기(방향키 + 누르기) · 재계산
|
||||
click("L3");
|
||||
type("2");
|
||||
key("Enter");
|
||||
type("=");
|
||||
key("ArrowUp");
|
||||
out.pointKey = ta().value;
|
||||
type("*");
|
||||
click("J5");
|
||||
out.pointClick = [
|
||||
ta().value,
|
||||
[...document.querySelectorAll(".fg-ref")].map((x) => x.dataset.ref),
|
||||
];
|
||||
key("Enter");
|
||||
out.calc = [ssCell("L4"), shown("L4")];
|
||||
click("L3");
|
||||
type("5");
|
||||
key("Enter");
|
||||
out.recalc = shown("L4");
|
||||
// 되돌리기 · 다시
|
||||
key("z", { ctrlKey: true });
|
||||
out.undo = [ssCell("L3"), shown("L4")];
|
||||
key("y", { ctrlKey: true });
|
||||
out.redo = [ssCell("L3"), shown("L4")];
|
||||
// Delete(서식 남김) · 되돌림
|
||||
click("B4");
|
||||
key("Delete");
|
||||
out.del = ssCell("B4");
|
||||
key("z", { ctrlKey: true });
|
||||
out.delUndo = ssCell("B4");
|
||||
// 병합 · Shift/Ctrl 방향
|
||||
click("A4");
|
||||
key("ArrowRight", { shiftKey: true });
|
||||
key("ArrowRight", { shiftKey: true });
|
||||
out.shift = sel();
|
||||
click("A3");
|
||||
key("ArrowDown", { ctrlKey: true });
|
||||
out.ctrl = sel();
|
||||
// 끌어 고르기 · Ctrl+Enter
|
||||
dragTo(at("P2"), at("Q3"));
|
||||
out.drag = sel();
|
||||
type("=L3+1");
|
||||
key("Enter", { ctrlKey: true });
|
||||
out.ctrlEnter = ["P2", "Q2", "P3", "Q3"].map((k) => ssCell(k)?.식);
|
||||
// 수식 입력줄로 고치기
|
||||
click("N2");
|
||||
const bar = document.querySelector(".ss-formula");
|
||||
bar.focus();
|
||||
bar.value = "=J4*10";
|
||||
bar.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
key("Enter");
|
||||
out.bar = [ssCell("N2"), shown("N2"), sel()];
|
||||
// F2 — 방향키가 글자 자리만
|
||||
click("N2");
|
||||
key("F2");
|
||||
const before = sel();
|
||||
key("ArrowLeft");
|
||||
out.f2 = [before === sel(), document.querySelector(".ss").classList.contains("ss--editing")];
|
||||
key("Escape");
|
||||
out.errors = window.ssErrors;
|
||||
return JSON.stringify(out);
|
||||
};
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
<body>
|
||||
<div id="fakes"></div>
|
||||
<div id="host"></div>
|
||||
<script>
|
||||
window.ssErrors = [];
|
||||
window.addEventListener("error", (e) =>
|
||||
window.ssErrors.push(String(e.error?.stack || e.message)),
|
||||
);
|
||||
</script>
|
||||
<script src="../../../tmp/spreadsheet_d/harness.js"></script>
|
||||
<script>
|
||||
document.getElementById("fakes").textContent =
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>E 화면 시험 — 도구 모음 · 탭 · 메뉴 · 클립보드</title>
|
||||
<link rel="stylesheet" href="../../../ui_template/ui_template_theme.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 16px;
|
||||
}
|
||||
#ss-root {
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
min-height: 200px;
|
||||
padding: 40px;
|
||||
}
|
||||
#tabs-slot {
|
||||
margin-top: 8px;
|
||||
}
|
||||
#log,
|
||||
#paste-out {
|
||||
white-space: pre-wrap;
|
||||
background: #f6f7fa;
|
||||
border: 1px solid #ccc;
|
||||
padding: 8px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
#paste-zone {
|
||||
border: 2px dashed #999;
|
||||
padding: 16px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h3>E 화면 시험 틀</h3>
|
||||
<div id="toolbar-slot"></div>
|
||||
<div id="ss-root">
|
||||
<p>격자 자리 표시자 — 왼쪽 30px = 행 머리 · 다음 30px = 열 머리 · 나머지 = 칸(우클릭 시험)</p>
|
||||
</div>
|
||||
<div id="tabs-slot"></div>
|
||||
<h4>붙여넣기 시험 — 여기 눌러 포커스 뒤 Ctrl+V</h4>
|
||||
<div id="paste-zone" tabindex="0">여기 클릭 뒤 Ctrl+V</div>
|
||||
<pre id="paste-out"></pre>
|
||||
<h4>로그</h4>
|
||||
<pre id="log"></pre>
|
||||
<script type="module" src="./e_harness.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -326,6 +326,7 @@ function createGrid(ctx) {
|
||||
});
|
||||
function show(v) {
|
||||
if (v === null || v === undefined) return "";
|
||||
if (typeof v === "object" && "n" in v) return String(Number(v.n) / Number(v.d)); // 진짜 엔진 분수
|
||||
if (typeof v === "object") return v.error;
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return String(v);
|
||||
@@ -351,13 +352,15 @@ function createGrid(ctx) {
|
||||
if (m && (m.r0 !== r || m.c0 !== c)) continue;
|
||||
const d = el("div", {
|
||||
className: "fg-cell",
|
||||
attrs: { "data-a1": toA1(r, c) },
|
||||
attrs: { "data-a1": toA1(r, c), "data-addr": toA1(r, c) }, // data-addr = C 격자와 같은 꼴
|
||||
text: show(ctx.engine.value(sheet.id, r, c)),
|
||||
});
|
||||
place(d, cellBox(r, c));
|
||||
d.style.cssText +=
|
||||
";border-right:1px solid #eee;border-bottom:1px solid #eee;padding:2px 3px;overflow:hidden;white-space:nowrap";
|
||||
if (typeof ctx.engine.value(sheet.id, r, c) === "number") d.style.textAlign = "right";
|
||||
const v = ctx.engine.value(sheet.id, r, c);
|
||||
if (typeof v === "number" || (v && typeof v === "object" && "n" in v))
|
||||
d.style.textAlign = "right";
|
||||
cellLayer.append(d);
|
||||
}
|
||||
renderSelection();
|
||||
@@ -461,6 +464,8 @@ function mountTabs(ctx) {
|
||||
|
||||
module.exports = {
|
||||
el: { el },
|
||||
// B 함수 — 없음(진짜 엔진이 부르면 #NAME?)
|
||||
functions: { getFunction: () => undefined, functionNames: () => [] },
|
||||
address,
|
||||
parser,
|
||||
refshift,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/* spreadsheet_clipboard.ts 의 TSV(밖 → 안, DOM 없이 되는 부분)만 — 값 · 참거짓 · 「엑셀 수식
|
||||
표시」 식 잡기(끝수 · 「=」 한 글자는 글). HTML(DOMParser) 쪽은 ORCA 화면 시험으로.
|
||||
진짜 앱 별칭(`@ui` 등)까지 그대로 vite 로 번들해 Node 로 돌린다(`build:formula` 방식) —
|
||||
A(주소) · B(형식) 는 부르지 않는 길만 시험하므로 0 계약 몸(던짐)이어도 됨. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const out = mkdtempSync(join(tmpdir(), "aislo-ss-clip-"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"./config/node_modules/vite/bin/vite.js",
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"spreadsheet/spreadsheet_clipboard.ts",
|
||||
"--outDir",
|
||||
out,
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const { parseClipboard } = await import(pathToFileURL(join(out, "spreadsheet_clipboard.js")).href);
|
||||
|
||||
const at = { r: 0, c: 0 };
|
||||
|
||||
// 값 · 참거짓 잡기
|
||||
{
|
||||
const block = parseClipboard("", "10\t20\nTRUE\t3.5", at);
|
||||
assert.equal(block.rows, 2);
|
||||
assert.equal(block.cols, 2);
|
||||
assert.deepEqual(block.칸["0,0"], { 값: 10 });
|
||||
assert.deepEqual(block.칸["1,0"], { 값: true });
|
||||
assert.deepEqual(block.칸["1,1"], { 값: 3.5 });
|
||||
assert.equal(block.출처, "text");
|
||||
assert.equal(block.원점, null);
|
||||
}
|
||||
|
||||
// 「=」 뒤에 글이 있으면 식 · 한 글자 「=」 는 글(엑셀 「수식 표시」 대응)
|
||||
{
|
||||
const block = parseClipboard("", "=SUM(A1:A2)\t=", at);
|
||||
assert.deepEqual(block.칸["0,0"], { 식: "SUM(A1:A2)" });
|
||||
assert.deepEqual(block.칸["0,1"], { 값: "=" });
|
||||
}
|
||||
|
||||
// 빈 칸은 안 적음(성긴 모양) · 끝 빈 줄 버림
|
||||
{
|
||||
const block = parseClipboard("", "a\t\tb\n\n", at);
|
||||
assert.equal(Object.keys(block.칸).length, 2);
|
||||
assert.equal(block.rows, 1);
|
||||
assert.equal(block.cols, 3);
|
||||
}
|
||||
|
||||
// 읽을 것 없으면 null
|
||||
{
|
||||
assert.equal(parseClipboard("", "", at), null);
|
||||
}
|
||||
|
||||
console.log("OK — 클립보드 TSV 값 · 참거짓 · 「수식 표시」 식 잡기");
|
||||
@@ -60,7 +60,10 @@ def test_M02_만_늦게_불러옴():
|
||||
check=True,
|
||||
).stdout.splitlines()
|
||||
for rel in listed:
|
||||
if rel.startswith("A00_Common/spreadsheet/") or not (ROOT / rel).is_file():
|
||||
# 시험용 진입(`resources/tester/`)은 앱 번들 밖
|
||||
if rel.startswith(("A00_Common/spreadsheet/", "resources/tester/")):
|
||||
continue
|
||||
if not (ROOT / rel).is_file():
|
||||
continue
|
||||
text = (ROOT / rel).read_text(encoding="utf-8", errors="replace")
|
||||
if "spreadsheet/spreadsheet" not in text:
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { resolve } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "../../..");
|
||||
|
||||
/* =============================================================================
|
||||
* vite.harness.config.ts — E 화면 시험 전용 빌드(정본 `vite.config.ts` 는 안 건드림).
|
||||
* A(`spreadsheet_address`) · A(`spreadsheet_commands`) 가 아직 0 계약 몸만 있어(던짐) 이
|
||||
* 폴더의 가짜로 바꿔 끼움 — E 가 만든 화면 부품만 시험. 진짜 앱 빌드에는 안 쓰임.
|
||||
* ========================================================================== */
|
||||
export default {
|
||||
root: __dirname,
|
||||
publicDir: false,
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: "./spreadsheet_address", replacement: resolve(__dirname, "./fakes/fake_address.ts") },
|
||||
{
|
||||
find: "./spreadsheet_commands",
|
||||
replacement: resolve(__dirname, "./fakes/fake_commands.ts"),
|
||||
},
|
||||
{ find: "./spreadsheet_numfmt", replacement: resolve(__dirname, "./fakes/fake_numfmt.ts") },
|
||||
{
|
||||
find: "./spreadsheet_refshift",
|
||||
replacement: resolve(__dirname, "./fakes/fake_refshift.ts"),
|
||||
},
|
||||
{ find: "@ui", replacement: resolve(repoRoot, "./ui_template") },
|
||||
],
|
||||
},
|
||||
build: {
|
||||
outDir: resolve(tmpdir(), "aislo_e_harness_dist"),
|
||||
emptyOutDir: true,
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user