2f398ae0b3
- 제어모드 IDE: ControlCardPanel, control/ide/* (Canvas/LeftRail/RightRail/PanZoomStage/V3RuleNode 등), schemas, lib/api/control - 레지스트리 정리: aggregation-widget, status-count, section-card/paper, table-list(legacy/v2), tabs-widget 폐기 → table/_shared/ 로 통합 - InvLegacyButtonConfigPanel cp 마이그레이션 - canonical data view cleanup 후속 노트
603 lines
22 KiB
TypeScript
603 lines
22 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState } from "react";
|
|
import { ComponentRendererProps } from "@/types/component";
|
|
import {
|
|
ContainerConfig,
|
|
ContainerType,
|
|
ContainerTab,
|
|
ContainerChildComponent,
|
|
} from "./types";
|
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
|
|
|
/**
|
|
* Container — 통합 레이아웃 컨테이너 컴포넌트
|
|
*
|
|
* containerType 으로 탭/섹션/아코디언/반복/조건부 분기.
|
|
*
|
|
* - Phase C-2 최소 구현: 각 모드는 스켈레톤 렌더.
|
|
* - Phase G.2: `containerType === "tabs"` 만 활성 탭의 자식 컴포넌트들을
|
|
* `DynamicComponentRenderer` 로 렌더. 비활성 탭은 렌더하지 않음 (성능).
|
|
* section / accordion / repeater / conditional 은 여전히 스켈레톤 — 별도 phase.
|
|
*/
|
|
|
|
const VALID_TYPES: ContainerType[] = [
|
|
"tabs",
|
|
"section",
|
|
"accordion",
|
|
"repeater",
|
|
"conditional",
|
|
];
|
|
|
|
export interface ContainerComponentProps extends ComponentRendererProps {
|
|
config?: ContainerConfig;
|
|
}
|
|
|
|
export const ContainerComponent: React.FC<ContainerComponentProps> = ({
|
|
component,
|
|
isDesignMode = false,
|
|
isSelected = false,
|
|
onClick,
|
|
onDragStart,
|
|
onDragEnd,
|
|
config,
|
|
className,
|
|
style,
|
|
...props
|
|
}) => {
|
|
const fromProps: Partial<ContainerConfig> = {};
|
|
const p = props as any;
|
|
if (typeof p.containerType === "string" && (VALID_TYPES as string[]).includes(p.containerType))
|
|
fromProps.containerType = p.containerType as ContainerType;
|
|
if (typeof p.title === "string") fromProps.title = p.title;
|
|
if (Array.isArray(p.tabs)) fromProps.tabs = p.tabs;
|
|
if (typeof p.defaultTab === "string") fromProps.defaultTab = p.defaultTab;
|
|
if (typeof p.sectionVariant === "string") fromProps.sectionVariant = p.sectionVariant;
|
|
if (typeof p.collapsible === "boolean") fromProps.collapsible = p.collapsible;
|
|
if (typeof p.defaultCollapsed === "boolean") fromProps.defaultCollapsed = p.defaultCollapsed;
|
|
if (typeof p.multiple === "boolean") fromProps.multiple = p.multiple;
|
|
if (typeof p.minRows === "number") fromProps.minRows = p.minRows;
|
|
if (typeof p.maxRows === "number") fromProps.maxRows = p.maxRows;
|
|
if (typeof p.addRowText === "string") fromProps.addRowText = p.addRowText;
|
|
if (typeof p.conditionField === "string") fromProps.conditionField = p.conditionField;
|
|
if (typeof p.conditionOperator === "string") fromProps.conditionOperator = p.conditionOperator;
|
|
if (typeof p.conditionValue === "string") fromProps.conditionValue = p.conditionValue;
|
|
if (typeof p.padding === "string") fromProps.padding = p.padding;
|
|
if (typeof p.transparent === "boolean") fromProps.transparent = p.transparent;
|
|
|
|
const componentConfig = {
|
|
...config,
|
|
...((component as any).config ?? {}),
|
|
...((component as any).component_config ?? {}),
|
|
...((component as any).componentConfig ?? {}),
|
|
...fromProps,
|
|
} as ContainerConfig;
|
|
const rawComponentType =
|
|
(component as any).componentType ??
|
|
(component as any).component_type ??
|
|
(component as any).componentId ??
|
|
(component as any).component_id ??
|
|
(component as any).type;
|
|
|
|
if (
|
|
componentConfig.containerType == null &&
|
|
(rawComponentType === "v2-tabs-widget" ||
|
|
rawComponentType === "tabs-widget" ||
|
|
rawComponentType === "tabs" ||
|
|
rawComponentType === "v2-tabs" ||
|
|
Array.isArray(componentConfig.tabs))
|
|
) {
|
|
componentConfig.containerType = "tabs";
|
|
}
|
|
|
|
const containerType: ContainerType = (VALID_TYPES as string[]).includes(
|
|
componentConfig.containerType as string,
|
|
)
|
|
? (componentConfig.containerType as ContainerType)
|
|
: "section";
|
|
|
|
const title = componentConfig.title;
|
|
const padding = componentConfig.padding ?? "12px";
|
|
const transparent = componentConfig.transparent ?? false;
|
|
|
|
const containerStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
background: transparent ? "transparent" : "hsl(var(--card))",
|
|
borderRadius: "8px",
|
|
border: transparent ? "1px dashed hsl(var(--border))" : "1px solid hsl(var(--border))",
|
|
overflow: "hidden",
|
|
...(component as any).style,
|
|
...style,
|
|
};
|
|
|
|
if (isDesignMode && isSelected) {
|
|
containerStyle.outline = "2px solid hsl(var(--primary))";
|
|
containerStyle.outlineOffset = "2px";
|
|
}
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick?.();
|
|
};
|
|
|
|
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
const {
|
|
selectedScreen: _1, onZoneComponentDrop: _2, onZoneClick: _3,
|
|
componentConfig: _4, component: _5, isSelected: _6,
|
|
onClick: _7, onDragStart: _8, onDragEnd: _9,
|
|
size: _10, position: _11, style: _12,
|
|
screenId: _13, tableName: _14, onRefresh: _15, onClose: _16,
|
|
web_type: _17, autoGeneration: _18, isInteractive: _19,
|
|
formData: _20, onFormDataChange: _21,
|
|
menuId: _22, menuObjid: _23, onSave: _24,
|
|
userId: _25, userName: _26, companyCode: _27,
|
|
isInModal: _28, readonly: _29, originalData: _30,
|
|
_originalData: _31, _initialData: _32, _groupedData: _33,
|
|
allComponents: _34, onUpdateLayout: _35,
|
|
selectedRows: _36, selectedRowsData: _37, onSelectedRowsChange: _38,
|
|
sortBy: _39, sortOrder: _40, tableDisplayData: _41,
|
|
flowSelectedData: _42, flowSelectedStepId: _43, onFlowSelectedDataChange: _44,
|
|
onConfigChange: _45, refreshKey: _46, flowRefreshKey: _47, onFlowRefresh: _48,
|
|
isPreview: _49, groupedData: _50,
|
|
containerType: _51, title: _52, tabs: _53, defaultTab: _54,
|
|
sectionVariant: _55, collapsible: _56, defaultCollapsed: _57,
|
|
multiple: _58, minRows: _59, maxRows: _60, addRowText: _61,
|
|
conditionField: _62, conditionOperator: _63, conditionValue: _64,
|
|
padding: _65, transparent: _66, disabled: _67, required: _68,
|
|
...domProps
|
|
} = props as any;
|
|
/* eslint-enable @typescript-eslint/no-unused-vars */
|
|
|
|
// ─── tabs ──────────────────────────────────────────────────────────────
|
|
const [activeTab, setActiveTab] = useState(
|
|
componentConfig.defaultTab ?? componentConfig.tabs?.[0]?.id ?? "tab1",
|
|
);
|
|
|
|
const renderTabs = () => {
|
|
const tabs: ContainerTab[] = componentConfig.tabs ?? [
|
|
{ id: "tab1", label: "탭 1" },
|
|
{ id: "tab2", label: "탭 2" },
|
|
{ id: "tab3", label: "탭 3" },
|
|
];
|
|
// 디자인 모드에서 setActiveTab 도 허용 (사용자가 탭별 미리보기 전환).
|
|
const handleSelect = (id: string) => setActiveTab(id);
|
|
const currentTab = tabs.find((t) => t.id === activeTab) ?? tabs[0];
|
|
const activeTabId = currentTab?.id ?? "";
|
|
return (
|
|
<>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: "0",
|
|
borderBottom: "1px solid hsl(var(--border))",
|
|
background: "hsl(var(--muted))",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
{tabs.map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleSelect(tab.id);
|
|
}}
|
|
style={{
|
|
padding: "8px 16px",
|
|
fontSize: "12px",
|
|
fontWeight: activeTabId === tab.id ? 700 : 500,
|
|
color:
|
|
activeTabId === tab.id
|
|
? "hsl(var(--primary))"
|
|
: "hsl(var(--muted-foreground))",
|
|
background: activeTabId === tab.id ? "hsl(var(--card))" : "transparent",
|
|
border: "none",
|
|
borderBottom:
|
|
activeTabId === tab.id
|
|
? "2px solid hsl(var(--primary))"
|
|
: "2px solid transparent",
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
gap: 4,
|
|
}}
|
|
>
|
|
{tab.icon && <span aria-hidden>{tab.icon}</span>}
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{/*
|
|
탭 body — 디자인 모드 빌더에서 drop / 선택을 받는 영역.
|
|
data 속성 3개로 ScreenDesigner.handleComponentDrop 의 기존 tabs 분기가 그대로
|
|
canonical container 도 인식하게 한다 (별도 코드 패스 추가 없음).
|
|
*/}
|
|
<div
|
|
data-tabs-container="true"
|
|
data-container-kind="canonical"
|
|
data-component-id={(component as any)?.id ?? ""}
|
|
data-active-tab-id={activeTabId}
|
|
style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}
|
|
onClick={(e) => {
|
|
// 빈 탭 안내 영역까지 포함해 body 클릭 시 탭 자식 선택 해제.
|
|
// ChildSlot click 은 stopPropagation 하므로 자식 선택과 충돌하지 않는다.
|
|
if (!isDesignMode) return;
|
|
if (typeof p.onSelectTabComponent === "function") {
|
|
e.stopPropagation();
|
|
p.onSelectTabComponent(activeTabId, "", null);
|
|
}
|
|
}}
|
|
>
|
|
{renderTabChildren(currentTab)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* 활성 탭 한 개의 자식 컴포넌트만 렌더. 비활성 탭은 mount 자체를 하지 않음
|
|
* (성능 + side-effect 격리). 자식이 비어 있으면 디자인 모드 drop-zone, 운영
|
|
* 모드 빈 영역.
|
|
*/
|
|
const renderTabChildren = (tab: ContainerTab | undefined): React.ReactNode => {
|
|
const childList: ContainerChildComponent[] = tab?.components ?? [];
|
|
const tabId = tab?.id ?? "";
|
|
const containerId = (component as any)?.id ?? "";
|
|
const onSelectChild = typeof p.onSelectTabComponent === "function"
|
|
? p.onSelectTabComponent
|
|
: undefined;
|
|
const selectedChildId: string | undefined =
|
|
typeof p.selectedTabComponentId === "string" ? p.selectedTabComponentId : undefined;
|
|
const onUpdateContainerComponent = typeof p.onUpdateComponent === "function"
|
|
? p.onUpdateComponent
|
|
: undefined;
|
|
|
|
if (childList.length === 0) {
|
|
if (isDesignMode) {
|
|
return (
|
|
<div
|
|
style={{
|
|
border: "1px dashed hsl(var(--border))",
|
|
borderRadius: 4,
|
|
padding: "24px",
|
|
textAlign: "center",
|
|
color: "hsl(var(--muted-foreground))",
|
|
fontSize: "11px",
|
|
minHeight: 80,
|
|
}}
|
|
>
|
|
이 탭이 비어 있습니다. 좌측 팔레트에서 컴포넌트를 끌어다 놓으세요.
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 탭에서 특정 자식 삭제. ScreenDesigner 의 onUpdateComponent 가 있으면 그쪽으로
|
|
* 상위 layout 업데이트를 위임. 없으면 no-op (운영 모드 등).
|
|
*/
|
|
const handleRemoveChild = (childId: string) => {
|
|
if (!onUpdateContainerComponent) return;
|
|
const nextTabs = (componentConfig.tabs ?? []).map((t) =>
|
|
t.id === tabId
|
|
? { ...t, components: (t.components ?? []).filter((c) => c.id !== childId) }
|
|
: t,
|
|
);
|
|
onUpdateContainerComponent({
|
|
...(component as any),
|
|
componentConfig: {
|
|
...((component as any).componentConfig ?? {}),
|
|
tabs: nextTabs,
|
|
},
|
|
});
|
|
// 선택 해제
|
|
onSelectChild?.(tabId, "", null);
|
|
};
|
|
|
|
// 운영 모드는 단순 vertical stack. 빌더의 자유 배치 (size/position) 는 G.2
|
|
// 범위 밖. 차후 phase 에서 builder layout 모드 추가 시 같은 components 배열
|
|
// 위에 좌표 / order 를 얹는 식으로 확장.
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: 8,
|
|
minHeight: 0,
|
|
}}
|
|
>
|
|
{childList.map((child) => {
|
|
const isChildSelected = !!selectedChildId && child.id === selectedChildId;
|
|
return (
|
|
<ChildSlot
|
|
key={child.id}
|
|
child={child}
|
|
isDesignMode={isDesignMode}
|
|
isSelected={isChildSelected}
|
|
containerId={containerId}
|
|
tabId={tabId}
|
|
onSelect={
|
|
onSelectChild
|
|
? (e) => {
|
|
e.stopPropagation();
|
|
onSelectChild(tabId, child.id, child);
|
|
}
|
|
: undefined
|
|
}
|
|
onRemove={onUpdateContainerComponent ? () => handleRemoveChild(child.id) : undefined}
|
|
passProps={{
|
|
formData: p.formData,
|
|
onFormDataChange: p.onFormDataChange,
|
|
userId: p.userId,
|
|
userName: p.userName,
|
|
companyCode: p.companyCode,
|
|
screenId: p.screenId,
|
|
menuId: p.menuId,
|
|
menuObjid: p.menuObjid,
|
|
tableName: p.tableName,
|
|
selectedRows: p.selectedRows,
|
|
selectedRowsData: p.selectedRowsData,
|
|
onSelectedRowsChange: p.onSelectedRowsChange,
|
|
refreshKey: p.refreshKey,
|
|
onRefresh: p.onRefresh,
|
|
isInModal: p.isInModal,
|
|
parentTabId: tabId,
|
|
parentTabsComponentId: containerId,
|
|
}}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// ─── section ───────────────────────────────────────────────────────────
|
|
const [collapsed, setCollapsed] = useState(componentConfig.defaultCollapsed ?? false);
|
|
|
|
const renderSection = () => {
|
|
const variant = componentConfig.sectionVariant ?? "card";
|
|
const collapsible = componentConfig.collapsible ?? false;
|
|
return (
|
|
<>
|
|
{title && (
|
|
<div style={{
|
|
display: "flex", alignItems: "center", justifyContent: "space-between",
|
|
padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))",
|
|
background: variant === "paper" ? "hsl(var(--muted))" : "transparent",
|
|
}} onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}>
|
|
<span style={{ fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
|
|
{collapsible && <span style={{ marginRight: "6px" }}>{collapsed ? "▶" : "▼"}</span>}
|
|
{title}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{!collapsed && (
|
|
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
|
|
<div style={{ color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center", padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px" }}>
|
|
섹션 컨텐츠 영역
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── accordion ─────────────────────────────────────────────────────────
|
|
const renderAccordion = () => (
|
|
<>
|
|
{["항목 1", "항목 2", "항목 3"].map((label, i) => (
|
|
<details key={i} open={i === 0} style={{ borderBottom: "1px solid hsl(var(--border))" }}>
|
|
<summary style={{
|
|
padding: "8px 12px", cursor: "pointer", fontSize: "12px", fontWeight: 600,
|
|
color: "hsl(var(--foreground))", background: "hsl(var(--muted))",
|
|
listStyle: "none", display: "flex", alignItems: "center", gap: "6px",
|
|
}}>
|
|
<span style={{ fontSize: "10px" }}>▶</span> {label}
|
|
</summary>
|
|
<div style={{ padding, fontSize: "11px", color: "hsl(var(--muted-foreground))" }}>
|
|
아코디언 컨텐츠 {i + 1}
|
|
</div>
|
|
</details>
|
|
))}
|
|
</>
|
|
);
|
|
|
|
// ─── repeater ──────────────────────────────────────────────────────────
|
|
const renderRepeater = () => {
|
|
const addText = componentConfig.addRowText ?? "+ 행 추가";
|
|
return (
|
|
<>
|
|
{title && (
|
|
<div style={{ padding: "8px 12px", borderBottom: "1px solid hsl(var(--border))", fontSize: "13px", fontWeight: 700, color: "hsl(var(--foreground))" }}>
|
|
{title}
|
|
</div>
|
|
)}
|
|
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
|
|
{[0, 1].map((i) => (
|
|
<div key={i} style={{
|
|
border: "1px dashed hsl(var(--border))", borderRadius: "4px",
|
|
padding: "10px", marginBottom: "8px", fontSize: "11px", color: "hsl(var(--muted-foreground))",
|
|
}}>
|
|
반복 행 #{i + 1}
|
|
</div>
|
|
))}
|
|
<button type="button" disabled={isDesignMode} style={{
|
|
width: "100%", padding: "6px", border: "1px dashed hsl(var(--border))",
|
|
borderRadius: "4px", background: "transparent", color: "hsl(var(--muted-foreground))",
|
|
fontSize: "11px", cursor: isDesignMode ? "default" : "pointer",
|
|
}}>
|
|
{addText}
|
|
</button>
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
// ─── conditional ───────────────────────────────────────────────────────
|
|
const renderConditional = () => (
|
|
<>
|
|
<div style={{
|
|
padding: "6px 12px", background: "hsl(var(--accent))",
|
|
borderBottom: "1px solid hsl(var(--border))", fontSize: "11px",
|
|
color: "hsl(var(--primary))", fontWeight: 600, display: "flex", alignItems: "center", gap: "6px",
|
|
}}>
|
|
⚡ 조건부 표시
|
|
{componentConfig.conditionField && (
|
|
<span style={{ fontWeight: 400, color: "hsl(var(--muted-foreground))" }}>
|
|
({componentConfig.conditionField} {componentConfig.conditionOperator ?? "="} {componentConfig.conditionValue ?? "?"})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ flex: 1, padding, minHeight: 0, overflow: "auto" }}>
|
|
<div style={{
|
|
color: "hsl(var(--muted-foreground))", fontSize: "11px", textAlign: "center",
|
|
padding: "20px", border: "1px dashed hsl(var(--border))", borderRadius: "4px",
|
|
}}>
|
|
조건이 충족되면 이 영역이 표시됩니다
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
|
|
const renderBody = () => {
|
|
switch (containerType) {
|
|
case "tabs": return renderTabs();
|
|
case "accordion": return renderAccordion();
|
|
case "repeater": return renderRepeater();
|
|
case "conditional": return renderConditional();
|
|
case "section":
|
|
default: return renderSection();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div style={containerStyle} className={className}
|
|
onClick={handleClick} onDragStart={onDragStart} onDragEnd={onDragEnd} {...filterDOMProps(domProps)}>
|
|
{renderBody()}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* ChildSlot — 탭 내부 단일 자식을 `DynamicComponentRenderer` 로 렌더하는 어댑터.
|
|
*
|
|
* `ContainerChildComponent` 의 `componentType / componentConfig / size` 를 renderer
|
|
* 가 기대하는 shape (`component: { componentType, componentConfig }`) 으로 정규화.
|
|
* 디자인 모드에서는 클릭 선택 / hover 삭제 버튼 / 선택 outline 을 제공.
|
|
*/
|
|
const ChildSlot: React.FC<{
|
|
child: ContainerChildComponent;
|
|
isDesignMode: boolean;
|
|
isSelected?: boolean;
|
|
containerId?: string;
|
|
tabId?: string;
|
|
onSelect?: (e: React.MouseEvent) => void;
|
|
onRemove?: () => void;
|
|
passProps: Record<string, any>;
|
|
}> = ({ child, isDesignMode, isSelected, containerId, tabId, onSelect, onRemove, passProps }) => {
|
|
const [hover, setHover] = React.useState(false);
|
|
|
|
const componentForRenderer = React.useMemo(
|
|
() => ({
|
|
id: child.id,
|
|
componentType: child.componentType,
|
|
type: child.componentType,
|
|
componentConfig: child.componentConfig ?? {},
|
|
// legacy snake_case 호환 — DynamicComponentRenderer 가 둘 다 읽음
|
|
component_type: child.componentType,
|
|
component_config: child.componentConfig ?? {},
|
|
// size 가 있으면 inline style 으로 박스 폭만 통제 (기본은 부모 폭 100%)
|
|
size: child.size,
|
|
}),
|
|
[child.id, child.componentType, child.componentConfig, child.size],
|
|
);
|
|
|
|
const wrapStyle: React.CSSProperties = {
|
|
width: child.size?.width ?? "100%",
|
|
height: child.size?.height,
|
|
minHeight: 0,
|
|
flexShrink: 0,
|
|
position: "relative",
|
|
cursor: isDesignMode && onSelect ? "pointer" : undefined,
|
|
outline:
|
|
isDesignMode && isSelected
|
|
? "2px solid hsl(var(--primary))"
|
|
: isDesignMode && hover
|
|
? "1px dashed hsl(var(--primary))"
|
|
: "none",
|
|
outlineOffset: 2,
|
|
borderRadius: 4,
|
|
};
|
|
|
|
return (
|
|
<div
|
|
style={wrapStyle}
|
|
data-tab-child-id={child.id}
|
|
data-parent-tab-id={tabId}
|
|
data-parent-tabs-id={containerId}
|
|
onClick={isDesignMode && onSelect ? onSelect : undefined}
|
|
onMouseEnter={isDesignMode ? () => setHover(true) : undefined}
|
|
onMouseLeave={isDesignMode ? () => setHover(false) : undefined}
|
|
>
|
|
{isDesignMode && (isSelected || hover) && onRemove && (
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onRemove();
|
|
}}
|
|
title="이 탭 자식 삭제"
|
|
style={{
|
|
position: "absolute",
|
|
top: 4,
|
|
right: 4,
|
|
zIndex: 5,
|
|
width: 22,
|
|
height: 22,
|
|
padding: 0,
|
|
border: "1px solid hsl(var(--border))",
|
|
borderRadius: 4,
|
|
background: "hsl(var(--card))",
|
|
color: "hsl(var(--destructive))",
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: 12,
|
|
lineHeight: 1,
|
|
boxShadow: "0 1px 2px rgba(0,0,0,0.1)",
|
|
}}
|
|
>
|
|
✕
|
|
</button>
|
|
)}
|
|
{/*
|
|
디자인 모드에서는 자식 컴포넌트의 자체 클릭 핸들러가 ChildSlot 의 선택 click 을
|
|
가로채지 않도록 pointer-events 차단. 운영 모드에서는 자식이 정상 동작.
|
|
*/}
|
|
<div
|
|
style={{
|
|
pointerEvents: isDesignMode ? "none" : undefined,
|
|
width: "100%",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<DynamicComponentRenderer
|
|
component={componentForRenderer as any}
|
|
isDesignMode={isDesignMode}
|
|
{...passProps}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ContainerWrapper: React.FC<ContainerComponentProps> = (props) => {
|
|
return <ContainerComponent {...props} />;
|
|
};
|