Files
Aislo/A00_Common/spreadsheet/spreadsheet_links.ts
T
eomsangdonandClaude Opus 5.5 7389335ca9 fix(spreadsheet): 16장 도구 막대 · 메뉴 · 대화 창 전수 점검 — 흠 스무 곳 고침
M02 면적산출 SA01010100 에서 도구 막대 · 데이터 메뉴 · 우클릭 · 셀 서식 여섯 탭 · 찾기 · 메모 ·
이름 관리 · 조건부 서식 · 유효성 · 이동 · 틀 고정 · 칸 넣기/지우기 · 데이터 도구 · 윤곽 ·
수식 분석 · 되돌리기/다시 · 저장 안 하고 떠나기 · 다시 열기를 빈 값 · 같은 이름 · 긴 이름 ·
취소 · Esc · Enter · 밝은/어두운 테마 예외까지 돌려 찾은 흠.
- 격자(C): 틀고정이 있으면 본문 판이 그릴 행열을 틀고정 폭만큼 덜 셈 — 아래 · 오른쪽 끝이
  빈 칸으로 남던 것(29 행 고정 때 1 행만 그림) · visibleRange 도 같음 · reveal 은 바로 그림
- 도구 막대: 세로 정렬 기본 표시 「위」 → 「아래」(칸 실제와 맞춤) · 크기 409 넘으면 409 ·
  테두리 · 단위 판이 단추 위로 떠 머리까지 덮던 것 → 단추 아래 · ✕ · 수식 안내 · 확대 글
  대비(흐림 투명도 없앰 · --color-text-secondary · 밝은 5.88 · 어두운 7.91)
- 병합(도구 막대 · 우클릭): 왼위 밖 값이 말없이 지워지던 것 → 엑셀처럼 먼저 물음(confirmMerge)
- 셀 서식 · 이동 · 칸 넣기/지우기 · 텍스트 나누기 · 중복 제거 · 연속 채우기 · 메모 창: 열 때
  초점이 창 밖(BODY)이라 Esc · Enter 가 안 먹던 것 → dialogKeys(창 안 초점 · Esc 닫기 ·
  Enter 실행) · 셀 서식 창 탭마다 높이가 널뛰던 것 → 본문 최소 높이
- 우클릭 메뉴(ui_template 공용): Esc 로 닫힘 · 화면 밖이면 안쪽으로 당김 · 데이터 ▾ 메뉴와 알약
  우클릭 메뉴가 화면 좌표를 부품 기준 자리로 써 오른쪽 아래로 떨어지던 것 고침 · 행 · 열 머리
  메뉴에 잘라내기 · 붙여넣기(안내) 더함
- 자동 필터 ▼ 가 칸 밑에 깔려 눌리지 않던 것(z-index) · 값 목록이 격자 아래 끝에서 잘리면
  위로 뒤집음
- Σ: 여러 칸을 고르면 첫 칸에 =SUM() 편집을 열어 Enter 로 값을 덮던 것 → 엑셀처럼 열마다
  바로 아래(또는 고름 끝 빈 칸)에 합계를 바로 넣음(autoSumCells)
- 조건부 서식: 빈 값이 「보다큼 0」 규칙이 되던 것 · #RRGGBB 아닌 색 · 색 없는 규칙 → 안내 후 막음
- 이름 관리: 255 자 넘는 이름 막음 · 긴 이름에 창이 화면 끝까지 늘던 것 · 시트 id(s1) 대신
  시트 이름 · 「이름 바꾸기」 가 브라우저 prompt(자동 취소됨)라 안 되던 것 → 줄 안에서 고침
- 표시 형식 「@」 를 수에 걸면 빈 칸이 되던 것 → 엑셀처럼 일반으로 보임
- 연속 데이터 채우기 · 텍스트 나누기가 칸 서식을 지우던 것 → 서식 유지
- 틀 고정 「지금 칸 기준」 을 먼 칸(Z1000)에서 하면 고정 판이 격자를 다 덮어 못 움직이던 것 →
  화면 안쪽 칸에서만(안내)

