Merge remote-tracking branch 'origin/dev' into sub_laptop_8

This commit is contained in:
2026-09-27 20:54:31 +09:00
65 changed files with 906 additions and 92 deletions
@@ -44,7 +44,7 @@ export interface ResolvedBorders {
오른?: BorderSide;
}
/** `서식` 표 번호 — 칸 → 행 → 열 → 0(기본) 차례(칸 안의 계약 주석과 같음). */
/** `서식` 표 번호 — 칸 → 행 → 열 → 시트 `기본.서식` → 0(기본) 차례(칸 안의 계약 주석과 같음). */
export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): number {
const cell = sheet.칸[key];
if (cell?.서식 !== undefined) return cell.서식;
@@ -52,6 +52,7 @@ export function styleIndexAt(sheet: Sheet, r: number, c: number, key: string): n
if (rowFmt !== undefined) return rowFmt;
const colFmt = sheet.열?.[colName(c)]?.서식;
if (colFmt !== undefined) return colFmt;
if (sheet.기본?.서식 !== undefined) return sheet.기본.서식;
return 0;
}
+2 -17
View File
@@ -7,22 +7,7 @@
import type { Cell, CellRange, FillDirection, Workbook } from "./spreadsheet_types";
import { moveFormula } from "./spreadsheet_refshift";
/** 0 → `A` · 27 → `AB`(엔진 A 의 `spreadsheet_address.ts` 와 같은 규칙 · 채우기는 자기 것을 씀) */
function colLetters(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;
}
function a1(r: number, c: number): string {
return `${colLetters(c)}${r + 1}`;
}
import { toA1 } from "./spreadsheet_address";
function detectDirection(source: CellRange, target: CellRange): FillDirection {
if (target.r1 > source.r1 && target.c0 === source.c0 && target.c1 === source.c1) return "down";
@@ -64,7 +49,7 @@ export function fillCells(
const targetEnd = vertical ? target.r1 : target.c1;
const lineLen = sourceEnd - sourceStart + 1;
const key = (pos: number): string => (vertical ? a1(pos, fixed) : a1(fixed, pos));
const key = (pos: number): string => (vertical ? toA1(pos, fixed) : toA1(fixed, pos));
const sourceCells: (Cell | undefined)[] = [];
for (let p = sourceStart; p <= sourceEnd; p++) sourceCells.push(sheetObj.칸[key(p)]);
@@ -172,6 +172,19 @@
z-index: 1;
}
/* 메모 칸 — 엑셀처럼 오른 위 모서리 빨간 세모. */
.aislo-cell.has-comment::after {
content: "";
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 6px 6px 0;
border-color: transparent #c00 transparent transparent;
}
.aislo-grid__ref-highlight {
position: absolute;
box-sizing: border-box;
+26 -5
View File
@@ -130,6 +130,7 @@ interface CellDisplay {
hAlign: HAlign;
title: string;
isError: boolean;
color?: string;
}
function cellDisplay(
@@ -151,7 +152,7 @@ function cellDisplay(
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 };
return { text: formatted.글, hAlign, title, isError, color: formatted.색 };
}
export function createGrid(ctx: SpreadsheetContext): GridHandle {
@@ -214,6 +215,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
let frozenCols = 0;
let frozenW = 0;
let frozenH = 0;
let lastSheetId = "";
const resize = new ResizeObserver(() => layoutPanes());
resize.observe(root);
@@ -313,6 +315,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
cellEl.style.height = `${h}px`;
cellEl.dataset.r = String(r0);
cellEl.dataset.c = String(c0);
const addr = toA1(r0, c0);
cellEl.dataset.addr = addr;
const style = cellStyleAt(book, sheet, r0, c0);
const borders: ResolvedBorders = resolveBoxBorders(
book,
@@ -326,8 +330,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
);
const display = cellDisplay(ctx, sheet, r0, c0, style.가로 ?? "general", style.형식);
applyCellStyle(cellEl, style, borders, display.hAlign);
setCellText(cellEl, display.text, display.title || undefined);
if (display.color) cellEl.style.color = display.color;
const comment = sheet.comments?.[addr];
setCellText(cellEl, display.text, display.title || comment || undefined);
cellEl.classList.toggle("is-error", display.isError);
cellEl.classList.toggle("has-comment", !!comment);
cellEl.classList.toggle(
"is-selected",
ctx.selection.활성.r === r0 && ctx.selection.활성.c === c0,
@@ -478,6 +485,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
function render(): void {
const sheet = ctx.sheet();
root.style.fontFamily = ctx.book.기본?.글꼴 ?? "";
root.style.fontSize = ctx.book.기본?.크기 ? `${ctx.book.기본.크기}pt` : "";
colGeo = buildColGeometry(ctx.book, sheet);
rowGeo = buildRowGeometry(ctx.book, sheet);
extent = computeExtent(sheet);
@@ -486,8 +495,11 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
frozenCols = Math.min(sheet.틀고정?.열 ?? 0, extent.cols);
spacer.style.width = "1px";
spacer.style.height = "1px";
paneBR.scrollLeft = 0;
paneBR.scrollTop = 0;
if (sheet.id !== lastSheetId) {
lastSheetId = sheet.id;
paneBR.scrollLeft = 0;
paneBR.scrollTop = 0;
}
layoutPanes();
}
@@ -667,8 +679,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
};
}
/** 칸이 든 판(틀고정 4 판 중 하나)의 안쪽 층 — 어느 판인지는 이 반환 요소로 앎. */
function paneLayerOf(r: number, c: number): HTMLElement {
const inFrozenRow = r < frozenRows;
const inFrozenCol = c < frozenCols;
if (inFrozenRow) return inFrozenCol ? paneTLInner : paneTRInner;
return inFrozenCol ? paneBLInner : paneBRInner;
}
/** 편집기 자리 — `layer` = 칸이 든 판의 안쪽 층 · `box` = 격자 뿌리 기준 화면 좌표(D 가 층 차이를 빼서 맞춤). */
function editorSlot(r: number, c: number): { layer: HTMLElement; box: CellBox } {
return { layer: overlay, box: screenBoxOf(r, c) };
return { layer: paneLayerOf(r, c), box: screenBoxOf(r, c) };
}
function destroy(): void {
@@ -100,7 +100,7 @@ class SparseGeometry implements LineGeometry {
}
export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = colCharsToPx(book.기본?.열폭 ?? DEFAULT_COL_CHARS);
const base = colCharsToPx(sheet.기본?.열폭 ?? book.기본?.열폭 ?? DEFAULT_COL_CHARS);
const overrides = new Map<number, number>();
for (const [key, info] of Object.entries(sheet.열 ?? {})) {
const c = colIndex(key);
@@ -112,7 +112,7 @@ export function buildColGeometry(book: Workbook, sheet: Sheet): LineGeometry {
}
export function buildRowGeometry(book: Workbook, sheet: Sheet): LineGeometry {
const base = ptToPx(book.기본?.행높이 ?? DEFAULT_ROW_PT);
const base = ptToPx(sheet.기본?.행높이 ?? 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;
+28 -12
View File
@@ -64,7 +64,12 @@ function extractColor(section: string): { rest: string; color?: string } {
let color: string | undefined;
const rest = section.replace(/\[([^\]]+)\]/g, (_m, inner: string) => {
const lower = inner.toLowerCase();
if (COLOR_NAMES.includes(lower)) color = lower;
if (COLOR_NAMES.includes(lower)) {
color = lower;
return "";
}
const currency = /^\$([^-\]]*)(-[0-9A-Fa-f]+)?$/.exec(inner);
if (currency) return currency[1]; // `[$₩-412]` 등 통화 태그 → 글자만 남김
return ""; // 조건 태그(`[>100]` 등)는 1단계에서 무시
});
return { rest, color };
@@ -78,6 +83,7 @@ type Tok =
| { k: "point" }
| { k: "percent" }
| { k: "text" }
| { k: "general" }
| { k: "lit"; ch: string };
function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
@@ -85,6 +91,12 @@ function tokenize(s: string): { toks: Tok[]; fillChar?: string } {
let fillChar: string | undefined;
for (let i = 0; i < s.length; i++) {
const ch = s[i];
// 코드 속에 섞인 맨 `General`(따옴표 없이) — 실무 흔한 `General"개"` 같은 단위 접미 버릇.
if (s.slice(i, i + 7).toLowerCase() === "general") {
toks.push({ k: "general" });
i += 6;
continue;
}
if (ch === '"') {
let j = i + 1;
while (j < s.length && s[j] !== '"') {
@@ -214,6 +226,10 @@ function renderNumericSection(toks: Tok[], magnitude: Frac): string {
continue;
}
if (t.k === "text") continue; // 수 구역엔 의미 없음(방어)
if (t.k === "general") {
out += generalFormat(magnitude);
continue;
}
out += t.ch;
}
return out;
@@ -238,34 +254,34 @@ function chooseSection(sections: string[], value: Frac): { code: string; forceMi
}
function formatNumber(value: Frac, code: string | undefined): FormattedText {
const trimmed = code?.trim();
if (!trimmed || trimmed.toLowerCase() === "general") {
if (code === undefined || code === "" || code.trim().toLowerCase() === "general") {
return { 글: generalFormat(value), 정렬: "right" };
}
const sections = splitSections(trimmed);
const sections = splitSections(code);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
if (fillChar) extra.채움글 = fillChar;
if (!toks.some((t) => t.k === "digit")) {
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
if (!toks.some((t) => t.k === "digit" || t.k === "general")) {
let out = "";
for (const t of toks) if (t.k === "lit") out += t.ch;
return { 글: out, 정렬: "right", ...extra };
}
const magnitude = cmp(value, ZERO) < 0 ? frac(-value.n, value.d) : value;
const body = renderNumericSection(toks, magnitude);
return { 글: (forceMinus ? "-" : "") + body, 정렬: "right", ...extra };
}
function formatText(value: string, code: string | undefined): FormattedText {
const trimmed = code?.trim();
if (!trimmed) return { 글: value, 정렬: "left" };
const sections = splitSections(trimmed);
// 구역 하나뿐(`;` 없음) = 그 구역이 수 · 글 모두에 적용(`General` 과 같은 이치) · 2~3 구역엔 글 자리가 없어 그대로.
const textCode =
sections.length >= 4 ? sections[3] : sections.length === 1 ? sections[0] : undefined;
if (code === undefined || code === "") return { 글: value, 정렬: "left" };
const sections = splitSections(code);
// 구역 하나뿐(`;` 없음)에 `@` 가 있을 때만 그 구역을 씀(순수 수 형식 하나뿐이면 글엔 안 씀) ·
// 넷째 구역은 명시적 글 자리라 `@` 없어도 그대로(고정 라벨 버릇) · 2~3 구역엔 글 자리가 없어 그대로.
let textCode: string | undefined;
if (sections.length >= 4) textCode = sections[3];
else if (sections.length === 1 && sections[0].includes("@")) textCode = sections[0];
if (textCode === undefined) return { 글: value, 정렬: "left" };
const { rest, color } = extractColor(textCode);
const { toks, fillChar } = tokenize(rest);