auto: 2026-09-28 18:46 (ESD_LAPTOP)

This commit is contained in:
2026-09-28 18:46:00 +09:00
parent 3f39b5b6c3
commit c32e2cbd5d
3 changed files with 117 additions and 44 deletions
@@ -108,3 +108,29 @@
padding: 4px 6px;
color: var(--color-text-muted);
}
/* 알약으로 이동한 칸 — 굵은 테두리 + 채움 깜빡임(1.6초 · 끝나면 스스로 지워짐) */
.ss-link-flash {
position: absolute;
z-index: 4;
box-sizing: border-box;
border: 3px solid var(--color-accent);
pointer-events: none;
animation: ss-link-flash 1.6s ease-out 1 both;
}
@keyframes ss-link-flash {
0%,
40%,
80% {
background: color-mix(in srgb, var(--color-accent) 40%, transparent);
}
20%,
60% {
background: transparent;
}
100% {
background: transparent;
opacity: 0;
}
}
+73 -38
View File
@@ -3,6 +3,8 @@
* 위 알약 막대 — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장).
* 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) ·
* 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약.
* 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] ·
* 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음).
* 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가
* 연결 칸을 옮김(지운 칸이면 빠짐). `createSpreadsheet` 가 `links` 선택 또는 연결 있는 문서일 때만 불러옴(크기 예산).
* ========================================================================== */
@@ -31,12 +33,16 @@ export interface LinkItem {
키: string;
이름: string;
단위?: string;
/** 자재 알약 — 연결하면 이 정보가 칸 연결에 들어감 */
자재?: LinkMaterial;
}
export interface LinkOptions {
항목: LinkItem[];
/** 입력 = 항목이 알약 · 자재 = 연결된 자재 + [자재 더하기] 로 고른 자재 */
/** 입력 = 항목이 알약 · 자재 = 항목(자재 정보 포함 · 호스트 목록) + [자재 더하기] 로 더한 자재 */
모드: "입력" | "자재";
/** 자재 모드에서 목록이 바뀔 때(더하기 · 빼기) 새 목록을 알림 — 저장은 부른 쪽 몫 */
onItemsChange?: (항목: LinkItem[]) => void;
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
}
@@ -51,12 +57,15 @@ const TEXT = {
free: "칸을 고른 채 누르면 이 칸에 연결",
linked: "누르면 연결 칸으로 · 우클릭 연결 풀기",
unlink: "연결 풀기",
remove: "목록에서 빼기",
removed: "목록에서 뺌 — 연결도 풀림",
add: "+ 자재 더하기",
search: "자재 이름 · 규격 · 코드",
searching: "찾는 중…",
none: "맞는 자재 없음",
fail: "자재 목록을 못 받음",
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
dup: "이미 목록에 있는 자재",
};
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
@@ -102,6 +111,14 @@ export function applyLinked(book: Workbook, command: Command): CommandEffect {
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,
@@ -113,8 +130,8 @@ export function mountLinks(
if (opts) ctx.root.prepend(bar);
const menu = createMapContextMenu("ss");
ctx.root.append(menu.element);
/** 이번에 고른 자재(아직 칸 없음 — 연결하면 문서에 들어감) */
const picked = new Map<string, LinkMaterial>();
/** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */
const items: LinkItem[] = opts ? [...opts.항목] : [];
let dots: HTMLElement[] = [];
let pick: HTMLElement | null = null;
@@ -133,31 +150,38 @@ export function mountLinks(
ctx.dispatch({ 종류: "시트통째", 시트: next });
}
function link(item: LinkItem, 자재?: LinkMaterial): void {
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 (자재) one.자재 = 자재;
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);
});
}
function pill(
item: LinkItem,
hit: { sheet: Sheet; link: CellLink } | undefined,
자재?: LinkMaterial,
) {
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.칸
@@ -173,21 +197,35 @@ export function mountLinks(
],
});
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.칸) : link(item)));
const unlink = (): void => {
if (hit)
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
};
b.addEventListener("contextmenu", (e) => {
e.preventDefault();
if (!hit || ctx.readOnly) return;
const inList = opts?.모드 === "자재" && items.some((i) => i.키 === item.키);
if (ctx.readOnly || !(hit || inList)) return;
menu.open(e.clientX, e.clientY, [
[
TEXT.unlink,
() => {
put(
hit.sheet,
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
);
back();
},
],
...(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;
@@ -196,29 +234,22 @@ export function mountLinks(
function renderBar(): void {
if (!opts) return;
const map = found();
const items: [LinkItem, LinkMaterial?][] = [];
const shown: LinkItem[] = [];
const seen = new Set<string>();
const add = (item: LinkItem, 자재?: LinkMaterial) => {
const add = (item: LinkItem) => {
if (seen.has(item.키)) return;
seen.add(item.키);
items.push([item, 자재]);
shown.push(item);
};
const ofMaterial = (m: LinkMaterial): LinkItem => ({
키: m.코드,
이름: m.규격 ? `${m.이름} ${m.규격}` : m.이름,
단위: m.단위,
});
if (opts.모드 === "입력") for (const item of opts.항목) add(item);
// 문서에 있는 연결(항목에서 빠진 것 · 자재) — 풀 수 있게 늘 보임
for (const item of items) 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.키), 자재));
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",
@@ -277,10 +308,14 @@ export function mountLinks(
let seq = 0;
let timer = 0;
const choose = (m: LinkMaterial) => {
picked.set(m.코드, m);
closePick();
renderBar();
showToast(TEXT.picked, "info");
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> => {
+18 -6
View File
@@ -1,4 +1,4 @@
/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록.
/* 알약 막대 시험 틀 — 진짜 부품(createSpreadsheet)에 `links` 선택 · 가짜 입력값 · 가짜 자재 목록(호스트 항목 + 자재찾기).
* Vite 개발 서버가 바로 풂: /resources/tester/spreadsheet/harness_links.html?mode=입력|자재
* window.ss = 부품 손잡이(getDoc · getLinkedValues) · window.ssFind = 자재찾기 부른 글 목록 */
import "../../../ui_template/ui_template_theme.css"; // 앱 색 · 간격 토큰
@@ -37,17 +37,29 @@ const MATERIALS: LinkMaterial[] = [
];
const asked: string[] = [];
w.ssFind = asked;
w.ssItems = [] as unknown[]; // onItemsChange 로 받은 목록들(부른 순서)
// 자재 모드 — 호스트가 주는 처음 목록(자재 정보 포함): 레미콘 하나
const hostItems = MATERIALS.slice(0, 1).map((m) => ({
키: m.코드,
이름: `${m.이름} ${m.규격}`,
단위: m.단위,
자재: m,
}));
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²" },
],
항목:
mode === "자재"
? hostItems
: [
{ 키: "L", 이름: "길이", 단위: "m" },
{ 키: "H", 이름: "높이", 단위: "m" },
{ 키: "A", 이름: "면적", 단위: "m²" },
],
onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록),
자재찾기: async (글) => {
asked.push(글);
await new Promise((r) => setTimeout(r, 50));