feat(spreadsheet): 칸 하나에 알약 여럿 — 다른 알약 칸이면 [함께 잇기] · [바꾸기] · [취소] 물음(알약 누름 · 끌어 놓기 · 칸 지정 · 엑셀 고르기) · 칸 색 = 먼저 이은 알약 · 오른위 수 · 마우스 올리면 이름들 · linkAreaCell 함께 기본 (PLAN 40 마무리)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
@@ -79,8 +79,8 @@ export interface SpreadsheetHandle {
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 = 값 · 식 없는 칸은 빠짐 — 「수량 없음」) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
/** 알약 키를 지금 시트 `칸`(A1)에 잇기 · 풀기(null) — 알약 누름과 같은 규칙 · 되돌리기 됨 ·
|
||||
* 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo): boolean;
|
||||
* 그 칸 다른 알약은 함께 둠(`replace` 면 뺌) · 주소가 틀리거나 읽기만이면 false */
|
||||
setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -295,9 +295,9 @@ export function createSpreadsheet(
|
||||
}
|
||||
return out;
|
||||
},
|
||||
setLink(key, cell, info) {
|
||||
setLink(key, cell, info, replace) {
|
||||
if (readOnly) return false;
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info);
|
||||
const command = linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace);
|
||||
if (command === false) return false;
|
||||
if (command) dispatch(command);
|
||||
loadLinks(); // 처음 연결이면 알약 막대 · 칸 세모를 불러옴
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_ask.ts (주인 D)
|
||||
* 같은 칸에 이미 다른 알약 — [함께 잇기](기본 · Enter) · [바꾸기] · [취소](Esc) 물음(40 마무리).
|
||||
* 알약 누름 · 끌어 놓기(`spreadsheet_links`) · M02 칸 지정 모달 · 엑셀 칸 고르기가 같이 씀 ·
|
||||
* 모양은 공용 확인창(`ui-confirm`) 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
|
||||
const TEXT = {
|
||||
ask: "{cell} 칸에 이미 {names} — 이 알약도 함께 이을까?",
|
||||
together: "함께 잇기",
|
||||
replace: "바꾸기",
|
||||
cancel: "취소",
|
||||
};
|
||||
|
||||
/** 「함께」 = 그 칸 다른 알약 그대로 · 「바꾸기」 = 다른 알약을 뺌 · null = 그만둠 */
|
||||
export type ShareChoice = "함께" | "바꾸기" | null;
|
||||
|
||||
export function askShare(cell: string, names: string[]): Promise<ShareChoice> {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = el("div", { className: "ui-confirm" });
|
||||
const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리
|
||||
const done = (choice: ShareChoice): void => {
|
||||
overlay.remove();
|
||||
if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true });
|
||||
resolve(choice);
|
||||
};
|
||||
const together = createButton({
|
||||
label: TEXT.together,
|
||||
variant: "filled",
|
||||
onClick: () => done("함께"),
|
||||
});
|
||||
overlay.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Escape") return;
|
||||
ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음
|
||||
done(null);
|
||||
});
|
||||
const message = TEXT.ask
|
||||
.replace("{cell}", cell)
|
||||
.replace("{names}", names.map((n) => `「${n}」`).join(" · "));
|
||||
overlay.append(
|
||||
el("div", {
|
||||
className: "ui-confirm__panel",
|
||||
children: [
|
||||
el("p", { className: "ui-confirm__message", text: message }),
|
||||
el("div", {
|
||||
className: "ui-confirm__actions",
|
||||
children: [
|
||||
createButton({ label: TEXT.cancel, variant: "ghost", onClick: () => done(null) }),
|
||||
createButton({
|
||||
label: TEXT.replace,
|
||||
variant: "ghost",
|
||||
onClick: () => done("바꾸기"),
|
||||
}),
|
||||
together,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
document.body.append(overlay);
|
||||
together.focus(); // Enter = 함께 잇기(기본)
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_link_set.ts (주인 D)
|
||||
* 알약 키를 칸에 잇기 · 풀기 — 밖(M02 칸 지정 모달)에서 부르는 길(손잡이 `setLink`).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나 = 키 하나 ·
|
||||
* 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* 알약 누름(`spreadsheet_links.ts` `link`)과 같은 규칙: 키 하나 = 칸 하나 · 칸 하나에 알약 여럿(같은 수량을 여러
|
||||
* 로직이 씀 · 40 마무리) — `replace` 면 그 칸의 다른 알약을 뺌 · 다른 시트에 있던 같은 키 연결은 빠짐. 바꿈은 `시트통째`(되돌리기 짝) · DOM 을 모름(시험이 Node 로 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { parseA1, toA1 } from "./spreadsheet_address";
|
||||
@@ -15,13 +15,28 @@ export interface LinkInfo {
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false */
|
||||
/** 칸 주소 맞춤(「 b6 」 → 「B6」) — 틀리면 null */
|
||||
export function normA1(cell: string): string | null {
|
||||
const at = parseA1(cell.trim().toUpperCase());
|
||||
return at ? toA1(at.r, at.c) : null;
|
||||
}
|
||||
|
||||
/** 그 시트 그 칸에 이은 다른 알약(`key` 빼고 · 이은 차례) — 물음 「함께 잇기 · 바꾸기」 에 씀 */
|
||||
export function linksAt(book: Workbook, sheetId: string, cell: string, key: string): CellLink[] {
|
||||
const a1 = normA1(cell);
|
||||
const sheet = book.시트.find((s) => s.id === sheetId);
|
||||
return (sheet?.연결 ?? []).filter((l) => l.칸 === a1 && l.키 !== key);
|
||||
}
|
||||
|
||||
/** `칸`(A1 · null = 풀기)을 `sheetId` 시트에 이을 명령 — 바뀔 것 없으면 null · 칸 주소가 틀리면 false ·
|
||||
* `replace` = 그 칸의 다른 알약 연결을 뺌(아니면 함께 둠) */
|
||||
export function linkCommand(
|
||||
book: Workbook,
|
||||
sheetId: string,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): Command | null | false {
|
||||
const at = cell === null ? null : parseA1(cell.trim().toUpperCase());
|
||||
if (cell !== null && !at) return false;
|
||||
@@ -29,7 +44,8 @@ export function linkCommand(
|
||||
const old = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === key);
|
||||
const home = book.시트.find((s) => s.id === sheetId);
|
||||
if (a1 && !home) return false;
|
||||
if (a1 && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const others = a1 && replace ? linksAt(book, sheetId, a1, key).length : 0;
|
||||
if (a1 && !others && home?.연결?.some((l) => l.키 === key && l.칸 === a1)) return null; // 이미 그 칸
|
||||
const one: CellLink | null = a1 ? { 키: key, 칸: a1 } : null;
|
||||
if (one) {
|
||||
const 이름 = info.이름 ?? old?.이름;
|
||||
@@ -42,7 +58,7 @@ export function linkCommand(
|
||||
const commands: Command[] = [];
|
||||
for (const s of book.시트) {
|
||||
const was = s.연결 ?? [];
|
||||
const rest = was.filter((l) => l.키 !== key && !(s === home && a1 && l.칸 === a1));
|
||||
const rest = was.filter((l) => l.키 !== key && !(replace && s === home && a1 && l.칸 === a1));
|
||||
const next = s === home && one ? [...rest, one] : rest;
|
||||
if (next.length === was.length && next.every((l, i) => l === was[i])) continue;
|
||||
const copy = structuredClone(s);
|
||||
@@ -55,16 +71,17 @@ export function linkCommand(
|
||||
}
|
||||
|
||||
/** 면적 문서에 알약 키를 잇기 · 풀기(`칸` null) — 활성 시트에 · 새 문서를 돌려줌(받은 문서는 그대로 ·
|
||||
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null. 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
|
||||
* 번호 등 계약 밖 칸도 그대로) · 칸 주소가 틀리면 null · 같은 칸 다른 알약은 함께 둠(`replace` 면 뺌). 일위대가 [저장] 이 면적 문서를 함께 쓸 때 부름 */
|
||||
export function linkAreaCell<T extends Workbook>(
|
||||
book: T,
|
||||
key: string,
|
||||
cell: string | null,
|
||||
info: LinkInfo = {},
|
||||
replace = false,
|
||||
): T | null {
|
||||
const sheet = book.시트.find((s) => s.id === book.활성) ?? book.시트[0];
|
||||
if (!sheet) return null;
|
||||
const command = linkCommand(book, sheet.id, key, cell, info);
|
||||
const command = linkCommand(book, sheet.id, key, cell, info, replace);
|
||||
if (command === false) return null;
|
||||
const next = structuredClone(book);
|
||||
const list = !command ? [] : command.종류 === "묶음" ? command.명령 : [command];
|
||||
|
||||
@@ -177,3 +177,21 @@
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 칸 하나에 알약 여럿 — 오른위 작은 수(먼저 이은 알약 색) */
|
||||
.ss-link-count {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
min-width: 14px;
|
||||
height: 14px;
|
||||
padding: 0 3px;
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l));
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_links.ts (주인 D)
|
||||
* 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면
|
||||
* 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 +
|
||||
* 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(40-3) · 칸 하나에 알약 여럿 —
|
||||
* 이미 다른 알약이 있으면 [함께 잇기] · [바꾸기] · [취소] 물음(`spreadsheet_link_ask`) · 이은 알약 = 키로 정해지는
|
||||
* 색(`spreadsheet_link_color`) · 이은 칸 = 먼저 이은 알약 색 옅게 + 왼아래 세모 + 여럿이면 오른위 작은 수 ·
|
||||
* 칸에 마우스를 올리면 이은 알약 이름들 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] ·
|
||||
* 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
|
||||
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
|
||||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
|
||||
@@ -12,10 +14,12 @@
|
||||
|
||||
import "./spreadsheet_links.css";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
import { linkCommand, linksAt } from "./spreadsheet_link_set";
|
||||
import type { slotOf } from "./spreadsheet_editor";
|
||||
import { shiftSpan } from "./spreadsheet_refshift";
|
||||
import { selectCell } from "./spreadsheet_selection";
|
||||
@@ -59,8 +63,6 @@ export interface Links extends PartHandle {
|
||||
const TEXT = {
|
||||
bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결",
|
||||
swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?",
|
||||
swapOk: "바꿈",
|
||||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||||
unlink: "연결 풀기",
|
||||
remove: "목록에서 빼기",
|
||||
@@ -160,16 +162,22 @@ export function mountLinks(
|
||||
ctx.dispatch({ 종류: "시트통째", 시트: next });
|
||||
}
|
||||
|
||||
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */
|
||||
function link(item: LinkItem, at?: string): void {
|
||||
/** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 · 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음 */
|
||||
async function link(item: LinkItem, at?: string): Promise<void> {
|
||||
if (ctx.readOnly) return;
|
||||
const sheet = ctx.sheet();
|
||||
const 칸 = at ?? 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]);
|
||||
const others = linksAt(ctx.book, sheet.id, 칸, item.키);
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
칸,
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice) return back();
|
||||
const info = { 이름: item.이름, 단위: item.단위, 자재: item.자재 };
|
||||
const command = linkCommand(ctx.book, sheet.id, item.키, 칸, info, choice === "바꾸기");
|
||||
if (command) ctx.dispatch(command);
|
||||
back();
|
||||
}
|
||||
|
||||
@@ -224,7 +232,7 @@ export function mountLinks(
|
||||
});
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item)));
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
if (hit)
|
||||
put(
|
||||
@@ -317,22 +325,46 @@ export function mountLinks(
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
const hues = linkHues(found().keys());
|
||||
for (const l of ctx.sheet().연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
for (const [a1, list] of byCell) {
|
||||
const p = parseA1(a1);
|
||||
if (!p) continue;
|
||||
const { layer, box } = slot(ctx, p.r, p.c);
|
||||
if (!box.w || !box.h) continue; // 숨긴 행열
|
||||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": l.키 } });
|
||||
const first = list[0];
|
||||
const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": first.키 } });
|
||||
tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`;
|
||||
const dot = el("div", { className: "ss-link-dot" });
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
for (const d of [tint, dot]) d.style.setProperty("--ss-link-h", String(hues.get(l.키) ?? 0));
|
||||
layer.append(tint, dot);
|
||||
dots.push(tint, dot);
|
||||
const marks = [tint, dot];
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
count.style.top = `${box.y}px`;
|
||||
marks.push(count);
|
||||
}
|
||||
for (const d of marks) d.style.setProperty("--ss-link-h", String(hues.get(first.키) ?? 0));
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
let tipped = false;
|
||||
const onHover = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
const a1 = hit && hit.r >= 0 && hit.c >= 0 ? toA1(hit.r, hit.c) : null;
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
};
|
||||
|
||||
// ── 알약 끌어 칸에 놓기(40-3) ──────────────────────────────────────────
|
||||
/** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */
|
||||
function cellAt(e: DragEvent): { r: number; c: number } | null {
|
||||
@@ -358,19 +390,8 @@ export function mountLinks(
|
||||
dragging = null;
|
||||
if (!item || !at) return;
|
||||
e.preventDefault();
|
||||
const 칸 = toA1(at.r, at.c);
|
||||
const other = (ctx.sheet().연결 ?? []).find((l) => l.칸 === 칸 && l.키 !== item.키);
|
||||
const name = other?.이름 ?? other?.키 ?? "";
|
||||
if (
|
||||
other &&
|
||||
!(await showConfirmDialog(
|
||||
TEXT.swap.replace("{cell}", 칸).replace("{name}", name),
|
||||
TEXT.swapOk,
|
||||
))
|
||||
)
|
||||
return back();
|
||||
ctx.select(selectCell(ctx.sheet(), at.r, at.c), true);
|
||||
link(item, 칸);
|
||||
await link(item, toA1(at.r, at.c));
|
||||
};
|
||||
|
||||
// ── 자재 고르기 창 ──────────────────────────────────────────────────────
|
||||
@@ -457,6 +478,7 @@ export function mountLinks(
|
||||
ctx.grid.root.addEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.addEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.addEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.addEventListener("mousemove", onHover);
|
||||
|
||||
return {
|
||||
root: opts ? bar : null,
|
||||
@@ -470,6 +492,7 @@ export function mountLinks(
|
||||
ctx.grid.root.removeEventListener("dragover", onDragOver);
|
||||
ctx.grid.root.removeEventListener("dragleave", onDragLeave);
|
||||
ctx.grid.root.removeEventListener("drop", onDropEvent);
|
||||
ctx.grid.root.removeEventListener("mousemove", onHover);
|
||||
dropMark.remove();
|
||||
closePick();
|
||||
for (const d of dots) d.remove();
|
||||
|
||||
@@ -5,10 +5,13 @@
|
||||
* 일위대가 [저장] 때 함께 저장(여기는 저장 안 함).
|
||||
* · 이미 이은 칸은 시트에 색 · 세모(알약 색 · `spreadsheet_links`) · 이 알약이 이은 칸은 머리 글에
|
||||
* · 칸 누름 · Enter = 그 칸 · [연결 풀기] = 풀기 · 닫기 · Esc · 바깥 = 그만둠(null)
|
||||
* · 그 칸에 다른 알약이 있으면 [함께 잇기](기본) · [바꾸기](`바꾸기: true` → `linkAreaCell(…, true)`) · [취소](모달 그대로)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
|
||||
import { linksAt } from "../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { SpreadsheetHandle, Workbook } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import "./M02_MasterTemplete_AreaPanel.css";
|
||||
@@ -40,8 +43,9 @@ export interface AreaCellPickOptions {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸 */
|
||||
export type AreaCellPick = { 칸: string | null } | null;
|
||||
/** 고른 결과 — `null` = 그만둠 · `{칸: null}` = 연결 풀기 · `{칸: "B6"}` = 그 칸(다른 알약과 함께) ·
|
||||
* `{칸: "B6", 바꾸기: true}` = 그 칸 다른 알약을 빼고 */
|
||||
export type AreaCellPick = { 칸: string | null; 바꾸기?: boolean } | null;
|
||||
|
||||
export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -76,10 +80,24 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
|
||||
el("p", { className: "m02-cellpick__hint", text: TEXT.hint }),
|
||||
host,
|
||||
);
|
||||
const choose = (): void => (finish({ 칸: picked }), close());
|
||||
let book: Workbook | null = null;
|
||||
const choose = async (): Promise<void> => {
|
||||
const at = picked;
|
||||
const sheetId = book?.시트.find((s) => s.id === book?.활성)?.id ?? book?.시트[0]?.id;
|
||||
const others = book && sheetId ? linksAt(book, sheetId, at, opts.pill) : [];
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
at,
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice || done) return;
|
||||
finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });
|
||||
close();
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const [{ createSpreadsheet }, book] = await Promise.all([
|
||||
const [{ createSpreadsheet }, got] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
opts.doc
|
||||
? Promise.resolve(opts.doc)
|
||||
@@ -88,10 +106,11 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
|
||||
),
|
||||
]);
|
||||
if (done) return;
|
||||
const at = book.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸;
|
||||
book = got;
|
||||
const at = got.시트.flatMap((s) => s.연결 ?? []).find((l) => l.키 === opts.pill)?.칸;
|
||||
head.textContent = at ? fill(TEXT.now, at) : TEXT.none;
|
||||
unlink.hidden = !at;
|
||||
handle = createSpreadsheet(host, structuredClone(book), {
|
||||
handle = createSpreadsheet(host, structuredClone(got), {
|
||||
readOnly: true,
|
||||
tabs: false,
|
||||
onSelect: (s) => {
|
||||
@@ -103,9 +122,9 @@ export function pickAreaCell(opts: AreaCellPickOptions): Promise<AreaCellPick> {
|
||||
const grid = host.querySelector(".ss-grid-host");
|
||||
grid?.addEventListener("mousedown", () => (moved = false), true);
|
||||
grid?.addEventListener("mouseup", (e) => {
|
||||
if ((e as MouseEvent).button === 0 && moved) choose();
|
||||
if ((e as MouseEvent).button === 0 && moved) void choose();
|
||||
});
|
||||
host.addEventListener("keydown", (e) => e.key === "Enter" && choose());
|
||||
host.addEventListener("keydown", (e) => e.key === "Enter" && void choose());
|
||||
} catch (error) {
|
||||
if (done) return;
|
||||
head.textContent = fill(TEXT.failed, error instanceof Error ? error.message : "");
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { askShare } from "../A00_Common/spreadsheet/spreadsheet_link_ask";
|
||||
import { linksAt, normA1 } from "../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { CalcValue, LinkInfo, SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const TEXT = {
|
||||
@@ -68,8 +70,21 @@ export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
now.textContent = `${TEXT.now} · ${text ?? TEXT.none}`;
|
||||
unlink.disabled = opts.readOnly || !text;
|
||||
};
|
||||
const done = (cell: string | null): boolean => {
|
||||
if (!opts.sheet.setLink(opts.key, cell, opts.info)) return false;
|
||||
/** 잇기 · 풀기 — 그 칸에 다른 알약이 있으면 함께 · 바꾸기 · 취소 물음(취소 = 모달 그대로) · 틀린 주소 false */
|
||||
const done = async (cell: string | null): Promise<boolean> => {
|
||||
const a1 = cell === null ? null : normA1(cell);
|
||||
if (cell !== null && !a1) return false;
|
||||
const book = opts.sheet.getDoc();
|
||||
const sheetId = (book.시트.find((s) => s.id === book.활성) ?? book.시트[0])?.id ?? "";
|
||||
const others = a1 ? linksAt(book, sheetId, a1, opts.key) : [];
|
||||
const choice = others.length
|
||||
? await askShare(
|
||||
a1 ?? "",
|
||||
others.map((l) => l.이름 ?? l.키),
|
||||
)
|
||||
: "함께";
|
||||
if (!choice) return true;
|
||||
if (!opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")) return false;
|
||||
close();
|
||||
return true;
|
||||
};
|
||||
@@ -85,9 +100,8 @@ export function openAreaLink(opts: AreaLinkOptions): void {
|
||||
label: TEXT.link,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (!done(address.input.value)) address.setError(TEXT.bad);
|
||||
},
|
||||
onClick: () =>
|
||||
void done(address.input.value).then((ok) => ok || address.setError(TEXT.bad)),
|
||||
});
|
||||
const unlink = createButton({
|
||||
label: TEXT.unlink,
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
* 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */
|
||||
import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands";
|
||||
import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color";
|
||||
import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import {
|
||||
linkAreaCell,
|
||||
linkCommand,
|
||||
linksAt,
|
||||
} from "../../../A00_Common/spreadsheet/spreadsheet_link_set";
|
||||
import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
|
||||
let pass = 0;
|
||||
@@ -58,10 +62,34 @@ b = book();
|
||||
run(b, linkCommand(b, "s1", "깔기|㎡", "B1"));
|
||||
eq("옮김", b.시트[0].연결, [{ 키: "깔기|㎡", 칸: "B1", 이름: "깔기", 단위: "㎡" }]);
|
||||
|
||||
// 같은 칸에 다른 키 — 그 칸 옛 키 빠짐(칸 하나 = 키 하나)
|
||||
// 같은 칸에 다른 키 — 기본 = 함께(칸 하나에 알약 여럿 · 40 마무리) · 바꾸기 = 그 칸 옛 키 빠짐
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }));
|
||||
eq("칸 하나 키 하나", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
eq("칸 하나 알약 여럿", b.시트[0].연결, [
|
||||
{ 키: "깔기|㎡", 이름: "깔기", 단위: "㎡", 칸: "B6" },
|
||||
{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" },
|
||||
]);
|
||||
eq(
|
||||
"그 칸 다른 알약",
|
||||
linksAt(b, "s1", " b6 ", "새|m").map((l) => l.키),
|
||||
["깔기|㎡"],
|
||||
);
|
||||
b = book();
|
||||
const undoSwap = run(b, linkCommand(b, "s1", "새|m", "B6", { 이름: "새", 단위: "m" }, true));
|
||||
eq("바꾸기", b.시트[0].연결, [{ 키: "새|m", 칸: "B6", 이름: "새", 단위: "m" }]);
|
||||
run(b, undoSwap);
|
||||
eq("바꾸기 되돌림", links(b), links(book()));
|
||||
b = book();
|
||||
run(b, linkCommand(b, "s1", "새|m", "B6"));
|
||||
eq("함께 둔 칸에 바꾸기 = 명령 있음", !!linkCommand(b, "s1", "새|m", "B6", {}, true), true);
|
||||
eq(
|
||||
"면적 문서 함께 · 바꾸기",
|
||||
[
|
||||
linkAreaCell(book(), "새|m", "B6")!.시트[0].연결!.length,
|
||||
linkAreaCell(book(), "새|m", "B6", {}, true)!.시트[0].연결!.length,
|
||||
],
|
||||
[2, 1],
|
||||
);
|
||||
|
||||
// 다른 시트에 있던 키를 이 시트로 — 저쪽 연결 빠짐(연결 칸 없으면 `연결` 지움) · 되돌리기 한 번에 둘 다
|
||||
b = book();
|
||||
|
||||
@@ -42,7 +42,10 @@ def test_패널_몸은_보기_갈래_줄_누름은_칸_지정():
|
||||
|
||||
def test_칸_지정_모달은_setLink_만():
|
||||
link = _text("M02_MasterTemplete_AreaLink.ts")
|
||||
assert "opts.sheet.setLink(opts.key, cell, opts.info)" in link
|
||||
assert 'opts.sheet.setLink(opts.key, a1, opts.info, choice === "바꾸기")' in link
|
||||
assert (
|
||||
"linksAt(book, sheetId, a1, opts.key)" in link and "askShare(" in link
|
||||
) # 칸 하나 알약 여럿
|
||||
assert "done(here)" in link and "done(address.input.value)" in link and "done(null)" in link
|
||||
assert "address.setError(TEXT.bad)" in link # 틀린 · 빈 주소는 모달 그대로
|
||||
assert "pick.disabled = link.disabled = address.input.disabled = opts.readOnly;" in link
|
||||
@@ -95,7 +98,9 @@ def test_엑셀_칸_고르기_모달_부품():
|
||||
pick = _text("M02_MasterTemplete_AreaCellPick.ts")
|
||||
assert 'export { linkAreaCell } from "../A00_Common/spreadsheet/spreadsheet_link_set";' in pick
|
||||
assert "readOnly: true," in pick and "beforeClose: () => (finish(null), true)," in pick
|
||||
assert "finish({ 칸: null })" in pick and "finish({ 칸: picked })" in pick
|
||||
assert "if ((e as MouseEvent).button === 0 && moved) choose();" in pick
|
||||
assert "finish({ 칸: null })" in pick
|
||||
assert 'finish(choice === "바꾸기" ? { 칸: at, 바꾸기: true } : { 칸: at });' in pick
|
||||
assert "linksAt(book, sheetId, at, opts.pill)" in pick and "askShare(" in pick
|
||||
assert "if ((e as MouseEvent).button === 0 && moved) void choose();" in pick
|
||||
assert "handle?.destroy();" in pick
|
||||
assert "saveTemplate" not in pick
|
||||
|
||||
@@ -55,15 +55,18 @@ def test_setLink_순수_길():
|
||||
assert run.stdout.strip(), run.stderr
|
||||
result = json.loads(run.stdout)
|
||||
assert result["fail"] == 0, result["failures"]
|
||||
assert result["pass"] == 24
|
||||
assert result["pass"] == 29
|
||||
|
||||
|
||||
def test_손잡이_setLink_몸():
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet.ts").read_text(encoding="utf-8")
|
||||
assert "setLink(key: string, cell: string | null, info?: LinkInfo): boolean;" in src
|
||||
body = src.split(" setLink(key, cell, info) {")[1].split(" destroy() {")[0]
|
||||
assert (
|
||||
"setLink(key: string, cell: string | null, info?: LinkInfo, replace?: boolean): boolean;"
|
||||
in src
|
||||
)
|
||||
body = src.split(" setLink(key, cell, info, replace) {")[1].split(" destroy() {")[0]
|
||||
assert "if (readOnly) return false;" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body
|
||||
assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info, replace)" in body
|
||||
assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님)
|
||||
assert "loadLinks();" in body
|
||||
|
||||
@@ -72,7 +75,12 @@ def test_알약_끌어_놓기_색():
|
||||
"""40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기."""
|
||||
src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8")
|
||||
assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src
|
||||
assert "showConfirmDialog(" in src and "link(item, 칸);" in src
|
||||
assert "await link(item, toA1(at.r, at.c));" in src
|
||||
# 칸 하나에 알약 여럿 — 다른 알약 칸이면 함께 · 바꾸기 · 취소 물음(알약 누름 · 끌어 놓기 한 길)
|
||||
body = src.split("async function link(")[1].split("\n }\n")[0]
|
||||
assert "linksAt(ctx.book, sheet.id, 칸, item.키)" in body and "askShare(" in body
|
||||
assert 'choice === "바꾸기"' in body
|
||||
assert 'className: "ss-link-count"' in src and 'ctx.grid.root.title = names.join(" · ")' in src
|
||||
assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음
|
||||
assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src
|
||||
css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text(
|
||||
|
||||
Reference in New Issue
Block a user