feat(m02): 면적 아래 알약 막대에 도면 알약 — 범위 · 상태 · 누르면 고름 · × 보내기 취소(114-3)

- 「T1 도면 E7:J17」 + 공용 꼬리표 상태(값 맞음 · 값 다름 · 도면에 표 없음 · 범위 없음) · 테두리 = 시트 위 점선 색
- 상태는 지금 책으로 판정(areaTable → sameValues) · 값이 바뀌면 150ms 모아 다시
- × = 기존 [보내기 취소](화면에서만 · 면적 저장 때 기록) · 시트 위 이름표 그대로
- 보낸 범위 몫을 spreadsheet_link_range.ts 로 나눔(700줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7
This commit is contained in:
2026-10-08 18:33:53 +09:00
co-authored by Claude Opus 5.5
parent ad511175bf
commit 8352e2e5b8
5 changed files with 340 additions and 48 deletions
@@ -0,0 +1,161 @@
/* =============================================================================
* spreadsheet_link_range.ts (주인 D)
* 보낸 범위(`LinkOptions.표시범위` · 예: 도면 표) 몫 — `spreadsheet_links` 가 씀.
* 지금 범위 풀기(104-1 · 이름 있으면 이름이 가리키는 범위) · 색 열쇠 ·
* 아래 알약 막대 알약(114-3 · 「T1 도면 E7:J17」 + 상태 꼬리표 · 색 = 시트 위 테두리 색 · 누르면 범위 고름 ·
* × = 보내기 취소) · 상태 모음(값이 바뀌면 150ms 모았다 호스트 `상태` 를 한 번에 부름).
* ========================================================================== */
import { createTag, el } from "@ui/ui_template_elements";
import { parseRange, quoteSheet, rangeToA1 } from "./spreadsheet_address";
import { linkHues } from "./spreadsheet_link_color";
import type { LinkOptions } from "./spreadsheet_links";
import { resolveName } from "./spreadsheet_names";
import { selectSpan } from "./spreadsheet_selection";
import type { CellRange, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type RangeMark = NonNullable<LinkOptions["표시범위"]>[number];
/** 보낸 범위 상태(114-3) — 꼬리표 글 · 색 갈래(공용 `createTag`) · 마우스 올림 글 */
export interface RangeState {
글: string;
종류: "success" | "warning" | "danger";
설명?: string;
}
const STATE_MS = 150; // 칸 고침이 이어지는 동안 모았다가 한 번에
/** 보낸 범위의 지금 범위 — `범위이름` 있으면 그 이름이 지금 가리키는 범위로(행열 넣기 · 지우기를 따라감) ·
* 이름이 지워졌으면 null(서버가 다음 저장 때 연결을 끊음) · 이름이 없으면(옛 연결) 적힌 `범위` 그대로 */
export function markRange(book: Workbook, mark: RangeMark): { 시트: string; g: CellRange } | null {
if (mark.범위이름) {
const found = resolveName(book, mark.시트, mark.범위이름);
return found && { 시트: found.시트, g: found.범위 };
}
const g = parseRange(mark.범위);
return g && { 시트: mark.시트, g };
}
/** 색 열쇠 — 범위 이름 없으면 시트!범위(표마다 다른 색 · 알약 색과는 다른 자리) */
export const markKey = (m: RangeMark): string => m.범위이름 ?? `${m.시트}!${m.범위}`;
/** 상태 모음 — `schedule` 마다 150ms 모았다 `상태` 있는 표시마다 부름 · 늦은 답은 버림 · 끝나면 `done` */
export function createRangeStates(
marks: () => RangeMark[],
book: () => Workbook,
done: () => void,
): { get: (m: RangeMark) => RangeState | undefined; schedule: () => void; stop: () => void } {
const got = new Map<RangeMark, RangeState>();
let timer: ReturnType<typeof setTimeout> | undefined;
let seq = 0;
async function run(): Promise<void> {
const mine = ++seq;
const list = marks().filter((m) => m.상태);
const b = book();
const out = await Promise.all(
list.map((m) =>
Promise.resolve()
.then(() => m.상태!(b))
.catch(() => undefined),
),
);
if (mine !== seq) return;
got.clear();
list.forEach((m, i) => out[i] && got.set(m, out[i]!));
done();
}
return {
get: (m) => got.get(m),
schedule: () => {
if (!marks().some((m) => m.상태)) return;
clearTimeout(timer);
timer = setTimeout(() => void run(), STATE_MS);
},
stop: () => {
clearTimeout(timer);
seq += 1;
},
};
}
type Box = { x: number; y: number; w: number; h: number };
/** 시트 위 점선 테두리 + 왼위 이름표(104-3) — 겹치면(dup) 테두리는 안쪽으로 · 이름표는 옆으로 · 이벤트는 부른 쪽 */
export function rangeFrame(
mark: RangeMark,
box: Box,
end: Box,
dup: number,
hue: number,
tip: string,
): { frame: HTMLElement; tag: HTMLElement } {
const w = Math.max(box.w, end.x + end.w - box.x);
const h = Math.max(box.h, end.y + end.h - box.y);
const inset = dup * 3;
const tag = el("span", {
className: "ss-link-range__tag",
text: mark.이름,
attrs: { title: mark.설명 ?? tip, role: "button", tabindex: "0" },
});
tag.style.left = `${dup * 44}px`;
const frame = el("div", {
className: "ss-link-range",
attrs: { "data-range": mark.범위 },
children: [tag],
});
frame.style.cssText = `left:${box.x + inset}px;top:${box.y + inset}px;width:${Math.max(w - inset * 2, 0)}px;height:${Math.max(h - inset * 2, 0)}px`;
frame.style.setProperty("--ss-link-h", String(hue));
return { frame, tag };
}
/** 아래 막대 도면 알약 — 「T1 도면 E7:J17」(다른 시트면 시트!범위) · 상태 꼬리표 · 색 = 시트 위 테두리 ·
* 누르면 그 범위 고름(다른 시트면 그 시트로) · × = `cancel`(`취소` 있고 고칠 수 있을 때만) */
export function rangePills(
ctx: SpreadsheetContext,
marks: RangeMark[],
stateOf: (m: RangeMark) => RangeState | undefined,
back: () => void,
cancel: (m: RangeMark) => void,
tips: { pick: string; cancel: string },
): HTMLButtonElement[] {
const hues = linkHues(marks.map(markKey));
return marks.map((mark) => {
const found = markRange(ctx.book, mark);
const sheet = found && ctx.book.시트.find((s) => s.id === found.시트);
const a1 = found && rangeToA1(found.g);
const where =
a1 && sheet && found.시트 !== ctx.selection.시트 ? `${quoteSheet(sheet.이름)}!${a1}` : a1;
const state = stateOf(mark);
const tag = state && createTag(state.글, state.종류);
if (tag && state?.설명) tag.title = state.설명;
const b = el("button", {
className: "ss-link ss-link--range",
attrs: { type: "button", title: `${mark.설명 ?? mark.이름} · ${tips.pick}` },
children: [
el("span", { text: where ? `${mark.이름} ${where}` : mark.이름 }),
...(tag ? [tag] : []),
],
});
b.style.setProperty("--ss-link-h", String(hues.get(markKey(mark)) ?? 0));
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에
b.addEventListener("click", () => {
if (found) {
if (found.시트 !== ctx.selection.시트) ctx.showSheet(found.시트);
const { g } = found;
ctx.select(selectSpan(ctx.sheet(), { r: g.r0, c: g.c0 }, { r: g.r1, c: g.c1 }), true);
}
back();
});
if (mark.취소 && !ctx.readOnly) {
const x = el("span", {
className: "ss-link__x",
text: "×",
attrs: { role: "button", title: tips.cancel },
});
x.addEventListener("click", (e) => (e.stopPropagation(), cancel(mark)));
b.append(x);
}
return b;
});
}