Files
ai-code-app/src/views/play/LayoutPlaygroundView.tsx

3128 lines
118 KiB
TypeScript

import {
AppstoreOutlined,
CaretDownOutlined,
CaretLeftOutlined,
CaretRightOutlined,
CaretUpOutlined,
CloseOutlined,
ColumnHeightOutlined,
ColumnWidthOutlined,
CopyOutlined,
DeploymentUnitOutlined,
DeleteOutlined,
DragOutlined,
EditOutlined,
MessageOutlined,
ReloadOutlined,
SaveOutlined,
} from '@ant-design/icons';
import { Button, Card, Divider, Input, InputNumber, List, Popconfirm, Select, Space, Splitter, Switch, Tag, Typography, message } from 'antd';
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { useNavigate } from 'react-router-dom';
import { SearchCommandModal, type SearchKeywordOption } from '../../components/search';
import { componentSampleEntries, widgetSampleEntries } from '../../app/manifests/samples.manifest';
import { createChatConversationRoom, fetchChatConversations } from '../../app/main/mainChatPanel';
import { resolveChatWebSocketUrl } from '../../app/main/mainChatPanel/chatUtils';
import { buildChatPath } from '../../app/main/routes';
import { canUseChatType, resolveCurrentChatPermissionRoles, useChatTypeRegistry } from '../../app/main/chatTypeAccess';
import { useTokenAccess } from '../../app/main/tokenAccess';
import type { SelectOptionItem } from '../../components/inputs/select';
import { StockAlertFilterPane, StockAlertGridPane, StockAlertLayoutProvider } from '../../features/layout/stock-alert';
import { resolveSampleEntries, type LoadedSampleEntry } from '../../samples/registry';
import { deleteLayout, listSavedLayouts, saveLayout, type LayoutAxis as StoredLayoutAxis, type SavedLayoutRecord, type SizeUnit as StoredSizeUnit } from './layoutStorage';
import {
LayoutPreviewActionPane,
LayoutPreviewBaseInputPane,
LayoutPreviewEmptyPane,
LayoutPreviewSelectPane,
LayoutPreviewTextMemoPane,
} from './LayoutPreviewWidgets';
import {
resolveLayoutPreviewBindingKind,
useLayoutPreviewRuntime,
type LayoutPreviewInteractionRule,
} from './layoutPreviewRuntime';
import './LayoutPlaygroundView.css';
const { Paragraph, Text, Title } = Typography;
type LayoutAxis = StoredLayoutAxis;
type SizeUnit = StoredSizeUnit;
type PaneTone = 'primary' | 'secondary' | 'accent' | 'neutral';
type LayoutCollapseDirection = 'auto' | 'left' | 'right' | 'up' | 'down';
type LayoutComponentBinding = {
optionId: string;
label: string;
description?: string;
keywords: string[];
};
type LayoutLeafNode = {
id: string;
type: 'leaf';
label: string;
tone: PaneTone;
showHideAction: boolean;
collapseDirection?: LayoutCollapseDirection;
useContentHeight: boolean;
componentBinding: LayoutComponentBinding | null;
};
type LayoutSplitNode = {
id: string;
type: 'split';
axis: LayoutAxis;
sizeUnit: SizeUnit;
sizeTarget: 'primary' | 'secondary';
primarySize: number;
primaryMin: number;
secondaryMin: number;
resizable: boolean;
previewSizes: number[];
children: [LayoutNode, LayoutNode];
};
type LayoutNode = LayoutLeafNode | LayoutSplitNode;
type LayoutToggleTarget = {
id: string;
childIndex: number;
label: string;
collapsed: boolean;
collapseDirection: LayoutCollapseDirection;
};
type LayoutInteractionRule = {
id: string;
sourceLeafId: string;
targetLeafId: string;
sourceComponent: LayoutComponentBinding | null;
targetComponent: LayoutComponentBinding | null;
title: string;
description: string;
implementationNotes: string;
};
type LayoutStoredPayload = {
root: LayoutNode | null;
interactions: LayoutInteractionRule[];
interactionMode?: 'scoped-v2';
};
type LayoutInteractionDraft = {
sourceLeafId: string;
targetLeafId: string;
sourceComponentOptionId: string;
targetComponentOptionId: string;
title: string;
description: string;
implementationNotes: string;
};
type SplitEditorState = {
sizeUnit: SizeUnit;
primarySize: number;
primaryMin: number;
secondaryMin: number;
resizable: boolean;
};
type SplitContext = {
split: LayoutSplitNode;
childIndex: 0 | 1;
};
type LayoutPaneSizingMeta = {
axis: LayoutAxis;
sizeSummary: string;
};
type LayoutPlaygroundViewProps = {
savedLayoutViewId?: string | null;
showSavedLayoutsOnly?: boolean;
onSavedLayoutsChange?: (layouts: SavedLayoutRecord[]) => void;
};
function buildSampleOptionId(entry: LoadedSampleEntry) {
return `${entry.modulePath.includes('/widgets/') ? 'widget' : 'component'}:${entry.sampleMeta.componentId}:${entry.sampleMeta.id}`;
}
function resolveSearchOptionLabel(entry: LoadedSampleEntry, duplicatedTitles: Set<string>) {
if (!duplicatedTitles.has(entry.sampleMeta.title)) {
return entry.sampleMeta.title;
}
if (entry.sampleMeta.variantLabel?.trim()) {
return `${entry.sampleMeta.title} · ${entry.sampleMeta.variantLabel.trim()}`;
}
if (entry.sampleMeta.kind?.trim()) {
return `${entry.sampleMeta.title} · ${entry.sampleMeta.kind.trim()}`;
}
return entry.sampleMeta.title;
}
const EMPTY_LAYOUT_TREE = {
type: 'empty-layout',
} as const;
const PRESET_LABELS: Record<LayoutAxis, string> = {
horizontal: '좌우 분할',
vertical: '상하 분할',
};
const COLLAPSE_DIRECTION_LABELS: Record<LayoutCollapseDirection, string> = {
auto: '자동',
left: '좌',
right: '우',
up: '상',
down: '하',
};
const TONE_CLASS_NAMES: Record<PaneTone, string> = {
primary: 'layout-playground__pane--primary',
secondary: 'layout-playground__pane--secondary',
accent: 'layout-playground__pane--accent',
neutral: 'layout-playground__pane--neutral',
};
const LEAF_PRESETS: Array<Pick<LayoutLeafNode, 'label' | 'tone'>> = [
{
label: 'Primary Pane',
tone: 'primary',
},
{
label: 'Secondary Pane',
tone: 'secondary',
},
{
label: 'Nested Pane',
tone: 'accent',
},
{
label: 'Utility Pane',
tone: 'neutral',
},
];
const DEFAULT_COMBO_VALUE_OPTIONS: SelectOptionItem[] = [
{ code: 'C001', value: '일반' },
{ code: 'C002', value: 'Plan' },
];
function resolveLayoutConversationTitle(layoutName: string) {
const normalizedLayoutName = layoutName.trim() || 'Layout Editor';
return `Layout-${normalizedLayoutName}`;
}
function isReusableLayoutConversation(
conversation: {
title: string;
chatTypeId: string | null;
lastChatTypeId: string | null;
},
title: string,
chatTypeId: string,
) {
return (
conversation.title.trim() === title &&
(conversation.chatTypeId === chatTypeId || conversation.lastChatTypeId === chatTypeId)
);
}
function parseSelectOptionItem(line: string): SelectOptionItem | null {
const normalized = line.trim().replace(/\s+/gu, ' ');
if (!normalized) {
return null;
}
const match =
normalized.match(/^([A-Z][A-Z0-9_-]*\d[A-Z0-9_-]*)\s*-\s*(.+)$/u) ??
normalized.match(/^([A-Z][A-Z0-9_-]*\d[A-Z0-9_-]*)\s+(.+)$/u);
if (!match) {
return null;
}
const [, code, value] = match;
const trimmedValue = value.trim();
if (!trimmedValue) {
return null;
}
return { code, value: trimmedValue };
}
function resolveSelectPreviewOptions(rules: LayoutInteractionRule[]) {
const parsedOptions = rules.flatMap((rule) =>
rule.description
.split(/\r?\n/u)
.map(parseSelectOptionItem)
.filter(Boolean) as SelectOptionItem[],
);
if (!parsedOptions.length) {
return DEFAULT_COMBO_VALUE_OPTIONS;
}
const seenCodes = new Set<string>();
return parsedOptions.filter((item) => {
if (seenCodes.has(item.code)) {
return false;
}
seenCodes.add(item.code);
return true;
});
}
function clampPercent(value: number) {
return Math.min(90, Math.max(10, value));
}
function clampPixel(value: number) {
return Math.min(960, Math.max(1, value));
}
function normalizeValue(value: number, unit: SizeUnit) {
return unit === '%' ? clampPercent(value) : clampPixel(value);
}
function isStockAlertLayoutRecord(record: SavedLayoutRecord | null | undefined) {
return record?.name.trim() === 'stock알림';
}
function formatPanelValue(value: number, unit: SizeUnit) {
return unit === '%' ? `${clampPercent(value)}%` : `${clampPixel(value)}px`;
}
function toSplitterSize(value: number, unit: SizeUnit) {
return unit === '%' ? `${clampPercent(value)}%` : clampPixel(value);
}
function countLeafNodes(node: LayoutNode): number {
if (node.type === 'leaf') {
return 1;
}
return countLeafNodes(node.children[0]) + countLeafNodes(node.children[1]);
}
function findLeafNode(node: LayoutNode, targetId: string): LayoutLeafNode | null {
if (node.type === 'leaf') {
return node.id === targetId ? node : null;
}
return findLeafNode(node.children[0], targetId) ?? findLeafNode(node.children[1], targetId);
}
function findSplitPath(node: LayoutNode, targetLeafId: string, trail: SplitContext[] = []): SplitContext[] | null {
if (node.type === 'leaf') {
return node.id === targetLeafId ? trail : null;
}
return (
findSplitPath(node.children[0], targetLeafId, [...trail, { split: node, childIndex: 0 }]) ??
findSplitPath(node.children[1], targetLeafId, [...trail, { split: node, childIndex: 1 }])
);
}
function getSizeTargetIndex(node: LayoutSplitNode): 0 | 1 {
return node.sizeTarget === 'secondary' ? 1 : 0;
}
function resolveSelectedSectionSize(node: LayoutSplitNode, childIndex: 0 | 1) {
if (getSizeTargetIndex(node) === childIndex) {
return node.primarySize;
}
if (node.sizeUnit === '%') {
return clampPercent(100 - node.primarySize);
}
if (node.previewSizes.length === 2) {
return clampPixel(node.previewSizes[childIndex]);
}
return childIndex === 0 ? clampPixel(node.primaryMin) : clampPixel(node.secondaryMin);
}
function resolveSplitEditorState(node: LayoutSplitNode, childIndex: 0 | 1): SplitEditorState {
return {
sizeUnit: node.sizeUnit,
primarySize: resolveSelectedSectionSize(node, childIndex),
primaryMin: childIndex === 0 ? node.primaryMin : node.secondaryMin,
secondaryMin: childIndex === 0 ? node.secondaryMin : node.primaryMin,
resizable: node.resizable,
};
}
function collectSplitSummaries(node: LayoutNode, entries: string[] = [], depth = 1) {
if (node.type === 'leaf') {
return entries;
}
const sizeLabel = formatPanelValue(node.primarySize, node.sizeUnit);
const sizeTargetLabel = getSizeTargetIndex(node) === 0 ? '1번 section 고정' : '2번 section 고정';
const sizeSummary =
node.previewSizes.length === 2
? `${Math.round(node.previewSizes[0])} / ${Math.round(node.previewSizes[1])}`
: `${sizeLabel} / auto`;
entries.push(
`depth ${depth}: ${PRESET_LABELS[node.axis]}, ${sizeTargetLabel} ${sizeLabel}, min ${formatPanelValue(node.primaryMin, node.sizeUnit)} / ${formatPanelValue(node.secondaryMin, node.sizeUnit)}, resize ${node.resizable ? 'on' : 'off'}, preview ${sizeSummary}`,
);
collectSplitSummaries(node.children[0], entries, depth + 1);
collectSplitSummaries(node.children[1], entries, depth + 1);
return entries;
}
function resolveAutoCollapseDirection(axis: LayoutAxis, childIndex: number): Exclude<LayoutCollapseDirection, 'auto'> {
if (axis === 'horizontal') {
return childIndex === 0 ? 'left' : 'right';
}
return childIndex === 0 ? 'up' : 'down';
}
function resolveCollapseDirection(
axis: LayoutAxis,
childIndex: number,
preferredDirection?: LayoutCollapseDirection,
): Exclude<LayoutCollapseDirection, 'auto'> {
if (preferredDirection && preferredDirection !== 'auto') {
return preferredDirection;
}
return resolveAutoCollapseDirection(axis, childIndex);
}
function renderCollapseMovementIcon(direction: Exclude<LayoutCollapseDirection, 'auto'>, collapsed: boolean) {
if (direction === 'left') {
return collapsed ? <CaretRightOutlined /> : <CaretLeftOutlined />;
}
if (direction === 'right') {
return collapsed ? <CaretLeftOutlined /> : <CaretRightOutlined />;
}
if (direction === 'up') {
return collapsed ? <CaretDownOutlined /> : <CaretUpOutlined />;
}
return collapsed ? <CaretUpOutlined /> : <CaretDownOutlined />;
}
function updateSplitNode(node: LayoutNode, targetId: string, updater: (current: LayoutSplitNode) => LayoutSplitNode): LayoutNode {
if (node.type === 'leaf') {
return node;
}
if (node.id === targetId) {
return updater(node);
}
return {
...node,
children: [
updateSplitNode(node.children[0], targetId, updater),
updateSplitNode(node.children[1], targetId, updater),
],
};
}
function updateLeafNode(node: LayoutNode, targetId: string, updater: (current: LayoutLeafNode) => LayoutLeafNode): LayoutNode {
if (node.type === 'leaf') {
return node.id === targetId ? updater(node) : node;
}
return {
...node,
children: [
updateLeafNode(node.children[0], targetId, updater),
updateLeafNode(node.children[1], targetId, updater),
],
};
}
function splitLeafNode(
node: LayoutNode,
targetId: string,
createSplit: (existingLeaf: LayoutLeafNode) => { splitNode: LayoutSplitNode; selectedLeafId: string },
): { nextNode: LayoutNode; selectedLeafId: string | null } {
if (node.type === 'leaf') {
if (node.id !== targetId) {
return { nextNode: node, selectedLeafId: null };
}
const next = createSplit(node);
return { nextNode: next.splitNode, selectedLeafId: next.selectedLeafId };
}
const leftResult = splitLeafNode(node.children[0], targetId, createSplit);
if (leftResult.selectedLeafId) {
return {
nextNode: {
...node,
children: [leftResult.nextNode, node.children[1]],
},
selectedLeafId: leftResult.selectedLeafId,
};
}
const rightResult = splitLeafNode(node.children[1], targetId, createSplit);
if (rightResult.selectedLeafId) {
return {
nextNode: {
...node,
children: [node.children[0], rightResult.nextNode],
},
selectedLeafId: rightResult.selectedLeafId,
};
}
return { nextNode: node, selectedLeafId: null };
}
function collectNodeIds(node: LayoutNode, ids: string[] = []) {
ids.push(node.id);
if (node.type === 'split') {
collectNodeIds(node.children[0], ids);
collectNodeIds(node.children[1], ids);
}
return ids;
}
function resolveNextNodeSequence(node: LayoutNode | null) {
if (!node) {
return 0;
}
return collectNodeIds(node).reduce((max, id) => {
const matched = id.match(/layout-node-(\d+)/);
const numericId = matched ? Number(matched[1]) : 0;
return Math.max(max, numericId);
}, 0);
}
function resolveSavedLayoutGrid(count: number) {
if (count <= 1) {
return { columns: 1, rows: 1 };
}
const columns = Math.ceil(Math.sqrt(count));
const rows = Math.ceil(count / columns);
return { columns, rows };
}
function isLayoutLeafNode(value: unknown): value is LayoutLeafNode {
return Boolean(value) && typeof value === 'object' && (value as { type?: unknown }).type === 'leaf';
}
function isLayoutSplitNode(value: unknown): value is LayoutSplitNode {
return Boolean(value) && typeof value === 'object' && (value as { type?: unknown }).type === 'split';
}
function normalizeStoredTree(value: unknown): LayoutNode | null {
if (isLayoutLeafNode(value)) {
return {
...value,
collapseDirection:
value.collapseDirection === 'left' ||
value.collapseDirection === 'right' ||
value.collapseDirection === 'up' ||
value.collapseDirection === 'down'
? value.collapseDirection
: 'auto',
useContentHeight: value.useContentHeight === true,
};
}
if (isLayoutSplitNode(value)) {
const splitValue = value as Partial<LayoutSplitNode>;
const normalizedChildren = Array.isArray(splitValue.children)
? [normalizeStoredTree(splitValue.children[0]), normalizeStoredTree(splitValue.children[1])]
: [null, null];
if (!normalizedChildren[0] || !normalizedChildren[1]) {
return null;
}
return {
...splitValue,
sizeTarget: splitValue.sizeTarget === 'secondary' ? 'secondary' : 'primary',
previewSizes: Array.isArray(splitValue.previewSizes) ? splitValue.previewSizes : [],
children: [normalizedChildren[0], normalizedChildren[1]],
} as LayoutSplitNode;
}
return null;
}
function isLayoutStoredPayload(value: unknown): value is LayoutStoredPayload {
return Boolean(value) && typeof value === 'object' && ('root' in (value as Record<string, unknown>) || 'interactions' in (value as Record<string, unknown>));
}
function normalizeInteractionRule(value: unknown): LayoutInteractionRule | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Partial<LayoutInteractionRule>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const sourceLeafId = typeof candidate.sourceLeafId === 'string' ? candidate.sourceLeafId.trim() : '';
const targetLeafId = typeof candidate.targetLeafId === 'string' ? candidate.targetLeafId.trim() : '';
const sourceComponent = normalizeInteractionComponentBinding(
(candidate as { sourceComponent?: unknown }).sourceComponent,
);
const targetComponent = normalizeInteractionComponentBinding(
(candidate as { targetComponent?: unknown }).targetComponent,
);
const title = typeof candidate.title === 'string' ? candidate.title.trim() : '';
const description = typeof candidate.description === 'string' ? candidate.description.trim() : '';
const implementationNotes =
typeof (candidate as { implementationNotes?: unknown }).implementationNotes === 'string'
? String((candidate as { implementationNotes?: unknown }).implementationNotes).trim()
: '';
if (!id || !title || !description) {
return null;
}
return {
id,
sourceLeafId,
targetLeafId,
sourceComponent,
targetComponent,
title,
description,
implementationNotes,
};
}
function normalizeInteractionComponentBinding(value: unknown): LayoutComponentBinding | null {
if (!value || typeof value !== 'object') {
return null;
}
const candidate = value as Partial<LayoutComponentBinding>;
const optionId = typeof candidate.optionId === 'string' ? candidate.optionId.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
if (!optionId || !label) {
return null;
}
return {
optionId,
label,
description: typeof candidate.description === 'string' ? candidate.description.trim() : undefined,
keywords: Array.isArray(candidate.keywords) ? candidate.keywords.filter((keyword): keyword is string => typeof keyword === 'string') : [],
};
}
function describeInteractionScope(rule: LayoutInteractionRule, leafMap: Map<string, LayoutLeafNode>) {
const sourceLabel = rule.sourceLeafId ? (leafMap.get(rule.sourceLeafId)?.label ?? rule.sourceLeafId) : '';
const targetLabel = rule.targetLeafId ? (leafMap.get(rule.targetLeafId)?.label ?? rule.targetLeafId) : '';
if (sourceLabel && targetLabel) {
return `${sourceLabel} -> ${targetLabel}`;
}
if (sourceLabel) {
return `${sourceLabel} 기준`;
}
if (targetLabel) {
return `${targetLabel} 적용`;
}
return '전체 레이아웃';
}
function describeInteractionComponents(rule: LayoutInteractionRule, leafMap: Map<string, LayoutLeafNode>) {
const sourceComponent =
rule.sourceComponent?.label?.trim() ??
(rule.sourceLeafId ? leafMap.get(rule.sourceLeafId)?.componentBinding?.label?.trim() ?? '' : '');
const targetComponent =
rule.targetComponent?.label?.trim() ??
(rule.targetLeafId ? leafMap.get(rule.targetLeafId)?.componentBinding?.label?.trim() ?? '' : '');
const componentLabels = [sourceComponent, targetComponent].filter(Boolean);
if (!componentLabels.length) {
return '컴포넌트 지정 없음';
}
return Array.from(new Set(componentLabels)).join(' / ');
}
function describeComponentBindingForCodex(binding: LayoutComponentBinding | null) {
if (!binding) {
return '컴포넌트 미지정';
}
const label = binding.label.trim();
return label || '컴포넌트 연결됨';
}
function collectLeafPromptEntries(node: LayoutNode | null, path: number[] = [], entries: string[] = []) {
if (!node) {
return entries;
}
if (node.type === 'leaf') {
entries.push(
`pane path=${path.length ? path.join('.') : 'root'} label=${node.label} component=${describeComponentBindingForCodex(node.componentBinding)}`,
);
return entries;
}
collectLeafPromptEntries(node.children[0], [...path, 0], entries);
collectLeafPromptEntries(node.children[1], [...path, 1], entries);
return entries;
}
function buildCodexRequestText(args: {
layoutName: string;
rules: LayoutInteractionRule[];
leafMap: Map<string, LayoutLeafNode>;
root: LayoutNode | null;
}) {
const title = args.layoutName.trim() || '이름 없는 레이아웃';
const normalizedRules = args.rules.filter((rule) => rule.title.trim() && rule.description.trim());
const splitSummaryEntries = args.root ? collectSplitSummaries(args.root) : [];
const paneSummaryEntries = collectLeafPromptEntries(args.root);
const structureSummary = args.root
? [
'현재 레이아웃 구조:',
`- pane 수: ${countLeafNodes(args.root)}`,
...splitSummaryEntries.map((entry, index) => `- split ${index + 1}: ${entry}`),
...paneSummaryEntries.map((entry) => `- ${entry}`),
].join('\n')
: '현재 레이아웃 구조:\n1. 아직 생성된 레이아웃이 없습니다.';
const featureBody = normalizedRules.length
? normalizedRules
.map((rule, index) => {
const scope = describeInteractionScope(rule, args.leafMap);
const components = describeInteractionComponents(rule, args.leafMap);
const implementationNotes = rule.implementationNotes.trim();
return [
`${index + 1}. 기능 제목: ${rule.title.trim()}`,
`설명: ${rule.description.trim()}`,
`적용 범위: ${scope}`,
`관련 컴포넌트: ${components}`,
implementationNotes ? `hooks/구현 메모: ${implementationNotes}` : 'hooks/구현 메모: 필요 시 적절히 설계',
'이 기능을 구현할 때 필요한 컴포넌트 간 이벤트 연결, 상태 전달, API 호출/응답 연결은 허용되며 적극 반영',
'다른 기능 항목과 무관한 상태나 이벤트만 불필요하게 섞이지 않게 정리',
].join('\n');
})
.join('\n\n')
: '등록된 기능 명세가 아직 없습니다. 자동으로 기능을 채우지 말고 필요한 항목만 직접 정의해 주세요.';
return [
`레이아웃 이름: ${title}`,
'아래 저장된 레이아웃 명세를 기준으로 프런트엔드 구현을 진행해 주세요.',
'범위: React UI, 컴포넌트 상호작용, hooks, 클라이언트 상태, 컴포넌트 간 연결, API 연동',
'완성된 레이아웃은 실제 메뉴 화면으로 바로 사용되므로 설명문, 구조 해설, 임시 안내 UI가 보이면 안 됩니다.',
'저장된 split 구조와 pane 수를 절대 바꾸지 말고, 보이지 않는 설명 전용 영역이나 새 section을 추가하지 마세요.',
'레이아웃 안에 기능 설명용 badge, tag, 보조 라벨, 상태 문구, 상단 안내문, 요약 헤더, pane 설명 박스, 구조 표시를 임의로 추가하지 말고 요청한 실제 UI만 배치해 주세요.',
'기능 설명, 레이아웃 설명, 구현 메모는 화면 텍스트로 출력하지 말고 동작 구현에만 사용해 주세요.',
'컴포넌트 설명에 combo/select 같은 입력 방식이 적혀 있으면 그 동작과 값 표현을 그대로 따르고 다른 UI 형태로 바꾸지 마세요.',
structureSummary,
'요구사항:',
featureBody,
'',
'응답에는 구현 계획보다 실제 코드 변경을 우선 포함하고, 각 기능은 서로 간섭되지 않게 처리해 주세요.',
]
.filter(Boolean)
.join('\n');
}
function resolveLayoutCodexRequestSocketUrl(sessionId: string) {
const resolvedUrl = new URL(resolveChatWebSocketUrl(sessionId));
if (typeof window === 'undefined') {
return resolvedUrl.toString();
}
if (['127.0.0.1', 'localhost', '0.0.0.0'].includes(window.location.hostname)) {
resolvedUrl.protocol = 'wss:';
resolvedUrl.hostname = 'preview.sm-home.cloud';
resolvedUrl.port = '';
resolvedUrl.pathname = '/ws/chat';
}
return resolvedUrl.toString();
}
function normalizeStoredLayoutPayload(value: unknown): LayoutStoredPayload {
if (isLayoutStoredPayload(value)) {
const payload = value as Partial<LayoutStoredPayload>;
return {
root: normalizeStoredTree(payload.root ?? null),
interactions: payload.interactionMode === 'scoped-v2' && Array.isArray(payload.interactions)
? payload.interactions.map(normalizeInteractionRule).filter(Boolean) as LayoutInteractionRule[]
: [],
};
}
return {
root: normalizeStoredTree(value),
interactions: [],
};
}
function collectLeafNodes(node: LayoutNode | null, leaves: LayoutLeafNode[] = []) {
if (!node) {
return leaves;
}
if (node.type === 'leaf') {
leaves.push(node);
return leaves;
}
collectLeafNodes(node.children[0], leaves);
collectLeafNodes(node.children[1], leaves);
return leaves;
}
function scopeLayoutLeafId(scopeKey: string, leafId: string) {
return `${scopeKey}:${leafId}`;
}
function scopeLayoutInteractionRules(scopeKey: string, rules: LayoutInteractionRule[]): LayoutPreviewInteractionRule[] {
return rules
.filter((rule) => rule.sourceLeafId.trim() && rule.targetLeafId.trim())
.map((rule) => ({
sourceLeafId: scopeLayoutLeafId(scopeKey, rule.sourceLeafId),
targetLeafId: scopeLayoutLeafId(scopeKey, rule.targetLeafId),
}));
}
function describePaneSizing(split: LayoutSplitNode, childIndex: 0 | 1): string {
const axisLabel = split.axis === 'horizontal' ? '좌우 분할' : '상하 분할';
const ownSize = formatPanelValue(resolveSelectedSectionSize(split, childIndex), split.sizeUnit);
const ownMin = formatPanelValue(childIndex === 0 ? split.primaryMin : split.secondaryMin, split.sizeUnit);
return `${axisLabel} · 현재 ${ownSize} · 최소 ${ownMin} · ${split.resizable ? '리사이즈 가능' : '리사이즈 고정'}`;
}
function LayoutSamplePreview({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
export function LayoutPlaygroundView({
savedLayoutViewId = null,
showSavedLayoutsOnly = false,
onSavedLayoutsChange,
}: LayoutPlaygroundViewProps) {
const navigate = useNavigate();
const { chatTypes } = useChatTypeRegistry();
const { hasAccess } = useTokenAccess();
const [messageApi, messageContextHolder] = message.useMessage();
const [isMobileViewport, setIsMobileViewport] = useState(() => {
if (typeof window === 'undefined') {
return false;
}
return window.innerWidth <= 768;
});
const [axis, setAxis] = useState<LayoutAxis>('horizontal');
const [splitEditor, setSplitEditor] = useState<SplitEditorState>({
sizeUnit: '%',
primarySize: 42,
primaryMin: 24,
secondaryMin: 20,
resizable: true,
});
const [layoutName, setLayoutName] = useState('');
const [savedLayouts, setSavedLayouts] = useState<SavedLayoutRecord[]>([]);
const [isSaving, setIsSaving] = useState(false);
const [isLoadingSavedLayouts, setIsLoadingSavedLayouts] = useState(true);
const [activeSavedLayoutId, setActiveSavedLayoutId] = useState<string | null>(null);
const [isSavedLayoutsOpen, setIsSavedLayoutsOpen] = useState(false);
const [savedLayoutPreviewId, setSavedLayoutPreviewId] = useState<string | null>(null);
const [sampleEntries, setSampleEntries] = useState<LoadedSampleEntry[]>([]);
const [isComponentSelectorOpen, setIsComponentSelectorOpen] = useState(false);
const [componentSelectorLeafId, setComponentSelectorLeafId] = useState<string | null>(null);
const [collapsedLeafIds, setCollapsedLeafIds] = useState<string[]>([]);
const [measuredContentHeights, setMeasuredContentHeights] = useState<Record<string, number>>({});
const [interactionRules, setInteractionRules] = useState<LayoutInteractionRule[]>([]);
const [interactionDraft, setInteractionDraft] = useState<LayoutInteractionDraft>({
sourceLeafId: '',
targetLeafId: '',
sourceComponentOptionId: '',
targetComponentOptionId: '',
title: '',
description: '',
implementationNotes: '',
});
const [editingInteractionRuleId, setEditingInteractionRuleId] = useState<string | null>(null);
const [activeInteractionIds, setActiveInteractionIds] = useState<string[]>([]);
const nodeIdRef = useRef(0);
const leafPresetRef = useRef(0);
const contentHeightObserverRef = useRef(new Map<string, ResizeObserver>());
const nextNodeId = () => {
nodeIdRef.current += 1;
return `layout-node-${nodeIdRef.current}`;
};
const chatPermissionRoles = useMemo(
() => resolveCurrentChatPermissionRoles(hasAccess),
[hasAccess],
);
const availableChatTypes = useMemo(
() => chatTypes.filter((item) => canUseChatType(item, chatPermissionRoles)),
[chatPermissionRoles, chatTypes],
);
const [selectedCodexChatTypeId, setSelectedCodexChatTypeId] = useState<string | null>(null);
const selectedCodexChatType = useMemo(
() => availableChatTypes.find((item) => item.id === selectedCodexChatTypeId) ?? availableChatTypes[0] ?? null,
[availableChatTypes, selectedCodexChatTypeId],
);
useEffect(() => {
if (availableChatTypes.length === 0) {
setSelectedCodexChatTypeId(null);
return;
}
if (selectedCodexChatTypeId && availableChatTypes.some((item) => item.id === selectedCodexChatTypeId)) {
return;
}
setSelectedCodexChatTypeId(availableChatTypes[0]?.id ?? null);
}, [availableChatTypes, selectedCodexChatTypeId]);
const createLeafNode = (): LayoutLeafNode => {
const preset = LEAF_PRESETS[leafPresetRef.current % LEAF_PRESETS.length];
leafPresetRef.current += 1;
return {
id: nextNodeId(),
type: 'leaf',
...preset,
showHideAction: false,
collapseDirection: 'auto',
useContentHeight: false,
componentBinding: null,
};
};
const createSplitNode = (nextAxis: LayoutAxis, leftNode: LayoutNode, rightNode: LayoutNode, sizeTarget: 'primary' | 'secondary' = 'primary'): LayoutSplitNode => ({
id: nextNodeId(),
type: 'split',
axis: nextAxis,
sizeUnit: splitEditor.sizeUnit,
sizeTarget,
primarySize: normalizeValue(splitEditor.primarySize, splitEditor.sizeUnit),
primaryMin: normalizeValue(splitEditor.primaryMin, splitEditor.sizeUnit),
secondaryMin: normalizeValue(splitEditor.secondaryMin, splitEditor.sizeUnit),
resizable: splitEditor.resizable,
previewSizes: [],
children: [leftNode, rightNode],
});
const createInitialLayout = (nextAxis: LayoutAxis) => {
const firstLeaf = createLeafNode();
const secondLeaf = createLeafNode();
return {
root: createSplitNode(nextAxis, firstLeaf, secondLeaf, 'primary'),
selectedLeafId: firstLeaf.id,
};
};
const [layoutTree, setLayoutTree] = useState<LayoutNode | null>(null);
const [selectedLeafId, setSelectedLeafId] = useState<string | null>(null);
const [crossAxisEditor, setCrossAxisEditor] = useState<SplitEditorState>({
sizeUnit: '%',
primarySize: 50,
primaryMin: 10,
secondaryMin: 10,
resizable: true,
});
const refreshSavedLayouts = async () => {
setIsLoadingSavedLayouts(true);
try {
const nextLayouts = await listSavedLayouts();
setSavedLayouts(nextLayouts);
onSavedLayoutsChange?.(nextLayouts);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '저장된 레이아웃 목록을 불러오지 못했습니다.');
} finally {
setIsLoadingSavedLayouts(false);
}
};
useEffect(() => {
void refreshSavedLayouts();
}, []);
useEffect(() => {
if (typeof window === 'undefined') {
return undefined;
}
const mediaQuery = window.matchMedia('(max-width: 768px)');
const updateViewport = () => {
setIsMobileViewport(mediaQuery.matches);
};
updateViewport();
mediaQuery.addEventListener('change', updateViewport);
return () => {
mediaQuery.removeEventListener('change', updateViewport);
};
}, []);
useEffect(() => {
let mounted = true;
void Promise.all([
resolveSampleEntries(componentSampleEntries, '/components/'),
resolveSampleEntries(widgetSampleEntries, '/widgets/'),
]).then(([componentEntries, widgetEntries]) => {
if (mounted) {
setSampleEntries([...componentEntries, ...widgetEntries]);
}
});
return () => {
mounted = false;
};
}, []);
useEffect(() => () => {
contentHeightObserverRef.current.forEach((observer) => observer.disconnect());
contentHeightObserverRef.current.clear();
}, []);
const startLayout = (nextAxis: LayoutAxis) => {
setAxis(nextAxis);
const nextLayout = createInitialLayout(nextAxis);
setLayoutTree(nextLayout.root);
setSelectedLeafId(nextLayout.selectedLeafId);
setCollapsedLeafIds([]);
setInteractionRules([]);
setEditingInteractionRuleId(null);
setInteractionDraft({
sourceLeafId: '',
targetLeafId: '',
sourceComponentOptionId: '',
targetComponentOptionId: '',
title: '',
description: '',
implementationNotes: '',
});
setActiveSavedLayoutId(null);
};
const resetLayout = () => {
setLayoutTree(null);
setSelectedLeafId(null);
setCollapsedLeafIds([]);
setInteractionRules([]);
setEditingInteractionRuleId(null);
setInteractionDraft({
sourceLeafId: '',
targetLeafId: '',
sourceComponentOptionId: '',
targetComponentOptionId: '',
title: '',
description: '',
implementationNotes: '',
});
setActiveSavedLayoutId(null);
};
const splitSelectedLeaf = (nextAxis: LayoutAxis) => {
if (!layoutTree || !selectedLeafId) {
return;
}
const result = splitLeafNode(layoutTree, selectedLeafId, (existingLeaf) => {
const nestedLeaf = createLeafNode();
const splitNode = createSplitNode(nextAxis, existingLeaf, nestedLeaf, 'secondary');
return {
splitNode,
selectedLeafId: nestedLeaf.id,
};
});
if (result.selectedLeafId) {
setLayoutTree(result.nextNode);
setSelectedLeafId(result.selectedLeafId);
setActiveSavedLayoutId(null);
}
};
const selectedLeaf = useMemo(
() => (layoutTree && selectedLeafId ? findLeafNode(layoutTree, selectedLeafId) : null),
[layoutTree, selectedLeafId],
);
const selectedSplitPath = useMemo(
() => (layoutTree && selectedLeafId ? findSplitPath(layoutTree, selectedLeafId) : null),
[layoutTree, selectedLeafId],
);
const selectedSplitContext = selectedSplitPath?.[selectedSplitPath.length - 1] ?? null;
const selectedSplit = selectedSplitContext?.split ?? null;
const selectedSplitChildIndex = selectedSplitContext?.childIndex ?? null;
const selectedCrossAxisContext = useMemo(() => {
if (!selectedSplitPath || selectedSplitPath.length < 2) {
return null;
}
const currentAxis = selectedSplitPath[selectedSplitPath.length - 1]?.split.axis;
for (let index = selectedSplitPath.length - 2; index >= 0; index -= 1) {
const candidate = selectedSplitPath[index];
if (candidate && candidate.split.axis !== currentAxis) {
return candidate;
}
}
return null;
}, [selectedSplitPath]);
useEffect(() => {
if (!selectedSplit || selectedSplitChildIndex === null) {
return;
}
setSplitEditor(resolveSplitEditorState(selectedSplit, selectedSplitChildIndex));
}, [selectedSplit, selectedSplitChildIndex]);
useEffect(() => {
if (!selectedCrossAxisContext) {
return;
}
setCrossAxisEditor(resolveSplitEditorState(selectedCrossAxisContext.split, selectedCrossAxisContext.childIndex));
}, [selectedCrossAxisContext]);
const totalPanes = useMemo(() => (layoutTree ? countLeafNodes(layoutTree) : 0), [layoutTree]);
const layoutSummary = useMemo(() => (layoutTree ? collectSplitSummaries(layoutTree).join('\n') : '아직 생성된 레이아웃이 없습니다.'), [layoutTree]);
const leafNodes = useMemo(() => collectLeafNodes(layoutTree), [layoutTree]);
const selectedSavedLayoutRecord = useMemo(
() => (savedLayoutViewId ? savedLayouts.find((record) => record.id === savedLayoutViewId) ?? null : null),
[savedLayoutViewId, savedLayouts],
);
const selectedSavedLayoutPayload = useMemo(
() => (selectedSavedLayoutRecord ? normalizeStoredLayoutPayload(selectedSavedLayoutRecord.tree) : null),
[selectedSavedLayoutRecord],
);
const previewRuntimeLeafNodes = useMemo(
() => {
if (savedLayoutViewId && selectedSavedLayoutRecord) {
return collectLeafNodes(selectedSavedLayoutPayload?.root ?? null).map((leaf) => ({
...leaf,
id: scopeLayoutLeafId(selectedSavedLayoutRecord.id, leaf.id),
}));
}
if (showSavedLayoutsOnly || isSavedLayoutsOpen) {
return savedLayouts.flatMap((record) => {
const payload = normalizeStoredLayoutPayload(record.tree);
return collectLeafNodes(payload.root).map((leaf) => ({
...leaf,
id: scopeLayoutLeafId(record.id, leaf.id),
}));
});
}
return leafNodes.map((leaf) => ({
...leaf,
id: scopeLayoutLeafId('editor', leaf.id),
}));
},
[
isSavedLayoutsOpen,
leafNodes,
savedLayoutViewId,
savedLayouts,
selectedSavedLayoutPayload,
selectedSavedLayoutRecord,
showSavedLayoutsOnly,
],
);
const previewRuntimeInteractionRules = useMemo(() => {
if (savedLayoutViewId && selectedSavedLayoutRecord) {
return scopeLayoutInteractionRules(selectedSavedLayoutRecord.id, selectedSavedLayoutPayload?.interactions ?? []);
}
if (showSavedLayoutsOnly || isSavedLayoutsOpen) {
return savedLayouts.flatMap((record) => {
const payload = normalizeStoredLayoutPayload(record.tree);
return scopeLayoutInteractionRules(record.id, payload.interactions);
});
}
return scopeLayoutInteractionRules('editor', interactionRules);
}, [
interactionRules,
isSavedLayoutsOpen,
savedLayoutViewId,
savedLayouts,
selectedSavedLayoutPayload,
selectedSavedLayoutRecord,
showSavedLayoutsOnly,
]);
const leafOptions = useMemo(
() =>
leafNodes.map((leaf) => ({
value: leaf.id,
label: leaf.componentBinding?.label?.trim() ? `${leaf.label} · ${leaf.componentBinding.label.trim()}` : leaf.label,
})),
[leafNodes],
);
const layoutPreviewRuntime = useLayoutPreviewRuntime(
previewRuntimeLeafNodes.map((leaf) => ({
id: leaf.id,
componentBinding: leaf.componentBinding
? {
optionId: leaf.componentBinding.optionId,
label: leaf.componentBinding.label,
}
: null,
})),
previewRuntimeInteractionRules,
);
const interactionLeafMap = useMemo(() => new Map(leafNodes.map((leaf) => [leaf.id, leaf])), [leafNodes]);
const layoutCss = useMemo(
() => [
`selected-pane: ${selectedLeaf?.label ?? 'none'};`,
`total-panes: ${totalPanes};`,
'splitters:',
layoutSummary,
].join('\n'),
[layoutSummary, selectedLeaf?.label, totalPanes],
);
const savedLayoutGrid = useMemo(() => resolveSavedLayoutGrid(savedLayouts.length), [savedLayouts.length]);
const duplicatedSampleTitles = useMemo(() => {
const titleCountMap = new Map<string, number>();
sampleEntries.forEach((entry) => {
titleCountMap.set(entry.sampleMeta.title, (titleCountMap.get(entry.sampleMeta.title) ?? 0) + 1);
});
return new Set(
Array.from(titleCountMap.entries())
.filter(([, count]) => count > 1)
.map(([title]) => title),
);
}, [sampleEntries]);
const componentSearchOptions = useMemo<SearchKeywordOption[]>(
() =>
sampleEntries.map((entry) => ({
id: buildSampleOptionId(entry),
label: resolveSearchOptionLabel(entry, duplicatedSampleTitles),
group: entry.modulePath.includes('/widgets/') ? 'Widget' : 'Component',
keywords: [
entry.sampleMeta.componentId,
entry.sampleMeta.id,
entry.sampleMeta.category,
entry.sampleMeta.kind ?? '',
entry.sampleMeta.variantLabel ?? '',
].filter(Boolean),
description: entry.sampleMeta.description,
onSelect: () => {},
})),
[duplicatedSampleTitles, sampleEntries],
);
const interactionComponentOptions = useMemo(
() =>
componentSearchOptions.map((option) => ({
value: option.id,
label: option.label,
})),
[componentSearchOptions],
);
const componentSampleMap = useMemo(
() =>
new Map(
sampleEntries.map((entry) => [
buildSampleOptionId(entry),
entry,
]),
),
[sampleEntries],
);
const baseSampleByComponentId = useMemo(
() =>
new Map(
sampleEntries
.filter((entry) => entry.sampleMeta.kind === 'base')
.map((entry) => [entry.sampleMeta.componentId, entry] as const),
),
[sampleEntries],
);
const handleSaveLayout = async (options?: { openCodexAfterSave?: boolean }) => {
const trimmedName = layoutName.trim();
if (!trimmedName) {
messageApi.warning('레이아웃 이름을 입력해 주세요.');
return;
}
setIsSaving(true);
const now = new Date().toISOString();
const existingRecord = savedLayouts.find((record) => record.name === trimmedName);
const nextRecord: SavedLayoutRecord = {
id: existingRecord?.id ?? `layout-${Date.now()}`,
name: trimmedName,
createdAt: existingRecord?.createdAt ?? now,
updatedAt: now,
axis,
sizeUnit: splitEditor.sizeUnit,
primarySize: splitEditor.primarySize,
primaryMin: splitEditor.primaryMin,
secondaryMin: splitEditor.secondaryMin,
resizable: splitEditor.resizable,
selectedLeafId: layoutTree ? selectedLeafId : null,
totalPanes,
summary: layoutSummary,
tree: {
root: layoutTree ?? EMPTY_LAYOUT_TREE,
interactions: interactionRules,
interactionMode: 'scoped-v2',
},
};
try {
await saveLayout(nextRecord);
await refreshSavedLayouts();
setActiveSavedLayoutId(nextRecord.id);
setSavedLayoutPreviewId(nextRecord.id);
messageApi.success(existingRecord ? '레이아웃을 덮어썼습니다.' : '레이아웃을 저장했습니다.');
if (options?.openCodexAfterSave) {
const savedPayload = normalizeStoredLayoutPayload(nextRecord.tree);
const savedLeafMap = new Map(collectLeafNodes(savedPayload.root).map((leaf) => [leaf.id, leaf]));
await openCodexLiveForPrompt(
buildCodexRequestText({
layoutName: nextRecord.name,
rules: savedPayload.interactions,
leafMap: savedLeafMap,
root: savedPayload.root,
}),
);
}
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '레이아웃 저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const openCodexLiveForPrompt = async (prompt: string) => {
if (typeof window === 'undefined') {
messageApi.error('Codex Live 요청문을 준비하지 못했습니다.');
return;
}
const text = prompt.trim();
const chatType = selectedCodexChatType;
const conversationTitle = resolveLayoutConversationTitle(layoutName);
if (!text) {
messageApi.warning('전송할 Codex 요청문이 없습니다.');
return;
}
if (!chatType) {
messageApi.error('사용 가능한 채팅유형이 없어 Codex 요청을 보낼 수 없습니다.');
return;
}
try {
const conversations = await fetchChatConversations();
const matchedConversation = conversations.find((item) =>
isReusableLayoutConversation(item, conversationTitle, chatType.id),
);
const shouldCreateConversation = !matchedConversation;
const targetSessionId =
matchedConversation?.sessionId ||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `chat-session-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`);
if (shouldCreateConversation) {
await createChatConversationRoom({
sessionId: targetSessionId,
title: conversationTitle,
chatTypeId: chatType.id,
lastChatTypeId: chatType.id,
contextLabel: chatType.name,
contextDescription: chatType.description,
notifyOffline: true,
});
}
const requestId = `client-${Date.now().toString(36)}`;
const socketUrl = resolveLayoutCodexRequestSocketUrl(targetSessionId);
await new Promise<void>((resolve, reject) => {
const socket = new WebSocket(socketUrl);
const timeoutId = window.setTimeout(() => {
socket.close();
reject(new Error('Codex 요청 연결 시간이 초과되었습니다.'));
}, 8000);
socket.addEventListener('open', () => {
socket.send(
JSON.stringify({
type: 'message:send',
payload: {
text,
chatTypeId: chatType.id,
chatTypeLabel: chatType.name,
chatTypeDescription: chatType.description,
requestId,
mode: 'queue',
},
}),
);
window.setTimeout(() => {
window.clearTimeout(timeoutId);
socket.close();
resolve();
}, 250);
});
socket.addEventListener('error', () => {
window.clearTimeout(timeoutId);
socket.close();
reject(new Error('Codex 요청 전송에 실패했습니다.'));
});
});
const nextUrl = new URL(buildChatPath('live'), window.location.origin);
nextUrl.searchParams.set('topMenu', 'chat');
nextUrl.searchParams.set('sessionId', targetSessionId);
navigate(`${nextUrl.pathname}${nextUrl.search}`);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : 'Codex 요청 전송에 실패했습니다.');
}
};
const handleLoadLayout = (record: SavedLayoutRecord) => {
const payload = normalizeStoredLayoutPayload(record.tree);
const nextTree = payload.root;
setLayoutTree(nextTree);
setInteractionRules(payload.interactions);
setSelectedLeafId(record.selectedLeafId);
setCollapsedLeafIds([]);
setAxis(record.axis);
setSplitEditor({
sizeUnit: record.sizeUnit,
primarySize: record.primarySize,
primaryMin: record.primaryMin,
secondaryMin: record.secondaryMin,
resizable: record.resizable,
});
setLayoutName(record.name);
setActiveSavedLayoutId(record.id);
setSavedLayoutPreviewId(record.id);
nodeIdRef.current = resolveNextNodeSequence(nextTree);
setInteractionDraft({
sourceLeafId: '',
targetLeafId: '',
sourceComponentOptionId: '',
targetComponentOptionId: '',
title: '',
description: '',
implementationNotes: '',
});
setEditingInteractionRuleId(null);
messageApi.success(`"${record.name}" 레이아웃을 불러왔습니다.`);
};
const handleDeleteLayout = async (record: SavedLayoutRecord) => {
try {
await deleteLayout(record.id);
await refreshSavedLayouts();
if (activeSavedLayoutId === record.id) {
setActiveSavedLayoutId(null);
}
if (savedLayoutPreviewId === record.id) {
const fallbackRecord = savedLayouts.find((item) => item.id !== record.id) ?? null;
setSavedLayoutPreviewId(fallbackRecord?.id ?? null);
}
messageApi.success(`"${record.name}" 레이아웃을 삭제했습니다.`);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '레이아웃 삭제에 실패했습니다.');
}
};
const openComponentSelector = (leafId: string) => {
setComponentSelectorLeafId(leafId);
setIsComponentSelectorOpen(true);
};
const handleSelectComponent = (option: SearchKeywordOption) => {
if (!componentSelectorLeafId) {
return;
}
setLayoutTree((previous) =>
previous
? updateLeafNode(previous, componentSelectorLeafId, (current) => ({
...current,
componentBinding: {
optionId: option.id,
label: option.label,
description: option.description,
keywords: option.keywords ?? [],
},
}))
: previous,
);
setActiveSavedLayoutId(null);
setIsComponentSelectorOpen(false);
messageApi.success(`"${option.label}" 컴포넌트를 section에 배치했습니다.`);
};
const updateSelectedLeafOptions = (updater: (current: LayoutLeafNode) => LayoutLeafNode) => {
if (!layoutTree || !selectedLeafId) {
messageApi.warning('먼저 수정할 section을 선택해 주세요.');
return;
}
setLayoutTree((previous) => (previous ? updateLeafNode(previous, selectedLeafId, updater) : previous));
setActiveSavedLayoutId(null);
};
const resetInteractionDraft = (nextSourceLeafId?: string) => {
setEditingInteractionRuleId(null);
setInteractionDraft({
sourceLeafId: nextSourceLeafId ?? '',
targetLeafId: '',
sourceComponentOptionId: '',
targetComponentOptionId: '',
title: '',
description: '',
implementationNotes: '',
});
};
const handleEditInteractionRule = (ruleId: string) => {
const targetRule = interactionRules.find((rule) => rule.id === ruleId);
if (!targetRule) {
messageApi.warning('수정할 기능 명세를 찾지 못했습니다.');
return;
}
setEditingInteractionRuleId(ruleId);
setInteractionDraft({
sourceLeafId: targetRule.sourceLeafId,
targetLeafId: targetRule.targetLeafId,
sourceComponentOptionId: targetRule.sourceComponent?.optionId ?? '',
targetComponentOptionId: targetRule.targetComponent?.optionId ?? '',
title: targetRule.title,
description: targetRule.description,
implementationNotes: targetRule.implementationNotes,
});
};
const handleSubmitInteractionRule = () => {
if (!layoutTree) {
messageApi.warning('먼저 레이아웃을 구성해 주세요.');
return;
}
const sourceLeafId = interactionDraft.sourceLeafId.trim();
const targetLeafId = interactionDraft.targetLeafId.trim();
const sourceComponent = interactionDraft.sourceComponentOptionId
? componentSearchOptions.find((option) => option.id === interactionDraft.sourceComponentOptionId)
: null;
const targetComponent = interactionDraft.targetComponentOptionId
? componentSearchOptions.find((option) => option.id === interactionDraft.targetComponentOptionId)
: null;
const title = interactionDraft.title.trim();
const description = interactionDraft.description.trim();
const implementationNotes = interactionDraft.implementationNotes.trim();
if (!title || !description) {
messageApi.warning('기능명과 설명을 입력해 주세요.');
return;
}
if ((sourceLeafId && !interactionLeafMap.has(sourceLeafId)) || (targetLeafId && !interactionLeafMap.has(targetLeafId))) {
messageApi.warning('선택한 section 정보를 다시 확인해 주세요.');
return;
}
const nextRule: LayoutInteractionRule = {
id: editingInteractionRuleId ?? `layout-interaction-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
sourceLeafId,
targetLeafId,
sourceComponent: sourceComponent
? {
optionId: sourceComponent.id,
label: sourceComponent.label,
description: sourceComponent.description,
keywords: sourceComponent.keywords ?? [],
}
: null,
targetComponent: targetComponent
? {
optionId: targetComponent.id,
label: targetComponent.label,
description: targetComponent.description,
keywords: targetComponent.keywords ?? [],
}
: null,
title,
description,
implementationNotes,
};
setInteractionRules((previous) =>
editingInteractionRuleId
? previous.map((rule) => (rule.id === editingInteractionRuleId ? nextRule : rule))
: [...previous, nextRule],
);
setActiveSavedLayoutId(null);
resetInteractionDraft();
messageApi.success(
editingInteractionRuleId ? `"${title}" 기능 명세를 수정했습니다.` : `"${title}" 기능 명세를 추가했습니다.`,
);
};
const handleDeleteInteractionRule = (ruleId: string) => {
setInteractionRules((previous) => previous.filter((rule) => rule.id !== ruleId));
setActiveSavedLayoutId(null);
if (editingInteractionRuleId === ruleId) {
resetInteractionDraft();
}
};
useEffect(() => {
setInteractionDraft((previous) => {
const nextSourceLeafId =
previous.sourceLeafId && interactionLeafMap.has(previous.sourceLeafId)
? previous.sourceLeafId
: '';
const nextTargetLeafId = previous.targetLeafId && interactionLeafMap.has(previous.targetLeafId) ? previous.targetLeafId : '';
if (previous.sourceLeafId === nextSourceLeafId && previous.targetLeafId === nextTargetLeafId) {
return previous;
}
return {
...previous,
sourceLeafId: nextSourceLeafId,
targetLeafId: nextTargetLeafId,
};
});
setInteractionRules((previous) =>
previous.filter(
(rule) =>
(!rule.sourceLeafId || interactionLeafMap.has(rule.sourceLeafId)) &&
(!rule.targetLeafId || interactionLeafMap.has(rule.targetLeafId)),
),
);
}, [interactionLeafMap, leafOptions, selectedLeafId]);
useEffect(() => {
if (!savedLayoutViewId || !selectedSavedLayoutRecord) {
setActiveInteractionIds([]);
return;
}
const nextRules = normalizeStoredLayoutPayload(selectedSavedLayoutRecord.tree).interactions;
if (!nextRules.length) {
setActiveInteractionIds([]);
return;
}
setActiveInteractionIds([]);
const timerId = window.setTimeout(() => {
setActiveInteractionIds(nextRules.map((rule) => rule.id));
}, 80);
return () => {
window.clearTimeout(timerId);
};
}, [savedLayoutViewId, selectedSavedLayoutRecord]);
const bindMeasuredContentHeight = (leafId: string, enabled: boolean) => (element: HTMLDivElement | null) => {
const previousObserver = contentHeightObserverRef.current.get(leafId);
if (previousObserver) {
previousObserver.disconnect();
contentHeightObserverRef.current.delete(leafId);
}
if (!enabled || !element || typeof ResizeObserver === 'undefined') {
return;
}
const updateHeight = () => {
const elementStyle = window.getComputedStyle(element);
const elementBorderHeight =
Number.parseFloat(elementStyle.borderTopWidth || '0') + Number.parseFloat(elementStyle.borderBottomWidth || '0');
const paneElement = element.closest('.layout-playground__pane');
const paneStyle = paneElement ? window.getComputedStyle(paneElement) : null;
const paneBorderHeight = paneStyle
? Number.parseFloat(paneStyle.borderTopWidth || '0') + Number.parseFloat(paneStyle.borderBottomWidth || '0')
: 0;
const childRectHeight = Array.from(element.children).reduce((maxHeight, child) => {
return Math.max(maxHeight, child.getBoundingClientRect().height);
}, 0);
const mobileRoundingSlack = 4;
const nextHeight = Math.ceil(
Math.max(
element.scrollHeight,
element.scrollHeight + elementBorderHeight + paneBorderHeight,
element.offsetHeight,
element.getBoundingClientRect().height,
childRectHeight + elementBorderHeight + paneBorderHeight,
),
) + mobileRoundingSlack;
setMeasuredContentHeights((previous) => (previous[leafId] === nextHeight ? previous : { ...previous, [leafId]: nextHeight }));
};
updateHeight();
const observer = new ResizeObserver(() => {
updateHeight();
});
observer.observe(element);
Array.from(element.children).forEach((child) => {
observer.observe(child);
});
contentHeightObserverRef.current.set(leafId, observer);
};
const applySettingsToSelectedSection = () => {
if (!layoutTree || !selectedSplit || selectedSplitChildIndex === null) {
messageApi.warning('먼저 수정할 section을 선택해 주세요.');
return;
}
let nextTree = updateSplitNode(layoutTree, selectedSplit.id, (current) => ({
...current,
axis: current.axis,
sizeUnit: splitEditor.sizeUnit,
sizeTarget: selectedSplitChildIndex === 0 ? 'primary' : 'secondary',
primarySize: normalizeValue(splitEditor.primarySize, splitEditor.sizeUnit),
primaryMin: normalizeValue(
selectedSplitChildIndex === 0 ? splitEditor.primaryMin : splitEditor.secondaryMin,
splitEditor.sizeUnit,
),
secondaryMin: normalizeValue(
selectedSplitChildIndex === 0 ? splitEditor.secondaryMin : splitEditor.primaryMin,
splitEditor.sizeUnit,
),
resizable: splitEditor.resizable,
previewSizes: [],
}));
if (selectedCrossAxisContext) {
nextTree = updateSplitNode(nextTree, selectedCrossAxisContext.split.id, (current) => ({
...current,
axis: current.axis,
sizeUnit: crossAxisEditor.sizeUnit,
sizeTarget: selectedCrossAxisContext.childIndex === 0 ? 'primary' : 'secondary',
primarySize: normalizeValue(crossAxisEditor.primarySize, crossAxisEditor.sizeUnit),
primaryMin: normalizeValue(
selectedCrossAxisContext.childIndex === 0 ? crossAxisEditor.primaryMin : crossAxisEditor.secondaryMin,
crossAxisEditor.sizeUnit,
),
secondaryMin: normalizeValue(
selectedCrossAxisContext.childIndex === 0 ? crossAxisEditor.secondaryMin : crossAxisEditor.primaryMin,
crossAxisEditor.sizeUnit,
),
resizable: crossAxisEditor.resizable,
previewSizes: [],
}));
}
setLayoutTree(nextTree);
setActiveSavedLayoutId(null);
messageApi.success(
selectedCrossAxisContext
? '선택 section 크기와 같은 라인 크기를 함께 갱신했습니다.'
: '선택 section 설정을 현재 프리셋으로 갱신했습니다.',
);
};
const updateSelectedLeafContentFit = (checked: boolean) => {
if (!layoutTree || !selectedLeaf || !selectedSplit || selectedSplitChildIndex === null) {
return;
}
const siblingIndex = selectedSplitChildIndex === 0 ? 1 : 0;
const siblingLeaf = selectedSplit.children[siblingIndex]?.type === 'leaf'
? (selectedSplit.children[siblingIndex] as LayoutLeafNode)
: null;
setLayoutTree((previous) => {
if (!previous) {
return previous;
}
let nextTree = updateLeafNode(previous, selectedLeaf.id, (current) => ({
...current,
useContentHeight: checked,
}));
if (checked && siblingLeaf) {
nextTree = updateLeafNode(nextTree, siblingLeaf.id, (current) => ({
...current,
useContentHeight: false,
}));
}
return updateSplitNode(nextTree, selectedSplit.id, (current) => ({
...current,
previewSizes: [],
}));
});
setActiveSavedLayoutId(null);
if (checked && siblingLeaf?.useContentHeight) {
messageApi.info('한 분할에서는 한 section만 컴포넌트 맞춤을 사용합니다.');
}
};
const renderNode = (
node: LayoutNode,
depth = 1,
options?: {
selectedId?: string | null;
selectable?: boolean;
onSelect?: (id: string) => void;
previewMode?: boolean;
previewSurface?: boolean;
hidePaneLabel?: boolean;
showComponentActions?: boolean;
componentBodyScrollable?: boolean;
interactionRules?: LayoutInteractionRule[];
activeInteractionIds?: string[];
interactionLeafMap?: Map<string, LayoutLeafNode>;
hideInteractionOverlays?: boolean;
scopeKey?: string;
paneSizingMeta?: LayoutPaneSizingMeta | null;
onPreviewActionClick?: () => void;
},
): React.ReactNode => {
const selectedId = options?.selectedId ?? selectedLeafId;
const selectable = options?.selectable ?? true;
const onSelect = options?.onSelect ?? setSelectedLeafId;
const previewMode = options?.previewMode ?? false;
const previewSurface = options?.previewSurface ?? previewMode;
const hidePaneLabel = options?.hidePaneLabel ?? previewMode;
const showComponentActions = options?.showComponentActions ?? (!previewMode && selectable);
const componentBodyScrollable = options?.componentBodyScrollable ?? previewSurface;
const canRenderToggleActions = showComponentActions || previewMode;
const renderedInteractionRules = options?.interactionRules ?? interactionRules;
const renderedActiveInteractionIds = options?.activeInteractionIds ?? [];
const scopeKey = options?.scopeKey ?? 'editor';
const paneSizingMeta = options?.paneSizingMeta ?? null;
const scopedRecord = savedLayoutRecordMap.get(scopeKey) ?? null;
const isStockAlertLayout = previewSurface && isStockAlertLayoutRecord(scopedRecord);
if (node.type === 'leaf') {
const scopedLeafId = scopeLayoutLeafId(scopeKey, node.id);
const isSelected = node.id === selectedId;
const sourceInteractions = renderedInteractionRules.filter((rule) => rule.sourceLeafId && rule.sourceLeafId === node.id);
const selectedComponentEntry = node.componentBinding ? componentSampleMap.get(node.componentBinding.optionId) : null;
const boundComponentEntry =
previewSurface && selectedComponentEntry?.modulePath.includes('/components/')
? (baseSampleByComponentId.get(selectedComponentEntry.sampleMeta.componentId) ?? selectedComponentEntry)
: selectedComponentEntry;
const BoundSample = boundComponentEntry?.Sample;
const previewBindingKind = resolveLayoutPreviewBindingKind(
boundComponentEntry
? {
optionId: buildSampleOptionId(boundComponentEntry),
label: boundComponentEntry.sampleMeta.title,
}
: node.componentBinding,
);
const selectPreviewOptions =
previewBindingKind === 'select-input' ? resolveSelectPreviewOptions(sourceInteractions) : DEFAULT_COMBO_VALUE_OPTIONS;
const shouldFillBaseInputPane =
previewBindingKind === 'base-input' && sourceInteractions.some((rule) => rule.title.trim() === '100%가득채움');
const memoPaneTitle =
previewMode && node.label.trim() && !/^section\s*\d+$/iu.test(node.label.trim()) ? node.label.trim() : ' ';
const customPreviewBody = isStockAlertLayout
? node.componentBinding?.optionId === 'component:select-input:select-input-base'
? <StockAlertFilterPane />
: node.componentBinding?.optionId === 'widget:ag-grid-widget:ag-grid-widget'
? <StockAlertGridPane />
: null
: previewSurface
? !BoundSample ? (
<LayoutPreviewEmptyPane
paneLabel={node.label}
paneDescription="컴포넌트가 아직 연결되지 않은 section입니다. 필요한 역할만 직접 정리할 수 있습니다."
sizeSummary={paneSizingMeta?.sizeSummary ?? '배치 정보 없음'}
state={
layoutPreviewRuntime.emptyPaneStates[scopedLeafId] ?? {
readiness: 'unassigned',
note: '',
updatedAt: null,
}
}
onReadinessChange={(nextValue) => {
layoutPreviewRuntime.setEmptyPaneReadiness(scopedLeafId, nextValue);
}}
onNoteChange={(nextValue) => {
layoutPreviewRuntime.setEmptyPaneNote(scopedLeafId, nextValue);
}}
/>
) : previewBindingKind === 'text-memo-widget' ? (
<LayoutPreviewTextMemoPane
skin="flat"
title={memoPaneTitle}
state={layoutPreviewRuntime.memoStates[scopedLeafId] ?? {
draftBody: '',
selectedId: null,
isListOpen: false,
isLoading: false,
notes: [],
}}
onStartDraft={() => {
layoutPreviewRuntime.startMemoDraft(scopedLeafId);
}}
onToggleList={() => {
layoutPreviewRuntime.toggleMemoList(scopedLeafId);
}}
onDeleteSelection={() => {
layoutPreviewRuntime.deleteMemoSelection(scopedLeafId);
}}
onSaveDraft={() => {
layoutPreviewRuntime.saveMemoDraft(scopedLeafId);
}}
onSelectNote={(noteId) => {
layoutPreviewRuntime.selectMemoNote(scopedLeafId, noteId);
}}
onMoveSelection={(direction) => {
layoutPreviewRuntime.moveMemoSelection(scopedLeafId, direction);
}}
onDraftChange={(nextValue) => {
layoutPreviewRuntime.setMemoDraftBody(scopedLeafId, nextValue);
}}
/>
) : previewBindingKind === 'action-button' ? (
<LayoutPreviewActionPane
label="Codex 실행"
onClick={options?.onPreviewActionClick}
/>
) : previewBindingKind === 'select-input' ? (
<LayoutPreviewSelectPane
state={layoutPreviewRuntime.selectStates[scopedLeafId] ?? {
selectedCode: selectPreviewOptions[0]?.code,
selectedItem: selectPreviewOptions[0] ?? null,
}}
data={selectPreviewOptions}
onChange={(nextCode, item) => {
layoutPreviewRuntime.setSelectValue(scopedLeafId, nextCode, item);
}}
/>
) : previewBindingKind === 'base-input' ? (
<LayoutPreviewBaseInputPane
state={layoutPreviewRuntime.baseInputStates[scopedLeafId] ?? {
value: '',
}}
fillPane={shouldFillBaseInputPane}
/>
) : null
: null;
const supportsContentHeight =
Boolean(customPreviewBody) ||
(
!!BoundSample &&
previewBindingKind !== 'text-memo-widget' &&
previewBindingKind !== 'select-input' &&
(previewBindingKind !== 'base-input' || !shouldFillBaseInputPane)
);
const useContentHeight = previewSurface && node.useContentHeight && supportsContentHeight;
const shouldRenderContentHeight = useContentHeight;
const showSelectComponentButton = !BoundSample && showComponentActions && (!previewSurface || !isSelected);
const showSelectPaneButton = !!BoundSample && showComponentActions && previewSurface && !isSelected;
const showChangeComponentButton = !!BoundSample && showComponentActions && (!previewSurface || !isSelected);
const showPaneToolbar = showSelectComponentButton || showSelectPaneButton || showChangeComponentButton;
const showPaneLabel = !hidePaneLabel && (showSelectComponentButton || showChangeComponentButton);
const selectComponentButtonLabel = isMobileViewport && previewSurface ? '등록' : '컴포넌트 선택';
const shouldScrollComponentBody =
componentBodyScrollable &&
!shouldRenderContentHeight &&
previewBindingKind !== 'text-memo-widget' &&
previewBindingKind !== 'select-input' &&
previewBindingKind !== 'base-input';
const targetInteractions = renderedInteractionRules.filter((rule) => rule.targetLeafId && rule.targetLeafId === node.id);
const isInteractionTargetActive = targetInteractions.some((rule) => renderedActiveInteractionIds.includes(rule.id));
return (
<section
role={selectable ? 'button' : undefined}
tabIndex={selectable ? 0 : undefined}
className={[
'layout-playground__pane',
TONE_CLASS_NAMES[node.tone],
selectable ? '' : 'layout-playground__pane--readonly',
previewMode ? 'layout-playground__pane--preview' : '',
previewSurface ? 'layout-playground__pane--surface' : '',
isSelected ? 'layout-playground__pane--selected' : '',
showPaneToolbar ? 'layout-playground__pane--has-toolbar' : '',
previewSurface && showPaneToolbar ? 'layout-playground__pane--floating-toolbar' : '',
isInteractionTargetActive ? 'layout-playground__pane--interaction-target' : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => {
if (selectable) {
onSelect(node.id);
}
}}
onKeyDown={(event) => {
if (selectable && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onSelect(node.id);
}
}}
>
{showPaneToolbar ? (
<div
className={[
'layout-playground__pane-toolbar',
previewMode ? 'layout-playground__pane-toolbar--overlay' : '',
previewSurface ? 'layout-playground__pane-toolbar--surface-overlay' : '',
!showPaneLabel ? 'layout-playground__pane-toolbar--compact' : '',
]
.filter(Boolean)
.join(' ')}
>
{showPaneLabel ? <Text className="layout-playground__pane-label">{node.label}</Text> : null}
<div className="layout-playground__pane-toolbar-actions">
{showChangeComponentButton ? (
<Button
size="small"
icon={<DeploymentUnitOutlined />}
onClick={(event) => {
event.stopPropagation();
openComponentSelector(node.id);
}}
>
</Button>
) : null}
{showSelectPaneButton ? (
<Button
size="small"
type="default"
icon={<DeploymentUnitOutlined />}
onClick={(event) => {
event.stopPropagation();
onSelect(node.id);
}}
>
</Button>
) : null}
{showSelectComponentButton ? (
<Button
size="small"
type="primary"
icon={<DeploymentUnitOutlined />}
onClick={(event) => {
event.stopPropagation();
openComponentSelector(node.id);
}}
>
{selectComponentButtonLabel}
</Button>
) : null}
</div>
</div>
) : null}
{customPreviewBody ? (
<div
ref={bindMeasuredContentHeight(scopedLeafId, shouldRenderContentHeight)}
className={[
'layout-playground__pane-component-body',
shouldScrollComponentBody ? 'layout-playground__pane-component-body--scrollable' : '',
shouldRenderContentHeight ? 'layout-playground__pane-component-body--content-height' : '',
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'layout-playground__pane-component-scroll-content',
shouldRenderContentHeight ? 'layout-playground__pane-component-scroll-content--auto-height' : '',
]
.filter(Boolean)
.join(' ')}
>
{customPreviewBody}
</div>
</div>
) : BoundSample ? (
<div
ref={bindMeasuredContentHeight(scopedLeafId, shouldRenderContentHeight)}
className={[
'layout-playground__pane-component-body',
shouldScrollComponentBody ? 'layout-playground__pane-component-body--scrollable' : '',
shouldRenderContentHeight ? 'layout-playground__pane-component-body--content-height' : '',
]
.filter(Boolean)
.join(' ')}
>
<div
className={[
'layout-playground__pane-component-scroll-content',
shouldRenderContentHeight ? 'layout-playground__pane-component-scroll-content--auto-height' : '',
]
.filter(Boolean)
.join(' ')}
>
<BoundSample disableWidgetCardWrapper={previewSurface} />
</div>
</div>
) : (
<div
className={[
'layout-playground__pane-placeholder',
]
.filter(Boolean)
.join(' ')}
>
<div className="layout-playground__pane-placeholder-card">
<Tag color={previewSurface ? 'default' : 'blue'}>{node.componentBinding ? '컴포넌트 연결됨' : '컴포넌트 미지정'}</Tag>
<Text strong>{node.label}</Text>
<Text type="secondary">
{node.componentBinding?.label?.trim()
? node.componentBinding.label.trim()
: previewSurface
? '연결된 컴포넌트 없이 구조만 저장된 section입니다.'
: hidePaneLabel
? '섹션을 선택한 뒤 컴포넌트를 배치하세요.'
: isSelected
? '선택된 영역'
: `Depth ${depth}`}
</Text>
</div>
</div>
)}
</section>
);
}
const sizeTargetIndex = getSizeTargetIndex(node);
const autoHeightLeafIndex =
node.axis === 'vertical'
? node.children.findIndex((child) => child.type === 'leaf' && child.useContentHeight && child.componentBinding)
: -1;
const autoHeightLeaf =
autoHeightLeafIndex >= 0 && node.children[autoHeightLeafIndex]?.type === 'leaf'
? (node.children[autoHeightLeafIndex] as LayoutLeafNode)
: null;
const scopedAutoHeightLeafId = autoHeightLeaf ? scopeLayoutLeafId(scopeKey, autoHeightLeaf.id) : null;
const autoHeightSize =
scopedAutoHeightLeafId && measuredContentHeights[scopedAutoHeightLeafId]
? Math.ceil(measuredContentHeights[scopedAutoHeightLeafId])
: null;
const controlledSizes =
autoHeightSize
? [
autoHeightLeafIndex === 0 ? autoHeightSize : undefined,
autoHeightLeafIndex === 1 ? autoHeightSize : undefined,
]
: node.previewSizes.length === 2
? [Math.round(node.previewSizes[0]), Math.round(node.previewSizes[1])]
: [
sizeTargetIndex === 0 ? toSplitterSize(node.primarySize, node.sizeUnit) : undefined,
sizeTargetIndex === 1 ? toSplitterSize(node.primarySize, node.sizeUnit) : undefined,
];
const primaryPanelSize = controlledSizes[0];
const primaryPanelMin = toSplitterSize(node.primaryMin, node.sizeUnit);
const secondaryPanelSize = controlledSizes[1];
const secondaryPanelMin = toSplitterSize(node.secondaryMin, node.sizeUnit);
const collapsedChildIndex = node.children.findIndex((child) => child.type === 'leaf' && collapsedLeafIds.includes(child.id));
const toggleTargets: LayoutToggleTarget[] = node.children.flatMap((child, childIndex) => {
if (child.type !== 'leaf' || !child.showHideAction) {
return [];
}
return [
{
id: child.id,
childIndex,
label: child.label,
collapsed: collapsedLeafIds.includes(child.id),
collapseDirection: child.collapseDirection ?? 'auto',
},
];
});
const renderToggleButtons = () => {
if (!canRenderToggleActions || !toggleTargets.length) {
return null;
}
return (
<div
className={[
'layout-playground__splitter-toggle-dock',
node.axis === 'horizontal'
? 'layout-playground__splitter-toggle-dock--horizontal'
: 'layout-playground__splitter-toggle-dock--vertical',
].join(' ')}
>
{toggleTargets.map((target) => {
const resolvedDirection = resolveCollapseDirection(node.axis, target.childIndex, target.collapseDirection);
const icon = renderCollapseMovementIcon(resolvedDirection, target.collapsed);
const actionLabel = target.collapsed ? '펼치기' : '접기';
const directionLabel = COLLAPSE_DIRECTION_LABELS[resolvedDirection];
return (
<Button
key={target.id}
size="small"
type={target.collapsed ? 'primary' : 'default'}
icon={icon}
shape="circle"
className="layout-playground__splitter-toggle-button"
aria-label={`${target.label} ${directionLabel} ${actionLabel}`}
title={`${target.label} ${directionLabel} ${actionLabel}`}
onClick={(event) => {
event.stopPropagation();
setCollapsedLeafIds((previous) =>
target.collapsed ? previous.filter((id) => id !== target.id) : [...previous, target.id],
);
}}
/>
);
})}
</div>
);
};
if (collapsedChildIndex >= 0) {
const expandedChild = node.children[collapsedChildIndex === 0 ? 1 : 0];
return (
<div
className={[
'layout-playground__splitter',
`layout-playground__splitter--depth-${Math.min(depth, 4)}`,
previewMode ? 'layout-playground__splitter--preview' : '',
'layout-playground__splitter--collapsed',
node.axis === 'horizontal'
? 'layout-playground__splitter--collapsed-horizontal'
: 'layout-playground__splitter--collapsed-vertical',
].join(' ')}
>
{renderNode(expandedChild, depth + 1, options)}
{renderToggleButtons()}
</div>
);
}
return (
<div
className={[
'layout-playground__splitter',
`layout-playground__splitter--depth-${Math.min(depth, 4)}`,
previewMode ? 'layout-playground__splitter--preview' : '',
].join(' ')}
>
<Splitter
layout={node.axis}
className="layout-playground__splitter-frame"
onResize={(sizes) => {
if (!selectable) {
return;
}
setLayoutTree((previous) =>
previous
? updateSplitNode(previous, node.id, (current) => ({
...current,
previewSizes: sizes,
}))
: previous,
);
setActiveSavedLayoutId(null);
}}
>
<Splitter.Panel size={primaryPanelSize} min={primaryPanelMin} resizable={node.resizable}>
{renderNode(node.children[0], depth + 1, {
...options,
paneSizingMeta: {
axis: node.axis,
sizeSummary: describePaneSizing(node, 0),
},
})}
</Splitter.Panel>
<Splitter.Panel size={secondaryPanelSize} min={secondaryPanelMin} resizable={node.resizable}>
{renderNode(node.children[1], depth + 1, {
...options,
paneSizingMeta: {
axis: node.axis,
sizeSummary: describePaneSizing(node, 1),
},
})}
</Splitter.Panel>
</Splitter>
{renderToggleButtons()}
</div>
);
};
const showSavedLayoutGallery = showSavedLayoutsOnly || isSavedLayoutsOpen;
const selectedSavedInteractionLeafMap = useMemo(
() => new Map(collectLeafNodes(selectedSavedLayoutPayload?.root ?? null).map((leaf) => [leaf.id, leaf])),
[selectedSavedLayoutPayload],
);
const savedLayoutRecordMap = useMemo(() => new Map(savedLayouts.map((record) => [record.id, record])), [savedLayouts]);
const selectedSavedLayoutPrompt = useMemo(
() =>
selectedSavedLayoutRecord
? buildCodexRequestText({
layoutName: selectedSavedLayoutRecord.name,
rules: selectedSavedLayoutPayload?.interactions ?? [],
leafMap: selectedSavedInteractionLeafMap,
root: selectedSavedLayoutPayload?.root ?? null,
})
: '',
[selectedSavedInteractionLeafMap, selectedSavedLayoutPayload, selectedSavedLayoutRecord],
);
const currentInteractionPrompt = useMemo(
() =>
buildCodexRequestText({
layoutName: layoutName.trim() || activeSavedLayoutId || '현재 레이아웃',
rules: interactionRules,
leafMap: interactionLeafMap,
root: layoutTree,
}),
[activeSavedLayoutId, interactionLeafMap, interactionRules, layoutName, layoutTree],
);
const hasCurrentPrompt = Boolean(layoutTree);
const isSavedLayoutReadyForCodex = Boolean(activeSavedLayoutId && layoutTree);
const handleCopyCodexPrompt = async (prompt: string) => {
if (typeof navigator === 'undefined' || !navigator.clipboard) {
messageApi.error('클립보드 복사를 지원하지 않는 환경입니다.');
return;
}
try {
await navigator.clipboard.writeText(prompt);
messageApi.success('Codex 요청문을 복사했습니다.');
} catch {
messageApi.error('Codex 요청문 복사에 실패했습니다.');
}
};
return (
<>
{messageContextHolder}
<SearchCommandModal
open={isComponentSelectorOpen}
options={componentSearchOptions}
mode="navigate"
title="컴포넌트 조회"
description="섹션에 채울 컴포넌트나 위젯을 찾습니다."
placeholder="이름이나 키워드 검색"
submitHint="선택하면 현재 section에 바로 채웁니다."
onClose={() => {
setIsComponentSelectorOpen(false);
}}
onSelectOption={handleSelectComponent}
/>
{savedLayoutViewId ? (
selectedSavedLayoutRecord ? (
<div className="layout-playground__fullscreen-shell layout-playground__fullscreen-shell--embedded layout-playground__fullscreen-shell--device layout-playground__fullscreen-shell--saved-menu">
<div className="layout-playground__saved-device-surface layout-playground__saved-device-surface--record layout-playground__saved-device-surface--menu">
{selectedSavedLayoutPayload?.root ? (
<>
<StockAlertLayoutProvider>
<LayoutSamplePreview>
{renderNode(selectedSavedLayoutPayload.root as LayoutNode, 1, {
selectedId: null,
selectable: false,
previewMode: true,
previewSurface: true,
hidePaneLabel: true,
componentBodyScrollable: true,
showComponentActions: false,
interactionRules: selectedSavedLayoutPayload.interactions,
activeInteractionIds,
interactionLeafMap: selectedSavedInteractionLeafMap,
hideInteractionOverlays: true,
scopeKey: selectedSavedLayoutRecord.id,
onPreviewActionClick: () => {
void openCodexLiveForPrompt(selectedSavedLayoutPrompt);
},
})}
</LayoutSamplePreview>
</StockAlertLayoutProvider>
</>
) : (
<div className="layout-playground__saved-empty-surface">
<Text strong>{selectedSavedLayoutRecord.name}</Text>
<Text type="secondary"> .</Text>
</div>
)}
</div>
</div>
) : (
<div className="layout-playground__fullscreen-shell layout-playground__fullscreen-shell--embedded">
<div className="layout-playground__empty-state layout-playground__empty-state--fullscreen">
<Tag color="default">Saved Layout</Tag>
<Title level={4}> .</Title>
<Paragraph> .</Paragraph>
</div>
</div>
)
) : showSavedLayoutGallery ? (
<div className="layout-playground__fullscreen-shell layout-playground__fullscreen-shell--embedded">
<div className="layout-playground__saved-browser">
{isLoadingSavedLayouts ? (
<div className="layout-playground__saved-browser-loading">
<List loading dataSource={[]} />
</div>
) : savedLayouts.length ? (
<div
className="layout-playground__saved-gallery"
style={
{
'--layout-gallery-columns': String(savedLayoutGrid.columns),
'--layout-gallery-rows': String(savedLayoutGrid.rows),
} as CSSProperties
}
>
{savedLayouts.map((record) => {
const savedPayload = normalizeStoredLayoutPayload(record.tree);
const savedLeafMap = new Map(collectLeafNodes(savedPayload.root).map((leaf) => [leaf.id, leaf]));
return (
<article
key={record.id}
className={record.id === savedLayoutPreviewId ? 'layout-playground__saved-card layout-playground__saved-card--active' : 'layout-playground__saved-card'}
onClick={() => {
setSavedLayoutPreviewId(record.id);
}}
>
<div className="layout-playground__saved-card-head">
<Space wrap size={8}>
<Text strong>{record.name}</Text>
{!showSavedLayoutsOnly ? <Tag color="cyan">{PRESET_LABELS[record.axis]}</Tag> : null}
</Space>
{!showSavedLayoutsOnly ? (
<Space size={4} wrap>
<Button
key="load"
type="link"
onClick={(event) => {
event.stopPropagation();
handleLoadLayout(record);
setIsSavedLayoutsOpen(false);
}}
>
</Button>
<Popconfirm
key="delete"
title="저장된 레이아웃을 삭제할까요?"
okText="삭제"
cancelText="취소"
onConfirm={() => void handleDeleteLayout(record)}
>
<Button
type="link"
danger
icon={<DeleteOutlined />}
onClick={(event) => {
event.stopPropagation();
}}
>
</Button>
</Popconfirm>
</Space>
) : (
<Tag>{record.totalPanes} panes</Tag>
)}
</div>
<div className="layout-playground__preview-frame layout-playground__preview-frame--gallery">
{savedPayload.root ? (
<StockAlertLayoutProvider>
<LayoutSamplePreview>
{renderNode(savedPayload.root as LayoutNode, 1, {
selectedId: record.selectedLeafId,
selectable: false,
previewMode: true,
previewSurface: true,
hidePaneLabel: true,
componentBodyScrollable: true,
interactionRules: savedPayload.interactions,
interactionLeafMap: savedLeafMap,
hideInteractionOverlays: true,
scopeKey: record.id,
onPreviewActionClick: () => {
void openCodexLiveForPrompt(
buildCodexRequestText({
layoutName: record.name,
rules: savedPayload.interactions,
leafMap: savedLeafMap,
root: savedPayload.root,
}),
);
},
})}
</LayoutSamplePreview>
</StockAlertLayoutProvider>
) : (
<div className="layout-playground__saved-card-empty">
<Text type="secondary"> </Text>
</div>
)}
</div>
</article>
);
})}
</div>
) : (
<div className="layout-playground__empty-state layout-playground__empty-state--fullscreen">
<Tag color="default">Saved Layouts</Tag>
<Title level={4}> .</Title>
<Paragraph> .</Paragraph>
</div>
)}
</div>
</div>
) : (
<Card title="Layout Editor" className="app-main-card layout-playground__editor-card" bordered={false}>
<Space direction="vertical" size={20} className="app-main-stack layout-playground__editor-stack">
<div className="layout-playground__hero">
<div>
<Title level={4} className="layout-playground__title">
Layout Editor
</Title>
</div>
<div className="layout-playground__meta">
<span>
<AppstoreOutlined />
<Text> : {totalPanes}</Text>
</span>
<span>
<DragOutlined />
<Text> : {splitEditor.resizable ? '허용' : '고정'}</Text>
</span>
<span>
<ColumnWidthOutlined />
<Text> : {selectedLeaf?.label ?? '없음'}</Text>
</span>
<span>
<Text>
: {selectedLeaf?.showHideAction ? `사용 · ${COLLAPSE_DIRECTION_LABELS[selectedLeaf?.collapseDirection ?? 'auto']}` : '미사용'}
</Text>
</span>
</div>
</div>
<div className="layout-playground__controls">
<div className="layout-playground__control-card">
<Text strong>{selectedSplit ? '선택 section 리사이즈' : '새 분할 리사이즈'}</Text>
<Switch
checked={splitEditor.resizable}
checkedChildren="ON"
unCheckedChildren="OFF"
onChange={(checked) => {
setSplitEditor((previous) => ({
...previous,
resizable: checked,
}));
}}
/>
</div>
<div className="layout-playground__control-card">
<Text strong> section </Text>
<Space direction="vertical" size={8}>
<Text type="secondary">{selectedLeaf?.label ?? '먼저 section을 선택하세요.'}</Text>
<Button
type="primary"
icon={<DeploymentUnitOutlined />}
block
disabled={!selectedLeaf}
onClick={() => {
if (!selectedLeaf) {
return;
}
openComponentSelector(selectedLeaf.id);
}}
>
{selectedLeaf?.componentBinding ? '컴포넌트 변경' : '컴포넌트 등록'}
</Button>
<Text type="secondary">
{selectedLeaf?.componentBinding
? `현재 컴포넌트: ${selectedLeaf.componentBinding.label}`
: '아직 등록된 컴포넌트가 없습니다.'}
</Text>
<Space wrap>
<Text> </Text>
<Switch
checked={selectedLeaf?.showHideAction ?? false}
checkedChildren="ON"
unCheckedChildren="OFF"
disabled={!selectedLeaf}
onChange={(checked) => {
updateSelectedLeafOptions((current) => ({
...current,
showHideAction: checked,
}));
}}
/>
</Space>
<Space wrap align="center">
<Text> </Text>
<Select<LayoutCollapseDirection>
value={selectedLeaf?.collapseDirection ?? 'auto'}
style={{ minWidth: 116 }}
disabled={!selectedLeaf || !(selectedLeaf?.showHideAction ?? false)}
options={[
{ value: 'auto', label: '자동' },
{ value: 'left', label: '좌' },
{ value: 'right', label: '우' },
{ value: 'up', label: '상' },
{ value: 'down', label: '하' },
]}
onChange={(value) => {
updateSelectedLeafOptions((current) => ({
...current,
collapseDirection: value,
}));
}}
/>
</Space>
<Space wrap>
<Text> </Text>
<Switch
checked={selectedLeaf?.useContentHeight ?? false}
checkedChildren="ON"
unCheckedChildren="OFF"
disabled={!selectedLeaf || !selectedSplit || selectedSplit.axis !== 'vertical' || !selectedLeaf.componentBinding}
onChange={(checked) => {
updateSelectedLeafContentFit(checked);
}}
/>
</Space>
<Text type="secondary">
{selectedSplit?.axis === 'vertical'
? selectedLeaf?.componentBinding
? '이 section만 컴포넌트 높이에 맞추고 나머지 영역은 다른 section이 사용합니다.'
: '컴포넌트를 먼저 배치해야 높이 자동 맞춤을 사용할 수 있습니다.'
: '높이 자동 맞춤은 상하 분할 section에서만 사용할 수 있습니다.'}
</Text>
</Space>
</div>
<div className="layout-playground__control-card layout-playground__control-card--wide">
<Text strong>{selectedSplit ? '선택 section 크기' : '새 분할 크기'}</Text>
<div className="layout-playground__size-grid">
<label className="layout-playground__size-row">
<span className="layout-playground__size-row-label">
{selectedSplit ? '현재 section width/height' : '기본 크기'}
</span>
<Space.Compact className="layout-playground__size-row-field">
<InputNumber
min={splitEditor.sizeUnit === '%' ? 10 : 1}
max={splitEditor.sizeUnit === '%' ? 90 : 960}
value={splitEditor.primarySize}
onChange={(value) => {
setSplitEditor((previous) => ({
...previous,
primarySize: normalizeValue(Number(value ?? 0), previous.sizeUnit),
}));
}}
/>
<Button
className="layout-playground__unit-toggle"
onClick={() => {
setSplitEditor((previous) => {
const nextUnit = previous.sizeUnit === '%' ? 'px' : '%';
return {
...previous,
sizeUnit: nextUnit,
primarySize: normalizeValue(previous.primarySize, nextUnit),
primaryMin: normalizeValue(previous.primaryMin, nextUnit),
secondaryMin: normalizeValue(previous.secondaryMin, nextUnit),
};
});
}}
>
{splitEditor.sizeUnit}
</Button>
</Space.Compact>
</label>
<label className="layout-playground__size-row">
<span className="layout-playground__size-row-label">
{selectedSplit ? '현재 section 최소' : '1번 최소'}
</span>
<InputNumber
min={splitEditor.sizeUnit === '%' ? 10 : 1}
max={splitEditor.sizeUnit === '%' ? 80 : 720}
value={splitEditor.primaryMin}
onChange={(value) => {
setSplitEditor((previous) => ({
...previous,
primaryMin: normalizeValue(Number(value ?? 0), previous.sizeUnit),
}));
}}
/>
</label>
<label className="layout-playground__size-row">
<span className="layout-playground__size-row-label">
{selectedSplit ? '반대 section 최소' : '2번 최소'}
</span>
<InputNumber
min={splitEditor.sizeUnit === '%' ? 10 : 1}
max={splitEditor.sizeUnit === '%' ? 80 : 720}
value={splitEditor.secondaryMin}
onChange={(value) => {
setSplitEditor((previous) => ({
...previous,
secondaryMin: normalizeValue(Number(value ?? 0), previous.sizeUnit),
}));
}}
/>
</label>
{selectedCrossAxisContext ? (
<label className="layout-playground__size-row">
<span className="layout-playground__size-row-label">
{selectedCrossAxisContext.split.axis === 'vertical' ? '같은 라인 height' : '같은 라인 width'}
</span>
<Space.Compact className="layout-playground__size-row-field">
<InputNumber
min={crossAxisEditor.sizeUnit === '%' ? 10 : 1}
max={crossAxisEditor.sizeUnit === '%' ? 90 : 960}
value={crossAxisEditor.primarySize}
onChange={(value) => {
setCrossAxisEditor((previous) => ({
...previous,
primarySize: normalizeValue(Number(value ?? 0), previous.sizeUnit),
}));
}}
/>
<Button
className="layout-playground__unit-toggle"
onClick={() => {
setCrossAxisEditor((previous) => {
const nextUnit = previous.sizeUnit === '%' ? 'px' : '%';
return {
...previous,
sizeUnit: nextUnit,
primarySize: normalizeValue(previous.primarySize, nextUnit),
primaryMin: normalizeValue(previous.primaryMin, nextUnit),
secondaryMin: normalizeValue(previous.secondaryMin, nextUnit),
};
});
}}
>
{crossAxisEditor.sizeUnit}
</Button>
</Space.Compact>
</label>
) : null}
</div>
<Text type="secondary" className="layout-playground__size-help">
{selectedSplit
? `${selectedSplit.axis === 'horizontal' ? '현재 선택 section은 width를 조절합니다.' : '현재 선택 section은 height를 조절합니다.'}${selectedCrossAxisContext ? ' 같은 라인 height/width도 여기서 함께 적용됩니다.' : ''} 숫자 입력 뒤 아래 "선택 section 설정 적용"을 누르세요.`
: '선택된 section이 없으면 현재 값이 다음 분할 기본값으로 사용됩니다.'}
</Text>
</div>
<div className="layout-playground__control-card layout-playground__control-card--wide">
<Text strong> </Text>
<Text type="secondary">
. API .
</Text>
<div className="layout-playground__interaction-editor">
<Select
allowClear
showSearch
placeholder="출발 section 선택사항"
value={interactionDraft.sourceLeafId || undefined}
options={leafOptions}
optionFilterProp="label"
notFoundContent={layoutTree ? '선택 가능한 section이 없습니다.' : '레이아웃 section을 먼저 추가하세요.'}
onChange={(value) => {
setInteractionDraft((previous) => ({
...previous,
sourceLeafId: value ?? '',
}));
}}
/>
<Select
allowClear
showSearch
placeholder="도착 section 선택사항"
value={interactionDraft.targetLeafId || undefined}
options={leafOptions}
optionFilterProp="label"
notFoundContent={layoutTree ? '선택 가능한 section이 없습니다.' : '레이아웃 section을 먼저 추가하세요.'}
onChange={(value) => {
setInteractionDraft((previous) => ({
...previous,
targetLeafId: value ?? '',
}));
}}
/>
<Select
allowClear
showSearch
placeholder="출발 component 선택사항"
value={interactionDraft.sourceComponentOptionId || undefined}
options={interactionComponentOptions}
optionFilterProp="label"
notFoundContent="선택 가능한 component가 없습니다."
onChange={(value) => {
setInteractionDraft((previous) => ({
...previous,
sourceComponentOptionId: value ?? '',
}));
}}
/>
<Select
allowClear
showSearch
placeholder="도착 component 선택사항"
value={interactionDraft.targetComponentOptionId || undefined}
options={interactionComponentOptions}
optionFilterProp="label"
notFoundContent="선택 가능한 component가 없습니다."
onChange={(value) => {
setInteractionDraft((previous) => ({
...previous,
targetComponentOptionId: value ?? '',
}));
}}
/>
<Input
placeholder="기능 타이틀"
value={interactionDraft.title}
onChange={(event) => {
setInteractionDraft((previous) => ({
...previous,
title: event.target.value,
}));
}}
/>
<Input.TextArea
rows={3}
placeholder="예: 메모 저장 버튼을 누르면 target section 목록을 새로고침하고 서버 응답 상태를 바로 반영합니다."
value={interactionDraft.description}
onChange={(event) => {
setInteractionDraft((previous) => ({
...previous,
description: event.target.value,
}));
}}
/>
<Input.TextArea
rows={3}
placeholder="예: source onSubmit -> API POST -> 성공 시 target query refetch, shared hook/state로 loading 동기화"
value={interactionDraft.implementationNotes}
onChange={(event) => {
setInteractionDraft((previous) => ({
...previous,
implementationNotes: event.target.value,
}));
}}
/>
<Space wrap>
<Button type="primary" onClick={handleSubmitInteractionRule} disabled={!layoutTree}>
{editingInteractionRuleId ? '기능 명세 수정 저장' : '기능 명세 추가'}
</Button>
{editingInteractionRuleId ? (
<Button icon={<CloseOutlined />} onClick={() => resetInteractionDraft()}>
</Button>
) : null}
</Space>
</div>
<div className="layout-playground__interaction-list">
{interactionRules.length ? (
<>
{interactionRules.map((rule, index) => (
<article key={rule.id} className="layout-playground__interaction-list-item">
<div className="layout-playground__interaction-list-head">
<Space wrap size={8}>
<Tag color="blue">{index + 1}</Tag>
<Text strong>{rule.title}</Text>
</Space>
<Space size={4}>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => {
handleEditInteractionRule(rule.id);
}}
>
</Button>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={() => {
handleDeleteInteractionRule(rule.id);
}}
>
</Button>
</Space>
</div>
<Text type="secondary">
{describeInteractionScope(rule, interactionLeafMap)}
</Text>
<Text type="secondary">
: {describeInteractionComponents(rule, interactionLeafMap)}
</Text>
<Paragraph>{rule.description}</Paragraph>
{rule.implementationNotes ? (
<div className="layout-playground__interaction-implementation">
<Text strong>hooks / </Text>
<Paragraph>{rule.implementationNotes}</Paragraph>
</div>
) : null}
<Space wrap size={8} className="layout-playground__interaction-item-actions">
<Button
icon={<CopyOutlined />}
onClick={() => {
void handleCopyCodexPrompt(
buildCodexRequestText({
layoutName: layoutName.trim() || '현재 레이아웃',
rules: [rule],
leafMap: interactionLeafMap,
root: layoutTree,
}),
);
}}
>
</Button>
<Button
type="primary"
icon={<MessageOutlined />}
disabled={!isSavedLayoutReadyForCodex}
onClick={() => {
openCodexLiveForPrompt(
buildCodexRequestText({
layoutName: layoutName.trim() || '현재 레이아웃',
rules: [rule],
leafMap: interactionLeafMap,
root: layoutTree,
}),
);
}}
>
Codex
</Button>
</Space>
</article>
))}
</>
) : (
<div className="layout-playground__interaction-empty">
<Text type="secondary"> .</Text>
</div>
)}
</div>
</div>
</div>
<div className="layout-playground__actions">
<Text strong> section </Text>
<Text type="secondary">
{selectedSplit
? `현재 선택 section 부모 분할: ${PRESET_LABELS[selectedSplit.axis]} / 선택 section ${formatPanelValue(splitEditor.primarySize, splitEditor.sizeUnit)} / 반대 section은 남은 영역`
: '선택 section이 없으면 현재 입력값이 새 분할 기본값으로 사용됩니다.'}
</Text>
<Space wrap>
<Button type="primary" icon={<ColumnWidthOutlined />} onClick={() => splitSelectedLeaf('horizontal')} disabled={!layoutTree}>
</Button>
<Button icon={<ColumnHeightOutlined />} onClick={() => splitSelectedLeaf('vertical')} disabled={!layoutTree}>
</Button>
<Button icon={<ReloadOutlined />} onClick={applySettingsToSelectedSection} disabled={!selectedSplit}>
section
</Button>
<Button icon={<ReloadOutlined />} onClick={resetLayout}>
</Button>
</Space>
</div>
<div className="layout-playground__preview-wrap">
<div className="layout-playground__preview-head">
<Text strong>Preview</Text>
<Text type="secondary">
, section .
</Text>
</div>
<div className="layout-playground__preview-frame">
{layoutTree ? (
<LayoutSamplePreview>
{renderNode(layoutTree, 1, {
previewMode: true,
previewSurface: true,
hidePaneLabel: true,
showComponentActions: true,
interactionRules,
activeInteractionIds: [],
hideInteractionOverlays: true,
scopeKey: 'editor',
onPreviewActionClick: () => {
void openCodexLiveForPrompt(currentInteractionPrompt);
},
})}
</LayoutSamplePreview>
) : (
<div className="layout-playground__empty-state">
<Tag color="blue">Step 1</Tag>
<Title level={4}> .</Title>
<Paragraph>
section에 .
</Paragraph>
<Space wrap>
<Button type="primary" icon={<ColumnWidthOutlined />} onClick={() => startLayout('horizontal')}>
</Button>
<Button icon={<ColumnHeightOutlined />} onClick={() => startLayout('vertical')}>
</Button>
</Space>
</div>
)}
</div>
</div>
<div className="layout-playground__bottom-menu">
<div className="layout-playground__bottom-menu-copy">
<Text strong> / </Text>
<Paragraph className="layout-playground__storage-copy">
.
</Paragraph>
<Text type="secondary" className="layout-playground__codex-chat-type-copy">
Codex
</Text>
<Select
size="small"
value={selectedCodexChatType?.id ?? undefined}
placeholder="채팅유형 선택"
className="layout-playground__codex-chat-type-select"
options={availableChatTypes.map((item) => ({
value: item.id,
label: item.name,
}))}
onChange={(nextValue) => {
setSelectedCodexChatTypeId(nextValue);
}}
/>
</div>
<div className="layout-playground__bottom-menu-actions">
<Tag color={isSavedLayoutReadyForCodex ? 'blue' : 'warning'}>
{isSavedLayoutReadyForCodex ? '저장 완료' : '저장 필요'}
</Tag>
<Space.Compact className="layout-playground__storage-input">
<Input
placeholder="예: 운영 대시보드 3단"
value={layoutName}
onChange={(event) => {
setLayoutName(event.target.value);
setActiveSavedLayoutId(null);
}}
onPressEnter={() => {
void handleSaveLayout();
}}
/>
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void handleSaveLayout()}>
</Button>
</Space.Compact>
<Button
type="primary"
icon={<MessageOutlined />}
loading={isSaving}
disabled={!selectedCodexChatType}
onClick={() => void handleSaveLayout({ openCodexAfterSave: true })}
>
Codex
</Button>
<Button
ghost
icon={<MessageOutlined />}
disabled={!hasCurrentPrompt || !isSavedLayoutReadyForCodex || !selectedCodexChatType}
onClick={() => {
openCodexLiveForPrompt(currentInteractionPrompt);
}}
>
Codex
</Button>
<Button
icon={<CopyOutlined />}
disabled={!hasCurrentPrompt}
onClick={() => {
void handleCopyCodexPrompt(currentInteractionPrompt);
}}
>
</Button>
<Button
icon={<AppstoreOutlined />}
htmlType="button"
onClick={() => {
setSavedLayoutPreviewId((previous) => previous ?? activeSavedLayoutId ?? savedLayouts[0]?.id ?? null);
setIsSavedLayoutsOpen(true);
}}
>
</Button>
</div>
</div>
<Divider className="layout-playground__divider" />
<div className="layout-playground__code">
<Text strong> </Text>
<pre className="layout-playground__code-block">{layoutCss}</pre>
</div>
</Space>
</Card>
)}
</>
);
}