diff --git a/frontend/components/dash/TemplateRenderer.tsx b/frontend/components/dash/TemplateRenderer.tsx index 736e9c45..df8a45e5 100644 --- a/frontend/components/dash/TemplateRenderer.tsx +++ b/frontend/components/dash/TemplateRenderer.tsx @@ -622,15 +622,18 @@ function LineGridView({ [layout, blocks, canvas.baseHeight], ); - // wrapper 실제 세로 크기 측정 — ResizeObserver 로 반응형 추적 + // wrapper 실제 크기 측정 — ResizeObserver 로 반응형 추적 const wrapperRef = useRef(null); const [containerH, setContainerH] = useState(null); + const [containerW, setContainerW] = useState(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); }; update(); const ro = new ResizeObserver(update); @@ -669,6 +672,152 @@ 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; @@ -767,6 +916,11 @@ 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 (
); @@ -795,6 +952,7 @@ 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}%`, @@ -807,6 +965,8 @@ function LineGridView({ context={context} view={view} canvas={canvas} + compactScaleX={sx} + compactScaleY={sy} /> ); @@ -964,6 +1124,8 @@ function BlockRenderer({ context, view, canvas, + compactScaleX, + compactScaleY, }: { block: BlockV2; context: TemplateRenderContext; @@ -973,6 +1135,13 @@ 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; }) { const def = ComponentRegistry.getComponent(block.componentId); if (!def?.component) { @@ -991,16 +1160,40 @@ 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 : 0, - height: bh > 0 ? block.hPct * bh : 0, + width: bw > 0 ? block.wPct * bw * sx : 0, + height: bh > 0 ? block.hPct * bh * sy : 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`; + } + } + return ( ); }