Skip to content

노드 시스템

Canvas 에디터의 확장 가능한 노드 아키텍처입니다.

핵심 개념

노드 = 캔버스 위의 하나의 UI 요소 (텍스트, 향후 사각형·이미지 등)

각 노드는 두 표현을 가집니다:

  1. State (CanvasNodeState) — Zustand store의 직렬화 가능한 데이터
  2. Fabric Object — Fabric.js 캔버스 위의 렌더링 객체

NodeDefinition

모든 노드 타입은 NodeDefinition을 구현합니다:

typescript
// stores/nodes/types.ts
interface NodeDefinition {
  type: string;
  tool: string;
  label: string;
  shortcut?: string;
  icon?: string;
  cursor?: string;

  createState: (placement: NodePlacement) => BaseNodeState;
  createFabricObject: (state: BaseNodeState) => FabricObject;
  stateFromFabricObject: (object: FabricObject) => CanvasNodeState;
  applyStateToFabricObject: (object: FabricObject, state: CanvasNodeState) => void;
  onPlaced?: (object: FabricObject, canvas: Canvas) => void;
}

왜 State와 Fabric을 둘 다 쓰나

Fabric ObjectZustand (CanvasNodeState)
역할그리기, 드래그, 리사이즈, 인라인 편집저장, 노드 목록, 속성 패널, 서버 동기화
형태Textbox 등 라이브 객체 (메서드·이벤트)직렬화 가능한 plain JSON

Fabric만 쓰면 서버 저장·패널·store 밖 로직이 Fabric API에 묶입니다. Store만 쓰면 화면 렌더링·조작을 할 수 없습니다. 그래서 둘 다 유지하고, 아래 5개 메서드로 맞춥니다.

메서드 역할 요약

메서드시점하는 일
createState배치(최초 1회)클릭 좌표(NodePlacement)로 초기 store 상태 생성
createFabricObject배치·복원(객체 없을 때)store 상태로 Fabric 객체를 새로 생성 (new Textbox(...))
stateFromFabricObject배치 이후(sync)Fabric에서 값을 읽어 CanvasNodeState로 변환
applyStateToFabricObject배치 이후(sync)CanvasNodeState기존 Fabric 객체에 반영 (object.set(...))
onPlaced배치 직후(선택)배치 후 UX 후처리 (텍스트: 편집 모드 진입)

createState / createFabricObject처음 만들 때, stateFromFabricObject / applyStateToFabricObject이후 계속 맞출 때 사용합니다.

createFabricObject vs applyStateToFabricObject

둘 다 store → Fabric 방향이지만 역할이 다릅니다.

  • createFabricObject: 캔버스에 아직 없을 때 생성자로 객체를 만듦. data: { nodeId, nodeType } 메타데이터도 여기서 설정.
  • applyStateToFabricObject: 이미 캔버스에 있는 객체의 속성만 갱신. 속성 패널·store→Fabric 복원 시 사용.

복원(createFabricObjectFromState)에서는 생성 직후 applyStateToFabricObject를 한 번 더 호출해 상태를 완전히 맞춥니다 (features/canvas/utils/nodes.ts).

Sync: Fabric read / write

stateFromFabricObjectapplyStateToFabricObjectZustand API가 아닙니다. Fabric ↔ CanvasNodeState 변환기이며, read/write 관점은 Fabric 기준입니다.

Fabric 관점Zustand는 누가 갱신?
stateFromFabricObjectread — Textbox 등에서 속성을 읽음호출부가 setNode / updateNode
applyStateToFabricObjectwriteobject.set(...) 등으로 반영호출부가 store에서 state를 넘김
[사용자가 캔버스 조작]
  Fabric 변경
    → stateFromFabricObject()   (Fabric read)
    → setNode(state)            (Zustand write)

[store만 변경 — 패널, 서버 로드 복원 등]
  nodes[id] 변경
    → applyStateToFabricObject() (Fabric write)

실제 sync 오케스트레이션은 features/canvas/hooks/useCanvasNodes.ts가 담당합니다.

  • Fabric → store
    • object:moving / object:resizing — 이동·리사이즈 중 positionupdateNode
    • object:modified / text:changed — 변환 완료 후 stateFromFabricObjectsetNode
    • object:removedremoveNodes
    • object:scaling — 텍스트박스 스케일 정규화(textboxScaling.ts) 후 렌더 갱신
  • store → Fabric: nodes 변경 시 Fabric 상태와 비교 후 다르면 → applyNodeStateToCanvas
  • 루프 방지: canvasSync.ts로 캔버스 주도 sync 구간 표시, isCanvasInteracting()으로 드래그/리사이즈 중 store→Fabric 덮어쓰기 스킵

