From c32e2cbd5de88e11cb8fce1f05de591836b3112f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 28 Sep 2026 18:46:00 +0900 Subject: [PATCH] auto: 2026-09-28 18:46 (ESD_LAPTOP) --- A00_Common/spreadsheet/spreadsheet_links.css | 26 ++++ A00_Common/spreadsheet/spreadsheet_links.ts | 111 ++++++++++++------ resources/tester/spreadsheet/harness_links.ts | 24 +++- 3 files changed, 117 insertions(+), 44 deletions(-) diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index e6402728..715161d6 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -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; + } +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 44c0d774..72dde7a1 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -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; } @@ -51,12 +57,15 @@ const TEXT = { free: "칸을 고른 채 누르면 이 칸에 연결", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", + remove: "목록에서 빼기", + removed: "목록에서 뺌 — 연결도 풀림", add: "+ 자재 더하기", search: "자재 이름 · 규격 · 코드", searching: "찾는 중…", none: "맞는 자재 없음", fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", + dup: "이미 목록에 있는 자재", }; type RowCol = Extract; @@ -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(); + /** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */ + 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(); - 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 => { diff --git a/resources/tester/spreadsheet/harness_links.ts b/resources/tester/spreadsheet/harness_links.ts index 9b36313a..ea6b0ff8 100644 --- a/resources/tester/spreadsheet/harness_links.ts +++ b/resources/tester/spreadsheet/harness_links.ts @@ -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));