diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index 01a82b2c9..30694cd8e 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -195,3 +195,72 @@ transform: translate(-100%, 0); pointer-events: none; } + +/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */ +.ss-link.is-here { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + font-weight: 600; +} + +.ss-link.is-pop { + animation: ss-link-pop 1.2s ease-out 1 both; +} + +@keyframes ss-link-pop { + 0%, + 40%, + 80% { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent); + } + 20%, + 60%, + 100% { + box-shadow: 0 0 0 0 transparent; + } +} + +/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */ +.ss-link.is-orphan { + border: 1px dashed var(--color-danger); + color: var(--color-danger); +} + +.ss-link__x { + margin-left: 2px; + padding: 0 4px; + border-radius: 50%; + font-weight: 700; + cursor: pointer; +} + +.ss-link__x:hover { + background: color-mix(in srgb, var(--color-danger) 20%, transparent); +} + +.ss-link-cell.is-orphan { + outline: 1px dashed var(--color-danger); + outline-offset: -2px; +} + +/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */ +.ss-link-range { + position: absolute; + z-index: 3; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; +} + +.ss-link-range__tag { + position: absolute; + left: -2px; + bottom: 100%; + padding: 0 4px; + border-radius: 3px 3px 0 0; + background: var(--color-accent); + color: var(--color-canvas); + font-size: 10px; + line-height: 14px; + white-space: nowrap; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 978a9c8b4..7525085a4 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -15,7 +15,7 @@ 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 { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; import { askShare } from "./spreadsheet_link_ask"; import { linkHues } from "./spreadsheet_link_color"; @@ -53,6 +53,11 @@ export interface LinkOptions { onItemsChange?: (항목: LinkItem[]) => void; /** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */ 자재찾기?: (글: string) => Promise; + /** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만 + * 목록에 없는 연결 알약에 ⚠ + [×] */ + 확정?: boolean; + /** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */ + 표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[]; } export interface Links extends PartHandle { @@ -74,6 +79,8 @@ const TEXT = { fail: "자재 목록을 못 받음", picked: "칸을 고른 뒤 새 알약을 누르면 연결", dup: "이미 목록에 있는 자재", + orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음", + orphanX: "연결 풀기", }; type RowCol = Extract; @@ -146,6 +153,8 @@ export function mountLinks( let dragging: LinkItem | null = null; const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; + /** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */ + let lastAt = ""; /** 키 → 연결(시트 · 줄) */ function found(): Map { @@ -204,6 +213,8 @@ export function mountLinks( item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined, hue: number | undefined, + orphan: boolean, + here: string, ) { const where = hit ? hit.sheet.id === ctx.selection.시트 @@ -211,15 +222,30 @@ export function mountLinks( : `${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.키 }, + className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${ + here ? ` is-here${here === "new" ? " is-pop" : ""}` : "" + }`, + attrs: { + type: "button", + title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free, + "data-key": item.키, + }, children: [ - el("span", { text: item.이름 }), + el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }), ...(item.단위 ? [el("small", { text: item.단위 })] : []), ...(where ? [el("code", { text: where })] : []), ], }); if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + if (orphan && !ctx.readOnly) { + const x = el("span", { + className: "ss-link__x", + text: "×", + attrs: { role: "button", title: TEXT.orphanX }, + }); + x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back())); + b.append(x); + } b.draggable = !ctx.readOnly; b.addEventListener("dragstart", (e) => { dragging = item; @@ -233,13 +259,13 @@ export function mountLinks( // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item))); - const unlink = (): void => { + function unlink(): void { if (hit) put( hit.sheet, (hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키), ); - }; + } b.addEventListener("contextmenu", (e) => { e.preventDefault(); const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키); @@ -307,7 +333,17 @@ export function mountLinks( : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); const hues = linkHues(map.keys()); - const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); + const listed = new Set(items.map((i) => i.키)); + const sure = opts.모드 === "입력" && opts.확정 === true; + // 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new") + const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`; + const fresh = at !== lastAt; + lastAt = at; + const pills = shown.map((item) => { + const hit = map.get(item.키); + const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : ""; + return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here); + }); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -318,6 +354,8 @@ export function mountLinks( pills.push(more); } bar.replaceChildren(...pills); + if (fresh) + bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" }); } /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ @@ -325,6 +363,8 @@ export function mountLinks( for (const d of dots) d.remove(); dots = []; const hues = linkHues(found().keys()); + const listed = new Set(items.map((i) => i.키)); + const sure = opts?.모드 === "입력" && opts.확정 === true; // 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수 const byCell = new Map(); for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]); @@ -340,6 +380,7 @@ export function mountLinks( dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; const marks = [tint, dot]; + if (sure && !listed.has(first.키)) tint.classList.add("is-orphan"); if (list.length > 1) { const count = el("div", { className: "ss-link-count", text: String(list.length) }); count.style.left = `${box.x + box.w}px`; @@ -350,6 +391,22 @@ export function mountLinks( layer.append(...marks); dots.push(...marks); } + // 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침) + for (const mark of opts?.표시범위 ?? []) { + const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null; + if (!g) continue; + const { layer, box } = slot(ctx, g.r0, g.c0); + const end = slot(ctx, g.r1, g.c1).box; + if (!box.w || !box.h) continue; + const frame = el("div", { + className: "ss-link-range", + attrs: { "data-range": mark.범위 }, + children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })], + }); + frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`; + layer.append(frame); + dots.push(frame); + } } /** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */ @@ -360,6 +417,11 @@ export function mountLinks( const names = a1 ? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키) : []; + if (hit && hit.r >= 0 && hit.c >= 0) + for (const m of opts?.표시범위 ?? []) { + const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null; + if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름); + } if (names.length) ctx.grid.root.title = names.join(" · "); else if (tipped) ctx.grid.root.removeAttribute("title"); tipped = names.length > 0; diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 029ce0d35..13762011a 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi # ── 로직 원문(출처 글 → 품셈 본문 절) ────────────────────────────────── -_NUMBER = re.compile(r"(?~`])") # 마크다운 이스케이프 `\.` `\-` … +_COMMENT = re.compile(r"") # 글 안 주석(글자층 표시) — 글자만 남김 +_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳" + + +def _plain(text: str) -> str: + """원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `.""" + text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip() + return text[1:].strip() if text.startswith(">") else text def _blocks(lines: list[str]) -> list[dict]: @@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]: if not text or text.startswith("