Core Concepts
핵심 개념
Flat Spec 아키텍처, 블록 시스템, 인라인 마크, EditorStore, 카탈로그 구조
reopt designUpdated
1. Flat Spec 아키텍처
opt-editor의 문서 모델은 EditorSpec이라는 flat map 구조입니다. 두 가지 핵심 필드로 구성됩니다:
root: string[]— 최상위 요소의 표시 순서elements: Record<string, EditorElement>— 모든 요소를 ID로 매핑한 flat map
tsx
/** 문서의 단일 요소. */
interface EditorElement {
id: string;
type: string;
props: Record<string, unknown>;
children?: string[]; // 컨테이너 블록의 자식 ID 배열
visible?: VisibilityCondition; // 조건부 표시
on?: Record<string, EventBinding>; // 이벤트 바인딩
}
/** 문서 전체 스펙. */
interface EditorSpec {
root: string[]; // 표시 순서
elements: Record<string, EditorElement>; // flat map
version: number; // 낙관적 동시성 버전
}트리가 아닌 flat map을 선택한 이유
- RFC 6902 JSON Patch와 자연스럽게 호환 (
/elements/p1/props/text로 직접 접근) - AI 스트리밍 시 요소별 독립 추가 가능 — 인덱스 시프트 없음
- O(1) 요소 접근 — ID로 바로 조회
toTree()/fromTree()로 트리 변환 가능
tsx
import { toTree, fromTree } from "@reopt-ai/opt-editor";
// Flat spec → 트리 변환 (렌더링용)
const tree: BlockNode[] = toTree(spec);
// tree[0] = { id: "h1", type: "heading", props: {...}, children: [] }
// 트리 → Flat spec 복원
const restored: EditorSpec = fromTree(tree);2. 블록 시스템
모든 콘텐츠는 블록 단위로 구성됩니다. 각 블록 타입은 BlockDefinition으로 정의되며, 타입 이름, attrs 스키마, rich-text content 여부, 렌더링 컴포넌트, AI 프롬프트 설명을 포함합니다.
tsx
interface BlockDefinition<Props extends object = object> {
type: string;
attrsSchema: Record<string, SchemaFieldDef>;
contentKind?: "rich-text";
component?: (props: BlockRenderProps<Props>) => React.ReactNode;
canHaveChildren?: boolean;
wrapperAs?: "div" | "li";
skipWrapper?: boolean;
prompt?: string;
zodSchema?: ZodLikeSchema;
operations?: {
split?: (element: EditorElement, offset: number) => [EditorElement, EditorElement];
merge?: (a: EditorElement, b: EditorElement) => EditorElement;
};
}기본 카탈로그에는 15개 블록이 포함됩니다. 세 가지 카테고리로 분류됩니다:
| 카테고리 | 블록 | 설명 |
|---|---|---|
| 텍스트 (5) | paragraph, heading, quote, callout, code | rich-text content. edit mode에서는 editableContent helper로 직접 편집 |
| 컨테이너 (5) | list, list-item, table, table-row, table-cell | 중첩 구조. canHaveChildren: true |
| 미디어/확장 (5) | image, video, file, embed, divider | 미디어 임베드와 시각적 구분자 |
3. 인라인 마크
텍스트 prop 내에 마크다운 인라인 구문을 사용할 수 있습니다. parseInlineMarks가 텍스트를 파싱하여 MarkedSegment[]배열로 변환하고, MarkedText 컴포넌트가 렌더링합니다.
| 서식 | 문법 | 렌더링 |
|---|---|---|
| Bold | **text** | <strong> |
| Italic | *text* | <em> |
| Code | `text` | <code> |
| Strikethrough | ~~text~~ | <s> |
| Link | [text](url) | <a> |
tsx
import { parseInlineMarks } from "@reopt-ai/opt-editor";
const segments = parseInlineMarks("**bold** and *italic* text");
// [
// { text: "bold", marks: ["bold"], attrs: undefined },
// { text: " and ", marks: [], attrs: undefined },
// { text: "italic", marks: ["italic"], attrs: undefined },
// { text: " text", marks: [], attrs: undefined },
// ]
// 역변환: segments → 마크다운 문자열
import { serializeInlineMarks } from "@reopt-ai/opt-editor";
const markdown = serializeInlineMarks(segments);
// "**bold** and *italic* text"4. EditorStore
createEditorStore가 반환하는 EditorStore는 문서 상태의 단일 소스입니다. React의 useSyncExternalStore와 호환되는 subscribe 패턴을 제공합니다.
| 메서드 | 설명 |
|---|---|
| getSpec() | 현재 flat spec 반환 |
| getTree() | 트리 표현 반환 (캐시됨, 변경 시 재계산) |
| applyPatch(ops) | RFC 6902 JSON Patch 적용 (스트림 모드) |
| updateElement(id, props) | 단일 요소 props 업데이트 (편집 모드) |
| insertElement(parentId, index, element) | 새 요소 삽입. parentId=null이면 root 레벨 |
| removeElement(id) | 요소와 하위 자식 모두 제거 |
| moveElement(id, newParentId, newIndex) | 요소 위치 이동 |
| subscribe(listener) | 변경 구독. useSyncExternalStore 호환 |
| undo() / redo() | 히스토리 탐색 |
| getLastChangedIds() | 마지막 mutation에서 변경된 요소 ID (포커스 복원용) |
tsx
import { createEditorStore } from "@reopt-ai/opt-editor";
const store = createEditorStore();
// 요소 삽입
store.insertElement(null, 0, {
id: "h1",
type: "heading",
props: { text: "New Document", level: 1 },
});
// 요소 업데이트
store.updateElement("h1", { text: "Updated Title" });
// JSON Patch 적용 (AI 스트리밍)
store.applyPatch([
{ op: "add", path: "/elements/p1", value: { id: "p1", type: "paragraph", props: { text: "Hello" } } },
{ op: "add", path: "/root/-", value: "p1" },
]);
// Undo/Redo
store.undo();
if (store.canRedo()) store.redo();
// 변경 구독
const unsubscribe = store.subscribe(() => {
console.log("Spec changed:", store.getSpec());
console.log("Changed IDs:", store.getLastChangedIds());
});5. 카탈로그
카탈로그는 사용 가능한 블록 타입의 레지스트리입니다. defineCatalog로 생성하며, 두 가지 역할을 합니다:
- 1. 블록 렌더링 — element type을 React 컴포넌트에 매핑
- 2. AI 프롬프트 생성 — 스키마를 기반으로 LLM이 이해할 수 있는 시스템 프롬프트 생성
tsx
import { defineCatalog, defaultCatalog } from "@reopt-ai/opt-editor";
// 기본 카탈로그 사용
const catalog = defaultCatalog;
// AI 시스템 프롬프트 생성
const systemPrompt = catalog.prompt();
// → "# Document Schema
## Structure
..."
// 등록된 모든 블록 타입, 스트리밍 규칙, 예제가 포함됨
// JSON Schema 생성 (Structured Output API용)
const jsonSchema = catalog.jsonSchema();
// → { $schema: "...", title: "EditorSpec", properties: {...} }
// Props 검증
const result = catalog.validateProps(spec.elements);
if (!result.valid) {
console.error("Validation errors:", result.errors);
}
// 커스텀 카탈로그 생성
const customCatalog = defineCatalog({
...defaultCatalog.blocks,
myBlock: myBlockDefinition,
});6. 검증
validateSpec은 EditorSpec의 구조적 무결성을 검사합니다. autoFixSpec은 발견된 문제를 자동으로 수정합니다.
| 검증 코드 | 심각도 | 설명 |
|---|---|---|
| root_missing_element | error | root가 존재하지 않는 요소를 참조 |
| circular_reference | error | children에 순환 참조 발견 |
| missing_child | error | children이 존재하지 않는 요소를 참조 |
| unknown_type | error | 카탈로그에 없는 블록 타입 사용 |
| id_mismatch | error | element key와 element.id 불일치 |
| orphan_element | warning | root에서 도달할 수 없는 고아 요소 |
| duplicate_root | warning | root에 같은 ID가 중복 등록 |
| unexpected_children | warning | 컨테이너가 아닌 블록에 children 존재 |
| invalid_parent | warning | 잘못된 부모-자식 관계 (예: table-cell이 list의 자식) |
tsx
import { validateSpec, autoFixSpec, diffSpecs } from "@reopt-ai/opt-editor";
// 검증
const result = validateSpec(spec, catalog);
if (!result.valid) {
for (const issue of result.issues) {
console.log(`[${issue.severity}] ${issue.code}: ${issue.message}`);
}
}
// 자동 수정
const { spec: fixedSpec, fixes } = autoFixSpec(spec, catalog);
console.log("Applied fixes:", fixes);
// ["Removed orphan element "old1"", "Fixed ID mismatch: ..."]
// 두 스펙의 차이를 JSON Patch로 생성
const patches = diffSpecs(oldSpec, newSpec);
// [{ op: "replace", path: "/elements/p1/props/text", value: "Updated" }, ...]