reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Start
Playground
Core Concepts
Core Concepts
Editor
Editor 개요
Block Types
Streaming
Markdown Conversion
Build & Operate
Skills
App Composition Guide
AI Integration
Authoring Playbook
Custom Blocks
Release Notes
Oopt-editor
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

Build & Operate
  1. Docs
  2. /
  3. Build & Operate
  4. /
  5. Custom Blocks

커스텀 블록

BlockDefinition 작성, defineCatalog 확장, 커스텀 렌더러 구현 가이드

reopt design · Updated Jun 26, 2026

시작하기Playground핵심 개념개요블록 타입스트리밍Markdown 변환Skills앱 조합 가이드AI 연동Authoring 플레이북커스텀 블록릴리즈 노트

1. 현재 BlockDefinition 구조

커스텀 블록은 schema나 editableFields를 쓰지 않습니다. 현재 public type은 attrsSchema로 attribute 스키마를 선언하고, rich text 블록은 contentKind="rich-text"와 editableContent() helper를 사용합니다.

tsx
import type { BlockDefinition } from "@reopt-ai/opt-editor";

const definition: BlockDefinition = {
  type: "custom",
  attrsSchema: {
    variant: { type: "string", default: "info" },
  },
  contentKind: "rich-text",
  canHaveChildren: false,
  component: CustomBlock,
  prompt: "Describe when AI should use this block.",
};

2. rich-text 블록

문장형 콘텐츠는 attrs에 텍스트를 넣지 않고, EditorElement.content의 canonical inline segment에 저장합니다. edit mode에서는 editableContent()가 contentEditable 렌더링을 만들고, stream/diff mode에서는 MarkedText로 같은 content를 렌더링합니다.

tsx
import {
  MarkedText,
  type BlockDefinition,
  type BlockRenderProps,
} from "@reopt-ai/opt-editor";

type AlertAttrs = {
  severity?: string;
};

function AlertBlock({
  attrs,
  content,
  editableContent,
}: BlockRenderProps<AlertAttrs>) {
  return (
    <aside data-alert-severity={attrs.severity ?? "info"}>
      {editableContent?.({
        as: "div",
        placeholder: "Alert message",
      }) ?? <MarkedText content={content} />}
    </aside>
  );
}

export const alertBlock: BlockDefinition<AlertAttrs> = {
  type: "alert",
  attrsSchema: {
    severity: { type: "string", default: "info" },
  },
  contentKind: "rich-text",
  component: AlertBlock,
  prompt: 'An alert box. severity is "info", "warning", or "error".',
};

3. string attr 블록

code, image, embed처럼 텍스트가 attribute 자체인 블록은 editableStringAttr("field")를 사용합니다. 이 경우 값은 EditorElement.attrs에 저장됩니다.

tsx
import type {
  BlockDefinition,
  BlockRenderProps,
} from "@reopt-ai/opt-editor";

type BannerAttrs = {
  title?: string;
  tone?: string;
};

function BannerBlock({
  attrs,
  mode,
  editableStringAttr,
}: BlockRenderProps<BannerAttrs>) {
  return (
    <section data-banner-tone={attrs.tone ?? "neutral"}>
      {mode === "edit"
        ? editableStringAttr?.("title", {
            as: "h3",
            placeholder: "Banner title",
          })
        : <h3>{attrs.title}</h3>}
    </section>
  );
}

export const bannerBlock: BlockDefinition<BannerAttrs> = {
  type: "banner",
  attrsSchema: {
    title: { type: "string", default: "" },
    tone: { type: "string", default: "neutral" },
  },
  component: BannerBlock,
  prompt: "A short title banner with a visual tone.",
};

4. 카탈로그 확장

defineCatalog()는 block definition map을 받습니다. 기본 블록을 유지하려면 defaultCatalog.blocks를 펼친 뒤 새 definition을 추가합니다. 이렇게 등록한 블록은 catalog.prompt(), catalog.jsonSchema(),catalog.validateAttrs()에 반영됩니다.

tsx
import { defineCatalog, defaultCatalog } from "@reopt-ai/opt-editor";
import { alertBlock } from "./alert-block";
import { bannerBlock } from "./banner-block";

export const catalog = defineCatalog({
  ...defaultCatalog.blocks,
  alert: alertBlock,
  banner: bannerBlock,
});

const systemPrompt = catalog.prompt();
const jsonSchema = catalog.jsonSchema();

5. split / merge

rich text 블록에서 Enter/Backspace 동작이 필요하면 operations.split과 operations.merge를 정의합니다. EditorElement는 props가 아니라attrs와 content를 갖습니다.

tsx
import type {
  BlockDefinition,
  EditorElement,
  InlineContentSegment,
} from "@reopt-ai/opt-editor";

function splitContent(
  content: InlineContentSegment[] = [],
  offset: number,
): [InlineContentSegment[], InlineContentSegment[]] {
  const text = content.map((segment) => segment.text).join("");
  return [
    [{ text: text.slice(0, offset), marks: [] }],
    [{ text: text.slice(offset), marks: [] }],
  ];
}

export const alertBlock: BlockDefinition<AlertAttrs> = {
  type: "alert",
  attrsSchema: {
    severity: { type: "string", default: "info" },
  },
  contentKind: "rich-text",
  component: AlertBlock,
  operations: {
    split(element: EditorElement, offset: number) {
      const [left, right] = splitContent(element.content, offset);
      return [
        { ...element, content: left },
        {
          id: `${element.id}-split`,
          type: "paragraph",
          attrs: {},
          content: right,
        },
      ];
    },
    merge(a: EditorElement, b: EditorElement) {
      return {
        ...a,
        content: [...(a.content ?? []), ...(b.content ?? [])],
      };
    },
  },
};
PreviousAuthoring Playbook생성, 스트리밍, 검수, 직접 편집, 배포 직전 검증까지 이어지는 운영형 authoring 워크플로우Build & Operate
Go to Authoring Playbook
NextRelease Notesopt-editor 버전별 변경 사항과 하이라이트를 패키지 기준으로 정리합니다.Build & Operate