커스텀 블록
BlockDefinition 작성, defineCatalog 확장, 커스텀 렌더러 구현 가이드
reopt designUpdated
1. 현재 BlockDefinition 구조
커스텀 블록은 schema나 editableFields를 쓰지 않습니다. 현재 public type은 attrsSchema로 attribute 스키마를 선언하고, rich text 블록은 contentKind="rich-text"와 editableContent() helper를 사용합니다.
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를 렌더링합니다.
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에 저장됩니다.
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()에 반영됩니다.
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를 갖습니다.
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 ?? [])],
};
},
},
};