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;
});
}
@@ -279,6 +279,12 @@
cursor: pointer;
}
/* 아래 막대 도면 알약(114-3) — 테두리 = 시트 위 그 범위 테두리 색(점선) · 상태는 공용 꼬리표(ui-tag) */
.ss-link--range {
align-items: center;
border: 2px dashed hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
}
.ss-link-range__tag:hover,
.ss-link-range__tag:focus-visible {
filter: brightness(1.15);
+37 -45
View File
@@ -15,13 +15,15 @@
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
import { inRange, parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import { askShare } from "./spreadsheet_link_ask";
import { linkHues } from "./spreadsheet_link_color";
import { createRangeStates, markKey, markRange } from "./spreadsheet_link_range";
import { rangeFrame, rangePills } from "./spreadsheet_link_range";
import type { RangeMark, RangeState } from "./spreadsheet_link_range";
import { clearLinksCommand, linkCommand, linksAt } from "./spreadsheet_link_set";
import type { slotOf } from "./spreadsheet_editor";
import { resolveName } from "./spreadsheet_names";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell, selectSpan } from "./spreadsheet_selection";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
@@ -60,7 +62,8 @@ export interface LinkOptions {
확정?: boolean;
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표 · 104-3) — 시트 id · 범위(이름이 없거나 지워졌으면 이 범위) ·
* `범위이름`(104-1) 있으면 이름정의로 지금 범위를 다시 품(행열 넣기 · 지우기를 따라감) · 이름표 · 마우스 올림 글 ·
* `취소`(있으면 이름표 메뉴에 [보내기 취소]) — 연결만 끊고(true) 화면에서 뗌 · 실패(false)면 그대로 */
* `취소`(있으면 이름표 메뉴 · 아래 막대 알약 × 에 [보내기 취소]) — 연결만 끊고(true) 화면에서 뗌 · 실패(false)면
* 그대로 · `상태`(114-3 · 있으면 아래 막대 알약 꼬리표) — 지금 책으로 판정(값이 바뀌면 150ms 모아 다시) */
표시범위?: {
시트: string;
범위: string;
@@ -68,6 +71,7 @@ export interface LinkOptions {
이름: string;
설명?: string;
취소?: () => Promise<boolean>;
상태?: (book: Workbook) => RangeState | Promise<RangeState>;
}[];
}
@@ -176,18 +180,21 @@ export function mountLinks(
/** 누른 보낸범위 테두리(104-3) — 다음에 누르면 강조를 옮김 · 다시 그릴 때 비움(DOM 이 새로 생김) */
let pickedFrame: HTMLElement | null = null;
/** 보낸 범위 마크의 지금 범위(104-1 · 104-3) — `범위이름` 있으면 그 이름이 지금 가리키는 범위로
* (행열 넣기 · 지우기를 따라감) · 이름이 지워졌으면 null(서버가 다음 저장 때 연결을 끊음) ·
* 이름이 없으면(옛 연결) 적힌 `범위` 그대로 */
function markRange(
mark: NonNullable<LinkOptions["표시범위"]>[number],
): { 시트: string; g: CellRange } | null {
if (mark.범위이름) {
const found = resolveName(ctx.book, mark.시트, mark.범위이름);
return found && { 시트: found.시트, g: found.범위 };
}
const g = parseRange(mark.범위);
return g && { 시트: mark.시트, g };
/** 보낸 범위 상태(114-3) — 값이 바뀌면 모아 판정 → 아래 막대 다시 */
const states = createRangeStates(
() => opts?.표시범위 ?? [],
() => ctx.book,
() => renderBar(),
);
/** [보내기 취소](이름표 메뉴 · 아래 막대 ×) — 연결만 끊고 화면에서 뗌 · 실패면 그대로 */
function cancelMark(mark: RangeMark): void {
void mark.취소?.().then((ok) => {
if (!ok) return showToast(TEXT.cancelFail, "error");
if (opts) opts.표시범위 = opts.표시범위?.filter((m) => m !== mark);
placeDots();
renderBar();
});
}
/** 키 → 연결(시트 · 줄) */
@@ -388,6 +395,12 @@ export function mountLinks(
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
});
pills.push(
...rangePills(ctx, opts.표시범위 ?? [], states.get, back, cancelMark, {
pick: TEXT.rangeTip,
cancel: `${TEXT.cancelSend} — ${TEXT.cancelTip}`,
}),
);
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
@@ -439,7 +452,7 @@ export function mountLinks(
cellSlots.push({ list, layer, box });
}
const rangeSlots: Array<{
mark: NonNullable<LinkOptions["표시범위"]>[number];
mark: RangeMark;
g: CellRange;
layer: HTMLElement;
box: ReturnType<typeof slot>["box"];
@@ -450,10 +463,10 @@ export function mountLinks(
}> = [];
const marks = opts?.표시범위 ?? [];
// 색 = 범위 이름(104-1) 없으면 시트!범위 — 표마다 다르게(104-3 · 알약 색과는 다른 자리)
const rangeHues = linkHues(marks.map((m) => m.범위이름 ?? `${m.시트}!${m.범위}`));
const rangeHues = linkHues(marks.map(markKey));
const dupCount = new Map<string, number>(); // 왼위 자리 키 → 지금까지 그 자리에 둔 수
for (const mark of marks) {
const found = markRange(mark);
const found = markRange(ctx.book, mark);
if (!found || found.시트 !== ctx.sheet().id) continue;
const { g } = found;
const { layer, box } = slot(ctx, g.r0, g.c0);
@@ -462,7 +475,7 @@ export function mountLinks(
const key = `${g.r0},${g.c0}`;
const dup = dupCount.get(key) ?? 0;
dupCount.set(key, dup + 1);
const hue = rangeHues.get(mark.범위이름 ?? `${mark.시트}!${mark.범위}`) ?? 0;
const hue = rangeHues.get(markKey(mark)) ?? 0;
rangeSlots.push({ mark, g, layer, box, end, dup, hue });
}
@@ -489,22 +502,7 @@ export function mountLinks(
// 보낸 범위(도면 표 등 · 104-1 · 104-3) — 채움 없는 점선 테두리 + 왼위 이름표 · 표마다 다른 색 ·
// 같은 자리가 겹치면(dup) 테두리는 안쪽으로 · 이름표는 옆으로 비킴 · 이름표만 누를 수 있음
for (const { mark, g, layer, box, end, dup, hue } of rangeSlots) {
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.설명 ?? TEXT.rangeTip, 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));
const { frame, tag } = rangeFrame(mark, box, end, dup, hue, TEXT.rangeTip);
tag.addEventListener("click", (e) => {
e.stopPropagation();
pickedFrame?.classList.remove("is-picked");
@@ -517,15 +515,7 @@ export function mountLinks(
e.preventDefault();
const o = ctx.root.getBoundingClientRect();
menu.open(e.clientX - o.left, e.clientY - o.top, [
[
TEXT.cancelSend,
() =>
void mark.취소!().then((ok) => {
if (!ok) return showToast(TEXT.cancelFail, "error");
if (opts) opts.표시범위 = opts.표시범위?.filter((m) => m !== mark);
placeDots();
}),
] as [string, () => void],
[TEXT.cancelSend, () => cancelMark(mark)] as [string, () => void],
]);
});
}
@@ -544,7 +534,7 @@ export function mountLinks(
: [];
if (hit && hit.r >= 0 && hit.c >= 0)
for (const m of opts?.표시범위 ?? []) {
const found = markRange(m);
const found = markRange(ctx.book, m);
if (found && found.시트 === ctx.sheet().id && inRange(found.g, hit.r, hit.c))
names.push(m.설명 ?? m.이름);
}
@@ -677,10 +667,12 @@ export function mountLinks(
renderBar();
if (dataChanged || !dotsReady) {
placeDots();
states.schedule(); // 보낸 범위 상태(114-3) — 값이 바뀌면 다시
dotsReady = true;
}
},
destroy() {
states.stop();
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
ctx.grid.root.removeEventListener("dragover", onDragOver);
ctx.grid.root.removeEventListener("dragleave", onDragLeave);