유틸 래퍼: features/canvas/utils/nodes.ts (stateFromFabricObject, applyStateToFabricObject, applyNodeStateToCanvas, createFabricObjectFromState)

레지스트리

stores/nodes/registry.ts:

typescript
export const NODE_DEFINITIONS = {
  text: textNodeDefinition,
} as const;

export const TOOL_TO_NODE: Record<NodeTool, NodeDefinition> = {
  text: textNodeDefinition,
};

export function getNodeDefinition(tool: NodeTool): NodeDefinition {
  /* ... */
}
export function isNodeTool(tool: string): tool is NodeTool {
  /* ... */
}
export function getNodeDefinitionByType(type: string): NodeDefinition | undefined {
  /* ... */
}

타입 추론:

typescript
type NodeType = keyof typeof NODE_DEFINITIONS; // 'text'
type NodeTool = (typeof NODE_DEFINITIONS)[NodeType]['tool']; // 'text'

텍스트 노드 예시

상태 (stores/nodes/text/index.ts)

typescript
interface TextNodeState extends BaseNodeState {
  type: 'text';
  text: string;
  fontSize: number;
  color: string | null;
  fill: string | null;
  stroke: string | null;
}

Fabric 매핑 (stores/nodes/text/definition.ts)

메서드텍스트 노드 동작
createStatecreateTextNodeState — 클릭 좌표에 초기 TextNodeState
createFabricObjectnew Textbox(...)
stateFromFabricObjectTextbox 속성 → TextNodeState (Fabric read)
applyStateToFabricObjectTextNodeStatetextbox.set(...) (Fabric write)
onPlacedsetActiveObjectenterEditing + selectAll

Fabric 메타데이터

모든 Fabric 객체에 data: { nodeId, nodeType }를 설정합니다. selection/sync에서 ID를 추출하는 데 사용됩니다.

배치 (Placement)

features/canvas/drawing/placement.tsattachPlacement:

  1. 도구 활성화 시 canvas에 mouse:down 리스너 등록 (기존 객체 위 클릭은 무시)
  2. 클릭 위치(opt.scenePoint)에 createState(placement)createFabricObject(state)configureNodeTransformcanvas.addaddNode(state)
  3. onCompletesetTool('move') (useDrawingTools에서 전달)
  4. onPlaced 콜백 (텍스트: 활성화 + 편집 모드)

Base Node Fields

stores/nodes/base.ts, stores/nodes/fabric.ts:

공통 필드(position, size, visibility, locked, opacity)를 BaseNodeStatereadBaseNodeFields / applyBaseNodeFields 유틸로 관리합니다. applyBaseNodeFieldsconfigureNodeTransform을 호출해 타입별 변환 규칙을 적용합니다.

텍스트박스 리사이즈

features/canvas/utils/textboxScaling.ts가 텍스트 노드 전용 동작을 담당합니다.

  • 가로 폭만 리사이즈 가능 (configureTextboxControls)
  • 스케일 대신 width/fontSize로 정규화 (normalizeTextboxScalesInTarget)
  • 다중 선택(ActiveSelection)은 이동만 허용 (configureSelectionMoveOnly)

노드 라벨

features/canvas/labels/NodeLabelsOverlay.tsx가 DOM 오버레이로 각 노드의 표시 이름(node.label) 을 렌더합니다. Fabric viewportTransform과 store의 zoom/position으로 화면 좌표를 계산합니다.

라벨은 Mod+.로 표시/숨김을 토글할 수 있으며, 표시 중에는 다음 상호작용이 가능합니다.

  • 클릭 — 해당 노드 선택 (setSelectedIds)
  • 드래그 — 노드 이동 (Fabric + store 동기화)
  • 더블클릭 — 라벨 이름 편집 (updateNode({ label }))

서버 저장 (워크스페이스)

노드 문서는 워크스페이스 단위로 PostgreSQL Node 테이블에 저장됩니다.

단계담당
배치·편집useCanvasNodes → store 갱신
저장useWorkspaceDocument → 500ms debounce PUT
복원GET /workspaces/:id/nodesreplaceDocumentuseCanvasHydration

자세한 persist/UI 설정 구분은 상태 관리를 참고하세요.

확장 가이드

새 노드 추가 절차는 노드 추가하기를 참고하세요.

관련 문서

Canvas — 시각적 프로토타입 에디터