WorkflowDesigner
캔버스 기반 워크플로우 편집기 컴포넌트
개요
WorkflowDesigner는 트리 구조의 워크플로우를 시각적으로 설계할 수 있는 캔버스 기반 편집기입니다. 노드와 커넥터를 자동 레이아웃하며, 팔레트에서 노드를 추가하고 캔버스에서 편집할 수 있습니다.
주요 특징
- ✅ 자동 레이아웃:
parentId기반 트리 구조를 자동으로 배치 - ✅ 팔레트: 좌측 사이드바에서 노드 유형 선택 및 추가
- ✅ 캔버스 조작: 줌(휠), 패닝(스페이스+드래그, 미들 클릭), 전체 화면
- ✅ 분기 노드: 조건 분기(Yes/No 등)와 라벨/색상 지원, 빈 분기 레인에 + 버튼 자동 표시 (
childrenLayout: "branch") - ✅ 종속 노드: 특정 노드 추가 시 자동으로 따라 붙는 종속(attached) 노드 지원
- ✅ 사이드 패널:
sidePanelprop으로 우측 패널 렌더링 (전체 화면에서도 표시) - ✅ 커스텀 렌더링:
renderNode로 노드 내부 커스터마이징 - ✅ 읽기 전용:
readOnly모드 지원 - ✅ Controlled/Uncontrolled: 선택 상태, 노드 배열 모두 제어 가능
아키텍처
| 영역 | 설명 |
|---|---|
| Palette | 좌측 사이드바. nodeTypes에 정의된 노드 유형을 그룹별로 표시 |
| Header | 캔버스 상단. title/description 또는 커스텀 header 렌더링 |
| Canvas | 중앙 캔버스. 노드, 커넥터, + 버튼을 렌더링 |
| Toolbar | 좌측 하단. 줌, 패닝/선택 모드, 화면 맞춤, 전체 화면 버튼 |
기본 사용
Preview
Node Status
노드는 status 속성으로 시각적 상태를 표현합니다.
Preview
Status 설명
| Status | 설명 | 시각적 표현 |
|---|---|---|
| default | 기본 상태 | 기본 테두리 |
| selected | 선택됨 | Primary 테두리 + 그림자 |
| completed | 완료됨 | 녹색 테두리 |
| error | 오류 | 빨간 테두리 + 그림자 |
| disabled | 비활성 | 50% 투명도, 클릭 불가 |
| locked | 잠김 | 70% 투명도, 잠금 아이콘 |
사용 예시
예시 1: 분기 워크플로우
조건 노드로 Yes/No 분기를 만들 수 있습니다.
Preview
예시 2: onNodesChange를 활용한 편집 모드
onNodesChange를 전달하면 내장 추가/삭제 로직이 활성화됩니다.
import { WorkflowDesigner } from "@vortex/ui-icignal"
import type { WorkflowNode, NodeTypeDefinition } from "@vortex/ui-icignal"
import { useState } from "react"
function WorkflowEditor() {
const [nodes, setNodes] = useState<WorkflowNode[]>([
{ id: "1", type: "trigger", title: "시작", parentId: null },
])
const nodeTypes: NodeTypeDefinition[] = [
{ type: "trigger", label: "트리거", maxCount: 1, maxChildren: 1 },
{ type: "action", label: "액션", maxChildren: 1 },
]
return (
<div style={{ height: 600 }}>
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={setNodes}
onNodeSelect={(id) => console.log("선택:", id)}
/>
</div>
)
}예시 3: 커스텀 노드 렌더링
renderNode prop으로 노드 내부를 자유롭게 커스터마이징할 수 있습니다.
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={setNodes}
renderNode={({ node, isSelected }) => (
<div className="p-3">
<div className="font-semibold">{node.title}</div>
{node.description && (
<div className="text-xs text-muted-foreground mt-1">
{node.description}
</div>
)}
{isSelected && (
<div className="text-xs text-primary mt-2">선택됨</div>
)}
</div>
)}
/>예시 4: 커스텀 헤더
header prop으로 캔버스 상단에 커스텀 UI를 배치하거나, title/description으로 간단히 텍스트를 표시합니다.
// 간단한 텍스트 헤더
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
title="캠페인 워크플로우"
description="고객 여정을 설계하세요"
/>
// 커스텀 헤더
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
header={
<div className="flex items-center justify-between">
<h3>캠페인 A</h3>
<button>저장</button>
</div>
}
/>예시 5: 수평 레이아웃
layoutDirection="horizontal"로 왼→오른쪽 방향 트리를 구성합니다.
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
layoutDirection="horizontal"
onNodesChange={setNodes}
/>API Reference
WorkflowDesignerProps
Props
| Prop | Type | Default | Description |
|---|---|---|---|
nodes | WorkflowNode[] | 필수 | 노드 목록 |
nodeTypes | NodeTypeDefinition[] | 필수 | 노드 유형 정의 (팔레트에 표시) |
connectors | WorkflowConnector[] | 자동 생성 | 커넥터 목록 (생략 시 parentId에서 자동 생성) |
selectedNodeId | string | null | - | 현재 선택된 노드 ID (controlled) |
focusedNodeId | string | null | null | 포커스 하이라이트 노드 ID |
invalidNodeIds | string[] | [] | 유효성 오류 노드 ID 목록 |
renderNode | (props: WorkflowNodeRenderProps) => ReactNode | - | 커스텀 노드 렌더러 |
nodeActions | WorkflowNodeAction[] | 기본(삭제, 설정) | 노드 hover/선택 시 표시되는 액션 버튼 |
paletteTitle | string | "워크플로우 구성" | 팔레트 타이틀 |
title | string | - | 캔버스 상단 타이틀 |
description | string | - | 캔버스 상단 설명 |
header | ReactNode | - | 커스텀 헤더 (설정 시 title/description보다 우선) |
readOnly | boolean | false | 읽기 전용 모드 |
defaultScale | number | 1 | 초기 줌 레벨 |
minScale | number | 0.25 | 최소 줌 레벨 |
maxScale | number | 2 | 최대 줌 레벨 |
nodeWidth | number | 200 | 노드 기본 너비 (px) |
nodeGapX | number | 60 | 노드 간 수평 간격 (px) |
nodeGapY | number | 60 | 노드 간 수직 간격 (px) |
layoutDirection | "vertical" | "horizontal" | "vertical" | 레이아웃 방향 |
generateNodeId | () => string | crypto.randomUUID | 노드 ID 생성기 |
sidePanel | ReactNode | - | 우측 사이드 패널 (전체 화면 시에도 함께 표시됨) |
className | string | - | 루트 요소 CSS 클래스 |
Events
| Event | Type | Description |
|---|---|---|
onNodeSelect | (nodeId: string | null) => void | 노드 선택 시 |
onNodeDblClick | (nodeId: string) => void | 노드 더블클릭 시 |
onNodeAdd | (nodeType: string, parentNodeId: string, insertBeforeNodeId?: string) => void | 노드 추가 시 |
onNodeDelete | (nodeId: string) => void | 노드 삭제 시 |
onNodeSettings | (nodeId: string) => void | 노드 설정 버튼 클릭 시 |
onNodesChange | (nodes: WorkflowNode[]) => void | 내장 추가/삭제 후 노드 배열 변경 시 |
onChange | (nodes: WorkflowNode[], connectors: WorkflowConnector[]) => void | 데이터 변경 시 |
onCanvasClick | () => void | 캔버스 빈 영역 클릭 시 (선택 초기화) |
WorkflowNode
노드 데이터 구조입니다.
| Property | Type | Default | Description |
|---|---|---|---|
id | string | 필수 | 노드 고유 식별자 |
type | string | 필수 | 노드 유형 (NodeTypeDefinition.type과 매칭) |
title | string | 필수 | 노드 타이틀 |
description | string | - | 노드 설명 (요약 정보) |
caption | string | - | 우상단 캡션 (날짜, 상태 텍스트 등) |
status | WorkflowNodeStatus | "default" | 노드 상태 |
icon | ReactNode | - | 노드 아이콘 |
badge | string | - | 상태 뱃지 텍스트 |
parentId | string | null | - | 부모 노드 ID (null = 루트 노드) |
branchIndex | number | 0 | 부모 노드의 분기 인덱스 (0-based) |
maxChildren | number | - | 이 노드의 최대 자식 수 (개별 오버라이드) |
data | Record<string, unknown> | - | 사용자 정의 데이터 |
locked | boolean | false | 잠금 상태 |
invalid | boolean | false | 유효성 오류 여부 |
attachedToId | string | - | 종속 대상 노드 ID (종속 노드일 때 설정) |
NodeTypeDefinition
팔레트에 표시되는 노드 유형 정의입니다.
| Property | Type | Default | Description |
|---|---|---|---|
type | string | 필수 | 노드 유형 식별자 |
label | string | 필수 | 노드 유형 라벨 |
icon | ReactNode | - | 노드 아이콘 |
group | string | - | 팔레트 그룹명 |
allowedAfter | string[] | - | 허용된 부모 노드 유형 (비어있으면 제한 없음) |
maxCount | number | - | 최대 배치 가능 수 |
maxChildren | number | 1 | 기본 최대 자식 수 (1=선형, 2+=분기) |
isBranching | boolean | false | 조건 분기 노드 여부 |
branchLabels | string[] | - | 분기 라벨 (예: ["Yes", "No"]) |
branchColors | string[] | - | 분기 색상 (예: ["#22c55e", "#ef4444"]) |
childrenLayout | "vertical" | "horizontal" | "branch" | string[] | "vertical" | 자식 배치 방향 ("branch": 분기 레인별 배치 + 빈 레인 placeholder) |
attachedNodes | AttachedNodeDefinition[] | - | 노드 추가 시 자동 생성되는 종속 노드 정의 |
hidden | boolean | false | true이면 팔레트에서 숨김 (종속 노드 전용) |
AttachedNodeDefinition
종속 노드 자동 생성 시 사용되는 정의입니다. NodeTypeDefinition.attachedNodes에서 사용됩니다.
| Property | Type | Description |
|---|---|---|
type | string | 종속 노드의 유형 (nodeTypes의 type과 매칭) |
title | string | 종속 노드의 기본 타이틀 |
WorkflowNodeAction
노드 hover/선택 시 상단에 표시되는 액션 버튼입니다.
| Property | Type | Description |
|---|---|---|
id | string | 액션 식별자 |
icon | ReactNode | 액션 아이콘 |
label | string | 액션 라벨 (툴팁) |
onClick | (nodeId: string) => void | 클릭 핸들러 |
visible | (node: WorkflowNode) => boolean | 표시 조건 (생략 시 항상 표시) |
WorkflowNodeRenderProps
renderNode 콜백에 전달되는 props입니다.
| Property | Type | Description |
|---|---|---|
node | WorkflowNode | 노드 데이터 |
isSelected | boolean | 선택 상태 |
isFocused | boolean | 포커스 상태 |
isHighlighted | boolean | 하이라이트 상태 |
WorkflowConnector
노드 간 연결선 데이터입니다. 생략 시 parentId 관계에서 자동 생성됩니다.
| Property | Type | Description |
|---|---|---|
id | string | 커넥터 고유 식별자 |
sourceId | string | 시작 노드 ID |
targetId | string | 끝 노드 ID |
label | string | 커넥터 라벨 |
color | string | 커넥터 색상 |
branchIndex | number | 분기 인덱스 |
기본 사용법
import { WorkflowDesigner } from "@vortex/ui-icignal"
import type { WorkflowNode, NodeTypeDefinition } from "@vortex/ui-icignal"
import { useState } from "react"
const nodeTypes: NodeTypeDefinition[] = [
{ type: "trigger", label: "트리거", maxCount: 1, maxChildren: 1 },
{ type: "action", label: "액션", maxChildren: 1 },
{ type: "condition", label: "조건", maxChildren: 2, isBranching: true,
branchLabels: ["Yes", "No"], branchColors: ["#22c55e", "#ef4444"] },
]
function MyWorkflow() {
const [nodes, setNodes] = useState<WorkflowNode[]>([
{ id: "1", type: "trigger", title: "시작", parentId: null },
])
const [selected, setSelected] = useState<string | null>(null)
return (
<div style={{ height: 600 }}>
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
selectedNodeId={selected}
onNodeSelect={setSelected}
onNodesChange={setNodes}
onNodeDelete={(id) => console.log("삭제:", id)}
onNodeSettings={(id) => console.log("설정:", id)}
/>
</div>
)
}캔버스 조작
마우스/키보드
| 조작 | 동작 |
|---|---|
| 마우스 휠 | 줌 인/아웃 (마우스 위치 기준) |
| 스페이스 + 드래그 | 캔버스 패닝 |
| 미들 클릭 + 드래그 | 캔버스 패닝 |
| 노드 클릭 | 노드 선택 |
| 노드 더블클릭 | onNodeDblClick 호출 |
| 캔버스 빈 영역 클릭 | 선택 초기화 |
툴바 버튼
| 버튼 | 동작 |
|---|---|
| 확대 (ZoomIn) | 줌 인 (+0.15 step) |
| 축소 (ZoomOut) | 줌 아웃 (-0.15 step) |
| 이동 (Hand) | 패닝 모드 |
| 선택 (MousePointer2) | 선택 모드 (기본) |
| 화면 맞춤 (Focus) | 모든 노드가 보이도록 줌/패닝 조정 |
| 전체 화면 (Maximize/Minimize) | 전체 화면 토글 (ESC 키로 해제). 전체 화면 모드에서는 Minimize 아이콘으로 전환 |
노드 추가/삭제 플로우
추가
- 커넥터 중간 또는 리프 노드 하단의 + 버튼 클릭
- 팔레트에서 추가 가능한 노드 유형이 하이라이트됨 (
allowedAfter기반 필터링) - 노드 유형 클릭 →
onNodesChange로 새 노드가 포함된 배열 전달 onNodeAdd콜백도 함께 호출 (기존 호환)
삭제
- 노드 hover/선택 시 상단 삭제 버튼 클릭
onNodesChange가 있으면 내장 로직으로 해당 노드 + 모든 하위 노드를 일괄 삭제- 종속 노드(
attachedToId)도 함께 삭제됨 - 종속 노드는 단독 삭제 불가 (소유 노드 삭제 시에만 제거)
- 종속 노드(
onNodeDelete콜백도 함께 호출
전체 화면 (Fullscreen)
전체 화면 모드는 createPortal을 사용하여 document.body에 렌더링됩니다.
- 진입: 툴바의 전체 화면 버튼 클릭
- 해제: ESC 키 또는 전체 화면 버튼 재클릭
- z-index:
50(모달/토스트와 같은 Portal 레벨이며, DOM 순서로 모달이 위에 표시됨) - sidePanel: 전체 화면에서도
sidePanelprop이 정상 표시됨
분기 노드 (Branch Nodes)
childrenLayout: "branch"를 설정하면 분기 노드의 각 레인별로 자식이 배치되고, 자식이 없는 빈 레인에는 자동으로 + 버튼(placeholder)이 표시됩니다. 각 + 버튼에는 branchLabels와 branchColors가 함께 적용되어 Yes/No 같은 분기 경로를 한 번에 시각화할 수 있습니다.
정의
const nodeTypes: NodeTypeDefinition[] = [
{
type: "condition",
label: "조건",
maxChildren: 2,
isBranching: true,
branchLabels: ["Yes", "No"],
branchColors: ["#22c55e", "#ef4444"],
childrenLayout: "branch", // 핵심: 레인별 배치 + 빈 레인 placeholder
},
]동작
- 레인 개수:
branchLabels.length→maxChildren→ 기본2순으로 결정됨 - 빈 레인 placeholder: 자식이 없는 레인마다 라벨/색상이 적용된 + 버튼이 자동 표시됨
- + 버튼 클릭: 해당 레인의
branchIndex로 새 자식이 추가됨 (자동 위치 이동 없음) - 일부만 채워진 경우: 채워진 레인은 기존 분기 커넥터로, 빈 레인에는 placeholder가 표시됨
- 모든 레인이 채워지면 placeholder는 숨겨짐
vs. isBranching: true 단독
| 설정 | 레인 공간 예약 | 빈 레인 + 버튼 | 분기 색상/라벨 |
|---|---|---|---|
isBranching: true | ❌ | ❌ (우측 + 만) | ✅ |
childrenLayout: "branch" | ✅ | ✅ | ✅ |
기존
isBranching: true설정은 호환성을 위해 유지되며, 새 분기 UX(빈 레인 + 버튼)를 원하면childrenLayout: "branch"를 함께 지정하세요.
종속 노드 (Attached Nodes)
특정 노드 유형을 추가할 때 자동으로 함께 생성되는 종속 노드를 정의할 수 있습니다.
정의
const nodeTypes: NodeTypeDefinition[] = [
{
type: "message",
label: "메시지",
maxChildren: 1,
attachedNodes: [
{ type: "schedule", title: "발송 스케줄" },
],
},
{
type: "schedule",
label: "발송 스케줄",
maxChildren: 1,
hidden: true, // 팔레트에서 숨김
},
]동작
- “메시지” 노드 추가 시 → “발송 스케줄” 종속 노드가 자동 생성됨
- 종속 노드는
attachedToId로 소유 노드에 연결됨 - 종속 노드는 팔레트에서 숨겨지고(
hidden: true), 단독 삭제 불가 - 소유 노드 삭제 시 종속 노드도 함께 삭제됨
- 종속 노드와 다음 노드 사이의 + 버튼은 숨겨짐
사이드 패널 (sidePanel)
sidePanel prop을 사용하여 워크플로우 디자이너 내부에 우측 패널을 렌더링할 수 있습니다. 전체 화면 모드에서도 Portal 내부에 포함되어 정상 표시됩니다.
<WorkflowDesigner
nodes={nodes}
nodeTypes={nodeTypes}
onNodesChange={setNodes}
sidePanel={
selectedNode ? (
<div className="absolute inset-y-0 right-0 w-80 border-l bg-background shadow-lg z-10">
<h3>{selectedNode.title} 설정</h3>
{/* 설정 폼 */}
</div>
) : undefined
}
/>접근성
권장 사항
- ✅ 각 노드에
title을 설정하여 용도를 명확히 전달 - ✅
readOnly모드에서 + 버튼과 노드 액션 버튼이 자동 숨김 - ✅ 키보드: 스페이스바로 패닝 모드 전환
- ❌ 컨테이너에 충분한 높이(
min-height: 400px)를 지정하지 않으면 콘텐츠가 잘릴 수 있음