Merge remote-tracking branch 'origin/dev' into sub_laptop_8
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user