diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 6c3c131a7..6d3f1c637 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -250,24 +250,37 @@ outline-offset: -2px; } -/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +/* 보낸 범위(도면 표 · 104-1 · 104-3) — 점선 테두리(채움 없음) + 왼위 이름표 · 표마다 다른 색 + * (--ss-link-h · spreadsheet_link_color) · 같은 자리에 겹치면(104-3) 테두리는 안쪽으로 · 이름표는 옆으로 */ .ss-link-range { position: absolute; z-index: 3; box-sizing: border-box; - border: 2px dashed var(--color-accent); + border: 2px dashed hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); pointer-events: none; } +.ss-link-range.is-picked { + border-style: solid; + box-shadow: 0 0 0 1px hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); +} + .ss-link-range__tag { position: absolute; - left: -2px; bottom: 100%; padding: 0 4px; border-radius: 3px 3px 0 0; - background: var(--color-accent); + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); color: var(--color-canvas); font-size: 10px; line-height: 14px; white-space: nowrap; + pointer-events: auto; + cursor: pointer; +} + +.ss-link-range__tag:hover, +.ss-link-range__tag:focus-visible { + filter: brightness(1.15); + outline: none; } diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 8643d1b3f..2c9e68581 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -21,11 +21,13 @@ import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; 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 } from "./spreadsheet_selection"; +import { selectCell, selectSpan } from "./spreadsheet_selection"; import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types"; import type { CellLink, + CellRange, Command, CommandEffect, LinkMaterial, @@ -56,8 +58,17 @@ export interface LinkOptions { /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 * 목록에 없는 연결 알약에 ⚠ + [×] */ 확정?: boolean; - /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ - 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표 · 104-3) — 시트 id · 범위(이름이 없거나 지워졌으면 이 범위) · + * `범위이름`(104-1) 있으면 이름정의로 지금 범위를 다시 품(행열 넣기 · 지우기를 따라감) · 이름표 · 마우스 올림 글 · + * `취소`(있으면 이름표 메뉴에 [보내기 취소]) — 연결만 끊고(true) 화면에서 뗌 · 실패(false)면 그대로 */ + 표시범위?: { + 시트: string; + 범위: string; + 범위이름?: string; + 이름: string; + 설명?: string; + 취소?: () => Promise; + }[]; } export interface Links extends PartHandle { @@ -84,6 +95,10 @@ const TEXT = { dup: "이미 목록에 있는 자재", orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", orphanX: "연결 풀기", + rangeTip: "누르면 이 범위를 고름", + cancelSend: "보내기 취소", + cancelTip: "연결만 끊음 — 도면 표는 남음", + cancelFail: "연결을 못 끊음", }; type RowCol = Extract; @@ -158,6 +173,22 @@ export function mountLinks( let pick: HTMLElement | null = null; /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ let lastAt = ""; + /** 누른 보낸범위 테두리(104-3) — 다음에 누르면 강조를 옮김 · 다시 그릴 때 비움(DOM 이 새로 생김) */ + let pickedFrame: HTMLElement | null = null; + + /** 보낸 범위 마크의 지금 범위(104-1 · 104-3) — `범위이름` 있으면 그 이름이 지금 가리키는 범위로 + * (행열 넣기 · 지우기를 따라감) · 이름이 지워졌으면 null(서버가 다음 저장 때 연결을 끊음) · + * 이름이 없으면(옛 연결) 적힌 `범위` 그대로 */ + function markRange( + mark: NonNullable[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 }; + } /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -386,6 +417,7 @@ export function mountLinks( function placeDots(): void { for (const d of dots) d.remove(); dots = []; + pickedFrame = null; // 테두리 DOM 이 새로 생김 — 눌린 상태는 다시 눌러야 함 const hues = linkHues(found().keys()); const listed = new Set(items.map((i) => i.키)); const sure = opts?.모드 === "입력" && opts.확정 === true; @@ -407,18 +439,31 @@ export function mountLinks( cellSlots.push({ list, layer, box }); } const rangeSlots: Array<{ - mark: { 시트: string; 범위: string; 이름: string; 설명?: string }; + mark: NonNullable[number]; + g: CellRange; layer: HTMLElement; box: ReturnType["box"]; end: ReturnType["box"]; + /** 같은 왼위 자리를 먼저 쓴 수(104-3 — 겹치면 몇째인지) */ + dup: number; + hue: number; }> = []; - for (const mark of opts?.표시범위 ?? []) { - const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; - if (!g) continue; + const marks = opts?.표시범위 ?? []; + // 색 = 범위 이름(104-1) 없으면 시트!범위 — 표마다 다르게(104-3 · 알약 색과는 다른 자리) + const rangeHues = linkHues(marks.map((m) => m.범위이름 ?? `${m.시트}!${m.범위}`)); + const dupCount = new Map(); // 왼위 자리 키 → 지금까지 그 자리에 둔 수 + for (const mark of marks) { + const found = markRange(mark); + if (!found || found.시트 !== ctx.sheet().id) continue; + const { g } = found; const { layer, box } = slot(ctx, g.r0, g.c0); const end = slot(ctx, g.r1, g.c1).box; if (!box.w || !box.h) continue; - rangeSlots.push({ mark, layer, box, end }); + 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; + rangeSlots.push({ mark, g, layer, box, end, dup, hue }); } // 2) 쓰기 — 모은 자리에 한 번에 그림 @@ -441,14 +486,49 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } - // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) - for (const { mark, layer, box, end } of rangeSlots) { + // 보낸 범위(도면 표 등 · 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: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + children: [tag], }); - frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`; + 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)); + tag.addEventListener("click", (e) => { + e.stopPropagation(); + pickedFrame?.classList.remove("is-picked"); + frame.classList.add("is-picked"); + pickedFrame = frame; + ctx.select(selectSpan(ctx.sheet(), { r: g.r0, c: g.c0 }, { r: g.r1, c: g.c1 }), true); + }); + if (mark.취소 && !ctx.readOnly) { + tag.addEventListener("contextmenu", (e) => { + 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], + ]); + }); + } layer.append(frame); dots.push(frame); } @@ -464,8 +544,9 @@ export function mountLinks( : []; if (hit && hit.r >= 0 && hit.c >= 0) for (const m of opts?.표시범위 ?? []) { - const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null; - if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름); + const found = markRange(m); + if (found && found.시트 === ctx.sheet().id && inRange(found.g, hit.r, hit.c)) + names.push(m.설명 ?? m.이름); } if (names.length) ctx.grid.root.title = names.join(" · "); else if (tipped) ctx.grid.root.removeAttribute("title");