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) { 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 = { horizontal: '좌우 분할', vertical: '상하 분할', }; const COLLAPSE_DIRECTION_LABELS: Record = { auto: '자동', left: '좌', right: '우', up: '상', down: '하', }; const TONE_CLASS_NAMES: Record = { primary: 'layout-playground__pane--primary', secondary: 'layout-playground__pane--secondary', accent: 'layout-playground__pane--accent', neutral: 'layout-playground__pane--neutral', }; const LEAF_PRESETS: Array> = [ { 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(); 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 { if (axis === 'horizontal') { return childIndex === 0 ? 'left' : 'right'; } return childIndex === 0 ? 'up' : 'down'; } function resolveCollapseDirection( axis: LayoutAxis, childIndex: number, preferredDirection?: LayoutCollapseDirection, ): Exclude { if (preferredDirection && preferredDirection !== 'auto') { return preferredDirection; } return resolveAutoCollapseDirection(axis, childIndex); } function renderCollapseMovementIcon(direction: Exclude, collapsed: boolean) { if (direction === 'left') { return collapsed ? : ; } if (direction === 'right') { return collapsed ? : ; } if (direction === 'up') { return collapsed ? : ; } return collapsed ? : ; } 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; 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) || 'interactions' in (value as Record)); } function normalizeInteractionRule(value: unknown): LayoutInteractionRule | null { if (!value || typeof value !== 'object') { return null; } const candidate = value as Partial; 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; 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) { 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) { 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; 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; 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('horizontal'); const [splitEditor, setSplitEditor] = useState({ sizeUnit: '%', primarySize: 42, primaryMin: 24, secondaryMin: 20, resizable: true, }); const [layoutName, setLayoutName] = useState(''); const [savedLayouts, setSavedLayouts] = useState([]); const [isSaving, setIsSaving] = useState(false); const [isLoadingSavedLayouts, setIsLoadingSavedLayouts] = useState(true); const [activeSavedLayoutId, setActiveSavedLayoutId] = useState(null); const [isSavedLayoutsOpen, setIsSavedLayoutsOpen] = useState(false); const [savedLayoutPreviewId, setSavedLayoutPreviewId] = useState(null); const [sampleEntries, setSampleEntries] = useState([]); const [isComponentSelectorOpen, setIsComponentSelectorOpen] = useState(false); const [componentSelectorLeafId, setComponentSelectorLeafId] = useState(null); const [collapsedLeafIds, setCollapsedLeafIds] = useState([]); const [measuredContentHeights, setMeasuredContentHeights] = useState>({}); const [interactionRules, setInteractionRules] = useState([]); const [interactionDraft, setInteractionDraft] = useState({ sourceLeafId: '', targetLeafId: '', sourceComponentOptionId: '', targetComponentOptionId: '', title: '', description: '', implementationNotes: '', }); const [editingInteractionRuleId, setEditingInteractionRuleId] = useState(null); const [activeInteractionIds, setActiveInteractionIds] = useState([]); const nodeIdRef = useRef(0); const leafPresetRef = useRef(0); const contentHeightObserverRef = useRef(new Map()); 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(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(null); const [selectedLeafId, setSelectedLeafId] = useState(null); const [crossAxisEditor, setCrossAxisEditor] = useState({ 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(); 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( () => 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((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; 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' ? : node.componentBinding?.optionId === 'widget:ag-grid-widget:ag-grid-widget' ? : null : previewSurface ? !BoundSample ? ( { layoutPreviewRuntime.setEmptyPaneReadiness(scopedLeafId, nextValue); }} onNoteChange={(nextValue) => { layoutPreviewRuntime.setEmptyPaneNote(scopedLeafId, nextValue); }} /> ) : previewBindingKind === 'text-memo-widget' ? ( { 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' ? ( ) : previewBindingKind === 'select-input' ? ( { layoutPreviewRuntime.setSelectValue(scopedLeafId, nextCode, item); }} /> ) : previewBindingKind === 'base-input' ? ( ) : 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 (
{ if (selectable) { onSelect(node.id); } }} onKeyDown={(event) => { if (selectable && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); onSelect(node.id); } }} > {showPaneToolbar ? (
{showPaneLabel ? {node.label} : null}
{showChangeComponentButton ? ( ) : null} {showSelectPaneButton ? ( ) : null} {showSelectComponentButton ? ( ) : null}
) : null} {customPreviewBody ? (
{customPreviewBody}
) : BoundSample ? (
) : (
{node.componentBinding ? '컴포넌트 연결됨' : '컴포넌트 미지정'} {node.label} {node.componentBinding?.label?.trim() ? node.componentBinding.label.trim() : previewSurface ? '연결된 컴포넌트 없이 구조만 저장된 section입니다.' : hidePaneLabel ? '섹션을 선택한 뒤 컴포넌트를 배치하세요.' : isSelected ? '선택된 영역' : `Depth ${depth}`}
)}
); } 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 (
{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 (
); }; if (collapsedChildIndex >= 0) { const expandedChild = node.children[collapsedChildIndex === 0 ? 1 : 0]; return (
{renderNode(expandedChild, depth + 1, options)} {renderToggleButtons()}
); } return (
{ if (!selectable) { return; } setLayoutTree((previous) => previous ? updateSplitNode(previous, node.id, (current) => ({ ...current, previewSizes: sizes, })) : previous, ); setActiveSavedLayoutId(null); }} > {renderNode(node.children[0], depth + 1, { ...options, paneSizingMeta: { axis: node.axis, sizeSummary: describePaneSizing(node, 0), }, })} {renderNode(node.children[1], depth + 1, { ...options, paneSizingMeta: { axis: node.axis, sizeSummary: describePaneSizing(node, 1), }, })} {renderToggleButtons()}
); }; 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} { setIsComponentSelectorOpen(false); }} onSelectOption={handleSelectComponent} /> {savedLayoutViewId ? ( selectedSavedLayoutRecord ? (
{selectedSavedLayoutPayload?.root ? ( <> {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); }, })} ) : (
{selectedSavedLayoutRecord.name} 분할되지 않은 빈 레이아웃입니다.
)}
) : (
Saved Layout 저장된 레이아웃을 찾을 수 없습니다. 선택한 레이아웃이 삭제되었거나 아직 목록을 불러오지 못했습니다.
) ) : showSavedLayoutGallery ? (
{isLoadingSavedLayouts ? (
) : savedLayouts.length ? (
{savedLayouts.map((record) => { const savedPayload = normalizeStoredLayoutPayload(record.tree); const savedLeafMap = new Map(collectLeafNodes(savedPayload.root).map((leaf) => [leaf.id, leaf])); return (
{ setSavedLayoutPreviewId(record.id); }} >
{record.name} {!showSavedLayoutsOnly ? {PRESET_LABELS[record.axis]} : null} {!showSavedLayoutsOnly ? ( void handleDeleteLayout(record)} > ) : ( {record.totalPanes} panes )}
{savedPayload.root ? ( {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, }), ); }, })} ) : (
빈 레이아웃
)}
); })}
) : (
Saved Layouts 저장된 레이아웃이 없습니다. 편집 화면에서 레이아웃을 저장하면 이 화면에 전체 목록이 바로 표시됩니다.
)}
) : (
Layout Editor
총 섹션: {totalPanes} 리사이즈 프리셋: {splitEditor.resizable ? '허용' : '고정'} 선택 섹션: {selectedLeaf?.label ?? '없음'} 접기 버튼: {selectedLeaf?.showHideAction ? `사용 · ${COLLAPSE_DIRECTION_LABELS[selectedLeaf?.collapseDirection ?? 'auto']}` : '미사용'}
{selectedSplit ? '선택 section 리사이즈' : '새 분할 리사이즈'} { setSplitEditor((previous) => ({ ...previous, resizable: checked, })); }} />
선택 section 옵션 {selectedLeaf?.label ?? '먼저 section을 선택하세요.'} {selectedLeaf?.componentBinding ? `현재 컴포넌트: ${selectedLeaf.componentBinding.label}` : '아직 등록된 컴포넌트가 없습니다.'} 접기 버튼 { updateSelectedLeafOptions((current) => ({ ...current, showHideAction: checked, })); }} /> 접기 방향 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, })); }} /> 컴포넌트 높이만 사용 { updateSelectedLeafContentFit(checked); }} /> {selectedSplit?.axis === 'vertical' ? selectedLeaf?.componentBinding ? '이 section만 컴포넌트 높이에 맞추고 나머지 영역은 다른 section이 사용합니다.' : '컴포넌트를 먼저 배치해야 높이 자동 맞춤을 사용할 수 있습니다.' : '높이 자동 맞춤은 상하 분할 section에서만 사용할 수 있습니다.'}
{selectedSplit ? '선택 section 크기' : '새 분할 크기'}
{selectedCrossAxisContext ? ( ) : null}
{selectedSplit ? `${selectedSplit.axis === 'horizontal' ? '현재 선택 section은 width를 조절합니다.' : '현재 선택 section은 height를 조절합니다.'}${selectedCrossAxisContext ? ' 같은 라인 height/width도 여기서 함께 적용됩니다.' : ''} 숫자 입력 뒤 아래 "선택 section 설정 적용"을 누르세요.` : '선택된 section이 없으면 현재 값이 다음 분할 기본값으로 사용됩니다.'}
컴포넌트 기능 명세 자동으로 채우지 않고 필요한 기능만 직접 저장합니다. 컴포넌트 간 연결과 API 연결도 여기서 명세할 수 있습니다.
{ setInteractionDraft((previous) => ({ ...previous, targetLeafId: value ?? '', })); }} /> { setInteractionDraft((previous) => ({ ...previous, targetComponentOptionId: value ?? '', })); }} /> { setInteractionDraft((previous) => ({ ...previous, title: event.target.value, })); }} /> { setInteractionDraft((previous) => ({ ...previous, description: event.target.value, })); }} /> { setInteractionDraft((previous) => ({ ...previous, implementationNotes: event.target.value, })); }} /> {editingInteractionRuleId ? ( ) : null}
{interactionRules.length ? ( <> {interactionRules.map((rule, index) => (
{index + 1} {rule.title}
{describeInteractionScope(rule, interactionLeafMap)} 관련 컴포넌트: {describeInteractionComponents(rule, interactionLeafMap)} {rule.description} {rule.implementationNotes ? (
hooks / 구현 메모 {rule.implementationNotes}
) : null}
))} ) : (
아직 등록된 기능 명세가 없습니다.
)}
선택 section 추가 분할 {selectedSplit ? `현재 선택 section 부모 분할: ${PRESET_LABELS[selectedSplit.axis]} / 선택 section ${formatPanelValue(splitEditor.primarySize, splitEditor.sizeUnit)} / 반대 section은 남은 영역` : '선택 section이 없으면 현재 입력값이 새 분할 기본값으로 사용됩니다.'}
Preview 처음에는 원하는 방향으로 시작하고, 이후에는 각 section 우하단의 선택 버튼이나 영역 탭으로 고른 뒤 좌우 또는 상하 분할을 계속 추가할 수 있습니다.
{layoutTree ? ( {renderNode(layoutTree, 1, { previewMode: true, previewSurface: true, hidePaneLabel: true, showComponentActions: true, interactionRules, activeInteractionIds: [], hideInteractionOverlays: true, scopeKey: 'editor', onPreviewActionClick: () => { void openCodexLiveForPrompt(currentInteractionPrompt); }, })} ) : (
Step 1 처음 시작할 분할 방향을 선택하세요. 첫 레이아웃을 만든 뒤에는 선택한 section에 좌우 또는 상하 분할을 계속 추가하면서 리사이즈 프리뷰를 바로 확인할 수 있습니다.
)}
레이아웃 저장 / 목록 현재 편집 결과와 기능 명세 항목을 함께 저장합니다. Codex 실행 유형 { setLayoutName(event.target.value); setActiveSavedLayoutId(null); }} onPressEnter={() => { void handleSaveLayout(); }} />
적용 기준
{layoutCss}
)} ); }