ORCA(IP http://100.72.32.27:5181 · 메인 폴더 탭 · 판정은 수치): 목록 80 항목 밝은 80/80 ·
어두운 80/80 · 사각형 겹침 · 잘림 · 화면 밖 · 대비 3 미만 0 건. 시험 — test_stage3_autosum
(여러 칸 넷 더함) · test_stage2_data_tools · test_stage2_format_code · spreadsheet_b_test_entry
197/197(@ 하나 더함) · test_spreadsheet_size(css gzip 3121 · 합 67403) 통과. 시험으로 바꾼 칸은
저장 안 하고 「다시 열기」 로 버림.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W9YEkMnYQnNnRZRPXoxFsw
2026-09-28 22:57:48 +09:00

401 lines
16 KiB
TypeScript

/* =============================================================================
* spreadsheet_links.ts (주인 D)
* 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
import "./spreadsheet_links.css";
import { createMapContextMenu } from "@ui/ui_template_context_menu";
import { el, showToast } from "@ui/ui_template_elements";
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
import { applyCommand } from "./spreadsheet_commands";
import type { slotOf } from "./spreadsheet_editor";
import { shiftSpan } from "./spreadsheet_refshift";
import { selectCell } from "./spreadsheet_selection";
import { MAX_COLS, MAX_ROWS } from "./spreadsheet_types";
import type {
CellLink,
Command,
CommandEffect,
LinkMaterial,
Sheet,
Workbook,
} from "./spreadsheet_types";
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
/** 알약 한 개(입력값) — `키` 는 부른 쪽(일위대가 입력값 키) */
export interface LinkItem {
키: string;
이름: string;
단위?: string;
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
자재?: LinkMaterial;
}
export interface LinkOptions {
항목: LinkItem[];
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
모드: "입력" | "자재";
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
}
export interface Links extends PartHandle {
/** `applyCommand` + 연결 따라가기 */
apply(book: Workbook, command: Command): CommandEffect;
}
const TEXT = {
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
free: "칸을 고른 채 누르면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 더하기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
const isRowCol = (c: Command): c is RowCol =>
c.종류 === "행넣기" || c.종류 === "행지우기" || c.종류 === "열넣기" || c.종류 === "열지우기";
const flat = (c: Command): Command[] => (c.종류 === "묶음" ? c.명령.flatMap(flat) : [c]);
/** 행열 넣기 · 지우기 한 번에 맞춰 연결 칸을 옮김 — 지운 칸 연결은 뺌(순수 · 시험이 부름) */
export function shiftLinks(links: CellLink[], cmd: RowCol): CellLink[] {
const rows = cmd.종류.startsWith("행");
const insert = cmd.종류.endsWith("넣기");
return links.flatMap((link) => {
const p = parseA1(link.칸);
if (!p) return [link];
const i = rows ? p.r : p.c;
const to = shiftSpan(i, i, insert, cmd.at, cmd.수, rows ? MAX_ROWS : MAX_COLS)?.[0];
if (to === undefined) return [];
return [{ ...link, 칸: rows ? toA1(to, p.c) : toA1(p.r, to) }];
});
}
const setLinks = (s: Sheet, list: CellLink[]): void => {
if (list.length) s.연결 = list;
else delete s.연결;
};
/** 명령 적용 — 행열 명령이 연결 있는 시트를 건드리면 연결도 옮기고 되돌림 짝 끝에 명령 전 시트를 더함
* (A 의 되돌림은 바뀐 시트만 통째로 — 연결만 바뀐 시트도 돌아오게) */
export function applyLinked(book: Workbook, command: Command): CommandEffect {
const ops = flat(command).filter(isRowCol);
const hit = new Set(ops.map((o) => o.시트));
const before = book.시트
.filter((s) => hit.has(s.id) && s.연결?.length)
.map((s) => structuredClone(s));
const eff = applyCommand(book, command);
if (!before.length) return eff;
for (const op of ops) {
const s = book.시트.find((x) => x.id === op.시트);
if (s?.연결) setLinks(s, shiftLinks(s.연결, op));
}
const restore = before.map((시트): Command => ({ 종류: "시트통째", 시트 }));
return { ...eff, undo: { 종류: "묶음", 명령: [eff.undo, ...restore] } };
}
/** 자재 → 알약(키 = 자재 코드) */
const ofMaterial = (m: LinkMaterial): LinkItem => ({
키: m.코드,
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
단위: m.단위,
자재: m,
});
export function mountLinks(
ctx: SpreadsheetContext,
opts: LinkOptions | undefined,
back: () => void,
/** 편집기 `slotOf` — 부른 쪽이 넘김(여기서 끌어오면 편집기가 따로 쪼개져 크기가 늚) */
slot: typeof slotOf,
): Links {
const bar = el("div", { className: "ss-links", attrs: { title: TEXT.bar } });
if (opts) ctx.root.prepend(bar);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
let pick: HTMLElement | null = null;
/** 키 → 연결(시트 · 줄) */
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
const out = new Map<string, { sheet: Sheet; link: CellLink }>();
for (const sheet of ctx.book.시트)
for (const link of sheet.연결 ?? []) out.set(link.키, { sheet, link });
return out;
}
/** 시트 연결을 바꿔 넣음 — `시트통째` 라 되돌리기 한 번 */
function put(sheet: Sheet, list: CellLink[]): void {
const next = structuredClone(sheet);
setLinks(next, list);
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
function link(item: LinkItem): void {
if (ctx.readOnly) return;
const sheet = ctx.sheet();
const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c);
const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키);
const one: CellLink = { 키: item.키, 이름: item.이름, 칸 };
if (item.단위) one.단위 = item.단위;
if (item.자재) one.자재 = item.자재;
put(sheet, [...rest, one]);
back();
}
/** 그 칸으로 가서 눈에 띄게 깜빡임(굵은 테두리 + 채움 · 테마 색 · 끝나면 스스로 사라짐) */
function goTo(sheet: Sheet, a1: string): void {
const p = parseA1(a1);
if (!p) return;
if (sheet.id !== ctx.selection.시트) ctx.showSheet(sheet.id);
ctx.select(selectCell(ctx.sheet(), p.r, p.c), true);
back();
requestAnimationFrame(() => {
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) return;
const f = el("div", { className: "ss-link-flash" });
f.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
f.addEventListener("animationend", () => f.remove());
layer.append(f);
});
}
const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i })));
function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) {
const where = hit
? hit.sheet.id === ctx.selection.시트
? hit.link.칸
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
: "";
const b = el("button", {
className: hit ? "ss-link is-linked" : "ss-link",
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
children: [
el("span", { text: item.이름 }),
...(item.단위 ? [el("small", { text: item.단위 })] : []),
...(where ? [el("code", { text: where })] : []),
],
});
b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
const unlink = (): void => {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
};
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
const o = ctx.root.getBoundingClientRect(); // 메뉴는 부품 안 절대 위치 — 누른 자리에 뜨게
menu.open(e.clientX - o.left, e.clientY - o.top, [
...(hit ? [[TEXT.unlink, () => (unlink(), back())] as [string, () => void]] : []),
...(inList
? [
[
TEXT.remove,
() => {
unlink();
items.splice(0, items.length, ...items.filter((i) => i.키 !== item.키));
commit();
renderBar();
if (hit) showToast(TEXT.removed, "info");
back();
},
] as [string, () => void],
]
: []),
]);
});
return b;
}
/** 옛 키(로직 키 등)로 저장된 연결 — 키가 알약 목록에 없으면 이름 + 단위가 같은 알약으로 이어
* 연결 키를 새 키로 바꿔 둠(다음 저장 때 새 키로 나감 · 안 맞으면 그대로 두어 늘 보임) */
function align(): void {
if (opts?.모드 !== "입력") return;
const keys = new Set(items.map((i) => i.키));
const taken = new Set<string>();
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) if (keys.has(l.키)) taken.add(l.키);
for (const sheet of ctx.book.시트)
for (const l of sheet.연결 ?? []) {
if (keys.has(l.키)) continue;
const hit = items.find(
(i) => !taken.has(i.키) && i.이름 === l.이름 && (i.단위 ?? "") === (l.단위 ?? ""),
);
if (!hit) continue;
l.키 = hit.키;
taken.add(hit.키);
}
}
function renderBar(): void {
if (!opts) return;
align();
const map = found();
const shown: LinkItem[] = [];
const seen = new Set<string>();
const add = (item: LinkItem) => {
if (seen.has(item.키)) return;
seen.add(item.키);
shown.push(item);
};
for (const item of items) add(item);
// 문서에 있는 연결(목록에서 빠진 것) — 풀 수 있게 늘 보임
for (const { link } of map.values())
add(
link.자재
? ofMaterial(link.자재)
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
);
const pills = shown.map((item) => pill(item, map.get(item.키)));
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
const more = el("button", {
className: "ss-link ss-link--add",
text: TEXT.add,
attrs: { type: "button" },
});
more.addEventListener("click", () => openPick(more));
pills.push(more);
}
bar.replaceChildren(...pills);
}
/** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */
function placeDots(): void {
for (const d of dots) d.remove();
dots = [];
for (const l of ctx.sheet().연결 ?? []) {
const p = parseA1(l.칸);
if (!p) continue;
const { layer, box } = slot(ctx, p.r, p.c);
if (!box.w || !box.h) continue; // 숨긴 행열
const dot = el("div", { className: "ss-link-dot" });
dot.style.left = `${box.x}px`;
dot.style.top = `${box.y + box.h - 7}px`;
layer.append(dot);
dots.push(dot);
}
}
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
function closePick(): void {
pick?.remove();
pick = null;
document.removeEventListener("mousedown", onOutside, true);
}
const onOutside = (e: MouseEvent): void => {
if (pick && !pick.contains(e.target as Node)) closePick();
};
function openPick(anchor: HTMLElement): void {
closePick();
const input = el("input", {
className: "ss-links__search",
attrs: { type: "search", placeholder: TEXT.search },
}) as HTMLInputElement;
const list = el("div", { className: "ss-links__list" });
const note = (text: string) => el("div", { className: "ss-links__note", text });
pick = el("div", { className: "ss-links__pick", children: [input, list] });
const a = anchor.getBoundingClientRect();
const o = ctx.root.getBoundingClientRect();
pick.style.left = `${a.left - o.left}px`;
pick.style.top = `${a.bottom - o.top + 4}px`;
ctx.root.append(pick);
document.addEventListener("mousedown", onOutside, true);
let seq = 0;
let timer = 0;
const choose = (m: LinkMaterial) => {
closePick();
if (items.some((i) => i.키 === m.코드)) showToast(TEXT.dup, "info");
else {
items.push(ofMaterial(m));
commit();
renderBar();
showToast(TEXT.picked, "info");
}
back();
};
const search = async (): Promise<void> => {
const mine = ++seq;
list.replaceChildren(note(TEXT.searching));
try {
const got = await opts!.자재찾기!(input.value.trim());
if (mine !== seq) return;
list.replaceChildren(
...(got.length
? got.map((m) => {
const row = el("button", {
className: "ss-links__row",
attrs: { type: "button", title: m.코드 },
children: [
el("span", { text: m.이름 }),
el("span", { text: m.규격 }),
el("small", { text: m.단위 }),
],
});
row.addEventListener("click", () => choose(m));
return row;
})
: [note(TEXT.none)]),
);
} catch {
if (mine === seq) list.replaceChildren(note(TEXT.fail));
}
};
input.addEventListener("input", () => {
clearTimeout(timer);
timer = window.setTimeout(() => void search(), 250);
});
input.addEventListener("keydown", (e) => {
if (e.key === "Escape") (e.stopPropagation(), closePick(), back());
if (e.key === "Enter") list.querySelector<HTMLElement>(".ss-links__row")?.click();
});
input.focus();
void search();
}
const onZoom = (): void => placeDots();
ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom);
return {
root: opts ? bar : null,
apply: applyLinked,
refresh() {
renderBar();
placeDots();
},
destroy() {
ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom);
closePick();
for (const d of dots) d.remove();
menu.element.remove();
bar.remove();
},
};
}