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

This commit is contained in:
2026-09-28 22:40:26 +09:00
5 changed files with 151 additions and 0 deletions
@@ -248,3 +248,18 @@
.aislo-grid__overlay > * {
pointer-events: auto;
}
/* 긴 글 넘침(`spreadsheet_spill.ts`) — 옆 빈 칸 위로 글을 폄 · 덮은 칸 사이 눈금선은 감춤(엑셀) ·
* 고치는 중에는 활성 칸 넘침을 감춤(편집기와 안 겹치게). */
.aislo-cell.is-spill {
overflow: visible;
z-index: 1;
}
.aislo-grid--gridlines .aislo-cell.is-spill-r {
border-right-color: transparent;
}
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden;
}
@@ -37,6 +37,7 @@ import {
} from "./spreadsheet_headers";
import { resolveCondFmtStyle, type CondFmtValue } from "./spreadsheet_condfmt";
import { formatValue } from "./spreadsheet_numfmt";
import { spillOf, type SpillAlign, type SpillSlot } from "./spreadsheet_spill";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellRange,
@@ -139,6 +140,19 @@ function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
let measureRuler: CanvasRenderingContext2D | null = null;
/** 글 너비(px) — 칸 재기용(수가 칸 폭보다 길면 `#####`). */
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
left: "left",
right: "right",
center: "center",
};
function resetSpill(cell: HTMLElement): void {
cell.classList.remove("is-spill", "is-spill-r");
const t = cell.firstElementChild as HTMLElement | null;
if (t) t.style.width = t.style.marginLeft = t.style.clipPath = "";
}
function measureText(text: string, font: string): number {
measureRuler ??= document.createElement("canvas").getContext("2d");
if (!measureRuler) return text.length * 7;
@@ -417,6 +431,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const sheet = ctx.sheet();
const book = ctx.book;
const keep = new Set<string>();
// 긴 글 넘침 — 줄마다 칸 자리를 모았다가 판을 다 그린 뒤 폄(옆 칸이 빈지 알아야 해서)
const slots = new Map<number, Map<number, SpillSlot>>();
const longs: {
r: number;
c: number;
el: HTMLElement;
own: number;
need: number;
align: SpillAlign;
}[] = [];
for (const r of rowsVisible) {
for (const c of colsVisible) {
const merge = merges.get(`${r},${c}`);
@@ -475,6 +499,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
}
}
setCellText(cellEl, text, display.title || comment || undefined);
resetSpill(cellEl);
if (!merge) {
(slots.get(r0) ?? slots.set(r0, new Map()).get(r0)!).set(c0, { w, empty: text === "" });
const align = SPILL_ALIGN[display.hAlign];
if (align && text && !display.isNumeric && !display.isError && !style.줄바꿈) {
// 캔버스 폭은 거르기만(글꼴 대체로 조금 어긋남) — 정확한 폭은 아래서 글 요소 scrollWidth 로
const need = measureText(text, cellFont(style, book, zoom)) + 4;
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
}
}
cellEl.classList.toggle("is-error", display.isError);
cellEl.classList.toggle("has-comment", !!comment);
cellEl.classList.toggle(
@@ -483,6 +517,20 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
);
}
}
// 글 폭 + 칸 안 여백(양쪽 2px) · 오른 선 1px — 읽기를 먼저 몰아 한 번만 배치 계산
for (const l of longs) l.need = (l.el.firstElementChild as HTMLElement).scrollWidth + 5;
for (const l of longs) {
const sp = spillOf(slots.get(l.r)!, l.c, l.own, l.need, l.align);
if (!sp) continue;
l.el.classList.add("is-spill");
const t = l.el.firstElementChild as HTMLElement;
t.style.width = `calc(100% + ${sp.left + sp.right}px)`;
t.style.marginLeft = `${-sp.left}px`;
if (sp.clipLeft || sp.clipRight)
t.style.clipPath = `inset(0 ${sp.clipRight}px 0 ${sp.clipLeft}px)`;
for (let k = sp.from; k < sp.to; k++)
pool.used.get(`${l.r},${k}`)?.classList.add("is-spill-r");
}
poolSweep(pool, keep);
}
@@ -0,0 +1,61 @@
/* =============================================================================
* spreadsheet_spill.ts
* 긴 글 넘침(엑셀) — 칸보다 긴 글을 옆 빈 칸 위로 보임. 왼쪽 · 일반 맞춤은 오른쪽으로 · 오른쪽 맞춤은
* 왼쪽으로 · 가운데는 양쪽으로. 비지 않은 칸 · 병합 칸 · 판 끝(틀 고정 경계 · 그린 범위 밖)에서 멈춤.
* 격자(`spreadsheet_grid.ts`)가 판 하나를 그릴 때 줄마다 칸 자리(`slots`)를 모아 부름 — 계산만 여기.
* ========================================================================== */
/** 같은 판 · 같은 줄 · 병합 아닌 칸 하나(병합 칸 · 안 그린 칸은 넣지 않음 → 거기서 멈춤) */
export interface SpillSlot {
w: number;
empty: boolean;
}
/** 넘침 — 왼 · 오른쪽으로 더 쓸 폭(px) · 덮은 칸 열 범위 · 가운데 맞춤이 한쪽만 막혀 잘라 낼 폭 */
export interface Spill {
left: number;
right: number;
from: number;
to: number;
/** 가운데 맞춤 — 글 상자는 양쪽 반씩 늘리고 막힌 쪽은 이만큼 잘라 냄(글은 칸 가운데 그대로) */
clipLeft: number;
clipRight: number;
}
export type SpillAlign = "left" | "right" | "center";
/** 칸 c(폭 own)의 글 폭 need 가 넘치면 정렬 쪽 빈 칸으로 늘린 결과 · 안 넘치거나 못 늘리면 null */
export function spillOf(
slots: Map<number, SpillSlot>,
c: number,
own: number,
need: number,
align: SpillAlign,
): Spill | null {
if (need <= own) return null;
let from = c;
let to = c;
const grow = (dir: -1 | 1, want: number): number => {
let got = 0;
for (let k = c + dir; got < want; k += dir) {
const s = slots.get(k);
if (!s || !s.empty) break;
got += s.w;
if (dir < 0) from = k;
else to = k;
}
return got;
};
if (align === "center") {
const half = (need - own) / 2;
const l = Math.min(grow(-1, half), half);
const r = Math.min(grow(1, half), half);
if (!l && !r) return null;
return { left: half, right: half, from, to, clipLeft: half - l, clipRight: half - r };
}
const got = grow(align === "right" ? -1 : 1, need - own);
if (!got) return null;
return align === "right"
? { left: got, right: 0, from, to, clipLeft: 0, clipRight: 0 }
: { left: 0, right: got, from, to, clipLeft: 0, clipRight: 0 };
}
@@ -7,6 +7,7 @@ const sel = load(path.join(SS, "spreadsheet_selection.ts"));
const ed = load(path.join(SS, "spreadsheet_editor.ts"));
const mouse = load(path.join(SS, "spreadsheet_mouse.ts"));
const addr = load(path.join(SS, "spreadsheet_address.ts"));
const spill = load(path.join(SS, "spreadsheet_spill.ts"));
const sample = require(path.join(SS, "spreadsheet_sample.json"));
const s1 = sample.시트[0];
@@ -69,6 +70,21 @@ const out = {
(t) => ed.textToCell(t, { 서식: 4 }),
),
textToCellNoStyle: ed.textToCell("", {}),
// 긴 글 넘침 — 칸 0..5 폭 50 · 3 은 값 있음 · 칸 2 에서 폭 130 글
spill: (() => {
const row = (full) =>
new Map([0, 1, 2, 3, 4, 5].map((c) => [c, { w: 50, empty: !full.includes(c) }]));
const f = (sp) => sp && [sp.left, sp.right, sp.from, sp.to, sp.clipLeft, sp.clipRight];
return {
left: f(spill.spillOf(row([2, 3]), 2, 50, 130, "left")),
leftFree: f(spill.spillOf(row([2]), 2, 50, 130, "left")),
right: f(spill.spillOf(row([2]), 2, 50, 130, "right")),
center: f(spill.spillOf(row([2]), 2, 50, 130, "center")),
centerBlocked: f(spill.spillOf(row([2, 3]), 2, 50, 130, "center")),
fits: f(spill.spillOf(row([2]), 2, 50, 40, "left")),
edge: f(spill.spillOf(new Map([[2, { w: 50, empty: false }]]), 2, 50, 130, "left")),
};
})(),
// 친 모양대로 숫자 형식 · 식 대문자 · 닫는 괄호 채움(엑셀)
typedFormat: ["50%", "12.5%", "1,234.5", "1,000", "7", "abc", "1e3%"].map(ed.typedFormat),
upperFormula: ["sum(a1:b2)+round(c3,0)", "'시트 a'!a1*true", '"abc"&a1', "내이름*2"].map(
@@ -128,3 +128,14 @@ def test_upper_formula(r):
def test_close_parens(r):
assert r["closeParens"] == ["=(1+2)", "=SUM(1,ROUND(2,0))", None, None, '=")"&(1)']
def test_spill(r):
sp = r["spill"]
assert sp["left"] is None # 오른 칸(3)에 값 → 못 넘침
assert sp["leftFree"] == [0, 100, 2, 4, 0, 0] # 빈 칸 둘 덮음
assert sp["right"] == [100, 0, 0, 2, 0, 0]
assert sp["center"] == [40, 40, 1, 3, 0, 0]
assert sp["centerBlocked"] == [40, 40, 1, 2, 0, 40] # 오른쪽 막힘 → 오른쪽 반 잘라 냄
assert sp["fits"] is None
assert sp["edge"] is None # 판 끝(그린 칸 없음)