Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-08 18:51:14 +09:00
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);
@@ -13,6 +13,8 @@
* 면적 [저장] 문서에 `보내기취소[]` 를 실어 서버가 그 연결표 줄 · 이름을 뺌(도면 표는 남음 · 112-2)
* ⑤ 여러 장(82-3) — 놓을 장 = 지금 탭 · 새 T번호 = 모든 장에서 안 쓴 것 · 연결표 줄 `장` = 표가 놓인 장id
* (1장 = 빈 값) · 안 실린 장 표 갱신 = 도면 손잡이(받아 둔 장) · 면적 [저장] 때는 서버가 모든 장
* ⑦ 이름 없는 옛 연결 — 면적을 열 때 책에 `도면_{연결}` 이름(114-1 · 면적 [저장] 때 서버가 줄에 적음) ·
* 아래 막대 알약 상태 = 도면에 표 있나 · 지금 값 = 마지막 도면 값인가(114-3)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -31,6 +33,7 @@ import {
type StructureDoc,
} from "./M02_MasterTemplete_Api_Fetch";
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet";
import type { RangeState } from "../A00_Common/spreadsheet/spreadsheet_link_range";
import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { Selection } from "./M02_MasterTemplete_UI_Main";
@@ -87,6 +90,14 @@ const TEXT = {
tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}",
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
rangeGone: "보낸 범위가 지워져 연결을 끊음 — {ids}(도면 표는 남음)",
same: "값 맞음",
sameTip: "도면 표 칸 글 = 지금 면적 값",
diff: "값 다름",
diffTip: "면적 값이 도면 표와 다름 — 면적 [저장] 때 도면 표를 맞춤",
noTable: "도면에 표 없음",
noTableTip: "도면(저장본)에 이 연결 표가 없음 — × 로 보내기 취소",
gone: "범위 없음",
goneTip: "보낸 범위가 지워짐 — 면적 [저장] 때 연결을 끊음",
} as const;
const SIZE = 2.5; // 표 글자 크기 기본(정함 41)
@@ -156,6 +167,25 @@ async function nameOld(book: Workbook, row: TableLink): Promise<string | undefin
return name;
}
/** 아래 막대 도면 알약 상태(114-3) — 도면에 표 없음 · 범위 없음 · 지금 책 값 = 마지막 도면 값이면 맞음 */
async function stateOf(
book: Workbook,
row: TableLink,
이름: string | undefined,
placed: boolean,
): Promise<RangeState> {
if (!placed) return { 글: TEXT.noTable, 종류: "danger", 설명: TEXT.noTableTip };
const { areaTable } = await import("../A00_Common/spreadsheet/spreadsheet_area_table");
try {
const got = areaTable({ book, 시트: row.시트, 범위: row.범위, 이름, 글자크기: row.글자크기 });
return sameValues(row.값, got.값)
? { 글: TEXT.same, 종류: "success", 설명: TEXT.sameTip }
: { 글: TEXT.diff, 종류: "warning", 설명: TEXT.diffTip };
} catch {
return { 글: TEXT.gone, 종류: "danger", 설명: TEXT.goneTip };
}
}
/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) ·
* `book`(면적 책 · 화면에 싣기 전) 있으면 이름 없는 옛 연결에 이름을 둠(114-1 · 행열 넣기를 따라감) */
export async function sentRanges(
@@ -165,7 +195,12 @@ export async function sentRanges(
): Promise<NonNullable<LinkOptions["표시범위"]>> {
try {
const got = await readTemplate(at.layer, "structure", at.name, at.projectId);
const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? [];
const doc = got.문서 as StructureDoc | undefined;
const rows = doc?.연결표 ?? [];
// 도면에 그 표가 있나(114-3 · 모든 장)
const placed = new Set(
doc ? sheetsOf(doc).flatMap(([, es]) => es.filter(isTable).map(linkOf)) : [],
);
const names = await Promise.all(
rows.map((row) =>
row.이름 || !book || !Array.isArray(book.시트)
@@ -180,6 +215,7 @@ export async function sentRanges(
이름: fill(TEXT.tag, "id", row.연결),
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
취소: async () => (onCancel(row.연결), true), // 화면에서만 — 서버는 면적 [저장] 때
상태: (now: Workbook) => stateOf(now, row, names[i], placed.has(row.연결)),
}));
} catch {
return [];
+99 -2
View File
@@ -3,6 +3,8 @@
- 114-1 면적 상세를 열 때 화면(`sentRanges` + 책)이 `도면_{연결}` 이름정의를 둠 → 행 넣기 → 면적 [저장]
→ 서버 `refresh_drawing` 이 그 이름을 연결표 줄에 적고 도면 표 칸 글 = 옮겨진 칸.
- 114-2 셀 밀기 · 당기기(칸 삽입 · 삭제 창)에 이름 따라감 · 카드 도면 저장도 표 없는 연결표 줄 뺌(`fitLinks`).
- 114-3 아래 막대 도면 알약(`spreadsheet_link_range.rangePills`) · 상태(표 있음/없음 · 값 맞음/다름 · 범위 없음)
· 누르면 범위 고름 · × = 보내기 취소 · 상태는 150ms 모아 한 번.
TS 는 Node 로(`AreaToDrawing` 은 그 자리 변환 · 스프레드시트 모듈은 `helper_spreadsheet_d_bundle.cjs`).
"""
@@ -28,6 +30,7 @@ const { load, SS } = require(path.join(ROOT, "resources/tester/spreadsheet/helpe
const ts = require(path.join(ROOT, "config/node_modules/typescript"));
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const ss = (name) => load(path.join(SS, name + ".ts"));
let drawingNow = input.drawing;
const tsLoad = (file, stubs) => {
const src = fs.readFileSync(path.join(ROOT, file), "utf8");
const js = ts.transpileModule(src, {
@@ -46,7 +49,7 @@ const toDrawing = tsLoad("M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts
"@ui/ui_template_modal": {},
"./M02_MasterTemplete_Api_Fetch": {
getActiveOwner: () => null,
readTemplate: async () => ({ 문서: input.drawing }),
readTemplate: async () => ({ 문서: drawingNow }),
},
"../A00_Common/spreadsheet/spreadsheet_address": ss("spreadsheet_address"),
"../A00_Common/spreadsheet/spreadsheet_area_table": ss("spreadsheet_area_table"),
@@ -96,7 +99,61 @@ const { applyCommand } = ss("spreadsheet_commands");
kept: toDrawing.fitLinks({ 도면: { entities: [tbl("T1")] }, 연결표: [row("T1"), row("T2")] }).연결표,
plain: "연결표" in toDrawing.fitLinks({ 도면: { entities: [tbl("T9")] } }),
};
console.log(JSON.stringify({ opened, book, shifted, fit }));
// 114-3 — 아래 막대 도면 알약 · 상태(표 있음/없음 · 값 맞음/다름 · 범위 없음)
const states = {};
const pills = {};
if (input.drawing3) {
drawingNow = input.drawing3;
const book3 = input.book3;
const marks3 = await toDrawing.sentRanges(area, () => {}, book3);
const st = await Promise.all(marks3.map((m) => m.상태(book3)));
states.first = st.map((s) => [s.글, s.종류]);
book3.시트[0].칸.B1 = { 값: 99, 서식: 1 }; // 저장 안 한 고침
states.edited = [(await marks3[0].상태(book3)).글];
const mk = (tag, o = {}) => ({ tag, className: o.className ?? "", text: o.text ?? "",
attrs: o.attrs ?? {}, children: [...(o.children ?? [])], title: "", on: {},
style: { props: {}, setProperty(k, v) { this.props[k] = v; } },
addEventListener(k, f) { this.on[k] = f; }, append(...x) { this.children.push(...x); } });
const lr = tsLoad("A00_Common/spreadsheet/spreadsheet_link_range.ts", {
"@ui/ui_template_elements": { el: mk, createTag: (l, v) => mk("span", { className: `ui-tag ui-tag--${v}`, text: l }) },
"./spreadsheet_address": ss("spreadsheet_address"),
"./spreadsheet_link_color": ss("spreadsheet_link_color"),
"./spreadsheet_names": ss("spreadsheet_names"),
"./spreadsheet_selection": ss("spreadsheet_selection"),
});
const got = new Map(marks3.map((m, i) => [m, st[i]]));
const picked = [];
const cancelled = [];
let backs = 0;
const ctx = { book: book3, readOnly: false, selection: { 시트: "s1" }, sheet: () => book3.시트[0],
showSheet: () => {}, select: (s) => picked.push(s) };
const made = lr.rangePills(ctx, marks3, (m) => got.get(m), () => (backs += 1),
(m) => cancelled.push(m.이름), { pick: "누르면 고름", cancel: "보내기 취소" });
made[0].on.click();
made[1].children.find((c) => c.className === "ss-link__x").on.click({ stopPropagation() {} });
const ro = lr.rangePills({ ...ctx, readOnly: true }, marks3, () => undefined, () => {}, () => {}, { pick: "", cancel: "" });
pills.made = made.map((p) => ({
text: p.children[0].text,
tag: p.children[1] && p.children[1].className.startsWith("ui-tag") ? [p.children[1].className, p.children[1].text] : null,
x: p.children.some((c) => c.className === "ss-link__x"),
cls: p.className,
hue: typeof p.style.props["--ss-link-h"],
}));
pills.picked = picked.map((s) => s.범위[0]);
pills.backs = backs;
pills.cancelled = cancelled;
pills.roX = ro.map((p) => p.children.some((c) => c.className === "ss-link__x"));
// 상태 모음 — 잇단 schedule 은 150ms 뒤 한 번만
let calls = 0;
let done = 0;
const fake = [{ 상태: () => (calls += 1, { 글: "x", 종류: "success" }) }];
const rs = lr.createRangeStates(() => fake, () => book3, () => (done += 1));
rs.schedule(); rs.schedule(); rs.schedule();
const early = calls;
await new Promise((r) => setTimeout(r, 250));
pills.gather = { early, calls, done, got: rs.get(fake[0]) };
}
console.log(JSON.stringify({ opened, book, shifted, fit, states, pills }));
})().catch((e) => {
console.error(e);
process.exit(1);
@@ -188,3 +245,43 @@ def test_카드_도면_저장도_표_없는_연결표_줄_뺌_114_2(world): # n
fit = _node({"book": area["문서"], "drawing": {"연결표": []}})["fit"]
assert fit["kept"] == [{"연결": "T1", "시트": "s1", "범위": "A1", "값": {}, "장": ""}]
assert fit["plain"] is False # 기록 없던 도면은 칸을 안 만듦
def test_아래_막대_도면_알약_상태_누르면_고름_x_는_보내기_취소_114_3(world): # noqa: F811
with store.at(world["system"]):
area = store.read("area", "rv")
book = area["문서"]
now = sheet_node.area_tables([{"book": book, "시트": "s1", "범위": "A1:B2"}])[0]["값"]
drawing3 = {
"도면": {"entities": [T1_TABLE, {**T1_TABLE, "shapeData": {"style": {"연결": "T3"}}}]},
"연결표": [
{**OLD_ROW, "값": now}, # 이름 없는 옛 연결 · 표 있음 · 값 같음
{"연결": "T2", "시트": "s1", "범위": "B1", "값": {}}, # 도면에 표 없음
{
"연결": "T3",
"시트": "s1",
"범위": "A1",
"이름": "도면_없음",
"값": {},
}, # 이름 지워짐
],
}
out = _node({"book": book, "drawing": {"연결표": []}, "drawing3": drawing3, "book3": book})
assert out["states"]["first"] == [
["값 맞음", "success"],
["도면에 표 없음", "danger"],
["범위 없음", "danger"],
]
assert out["states"]["edited"] == ["값 다름"] # 저장 전 칸 고침 = 지금 책으로 판정
p = out["pills"]
assert [x["text"] for x in p["made"]] == ["T1 도면 A1:B2", "T2 도면 B1", "T3 도면"]
assert p["made"][0]["tag"] == ["ui-tag ui-tag--success", "값 맞음"] # 공용 꼬리표
assert p["made"][1]["tag"] == ["ui-tag ui-tag--danger", "도면에 표 없음"]
assert all(x["x"] and x["cls"] == "ss-link ss-link--range" for x in p["made"])
assert all(x["hue"] == "string" for x in p["made"]) # 색 = 시트 위 테두리 색
assert (
p["picked"] == [{"r0": 0, "c0": 0, "r1": 1, "c1": 1}] and p["backs"] == 1
) # 누르면 범위 고름
assert p["cancelled"] == ["T2 도면"] # × = 보내기 취소(화면에서만 · 저장 때 기록)
assert p["roX"] == [False, False, False] # 읽기 = × 없음
assert p["gather"] == {"early": 0, "calls": 1, "done": 1, "got": {"글": "x", "종류": "success"}}