From bc66d8c549e4c042d7f2d39de949a48f439f259a Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Mon, 20 Apr 2026 18:32:12 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=EC=88=98=EC=A0=95=20Templ?= =?UTF-8?q?ateRenderer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/dash/TemplateRenderer.tsx | 270 +++++------------- 1 file changed, 64 insertions(+), 206 deletions(-) diff --git a/frontend/components/dash/TemplateRenderer.tsx b/frontend/components/dash/TemplateRenderer.tsx index df8a45e5..edb2053a 100644 --- a/frontend/components/dash/TemplateRenderer.tsx +++ b/frontend/components/dash/TemplateRenderer.tsx @@ -622,18 +622,19 @@ function LineGridView({ [layout, blocks, canvas.baseHeight], ); - // wrapper 실제 크기 측정 — ResizeObserver 로 반응형 추적 + // wrapper 실제 세로 크기 측정 — ResizeObserver 로 반응형 추적 const wrapperRef = useRef(null); - const [containerH, setContainerH] = useState(null); - const [containerW, setContainerW] = useState(null); + const [containerSize, setContainerSize] = useState<{ + width: number; + height: number; + } | null>(null); useLayoutEffect(() => { const el = wrapperRef.current; if (!el) return; const update = () => { - const h = el.clientHeight; - const w = el.clientWidth; - if (h > 0) setContainerH(h); - if (w > 0) setContainerW(w); + const width = el.clientWidth; + const height = el.clientHeight; + if (width > 0 && height > 0) setContainerSize({ width, height }); }; update(); const ro = new ResizeObserver(update); @@ -644,15 +645,40 @@ function LineGridView({ // finalPx: containerH 측정 전이면 preferred 그대로(첫 프레임 fallback). // 측정된 뒤엔 available 에 정확히 맞도록 재분배. const finalPx = useMemo(() => { - if (containerH == null) return plans.map((p) => p.preferredPx); - return allocateRowHeightsPx(plans, containerH); - }, [plans, containerH]); + if (containerSize == null) return plans.map((p) => p.preferredPx); + return allocateRowHeightsPx(plans, containerSize.height); + }, [plans, containerSize]); const gridTemplateRows = aspectPolicy === 'free' ? layout.rows.map(() => 'auto').join(' ') : finalPx.map((px) => `${px}px`).join(' '); + const runtimeSizeById = useMemo(() => { + const m = new Map(); + const containerW = containerSize?.width ?? 0; + if (containerW <= 0) return m; + + const colWidthsPx = layout.cols.map((c) => c.weight * containerW); + + for (const bl of layout.blocks) { + if (bl.mode !== 'grid') continue; + const colStart = Math.max(1, bl.colStart ?? 1) - 1; + const colEnd = Math.max(colStart, (bl.colEnd ?? 1) - 1); + const rowStart = Math.max(1, bl.rowStart ?? 1) - 1; + const rowEnd = Math.max(rowStart, (bl.rowEnd ?? 1) - 1); + + let width = 0; + for (let i = colStart; i < colEnd; i++) width += colWidthsPx[i] ?? 0; + + let height = 0; + for (let i = rowStart; i < rowEnd; i++) height += finalPx[i] ?? 0; + + m.set(bl.blockId, { width, height }); + } + return m; + }, [layout, containerSize, finalPx]); + // 각 블록 cell 의 final 높이가 preferred 대비 많이 줄어든 경우 compact 클래스. // 기준: finalH < preferredH * 0.9 (10% 이상 축소) const blockCompact = useMemo(() => { @@ -672,152 +698,6 @@ function LineGridView({ return m; }, [layout, byId, finalPx, canvas.baseHeight]); - /** - * tight-fit button compaction — 충돌 직전 박스 자체 축소. - * - * 1) row 단위 그룹 계산 (같은 rowStart 의 button 블록 묶음) - * groupW = Σ colWeight (group bbox) × containerW - * required = Σ designW + MIN_GAP*(n-1) + OUTER_INSET*2 - * groupScaleX = groupW / required (cap 1) - * 2) 각 cell overflow 방지 - * eachScaleX = (cellW - 2*CELL_INSET) / designW - * 3) text-aware floor — 라벨/아이콘/최소 padding 을 감당할 최소 px 까지 축소 - * minWidthPx = textW + iconW + 2*MIN_PAD_X + safety (하한 80px) - * floorScaleX = minWidthPx / designW - * scaleX = min(1, max(floorScaleX, min(groupScaleX, eachScaleX))) - * 4) scaleY — row 의 final px 가 designH 보다 작을 때만 - * - * 짧은 라벨("수정","저장")은 80px 근처까지, 긴 라벨은 라벨 길이에 따라 - * 더 큰 하한을 가진다. 결과는 BlockRenderer 가 실제 width/height/padding - * px 로 반영. - */ - const buttonScales = useMemo(() => { - const m = new Map(); - if (containerW == null || containerW <= 0) return m; - const MIN_GAP = 8; - const OUTER_INSET = 6; - const CELL_INSET = 4; - const MIN_SCALE_Y = 0.8; - - // tight 상태에서 버튼이 라벨을 감당할 최소 px 폭. - // textW : 글자수 × fontSize × factor (한/영 평균 근사) - // iconW : 아이콘 + gap (아이콘 있을 때) - // padding-inline 최소 8px × 2 + safety 6px - // 절대 floor 80px — 빈/극히 짧은 라벨도 최소 80 확보 - const computeMinWidthPx = (label: string, hasIcon: boolean): number => { - const len = (label ?? '').toString().length; - const textW = Math.max(12, len * 13 * 1.05); - const iconW = hasIcon ? 16 + 6 : 0; - const MIN_PAD_X = 8; - const SAFETY = 6; - return Math.max(80, textW + iconW + 2 * MIN_PAD_X + SAFETY); - }; - - type ButtonInfo = { - id: string; - colStartIdx: number; - colEndIdx: number; - rowStartIdx: number; - rowEndIdx: number; - cellW: number; - designW: number; - designH: number; - minWidthPx: number; - }; - - // row 별 button 수집 - const byRowStart = new Map(); - for (const bl of layout.blocks) { - if (bl.mode !== 'grid') continue; - const b = byId.get(bl.blockId); - if (!b || b.componentId !== 'button') continue; - const cs = (bl.colStart ?? 1) - 1; - const ce = (bl.colEnd ?? 1) - 1; - const rs = (bl.rowStart ?? 1) - 1; - const re = (bl.rowEnd ?? 1) - 1; - if (ce <= cs || re <= rs) continue; - let cellWeight = 0; - for (let i = cs; i < ce; i++) cellWeight += layout.cols[i]?.weight ?? 0; - const cellW = containerW * cellWeight; - const cfg = (b.config ?? {}) as Record; - const label = typeof cfg.text === 'string' ? cfg.text : ''; - const hasIcon = typeof cfg.icon === 'string' && cfg.icon.length > 0; - const info: ButtonInfo = { - id: bl.blockId, - colStartIdx: cs, - colEndIdx: ce, - rowStartIdx: rs, - rowEndIdx: re, - cellW, - designW: b.wPct * canvas.baseWidth, - designH: b.hPct * canvas.baseHeight, - minWidthPx: computeMinWidthPx(label, hasIcon), - }; - const list = byRowStart.get(rs); - if (list) list.push(info); - else byRowStart.set(rs, [info]); - } - - for (const buttons of byRowStart.values()) { - if (buttons.length === 0) continue; - let minColStart = Number.POSITIVE_INFINITY; - let maxColEnd = Number.NEGATIVE_INFINITY; - for (const b of buttons) { - if (b.colStartIdx < minColStart) minColStart = b.colStartIdx; - if (b.colEndIdx > maxColEnd) maxColEnd = b.colEndIdx; - } - let groupWeightSum = 0; - for (let i = minColStart; i < maxColEnd; i++) { - groupWeightSum += layout.cols[i]?.weight ?? 0; - } - const groupW = containerW * groupWeightSum; - - const n = buttons.length; - const requiredW = - buttons.reduce((s, b) => s + b.designW, 0) + - MIN_GAP * Math.max(0, n - 1) + - OUTER_INSET * 2; - - const groupScaleX = - groupW > 0 && requiredW > 0 ? Math.min(1, groupW / requiredW) : 1; - - for (const b of buttons) { - // 각 cell overflow 방지 - const eachScaleX = - b.cellW > 0 && b.designW > 0 - ? Math.min(1, (b.cellW - 2 * CELL_INSET) / b.designW) - : 1; - // text-aware floor — 버튼별 최소폭으로 clamp - const floorX = b.designW > 0 ? b.minWidthPx / b.designW : 0; - const rawScaleX = Math.min(groupScaleX, eachScaleX); - let scaleX = Math.max(floorX, rawScaleX); - scaleX = Math.min(1, scaleX); - - // scaleY — row finalPx 합이 designH 보다 작아질 때만 - let cellH = 0; - for (let i = b.rowStartIdx; i < b.rowEndIdx; i++) { - cellH += finalPx[i] ?? 0; - } - let scaleY = 1; - if (b.designH > 0 && cellH > 0 && cellH < b.designH) { - scaleY = Math.max(MIN_SCALE_Y, cellH / b.designH); - } - - if (scaleX < 1 || scaleY < 1) { - m.set(b.id, { x: scaleX, y: scaleY }); - } - } - } - return m; - }, [ - layout, - byId, - containerW, - canvas.baseWidth, - canvas.baseHeight, - finalPx, - ]); - // 디버그 — row type / preferred / final / container / deficit·surplus useEffect(() => { if (typeof window === 'undefined' || blocks.length === 0) return; @@ -829,7 +709,7 @@ function LineGridView({ if (!enabled) return; const totalPref = plans.reduce((s, p) => s + p.preferredPx, 0); const totalFinal = finalPx.reduce((s, v) => s + v, 0); - const ch = containerH ?? 0; + const ch = containerSize?.height ?? 0; const delta = ch > 0 ? ch - totalPref : 0; /* eslint-disable no-console */ console.log( @@ -858,7 +738,7 @@ function LineGridView({ } catch { // ignore } - }, [plans, finalPx, containerH, gridTemplateRows, blocks.length]); + }, [plans, finalPx, containerSize, gridTemplateRows, blocks.length]); // 디버그 덤프 — 엔진/블록수/grid/overlay/cols/rows/tolerance + 블록별 box if (typeof window !== 'undefined' && blocks.length > 0) { @@ -916,11 +796,6 @@ function LineGridView({ const block = byId.get(bl.blockId); if (!block) return null; const compact = blockCompact.get(block.id) ? ' compact' : ''; - const btnScale = buttonScales.get(block.id); - const sx = btnScale?.x ?? 1; - const sy = btnScale?.y ?? 1; - const dataScale = - btnScale ? `${sx.toFixed(3)}/${sy.toFixed(3)}` : undefined; if (bl.mode === 'grid') { return (
); @@ -952,7 +825,6 @@ function LineGridView({ className={`itpl-overlay role-${block.role} policy-${block.responsivePolicy}${compact}`} data-comp={block.componentId} data-overlay-reason={bl.overlayReason} - data-btn-scale={dataScale} style={{ left: `${(bl.leftPct ?? 0) * 100}%`, top: `${(bl.topPct ?? 0) * 100}%`, @@ -965,8 +837,6 @@ function LineGridView({ context={context} view={view} canvas={canvas} - compactScaleX={sx} - compactScaleY={sy} /> ); @@ -1124,8 +994,7 @@ function BlockRenderer({ context, view, canvas, - compactScaleX, - compactScaleY, + runtimeSize, }: { block: BlockV2; context: TemplateRenderContext; @@ -1135,13 +1004,10 @@ function BlockRenderer({ * line 경로에서만 넘어오고, band 경로는 기존 동작(size 0) 유지 — 회귀 방지. */ canvas?: CanvasV2; - /** - * tight-fit compaction — button 전용. 가로/세로 독립 scale. - * 1 = 디자인 크기 그대로, < 1 이면 해당 축의 width/padding 을 실제 px 축소. - * transform scale 은 사용하지 않는다 — 실제 box 가 줄어야 cell 안에 들어온다. - */ - compactScaleX?: number; - compactScaleY?: number; + runtimeSize?: { + width: number; + height: number; + }; }) { const def = ComponentRegistry.getComponent(block.componentId); if (!def?.component) { @@ -1160,39 +1026,32 @@ function BlockRenderer({ const bw = canvas?.baseWidth ?? 0; const bh = canvas?.baseHeight ?? 0; - const sx = - compactScaleX && compactScaleX > 0 && compactScaleX < 1 ? compactScaleX : 1; - const sy = - compactScaleY && compactScaleY > 0 && compactScaleY < 1 ? compactScaleY : 1; const position = { x: bw > 0 ? block.xPct * bw : 0, y: bh > 0 ? block.yPct * bh : 0, z: 1, }; - // 가로/세로 각 축 독립 scale. ButtonComponent 의 useDesignPx 가 이 값을 - // 그대로 width/height/minHeight 로 박아 실제 박스 크기로 반영한다. const size = { - width: bw > 0 ? block.wPct * bw * sx : 0, - height: bh > 0 ? block.hPct * bh * sy : 0, + width: bw > 0 ? block.wPct * bw : 0, + height: bh > 0 ? block.hPct * bh : 0, }; - - // button 의 padding 축소 — margin 은 사용하지 않는다. 박스 자체를 줄인다. - // padding-inline : scaleX 비례 - // padding-block : scaleY 가 1 미만인 드문 경우에만 축소 - // font-size : 유지 - const componentStyle: React.CSSProperties = {}; - if (block.componentId === 'button') { - if (sx < 1) { - const padX = Math.max(4, Math.round(14 * sx)); - componentStyle.paddingLeft = `${padX}px`; - componentStyle.paddingRight = `${padX}px`; - } - if (sy < 1) { - const padY = Math.max(2, Math.round(6 * sy)); - componentStyle.paddingTop = `${padY}px`; - componentStyle.paddingBottom = `${padY}px`; - } - } + const isButtonLike = + block.componentId === 'button' || + block.componentId === 'button-bar' || + block.componentId === 'pagination'; + const effectiveSize = + isButtonLike && runtimeSize + ? { + width: + runtimeSize.width > 0 + ? Math.min(size.width, Math.max(0, runtimeSize.width - 2)) + : size.width, + height: + runtimeSize.height > 0 + ? Math.min(size.height, Math.max(0, runtimeSize.height - 2)) + : size.height, + } + : size; return ( ); }