Skip to content

Commit 30c1b83

Browse files
authored
✨ feat(content-blocks): add plugin for extracting AI message content blocks (#162)
1 parent 945b70c commit 30c1b83

13 files changed

Lines changed: 1382 additions & 0 deletions

File tree

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export * from './plugins/code';
77
export * from './plugins/codeblock';
88
export * from './plugins/codemirror-block';
99
export * from './plugins/common';
10+
export * from './plugins/content-blocks';
1011
export * from './plugins/file';
1112
export * from './plugins/hr';
1213
export * from './plugins/image';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { LexicalEditor } from 'lexical';
2+
3+
import { DataSource } from '@/editor-kernel';
4+
import type { IMarkdownShortCutService } from '@/plugins/markdown/service/shortcut';
5+
6+
import type { ContentBlock, ExtractContentBlocksOptions } from '../types';
7+
import { CONTENT_BLOCKS_DATA_TYPE } from '../types';
8+
import { extractContentBlocks } from '../utils/extract';
9+
10+
export class ContentBlocksDataSource extends DataSource {
11+
constructor(
12+
private getMarkdownService: () => IMarkdownShortCutService | null,
13+
private defaultOptions: ExtractContentBlocksOptions = {},
14+
) {
15+
super(CONTENT_BLOCKS_DATA_TYPE);
16+
}
17+
18+
override read(): void {
19+
throw new Error("'content-blocks' data source is write-only (editor → blocks).");
20+
}
21+
22+
override write(editor: LexicalEditor): ContentBlock[] {
23+
const service = this.getMarkdownService();
24+
if (!service) {
25+
throw new Error(
26+
'ContentBlocksPlugin requires MarkdownPlugin to be registered first; markdown writer service is missing.',
27+
);
28+
}
29+
return extractContentBlocks(editor, service, this.defaultOptions);
30+
}
31+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
{
2+
"root": {
3+
"children": [
4+
{
5+
"children": [
6+
{
7+
"detail": 0,
8+
"format": 0,
9+
"mode": "normal",
10+
"style": "",
11+
"text": "Tell me what's in this picture: ",
12+
"type": "text",
13+
"version": 1
14+
},
15+
{
16+
"type": "image",
17+
"version": 1,
18+
"altText": "avatar.png",
19+
"caption": {
20+
"editorState": {
21+
"root": {
22+
"children": [],
23+
"direction": null,
24+
"format": "",
25+
"indent": 0,
26+
"type": "root",
27+
"version": 1
28+
}
29+
}
30+
},
31+
"height": 0,
32+
"maxWidth": 320,
33+
"showCaption": false,
34+
"src": "https://avatars.githubusercontent.com/u/17870709?v=4",
35+
"status": "uploaded",
36+
"width": 0
37+
},
38+
{
39+
"detail": 0,
40+
"format": 0,
41+
"mode": "normal",
42+
"style": "",
43+
"text": " — and reference the file below.",
44+
"type": "text",
45+
"version": 1
46+
}
47+
],
48+
"direction": null,
49+
"format": "",
50+
"indent": 0,
51+
"type": "paragraph",
52+
"version": 1,
53+
"textFormat": 0,
54+
"textStyle": ""
55+
},
56+
{
57+
"altText": "Block image example",
58+
"height": 240,
59+
"maxWidth": 480,
60+
"src": "https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=640",
61+
"status": "uploaded",
62+
"type": "block-image",
63+
"version": 1,
64+
"width": 480
65+
},
66+
{
67+
"type": "file",
68+
"version": 1,
69+
"fileUrl": "https://example.com/spec.pdf",
70+
"name": "spec.pdf",
71+
"size": 20480,
72+
"status": "uploaded"
73+
}
74+
],
75+
"direction": null,
76+
"format": "",
77+
"indent": 0,
78+
"type": "root",
79+
"version": 1
80+
}
81+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
'use client';
2+
3+
import {
4+
CONTENT_BLOCKS_DATA_TYPE,
5+
type ContentBlock,
6+
ContentBlocksPlugin,
7+
FilePlugin,
8+
ImagePlugin,
9+
type MediaLists,
10+
extractMediaLists,
11+
} from '@lobehub/editor';
12+
import { CodeEditor, Collapse, Highlighter } from '@lobehub/ui';
13+
import { debounce } from 'es-toolkit';
14+
import { useMemo, useState } from 'react';
15+
16+
import { DEFAULT_HEADLESS_EDITOR_PLUGINS, createHeadlessEditor } from '@/headless';
17+
18+
import sample from './data.json';
19+
20+
const noopUpload = async (): Promise<{ url: string }> => ({ url: '' });
21+
22+
const buildHeadless = () =>
23+
createHeadlessEditor({
24+
additionalPlugins: [
25+
[ImagePlugin, { handleUpload: noopUpload, renderImage: () => null }],
26+
[FilePlugin, { decorator: () => null, handleUpload: noopUpload }],
27+
ContentBlocksPlugin,
28+
],
29+
plugins: DEFAULT_HEADLESS_EDITOR_PLUGINS,
30+
});
31+
32+
interface Result {
33+
blocks: ContentBlock[];
34+
error?: string;
35+
media: MediaLists;
36+
}
37+
38+
const extract = (jsonText: string): Result => {
39+
let parsed: unknown;
40+
try {
41+
parsed = JSON.parse(jsonText);
42+
} catch (error) {
43+
return {
44+
blocks: [],
45+
error: `Invalid JSON: ${(error as Error).message}`,
46+
media: { fileList: [], imageList: [] },
47+
};
48+
}
49+
50+
const headless = buildHeadless();
51+
try {
52+
headless.hydrate({ content: parsed, type: 'json' });
53+
const blocks = headless.kernel.getDocument(
54+
CONTENT_BLOCKS_DATA_TYPE,
55+
) as unknown as ContentBlock[];
56+
return { blocks: blocks ?? [], media: extractMediaLists(blocks ?? []) };
57+
} catch (error) {
58+
return {
59+
blocks: [],
60+
error: (error as Error).message,
61+
media: { fileList: [], imageList: [] },
62+
};
63+
} finally {
64+
headless.destroy();
65+
}
66+
};
67+
68+
const initialInput = JSON.stringify(sample, null, 2);
69+
70+
export default () => {
71+
const [input, setInput] = useState(initialInput);
72+
const [result, setResult] = useState<Result>(() => extract(initialInput));
73+
74+
const debouncedExtract = useMemo(
75+
() => debounce((value: string) => setResult(extract(value)), 200),
76+
[],
77+
);
78+
79+
const handleChange = (next: string) => {
80+
setInput(next);
81+
debouncedExtract(next);
82+
};
83+
84+
return (
85+
<Collapse
86+
defaultActiveKey={['input', 'blocks', 'media']}
87+
items={[
88+
{
89+
children: (
90+
<CodeEditor
91+
language="json"
92+
onValueChange={handleChange}
93+
style={{ fontSize: 12 }}
94+
value={input}
95+
variant="borderless"
96+
/>
97+
),
98+
key: 'input',
99+
label: 'Editor JSON (input)',
100+
},
101+
{
102+
children: (
103+
<Highlighter language="json" style={{ fontSize: 12, padding: 16 }} variant="borderless">
104+
{result.error ? `// ${result.error}` : JSON.stringify(result.blocks, null, 2)}
105+
</Highlighter>
106+
),
107+
key: 'blocks',
108+
label: `content-blocks (${result.blocks.length})`,
109+
},
110+
{
111+
children: (
112+
<Highlighter language="json" style={{ fontSize: 12, padding: 16 }} variant="borderless">
113+
{JSON.stringify(result.media, null, 2)}
114+
</Highlighter>
115+
),
116+
key: 'media',
117+
label: `extractMediaLists → ${result.media.imageList.length} image · ${result.media.fileList.length} file`,
118+
},
119+
]}
120+
padding={{ body: 0 }}
121+
style={{ border: 'none', borderRadius: 0, width: '100%' }}
122+
variant="outlined"
123+
/>
124+
);
125+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
'use client';
2+
3+
import {
4+
CONTENT_BLOCKS_DATA_TYPE,
5+
type ContentBlock,
6+
ContentBlocksPlugin,
7+
type IEditor,
8+
type MediaLists,
9+
ReactFilePlugin,
10+
ReactImagePlugin,
11+
extractMediaLists,
12+
} from '@lobehub/editor';
13+
import { Editor, useEditor } from '@lobehub/editor/react';
14+
import { Collapse, Highlighter, ToastHost } from '@lobehub/ui';
15+
import { debounce } from 'es-toolkit';
16+
import { type FC, useLayoutEffect, useMemo, useState } from 'react';
17+
18+
import { useLexicalComposerContext } from '@/editor-kernel/react';
19+
20+
import content from './data.json';
21+
22+
const ReactContentBlocksPlugin: FC = () => {
23+
const [editor] = useLexicalComposerContext();
24+
useLayoutEffect(() => {
25+
editor.registerPlugin(ContentBlocksPlugin);
26+
}, [editor]);
27+
return null;
28+
};
29+
30+
const Panel: FC<{ value: unknown }> = ({ value }) => (
31+
<Highlighter language="json" style={{ fontSize: 12, padding: 16 }} variant="borderless">
32+
{JSON.stringify(value, null, 2)}
33+
</Highlighter>
34+
);
35+
36+
const emptyMedia: MediaLists = { fileList: [], imageList: [] };
37+
38+
const fileUpload = async (file: File): Promise<{ url: string }> =>
39+
new Promise((resolve) => {
40+
setTimeout(() => resolve({ url: URL.createObjectURL(file) }), 600);
41+
});
42+
43+
export default () => {
44+
const editor = useEditor();
45+
const [blocks, setBlocks] = useState<ContentBlock[]>([]);
46+
const [media, setMedia] = useState<MediaLists>(emptyMedia);
47+
48+
const handleChange = useMemo(
49+
() =>
50+
debounce((ed: IEditor) => {
51+
try {
52+
const next = ed.getDocument(CONTENT_BLOCKS_DATA_TYPE) as unknown as ContentBlock[];
53+
setBlocks(next ?? []);
54+
setMedia(extractMediaLists(next ?? []));
55+
} catch (error) {
56+
console.error('[content-blocks demo]', error);
57+
}
58+
}, 150),
59+
[],
60+
);
61+
62+
return (
63+
<>
64+
<ToastHost />
65+
<Collapse
66+
defaultActiveKey={['editor', 'blocks', 'media']}
67+
items={[
68+
{
69+
children: (
70+
<Editor
71+
content={content}
72+
editor={editor}
73+
onInit={handleChange}
74+
onTextChange={handleChange}
75+
placeholder="Edit text · drop images · attach files…"
76+
plugins={[
77+
ReactContentBlocksPlugin,
78+
Editor.withProps(ReactImagePlugin, { defaultBlockImage: true }),
79+
Editor.withProps(ReactFilePlugin, { handleUpload: fileUpload }),
80+
]}
81+
style={{ padding: 16 }}
82+
type="json"
83+
/>
84+
),
85+
key: 'editor',
86+
label: 'Playground',
87+
},
88+
{
89+
children: <Panel value={blocks} />,
90+
key: 'blocks',
91+
label: `content-blocks (${blocks.length})`,
92+
},
93+
{
94+
children: <Panel value={media} />,
95+
key: 'media',
96+
label: `extractMediaLists → ${media.imageList.length} image · ${media.fileList.length} file`,
97+
},
98+
]}
99+
padding={{ body: 0 }}
100+
style={{ border: 'none', borderRadius: 0, width: '100%' }}
101+
variant="outlined"
102+
/>
103+
</>
104+
);
105+
};

0 commit comments

Comments
 (0)