/* ============================================================================= * 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[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(); let timer: ReturnType | undefined; let seq = 0; async function run(): Promise { 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; }); }