모듈 시스템
모듈 시스템은 다른 개발자가 만든 기능을 캔버스에 추가할 수 있게 하기 위한 계약입니다. 모듈은 데이터 인스턴스와 렌더링 정의를 분리합니다.
인스턴스 데이터
CanvasModuleInstance는 캔버스가 직접 관리하는 최소 데이터입니다.
export type CanvasModuleInstance = {
id: string;
kind: string;
title: string;
description: string;
position: CanvasPoint;
rotation?: number;
scale?: number;
state: ModuleInstanceState;
};
| 필드 | 의미 |
|---|---|
id | 캔버스 내 개별 모듈 인스턴스 식별자 |
kind | 어떤 모듈 정의를 사용할지 찾는 키 |
title | 카드와 확장 패널 제목 |
description | 카드와 확장 패널 설명 |
position | 캔버스 좌표 |
rotation | 공통 회전 각도. degree 단위이며 없으면 0으로 처리합니다. |
scale | 공통 확대 배율. 없으면 1로 처리합니다. |
state | 모듈 내부 사용자 상태. undo/redo 복원을 위해 직렬화 가능한 값만 저장합니다. |
상태와 undo/redo 계약
App은 전체 앱 상태의 단일 source of truth입니다. 모든 사용자 액션은 App의 히스토리 상태를 갱신해야 하며, undo/redo는 이 상태 스냅샷을 기준으로 복원됩니다.
새 모듈을 만들 때 지켜야 하는 규칙은 다음과 같습니다.
createInstance는 반드시state기본값을 포함해야 합니다.- 모듈 내부 사용자 상태는
useState로 소유하지 않습니다. 예: 본문, 색상, 크기, 모드, 선택 옵션, 도메인 값. - 모듈
Provider는instance.state를 읽고updateModuleState로만 상태를 변경합니다. state에는 함수, DOM 객체, Date 인스턴스, 클래스 인스턴스처럼 직렬화하기 어려운 값을 넣지 않습니다.- 타이머 tick, 비동기 응답 메시지처럼 사용자 액션이 아닌 변화는 필요할 때
{ recordHistory: false }로 현재 상태만 교체합니다. - 드래그, 리사이즈, 회전, 시계 바늘 이동, 휠 줌, 슬라이더, 숫자 입력처럼 시작과 끝이 명확한 액션은 시작 시
beginInteractionHistory, 변경 중에는{ recordHistory: false }, 종료 시commitInteractionHistory를 사용합니다. 종료 시점은 pointer up/cancel, blur, Enter, 또는 휠 입력이 잠시 멈춘 debounce 시점입니다. undo/redo는 전체 제스처나 편집을 하나의 액션으로 되돌립니다. - hover, 드래그 중 여부, 임시 포인터 캡처처럼 복원할 필요가 없는 UI transient 상태만 로컬
useState/useRef에 둘 수 있습니다.
공통 런타임 책임
모든 모듈은 InfiniteCanvas 런타임이 제공하는 공통 기능 위에서 동작합니다. 모듈 구현자는 이 기능을 직접 다시 만들지 않고, 옵션과 props로 필요한 지점만 지정합니다.
| 공통 기능 | 런타임 책임 | 모듈이 정하는 부분 |
|---|---|---|
| 드래그 앤 드롭 | 포인터 캡처, 좌표 변환, 캔버스 scale 보정, 인스턴스 위치 업데이트 | 실제로 잡을 수 있는 보이는 표면에 moduleDragHandleProps 부착 |
| Undo/Redo | App의 past/present/future 히스토리로 전체 앱 상태 스냅샷 복원 | 모든 사용자 상태를 CanvasModuleInstance.state에 저장 |
| 확대 | 선택된 모듈의 사이드바 공통 설정에서 배율 변경, CanvasModuleInstance.scale 저장 | 기본 scale 값과 모듈 콘텐츠가 확대에 견디는 레이아웃 |
| 회전 | 모듈 오른쪽 아래 바깥 대각선 위치의 공통 회전 핸들 표시, 포인터 각도 계산, CanvasModuleInstance.rotation 저장 | 모듈 콘텐츠가 회전되어도 overflow가 잘리지 않는 레이아웃 |
| 삭제 | Option 키를 누르는 동안 모든 모듈에 Remove 버튼 표시, 클릭 시 인스턴스 제거 | container.removeButton.position |
| 사이드바 | 선택된 모듈의 Extension과 공통 설정 패널 표시 | 모듈 전용 설정/정보를 Extension에 렌더링 |
| 컨테이너 크롬 | 박스, 상단 이동 바, 표시/숨김 상태 관리 | container.chromeVisibility, container.size |
모듈 정의
CanvasModuleDefinition은 외부 모듈이 앱에 등록하기 위해 구현해야 하는 계약입니다.
export type CanvasModuleDefinition = {
kind: string;
displayName: string;
addLabel: string;
container?: ModuleContainerOptions;
createInstance: (index: number, position: CanvasPoint) => CanvasModuleInstance;
AdvancedExtension?: ComponentType<ModuleSurfaceProps>;
SizeExtension?: ComponentType<ModuleSurfaceProps>;
Provider: ComponentType<ModuleProviderProps>;
Module: ComponentType<ModuleSurfaceProps>;
Extension: ComponentType<ModuleSurfaceProps>;
};
| 필드 | 의미 |
|---|---|
kind | 레지스트리에서 모듈을 찾는 고유 키 |
displayName | 확장 패널 상단에 표시되는 모듈 종류 이름 |
addLabel | 캔버스 툴바의 추가 버튼 텍스트 |
container | 캔버스 컨테이너의 표시 방식, 크기, 삭제 버튼 위치 설정 |
createInstance | 새 모듈을 만들 때 기본 인스턴스 데이터를 생성 |
AdvancedExtension | 공통 Advance 패널의 Custom 그룹에 표시할 모듈 전용 고급 설정 |
SizeExtension | 공통 Advance 패널의 Shared > 크기 그룹에 표시할 가로/세로 크기 설정 |
Provider | Module과 Extension이 공유할 내부 상태 제공 |
Module | 캔버스 카드 안에 표시되는 핵심 작업 화면 |
Extension | 모듈 선택 시 오른쪽 패널에 표시되는 상세 UI |
컨테이너 옵션
container는 모듈 런타임이 공통으로 제공하는 컨테이너 동작을 설정합니다.
export type ModuleContainerOptions = {
chromeVisibility?: "always" | "transient" | "hidden";
dragSurface?: {
ignoreSelector?: string;
shape?: "circle" | "rect";
};
removeButton?: {
position?: "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right";
};
resize?: {
mode?: "none" | "freeform" | "locked-aspect-ratio";
};
size?: "fixed" | "fit-content";
};
| 옵션 | 의미 |
|---|---|
chromeVisibility: "always" | 컨테이너 박스와 상단 이동 바를 항상 표시합니다. 기본값입니다. |
chromeVisibility: "transient" | 모듈이 제공한 드래그 표면을 더블클릭하면 박스와 상단 이동 바를 표시하고, 마우스가 벗어나면 숨깁니다. |
chromeVisibility: "hidden" | 공통 컨테이너 크롬을 표시하지 않습니다. |
dragSurface.shape | moduleDragHandleProps를 부착한 표면의 hit-test 형태입니다. rect가 기본값이고, 시계처럼 원형 표면만 잡혀야 할 때 circle을 사용합니다. |
dragSurface.ignoreSelector | 드래그 표면 안에서 모듈 이동이 시작되면 안 되는 하위 요소 selector입니다. 예: 시계 바늘 .clock-hand. |
resize.mode: "none" | 모듈 자체 크기 조절 affordance를 제공하지 않습니다. |
resize.mode: "freeform" | 가로/세로 크기를 독립적으로 조절하는 모듈입니다. 포스트잇처럼 자유 비율이 필요한 모듈에 사용합니다. |
resize.mode: "locked-aspect-ratio" | 가로/세로가 같은 비율로 커지는 모듈입니다. 시계처럼 정사각형 또는 고정 비율이 필요한 모듈에 사용합니다. |
| 리사이즈 크기 제한 | 런타임 공통 옵션은 리사이즈 방식만 선언합니다. 최소/최대 크기, 상한 없음, 텍스트 크기 유지 같은 세부 정책은 각 모듈의 상태와 핸들 구현에서 결정합니다. |
size: "fixed" | 기본 고정 폭 컨테이너를 사용합니다. 기본값입니다. |
size: "fit-content" | 컨테이너가 모듈 콘텐츠 크기에 맞게 줄어듭니다. 투명 영역을 드래그 표면으로 쓰면 안 되는 모듈에 적합합니다. |
removeButton.position | Option 키를 누르는 동안 표시되는 Remove 버튼 위치를 지정합니다. 기본값은 center입니다. |
모듈 표면 props
ModuleSurfaceProps는 모듈 본문과 확장 패널에 전달됩니다.
export type ModuleProviderProps = {
instance: CanvasModuleInstance;
beginInteractionHistory: () => void;
commitInteractionHistory: () => void;
updateModuleState: ModuleStateUpdateHandler;
children: ReactNode;
};
export type ModuleSurfaceProps = {
instance: CanvasModuleInstance;
moduleDragHandleProps?: ModuleDragHandleProps;
selected: boolean;
};
updateModuleState는 모듈 내부 상태를 갱신하는 유일한 쓰기 경로입니다. 이 함수를 거치지 않은 상태는 undo/redo가 복원할 수 없으므로 새 모듈에서는 사용자 상태를 별도 로컬 state에 저장하지 않습니다.
beginInteractionHistory와 commitInteractionHistory는 연속 interaction을 하나의 히스토리 항목으로 묶는 계약입니다. 새 모듈이 자체 리사이즈 핸들, 회전 핸들, 도메인별 드래그 기능, 슬라이더, 숫자 입력을 구현한다면 변경 중에는 updateModuleState(..., { recordHistory: false })를 사용하고 종료 이벤트에서 커밋해야 합니다.
moduleDragHandleProps는 캔버스 런타임이 제공하는 공통 이동 핸들 이벤트입니다. 모듈은 실제로 사용자가 잡을 수 있는 보이는 표면에만 이 props를 부착해야 합니다. 투명한 패딩이나 빈 컨테이너에 부착하면 사용자가 보이지 않는 영역에서 모듈을 이동시키게 됩니다.
예를 들어 시계 모듈은 moduleDragHandleProps를 시계판 원형 요소에만 부착하고 dragSurface.shape: "circle"을 사용합니다. 포스트잇 모듈은 텍스트 편집 영역과 드래그가 충돌하지 않도록 상단 strip에만 moduleDragHandleProps를 부착합니다.
삭제 동작
사용자가 Option 키를 누르면 모든 모듈에 Remove 버튼이 나타납니다. 버튼 위치는 각 모듈 정의의 container.removeButton.position으로 정합니다. 클릭하면 해당 인스턴스가 캔버스에서 제거됩니다.
확대 동작
선택된 모듈의 사이드바에는 공통 Module 설정 패널이 표시됩니다. Advance 버튼을 열면 Shared 그룹에서 위치, 가로/세로 크기, 확대/축소, 회전 값을 직접 입력할 수 있습니다. 확대/축소는 CanvasModuleInstance.scale, 회전은 CanvasModuleInstance.rotation, 위치는 CanvasModuleInstance.position을 갱신합니다.
모듈별 Extension은 일반 작업 패널을 담당하고, 공통 Advance 안에 들어가야 하는 고급 설정은 AdvancedExtension으로 분리합니다. 마우스 리사이즈와 같은 모듈 내부 가로/세로 크기 설정은 SizeExtension으로 제공하며, 시계처럼 고정 비율이 필요한 모듈은 가로와 세로 입력 중 어느 쪽을 바꿔도 같은 값으로 동기화해야 합니다.
회전 동작
모든 모듈은 공통 회전 핸들을 가집니다. 사용자가 모듈 오른쪽 아래 코너에 마우스를 올리면 코너에 가까운 대각선 바깥쪽 위치에 회전 핸들이 나타나고, 해당 핸들을 드래그하면 모듈 중심을 기준으로 회전합니다.
회전 값은 CanvasModuleInstance.rotation에 degree 단위로 저장됩니다. 모듈 구현자는 회전 핸들을 직접 만들 필요가 없고, 모듈 본문과 그림자가 회전 중 잘리지 않도록 컨테이너와 내부 레이아웃의 overflow만 고려하면 됩니다.
새 모듈 추가 절차
- 모듈 컴포넌트 파일을 만듭니다.
CanvasModuleDefinition형태의 정의 객체를 export합니다.Provider안에서 해당 모듈의 내부 상태를 소유합니다.container옵션으로 박스 표시 방식, 크기 맞춤, 삭제 버튼 위치를 정합니다.Module에는 캔버스에 놓일 핵심 화면만 렌더링하고, 필요한 보이는 드래그 표면에moduleDragHandleProps를 부착합니다.Extension에는 선택 시 보여줄 조작 패널이나 세부 정보를 렌더링합니다.src/modules/registry.ts의MODULE_DEFINITIONS배열에 정의를 추가합니다.
예시
export const customModuleDefinition: CanvasModuleDefinition = {
kind: "custom",
displayName: "Custom Module",
addLabel: "Custom 추가",
container: {
chromeVisibility: "transient",
dragSurface: {
shape: "rect",
},
removeButton: {
position: "center",
},
resize: {
mode: "freeform",
},
size: "fit-content",
},
createInstance: (index, position) => ({
id: `custom-${index}`,
kind: "custom",
title: `Custom ${index}`,
description: "새 모듈 설명",
position,
state: {
body: "",
},
}),
Provider: CustomModuleProvider,
Module: CustomModuleCanvas,
Extension: CustomModuleExtension,
};
레지스트리에 등록하면 캔버스 툴바에 추가 버튼이 표시되고, 선택된 모듈의 Extension이 오른쪽 패널에 자동으로 표시됩니다.