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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user