feat(spreadsheet): 긴 글이 옆 빈 칸으로 넘쳐 보이게(엑셀)
- 왼쪽 · 일반 맞춤 글은 오른쪽으로 · 오른쪽 맞춤은 왼쪽으로 · 가운데는 양쪽으로 — 옆 칸이 빌 때만 - 비지 않은 칸 · 병합 칸 · 틀 고정 경계에서 멈춤 · 가운데 맞춤이 한쪽만 막히면 그쪽을 잘라 냄 - 줄 바꿈 켠 칸 · 숫자(####) · 오류는 안 넘침 · 덮은 칸 사이 눈금선 감춤 · 고치는 중에는 활성 칸 넘침 감춤 - 폭 계산은 새 파일 spreadsheet_spill.ts(격자 파일이 이미 700 줄 넘어 따로) · 정확한 글 폭은 글 요소 scrollWidth 를 판마다 한 번에 읽음 - ORCA(M02 면적산출 편집 창) 수치 확인 — 왼 · 오른 · 가운데 넘침 글 잘림 0 · 막힘 · 병합 · 틀 고정 멈춤 · 스크롤 · 확대 뒤 칸에 붙어 있음 · 비지 않은 칸과 겹침 0 · 실제 문서 새 그림 겹침 0 · 크기 시험 통과 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BYN9gMie8e6pXzSxJNb6Xt
This commit is contained in:
@@ -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 # 판 끝(그린 칸 없음)
|
||||
|
||||
Reference in New Issue
Block a user