e44ba2953a
T3 (본체) + T4 (ConfigPanel) 흡수 완료에 따라 옛 별도 컴포넌트 폐기.
v2-split-panel-layout 은 별도 컨테이너로 유지 (Codex 권고).
폴더 5개 삭제
- v2-table-grouped/ (537 + ConfigPanel)
- v2-pivot-grid/ (1963 + utils + components + hooks + ConfigPanel)
- v2-card-display/ (1314 + ConfigPanel)
- pivot-grid/ (legacy)
- card-display/ (legacy)
ConfigPanel 3개 삭제
- V2TableGroupedConfigPanel.tsx
- V2PivotGridConfigPanel.tsx
- V2CardDisplayConfigPanel.tsx
레지스트리 / alias / hidden 정리
- lib/registry/components/index.ts: 5개 import 라인 제거 (renderer 자동 등록 폐기)
- ComponentsPanel.tsx: hidden 목록의 v2-card-display ("→ stats" 잘못된 매핑) /
v2-table-grouped / v2-pivot-grid / pivot-grid / card-display 모두 제거
- DynamicComponentRenderer.tsx LEGACY_TO_UNIFIED: v2-card-display "→stats"
(잘못) / v2-table-grouped / v2-pivot-grid / card-display alias 제거
- getComponentConfigPanel.tsx: 4개 dynamic import + alias 제거
- templateMigrate.ts: 3 매핑 제거
- componentConfig.ts: v2PivotGridOverridesSchema, v2CardDisplayOverridesSchema
+ 등록 + defaults 제거
이벤트 dead code
- types/component-events.ts: RefreshCardDisplayDetail / REFRESH_CARD_DISPLAY
이벤트 (사용처 0건 — v2-card-display 전용) 제거
ScreenDesigner
- 4058 의 isCardDisplay 분기 (66.67% 그리드 특수 처리) 제거
- 4098 의 gridColumnsRatioMap 의 "card-display" 항목 제거
검증
- npx tsc --noEmit 우리 작업 파일 새 에러 0
- v2-table-grouped/v2-pivot-grid/v2-card-display/RefreshCardDisplay 잔존
grep — 의도적 주석 5곳만 (폐기 흔적 설명)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
457 lines
15 KiB
TypeScript
457 lines
15 KiB
TypeScript
/**
|
|
* v1 → v2 템플릿 저장 포맷 마이그레이션
|
|
* ============================================================================
|
|
*
|
|
* DB 의 TEMPLATES.VIEWS jsonb 를 v2 스키마 (% 좌표 + role + responsivePolicy)
|
|
* 로 변환한다. 런타임에 로드 시 version 필드 확인 후 v1 이면 즉석 변환.
|
|
*
|
|
* role / responsivePolicy 추론은 마이그레이션 1회 규칙이다. 일단 현재 렌더
|
|
* 결과와 비슷하게 나오도록 좌표 기반 추론을 사용하되, v2 포맷으로 저장된
|
|
* 이후엔 디자이너가 명시 값으로 덮어쓰는 것을 전제로 한다.
|
|
*
|
|
* 설계 문서: notes/gbpark/2026-04-20-template-storage-model-v2.md
|
|
* ============================================================================
|
|
*/
|
|
|
|
import type {
|
|
BlockV2,
|
|
CanvasV2,
|
|
ResponsivePolicy,
|
|
TemplateViewsV2,
|
|
ViewV2,
|
|
} from '@/types/invyone-component';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 1. v1 파싱 헬퍼
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/** v1 저장본의 다양한 legacy componentId 를 통합 ID 로 정규화. */
|
|
const LEGACY_TO_UNIFIED: Record<string, string> = {
|
|
'v2-divider-line': 'divider',
|
|
'divider-line': 'divider',
|
|
'v2-split-line': 'divider',
|
|
'v2-text-display': 'title',
|
|
'text-display': 'title',
|
|
'v2-button-primary': 'button',
|
|
'button-primary': 'button',
|
|
'v2-table-search-widget': 'search',
|
|
'table-search-widget': 'search',
|
|
'v2-input': 'input',
|
|
'v2-select': 'input',
|
|
'text-input': 'input',
|
|
'number-input': 'input',
|
|
'date-input': 'input',
|
|
'select-basic': 'input',
|
|
'checkbox-basic': 'input',
|
|
'textarea-basic': 'input',
|
|
'slider-basic': 'input',
|
|
'radio-basic': 'input',
|
|
'toggle-switch': 'input',
|
|
'v2-aggregation-widget': 'stats',
|
|
'aggregation-widget': 'stats',
|
|
'v2-status-count': 'stats',
|
|
'card-display': 'stats',
|
|
'v2-table-list': 'table',
|
|
'table-list': 'table',
|
|
'v2-tabs-widget': 'container',
|
|
'v2-section-card': 'container',
|
|
'v2-section-paper': 'container',
|
|
'v2-repeat-container': 'container',
|
|
'v2-repeater': 'container',
|
|
'section-card': 'container',
|
|
'section-paper': 'container',
|
|
'accordion-basic': 'container',
|
|
};
|
|
|
|
const FULL_WIDTH_IDS = new Set([
|
|
'table',
|
|
'container',
|
|
'search',
|
|
'accordion',
|
|
'tabs',
|
|
'split-panel',
|
|
]);
|
|
|
|
const MAIN_CONTENT_IDS = new Set([
|
|
...FULL_WIDTH_IDS,
|
|
'stats',
|
|
'form',
|
|
'title',
|
|
]);
|
|
|
|
function extractIdFromUrl(url?: unknown): string {
|
|
if (typeof url !== 'string' || !url) return '';
|
|
const parts = url.split('/').filter(Boolean);
|
|
return parts[parts.length - 1] ?? '';
|
|
}
|
|
|
|
function extractComponents(viewData: unknown): any[] {
|
|
if (!viewData) return [];
|
|
if (Array.isArray(viewData)) return viewData;
|
|
if (typeof viewData === 'object' && viewData !== null) {
|
|
const v = viewData as any;
|
|
if (Array.isArray(v.components)) return v.components;
|
|
if (Array.isArray(v.blocks)) return v.blocks;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function unifiedComponentId(c: any): string {
|
|
const raw = String(
|
|
c?.componentId ??
|
|
c?.componentType ??
|
|
c?.type ??
|
|
extractIdFromUrl(c?.url) ??
|
|
c?.overrides?.type ??
|
|
'',
|
|
);
|
|
return LEGACY_TO_UNIFIED[raw] ?? raw;
|
|
}
|
|
|
|
function readPos(c: any): {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
} {
|
|
const p = c?.position ?? {};
|
|
const s = c?.size ?? {};
|
|
return {
|
|
left: Number(p.left ?? p.x ?? 0) || 0,
|
|
top: Number(p.top ?? p.y ?? 0) || 0,
|
|
width: Number(p.width ?? s.width ?? 200) || 200,
|
|
height: Number(p.height ?? s.height ?? 100) || 100,
|
|
};
|
|
}
|
|
|
|
function readConfig(c: any): Record<string, any> {
|
|
const a = c?.config;
|
|
if (a && typeof a === 'object' && !Array.isArray(a)) return a;
|
|
const b = c?.componentConfig;
|
|
if (b && typeof b === 'object' && !Array.isArray(b)) return b;
|
|
const d = c?.overrides;
|
|
if (d && typeof d === 'object' && !Array.isArray(d)) return d;
|
|
return {};
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 2. role / policy 추론 (마이그레이션 1회용)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface MainBbox {
|
|
top: number;
|
|
bottom: number;
|
|
left: number;
|
|
right: number;
|
|
}
|
|
|
|
/**
|
|
* 레거시 컴포넌트의 role 을 추론.
|
|
* - button 은 항상 action: 원본 좌표가 main band 내부에 걸쳐있어도
|
|
* "action bar" 의미로 취급한다. 이렇게 해야 TemplateRenderer 의
|
|
* content-size 정책이 버튼에 일관되게 적용된다.
|
|
* - input 은 좌표 기반 추정: main band 내부면 companion (필터 인풋 등),
|
|
* 바깥이면 action.
|
|
* - divider 는 companion.
|
|
*/
|
|
function inferRole(cid: string, pos: {
|
|
top: number;
|
|
height: number;
|
|
}, mains: MainBbox[]): BlockRole {
|
|
if (MAIN_CONTENT_IDS.has(cid)) return 'main';
|
|
if (cid === 'divider') return 'companion';
|
|
if (cid === 'button') return 'action';
|
|
|
|
if (cid === 'input') {
|
|
const centerY = pos.top + pos.height / 2;
|
|
for (const m of mains) {
|
|
if (centerY >= m.top && centerY <= m.bottom) return 'companion';
|
|
}
|
|
return 'action';
|
|
}
|
|
|
|
return 'main';
|
|
}
|
|
|
|
function inferPolicy(cid: string): ResponsivePolicy {
|
|
if (FULL_WIDTH_IDS.has(cid)) return 'scroll';
|
|
if (cid === 'stats' || cid === 'form') return 'reflow';
|
|
if (
|
|
cid === 'button' ||
|
|
cid === 'input' ||
|
|
cid === 'title' ||
|
|
cid === 'divider'
|
|
) {
|
|
return 'fixed';
|
|
}
|
|
return 'fixed';
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 3. 뷰 변환
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
interface BandBbox {
|
|
id: string;
|
|
top: number;
|
|
bottom: number;
|
|
}
|
|
|
|
function migrateView(
|
|
raw: unknown,
|
|
canvas: { width: number; height: number },
|
|
): ViewV2 {
|
|
const rawBlocks = (extractComponents(raw) as any[]).filter(
|
|
(c) => c && typeof c === 'object',
|
|
);
|
|
|
|
// 1차: 정규화 정보 수집
|
|
interface Prelim {
|
|
raw: any;
|
|
i: number;
|
|
cid: string;
|
|
pos: ReturnType<typeof readPos>;
|
|
config: Record<string, any>;
|
|
role: BlockRole;
|
|
}
|
|
|
|
const mains: MainBbox[] = [];
|
|
const prelim: Prelim[] = rawBlocks.map((c, i) => {
|
|
const cid = unifiedComponentId(c);
|
|
const pos = readPos(c);
|
|
const config = readConfig(c);
|
|
// role 은 main 수집 후에 확정해야 companion 판정 가능
|
|
return { raw: c, i, cid, pos, config, role: 'main' as BlockRole };
|
|
});
|
|
|
|
// main bbox 수집 (role 추론 재료)
|
|
for (const p of prelim) {
|
|
if (MAIN_CONTENT_IDS.has(p.cid)) {
|
|
mains.push({
|
|
top: p.pos.top,
|
|
bottom: p.pos.top + p.pos.height,
|
|
left: p.pos.left,
|
|
right: p.pos.left + p.pos.width,
|
|
});
|
|
}
|
|
}
|
|
|
|
// role 확정
|
|
for (const p of prelim) {
|
|
p.role = inferRole(p.cid, p.pos, mains);
|
|
}
|
|
|
|
// 2차: main 블록 y-overlap clustering → bandId 생성
|
|
// (normalize 단계의 1회 추론. 런타임에서는 이 bandId 를 그대로 사용)
|
|
const mainPrelims = prelim
|
|
.filter((p) => p.role === 'main')
|
|
.sort((a, b) => a.pos.top - b.pos.top);
|
|
|
|
const bandIdOf = new Map<Prelim, string>();
|
|
const bandBboxes: BandBbox[] = [];
|
|
let bandIdx = 0;
|
|
|
|
for (const m of mainPrelims) {
|
|
const myTop = m.pos.top;
|
|
const myBot = m.pos.top + m.pos.height;
|
|
// 기존 band 중 세로 overlap 이 충분한 것 찾기 (작은 쪽 h 의 30% 이상)
|
|
let matched: BandBbox | null = null;
|
|
for (const bb of bandBboxes) {
|
|
const overlap =
|
|
Math.min(bb.bottom, myBot) - Math.max(bb.top, myTop);
|
|
const smallerH = Math.min(bb.bottom - bb.top, myBot - myTop);
|
|
if (smallerH > 0 && overlap / smallerH > 0.3) {
|
|
matched = bb;
|
|
break;
|
|
}
|
|
}
|
|
if (!matched) {
|
|
matched = { id: `band-${bandIdx++}`, top: myTop, bottom: myBot };
|
|
bandBboxes.push(matched);
|
|
} else {
|
|
matched.top = Math.min(matched.top, myTop);
|
|
matched.bottom = Math.max(matched.bottom, myBot);
|
|
}
|
|
bandIdOf.set(m, matched.id);
|
|
}
|
|
|
|
// 3차: companion/action/overlay 는 가장 가까운 main band 에 합류
|
|
// - centerY 가 band 내부면 그 band
|
|
// - 아니면 가장 가까운 (top/bottom 거리 최소) band
|
|
// - main band 가 전혀 없으면 bandId 비워둠 (런타임 fallback 에 위임)
|
|
for (const p of prelim) {
|
|
if (bandIdOf.has(p)) continue;
|
|
if (bandBboxes.length === 0) continue;
|
|
const centerY = p.pos.top + p.pos.height / 2;
|
|
let target: BandBbox | null = null;
|
|
let minDist = Infinity;
|
|
for (const bb of bandBboxes) {
|
|
if (centerY >= bb.top && centerY <= bb.bottom) {
|
|
target = bb;
|
|
break;
|
|
}
|
|
const dist = Math.min(
|
|
Math.abs(centerY - bb.top),
|
|
Math.abs(centerY - bb.bottom),
|
|
);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
target = bb;
|
|
}
|
|
}
|
|
if (target) bandIdOf.set(p, target.id);
|
|
}
|
|
|
|
// 4차: BlockV2 생성
|
|
const blocks: BlockV2[] = prelim.map((p) => {
|
|
const bandId = bandIdOf.get(p);
|
|
return {
|
|
id: String(p.raw.id ?? `blk_${p.i}`),
|
|
componentId: p.cid,
|
|
xPct: canvas.width > 0 ? p.pos.left / canvas.width : 0,
|
|
yPct: canvas.height > 0 ? p.pos.top / canvas.height : 0,
|
|
wPct: canvas.width > 0 ? p.pos.width / canvas.width : 0,
|
|
hPct: canvas.height > 0 ? p.pos.height / canvas.height : 0,
|
|
role: p.role,
|
|
responsivePolicy: inferPolicy(p.cid),
|
|
...(bandId ? { bandId } : {}),
|
|
config: p.config,
|
|
};
|
|
});
|
|
|
|
return { blocks };
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 4. Public API
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
export function isV2Views(views: unknown): views is TemplateViewsV2 {
|
|
return (
|
|
!!views &&
|
|
typeof views === 'object' &&
|
|
(views as any).version === 2
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 런타임 정규화.
|
|
* 이미 v2 포맷으로 저장된 stale 데이터가 최신 규칙을 반영하지 못해 화면이
|
|
* 틀어지는 문제 방지. 현재는 한 단계로 동작:
|
|
*
|
|
* [Stage 1] componentId / responsivePolicy 보정
|
|
* - LEGACY_TO_UNIFIED 로 componentId 를 통합 ID 로 교체
|
|
* - unified ID 기준으로 inferPolicy 재적용
|
|
* - role / bandId 는 사용자가 저장한 값을 그대로 보존
|
|
*/
|
|
function normalizeRoles(v: TemplateViewsV2): TemplateViewsV2 {
|
|
const patchView = (view: ViewV2 | undefined): ViewV2 | undefined => {
|
|
if (!view) return view;
|
|
|
|
// Stage 1: componentId / policy 보정. role / bandId 는 그대로 보존.
|
|
const stage1: BlockV2[] = view.blocks.map((b) => {
|
|
const unified = LEGACY_TO_UNIFIED[b.componentId] ?? b.componentId;
|
|
const idChanged = unified !== b.componentId;
|
|
const nextPolicy = inferPolicy(unified);
|
|
const policyChanged = b.responsivePolicy !== nextPolicy;
|
|
|
|
if (!idChanged && !policyChanged) return b;
|
|
|
|
const { bandId: _drop, ...rest } = b;
|
|
return {
|
|
...rest,
|
|
componentId: unified,
|
|
responsivePolicy: nextPolicy,
|
|
};
|
|
});
|
|
return { ...view, blocks: stage1 };
|
|
};
|
|
return {
|
|
...v,
|
|
list: patchView(v.list)!,
|
|
...(v.create ? { create: patchView(v.create)! } : {}),
|
|
...(v.edit ? { edit: patchView(v.edit)! } : {}),
|
|
};
|
|
}
|
|
|
|
export function migrateV1toV2(v1Views: unknown): TemplateViewsV2 {
|
|
const root = (v1Views ?? {}) as any;
|
|
const sr = root.screenResolution ?? root.designSize ?? {};
|
|
const defaultCanvas: CanvasV2 = {
|
|
baseWidth: Number(sr.width) || 1920,
|
|
baseHeight: Number(sr.height) || 1080,
|
|
aspectPolicy: 'preserve',
|
|
};
|
|
|
|
// 뷰별 해상도 파싱 — 빌더(ScreenDesigner)가 저장한 viewScreenResolutions
|
|
// (JSON 키는 screenResolutions). 없는 뷰는 defaultCanvas 로 폴백.
|
|
const srs = root.screenResolutions ?? root.screen_resolutions;
|
|
const makeCanvas = (v: any): CanvasV2 | undefined => {
|
|
if (!v) return undefined;
|
|
const w = Number(v.width);
|
|
const h = Number(v.height);
|
|
if (!isFinite(w) || !isFinite(h) || w <= 0 || h <= 0) return undefined;
|
|
return { baseWidth: w, baseHeight: h, aspectPolicy: 'preserve' };
|
|
};
|
|
const listCanvas = makeCanvas(srs?.list) ?? defaultCanvas;
|
|
const createCanvas = makeCanvas(srs?.create) ?? defaultCanvas;
|
|
const editCanvas = makeCanvas(srs?.edit) ?? defaultCanvas;
|
|
|
|
// 각 뷰의 컴포넌트 좌표는 자기 canvas 기준으로 정규화.
|
|
// (빌더가 activeView 별 screenResolution 으로 px 를 저장하므로, 그 기준으로 나눠야 정확.)
|
|
const list = migrateView(root.list, {
|
|
width: listCanvas.baseWidth,
|
|
height: listCanvas.baseHeight,
|
|
});
|
|
const create = root.create
|
|
? migrateView(root.create, {
|
|
width: createCanvas.baseWidth,
|
|
height: createCanvas.baseHeight,
|
|
})
|
|
: undefined;
|
|
const edit = root.edit
|
|
? migrateView(root.edit, {
|
|
width: editCanvas.baseWidth,
|
|
height: editCanvas.baseHeight,
|
|
})
|
|
: undefined;
|
|
|
|
const viewCanvases: TemplateViewsV2['viewCanvases'] = {
|
|
...(makeCanvas(srs?.list) ? { list: listCanvas } : {}),
|
|
...(makeCanvas(srs?.create) ? { create: createCanvas } : {}),
|
|
...(makeCanvas(srs?.edit) ? { edit: editCanvas } : {}),
|
|
};
|
|
|
|
return {
|
|
version: 2,
|
|
canvas: defaultCanvas,
|
|
list,
|
|
...(create ? { create } : {}),
|
|
...(edit ? { edit } : {}),
|
|
...(viewCanvases && Object.keys(viewCanvases).length > 0
|
|
? { viewCanvases }
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 로드 시점 정규화 헬퍼 — v1 이든 v2 이든 받아서 항상 v2 로 반환.
|
|
* 기존 v2 저장본이라도 현재 role 규칙(button→action 등)에 맞춰 재정규화한다.
|
|
*/
|
|
export function ensureV2Views(views: unknown): TemplateViewsV2 {
|
|
const v2 = isV2Views(views) ? views : migrateV1toV2(views);
|
|
return normalizeRoles(v2);
|
|
}
|
|
|
|
/**
|
|
* template 객체의 views 필드를 v2 로 정규화한 사본을 반환.
|
|
* template 자체는 변형하지 않는다.
|
|
*/
|
|
export function ensureTemplateV2(template: any): any {
|
|
if (!template) return template;
|
|
const views = template.views ?? template.VIEWS;
|
|
if (!views) return template;
|
|
const v2 = ensureV2Views(views);
|
|
return { ...template, views: v2 };
|
|
}
|