Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_2
This commit is contained in:
@@ -15,12 +15,14 @@ import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
|
||||
import { attachClipboard } from "./spreadsheet_clipboard";
|
||||
import { applyCommand, CommandError } from "./spreadsheet_commands";
|
||||
import { createEditor } from "./spreadsheet_editor";
|
||||
import { createEditor, slotOf } from "./spreadsheet_editor";
|
||||
import { toCalcValue } from "./spreadsheet_eval";
|
||||
import { attachExtras } from "./spreadsheet_extras";
|
||||
import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
|
||||
@@ -28,6 +30,7 @@ import { createStatusBar } from "./spreadsheet_statusbar";
|
||||
import { mountTabs } from "./spreadsheet_tabs";
|
||||
import { mountToolbar } from "./spreadsheet_toolbar";
|
||||
import type {
|
||||
CalcValue,
|
||||
CalcValues,
|
||||
Command,
|
||||
CommandEffect,
|
||||
@@ -42,7 +45,8 @@ import type {
|
||||
SpreadsheetContext,
|
||||
} from "./spreadsheet_view_types";
|
||||
|
||||
export type { Workbook, CalcValues } from "./spreadsheet_types";
|
||||
export type { Workbook, CalcValues, CalcValue, LinkMaterial } from "./spreadsheet_types";
|
||||
export type { LinkItem, LinkOptions } from "./spreadsheet_links";
|
||||
|
||||
export interface SpreadsheetOptions {
|
||||
/** 읽기만 — 도구 모음 · 메뉴 없음 · 편집 · 붙여넣기 · 끌기 막음(고르기 · 복사는 됨) */
|
||||
@@ -53,6 +57,8 @@ export interface SpreadsheetOptions {
|
||||
onSelect?: (selection: { 시트: string; 칸: string; 범위: string }) => void;
|
||||
/** 아래 시트 탭 줄 — false 면 안 그림(산출근거 = 시트 하나) · 시트 오가기 · 새 시트 단축키 · 시트 메뉴도 끔 */
|
||||
tabs?: boolean;
|
||||
/** 위 알약 막대 — 입력값 · 자재를 칸에 연결(`spreadsheet_links.ts` · 이때만 불러옴) */
|
||||
links?: LinkOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -62,6 +68,8 @@ export interface SpreadsheetHandle {
|
||||
setDoc(book: Workbook): void;
|
||||
/** 전부 다시 풀고 다시 그림 · 결과(저장 모양) */
|
||||
recalc(): CalcValues;
|
||||
/** 알약 키 → 연결 칸 값(빈 칸 0) */
|
||||
getLinkedValues(): Record<string, CalcValue>;
|
||||
destroy(): void;
|
||||
}
|
||||
|
||||
@@ -136,9 +144,23 @@ export function createSpreadsheet(
|
||||
const detachMouse = attachMouse(ctx, ed, extras.mouse);
|
||||
const toast = el("div", { className: "ss-toast" });
|
||||
let toastTimer = 0;
|
||||
/** 알약 막대 · 연결 따라가기 — 선택이 있거나 문서에 연결이 있을 때만 불러옴(크기 예산) */
|
||||
let links: Links | null = null;
|
||||
let linking = false;
|
||||
function loadLinks(): void {
|
||||
if (linking || !(opts.links || ctx.book.시트.some((s) => s.연결?.length))) return;
|
||||
linking = true;
|
||||
void import("./spreadsheet_links").then((m) => {
|
||||
if (!root.isConnected) return; // 받기 전에 닫힘
|
||||
links = m.mountLinks(ctx, opts.links, () => ed.focus(), slotOf);
|
||||
others.push(links);
|
||||
links.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
@@ -149,7 +171,7 @@ export function createSpreadsheet(
|
||||
|
||||
function apply(command: Command): CommandEffect | null {
|
||||
try {
|
||||
return applyCommand(ctx.book, command);
|
||||
return (links?.apply ?? applyCommand)(ctx.book, command);
|
||||
} catch (e) {
|
||||
if (e instanceof CommandError) return (notify(e.message), null);
|
||||
throw e;
|
||||
@@ -238,6 +260,7 @@ export function createSpreadsheet(
|
||||
ctx.selection = selectCell(firstSheet(next), 0, 0);
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
},
|
||||
recalc() {
|
||||
ctx.engine.rebuild(ctx.book);
|
||||
@@ -245,6 +268,15 @@ export function createSpreadsheet(
|
||||
for (const p of others) p.refresh();
|
||||
return ctx.engine.snapshot();
|
||||
},
|
||||
getLinkedValues() {
|
||||
const out: Record<string, CalcValue> = {};
|
||||
for (const s of ctx.book.시트)
|
||||
for (const l of s.연결 ?? []) {
|
||||
const p = parseA1(l.칸);
|
||||
if (p) out[l.키] = toCalcValue(ctx.engine.value(s.id, p.r, p.c));
|
||||
}
|
||||
return out;
|
||||
},
|
||||
destroy() {
|
||||
clearTimeout(toastTimer);
|
||||
detachKeys();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/* spreadsheet_links.css (주인 D) — 위 알약 막대 · 연결 칸 세모 · 자재 고르기 창 */
|
||||
|
||||
.ss-links {
|
||||
display: flex;
|
||||
flex: none;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.ss-link {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-4);
|
||||
padding: 2px 10px;
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-link code {
|
||||
font-size: 11px;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 연결됨 — 흐림(연결 현황) */
|
||||
.ss-link.is-linked {
|
||||
opacity: 0.5;
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-link.is-linked:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.ss-link--add {
|
||||
border-style: dashed;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 연결 칸 왼아래 세모(초록 오류 세모는 왼위 · 빨강 메모는 오른위) */
|
||||
.ss-link-dot {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-left: 6px solid var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-links__pick {
|
||||
position: absolute;
|
||||
z-index: var(--z-dropdown);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
width: 320px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-links__search {
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ss-links__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.ss-links__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: var(--spacing-8);
|
||||
padding: 4px 6px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-links__row:hover {
|
||||
background: var(--color-surface-sunken);
|
||||
}
|
||||
|
||||
.ss-links__note {
|
||||
padding: 4px 6px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_links.ts (주인 D)
|
||||
* 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
|
||||
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
|
||||
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
|
||||
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `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;
|
||||
}
|
||||
|
||||
export interface LinkOptions {
|
||||
항목: LinkItem[];
|
||||
/** 입력 = 항목이 알약 · 자재 = 연결된 자재 + [자재 더하기] 로 고른 자재 */
|
||||
모드: "입력" | "자재";
|
||||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
/** `applyCommand` + 연결 따라가기 */
|
||||
apply(book: Workbook, command: Command): CommandEffect;
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기",
|
||||
free: "칸을 고른 채 누르면 이 칸에 연결",
|
||||
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
|
||||
unlink: "연결 풀기",
|
||||
add: "+ 자재 더하기",
|
||||
search: "자재 이름 · 규격 · 코드",
|
||||
searching: "찾는 중…",
|
||||
none: "맞는 자재 없음",
|
||||
fail: "자재 목록을 못 받음",
|
||||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||||
};
|
||||
|
||||
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] } };
|
||||
}
|
||||
|
||||
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 picked = new Map<string, LinkMaterial>();
|
||||
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, 자재?: LinkMaterial): 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 (자재) one.자재 = 자재;
|
||||
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();
|
||||
}
|
||||
|
||||
function pill(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
자재?: LinkMaterial,
|
||||
) {
|
||||
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, 자재)));
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
if (!hit || ctx.readOnly) return;
|
||||
menu.open(e.clientX, e.clientY, [
|
||||
[
|
||||
TEXT.unlink,
|
||||
() => {
|
||||
put(
|
||||
hit.sheet,
|
||||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||||
);
|
||||
back();
|
||||
},
|
||||
],
|
||||
]);
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
function renderBar(): void {
|
||||
if (!opts) return;
|
||||
const map = found();
|
||||
const items: [LinkItem, LinkMaterial?][] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (item: LinkItem, 자재?: LinkMaterial) => {
|
||||
if (seen.has(item.키)) return;
|
||||
seen.add(item.키);
|
||||
items.push([item, 자재]);
|
||||
};
|
||||
const ofMaterial = (m: LinkMaterial): LinkItem => ({
|
||||
키: m.코드,
|
||||
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
|
||||
단위: m.단위,
|
||||
});
|
||||
if (opts.모드 === "입력") for (const item of opts.항목) add(item);
|
||||
// 문서에 있는 연결(항목에서 빠진 것 · 자재) — 풀 수 있게 늘 보임
|
||||
for (const { link } of map.values())
|
||||
add(
|
||||
link.자재
|
||||
? ofMaterial(link.자재)
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
link.자재,
|
||||
);
|
||||
for (const m of picked.values()) add(ofMaterial(m), m);
|
||||
const pills = items.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) => {
|
||||
picked.set(m.코드, m);
|
||||
closePick();
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -51,6 +51,7 @@
|
||||
* D spreadsheet_keys.ts 엑셀 단축키 지도 ~300
|
||||
* D spreadsheet_mouse.ts 끌기 고르기 · 채우기 끌기 · 두 번 누르기 ~300
|
||||
* D spreadsheet.css ~150
|
||||
* D spreadsheet_links.ts 위 알약 막대 — 입력값 · 자재를 칸에 연결(늦게 불러옴) ~350
|
||||
* E spreadsheet_toolbar.ts 서식 도구 모음 ~550
|
||||
* E spreadsheet_tabs.ts 시트 탭 ~300
|
||||
* E spreadsheet_menu.ts 우클릭 메뉴 ~300
|
||||
@@ -191,6 +192,27 @@ export interface Sheet {
|
||||
숨김?: boolean;
|
||||
/** 시트 탭 색 `#RRGGBB`(`spreadsheet_sheet_extra.ts`) — 탭 칠은 E 몫(안 이어짐). */
|
||||
탭색?: string;
|
||||
/** 알약 연결(`spreadsheet_links.ts` · sub7 계약 더함) — 위 알약(입력값 · 자재)이 가리키는 이 시트 칸.
|
||||
* 행열 넣기 · 지우기에 따라감(지운 칸이면 빠짐) · 바꿈은 `시트통째` 로(되돌리기 짝). */
|
||||
연결?: CellLink[];
|
||||
}
|
||||
|
||||
/** 자재DB 한 줄 — 재료산출 알약. */
|
||||
export interface LinkMaterial {
|
||||
코드: string;
|
||||
이름: string;
|
||||
규격: string;
|
||||
단위: string;
|
||||
}
|
||||
|
||||
/** 알약 ↔ 칸 한 줄 — `키` = 입력값 키 또는 자재 코드 · 키 하나에 칸 하나 · 칸 하나에 키 하나. */
|
||||
export interface CellLink {
|
||||
키: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
/** A1 */
|
||||
칸: string;
|
||||
자재?: LinkMaterial;
|
||||
}
|
||||
|
||||
/** 새 시트 기본값 — 값은 사용자 협의로 확정(9_엑셀검토 8장 · 임의 확정 금지). 없으면 A 의 임시값. */
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>알약 막대 시험 틀</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
#host {
|
||||
height: 520px;
|
||||
margin: 8px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="host"></div>
|
||||
<script type="module" src="./harness_links.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,57 @@
|
||||
/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록.
|
||||
* Vite 개발 서버가 바로 풂: /resources/tester/spreadsheet/harness_links.html?mode=입력|자재
|
||||
* window.ss = 부품 손잡이(getDoc · getLinkedValues) · window.ssFind = 자재찾기 부른 글 목록 */
|
||||
import "../../../ui_template/ui_template_theme.css"; // 앱 색 · 간격 토큰
|
||||
import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력";
|
||||
|
||||
const book: Workbook = {
|
||||
종류: "통합문서",
|
||||
판: 1,
|
||||
열: "harness",
|
||||
서식: [{}],
|
||||
시트: [
|
||||
{
|
||||
id: "s1",
|
||||
이름: "면적산출",
|
||||
칸: {
|
||||
A1: { 값: "길이" },
|
||||
B1: { 값: 12.5 },
|
||||
A2: { 값: "높이" },
|
||||
B2: { 값: 3 },
|
||||
A3: { 값: "면적" },
|
||||
B3: { 식: "B1*B2" },
|
||||
},
|
||||
},
|
||||
],
|
||||
활성: "s1",
|
||||
};
|
||||
|
||||
const MATERIALS: LinkMaterial[] = [
|
||||
{ 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" },
|
||||
{ 코드: "M002", 이름: "철근", 규격: "SD400 D13", 단위: "ton" },
|
||||
{ 코드: "M003", 이름: "거푸집", 규격: "합판 3회", 단위: "m²" },
|
||||
];
|
||||
const asked: string[] = [];
|
||||
w.ssFind = asked;
|
||||
|
||||
w.ss = createSpreadsheet(document.getElementById("host")!, book, {
|
||||
tabs: false,
|
||||
onChange: () => (w.ssChanges = ((w.ssChanges as number) ?? 0) + 1),
|
||||
links: {
|
||||
모드: mode,
|
||||
항목: [
|
||||
{ 키: "L", 이름: "길이", 단위: "m" },
|
||||
{ 키: "H", 이름: "높이", 단위: "m" },
|
||||
{ 키: "A", 이름: "면적", 단위: "m²" },
|
||||
],
|
||||
자재찾기: async (글) => {
|
||||
asked.push(글);
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
return MATERIALS.filter((m) => !글 || `${m.코드}${m.이름}${m.규격}`.includes(글));
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -123,5 +123,28 @@ const out = {
|
||||
};
|
||||
return addr.toA1(sel.move(s, sel.selectCell(s, 1, 11), 1, 0).활성.r, 11);
|
||||
})(),
|
||||
// 알약 연결 — 행 넣기 · 열 지우기(연결 칸 지움 → 빠짐) · 되돌리기 두 번
|
||||
links: (() => {
|
||||
const lk = load(path.join(SS, "spreadsheet_links.ts"));
|
||||
const book = JSON.parse(JSON.stringify(sample));
|
||||
const id = book.시트[0].id;
|
||||
book.시트[0].연결 = [
|
||||
{ 키: "L", 이름: "길이", 칸: "B5" },
|
||||
{ 키: "W", 이름: "폭", 칸: "D3" },
|
||||
];
|
||||
const read = () => (book.시트[0].연결 ?? []).map((l) => `${l.키}:${l.칸}`).join(",");
|
||||
const out = [];
|
||||
const ins = lk.applyLinked(book, { 종류: "행넣기", 시트: id, at: 2, 수: 2 });
|
||||
out.push(read());
|
||||
const del = lk.applyLinked(book, { 종류: "열지우기", 시트: id, at: 3, 수: 1 });
|
||||
out.push(read());
|
||||
lk.applyLinked(book, del.undo);
|
||||
out.push(read());
|
||||
lk.applyLinked(book, ins.undo);
|
||||
out.push(read());
|
||||
const one = [{ 키: "a", 칸: "C1" }];
|
||||
out.push(lk.shiftLinks(one, { 종류: "열넣기", 시트: id, at: 0, 수: 1 })[0].칸);
|
||||
return out;
|
||||
})(),
|
||||
};
|
||||
console.log(JSON.stringify(out));
|
||||
|
||||
@@ -106,3 +106,8 @@ def test_abs_cycle_and_fold(r):
|
||||
None,
|
||||
]
|
||||
assert r["foldSkip"] == "L5"
|
||||
|
||||
|
||||
def test_links_follow_rows_cols(r):
|
||||
# 행 넣기 → 밀림 · 연결 칸 열 지움 → 빠짐 · 되돌리기 → 제자리
|
||||
assert r["links"] == ["L:B7,W:D5", "L:B7", "L:B7,W:D5", "L:B5,W:D3", "D1"]
|
||||
|
||||
Reference in New Issue
Block a user