버튼 수정 TemplateRenderer
Build & Deploy to K8s / build-and-deploy (push) Successful in 4m0s

This commit is contained in:
DDD1542
2026-04-20 18:32:12 +09:00
parent fbd46c53cf
commit bc66d8c549
+64 -206
View File
@@ -622,18 +622,19 @@ function LineGridView({
[layout, blocks, canvas.baseHeight],
);
// wrapper 실제 크기 측정 — ResizeObserver 로 반응형 추적
// wrapper 실제 세로 크기 측정 — ResizeObserver 로 반응형 추적
const wrapperRef = useRef<HTMLDivElement>(null);
const [containerH, setContainerH] = useState<number | null>(null);
const [containerW, setContainerW] = useState<number | null>(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<string, { width: number; height: number }>();
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<string, { x: number; y: number }>();
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<number, ButtonInfo[]>();
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<string, any>;
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 (
<div
@@ -929,7 +804,6 @@ function LineGridView({
data-comp={block.componentId}
data-col={`${bl.colStart}/${bl.colEnd}`}
data-row={`${bl.rowStart}/${bl.rowEnd}`}
data-btn-scale={dataScale}
style={{
gridColumn: `${bl.colStart} / ${bl.colEnd}`,
gridRow: `${bl.rowStart} / ${bl.rowEnd}`,
@@ -940,8 +814,7 @@ function LineGridView({
context={context}
view={view}
canvas={canvas}
compactScaleX={sx}
compactScaleY={sy}
runtimeSize={runtimeSizeById.get(block.id)}
/>
</div>
);
@@ -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}
/>
</div>
);
@@ -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 (
<Cmp
@@ -1200,7 +1059,7 @@ function BlockRenderer({
id: block.id,
componentType: block.componentId,
position,
size,
size: effectiveSize,
componentConfig: block.config,
component_config: block.config,
style: {},
@@ -1214,7 +1073,6 @@ function BlockRenderer({
context.onFormRowChange?.({ [fieldName]: value })
}
view={view}
style={componentStyle}
/>
);
}