3128 lines
118 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|