mirror of https://github.com/buster-so/buster.git
Make a streaming plugin
This commit is contained in:
parent
6424ea00c6
commit
f68d0835b3
|
@ -0,0 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { StreamingContentExample } from '@/components/ui/report/StreamingContentExample';
|
||||
|
||||
export default function StreamingContentPage() {
|
||||
return (
|
||||
<div className="bg-background h-full w-full rounded border p-5">
|
||||
<StreamingContentExample />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,456 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import type { Value } from 'platejs';
|
||||
|
||||
import { Plate, PlateContent } from 'platejs/react';
|
||||
import { createPlateEditor } from 'platejs/react';
|
||||
|
||||
// Minimal required plugins for a working editor
|
||||
import { BasicBlocksPlugin } from '@platejs/basic-nodes/react';
|
||||
import { BoldPlugin, ItalicPlugin } from '@platejs/basic-nodes/react';
|
||||
|
||||
import { StreamContentPlugin, type StreamChunk } from './plugins/stream-content-plugin';
|
||||
|
||||
// Create an editor with minimal but functional plugins
|
||||
const editor = createPlateEditor({
|
||||
plugins: [
|
||||
// Basic blocks (paragraphs, headings)
|
||||
BasicBlocksPlugin,
|
||||
// Text formatting
|
||||
BoldPlugin,
|
||||
ItalicPlugin,
|
||||
// Our streaming plugin
|
||||
StreamContentPlugin
|
||||
],
|
||||
// Initial editor value
|
||||
value: [
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text: 'Start typing or use the streaming buttons below...' }]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
export function StreamingContentExample() {
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
|
||||
// Get the streaming plugin context
|
||||
const getStreamingPlugin = () => editor.getPlugin(StreamContentPlugin);
|
||||
|
||||
// Start streaming content
|
||||
const handleStartStreaming = useCallback(() => {
|
||||
getStreamingPlugin().api.streamContent.start();
|
||||
setIsStreaming(true);
|
||||
}, []);
|
||||
|
||||
// Stop streaming content
|
||||
const handleStopStreaming = useCallback(() => {
|
||||
getStreamingPlugin().api.streamContent.stop();
|
||||
setIsStreaming(false);
|
||||
}, []);
|
||||
|
||||
// Stream intelligent content with ID-based replacement
|
||||
const handleStreamIntelligentContent = useCallback(async () => {
|
||||
if (!isStreaming) {
|
||||
handleStartStreaming();
|
||||
}
|
||||
|
||||
// Simulate streaming content that gets updated/replaced
|
||||
const intelligentChunks: StreamChunk[] = [
|
||||
// Create initial heading
|
||||
createHeadingChunk('dynamic-heading', 'Loading...', 1),
|
||||
|
||||
// Replace the heading with final content (same ID)
|
||||
createHeadingChunk('dynamic-heading', 'Streaming Content Example', 1),
|
||||
|
||||
// Create initial paragraph
|
||||
createParagraphChunk('dynamic-paragraph', 'Initial content...'),
|
||||
|
||||
// Replace paragraph with more content (same ID)
|
||||
createParagraphChunk('dynamic-paragraph', 'This paragraph was updated with new content.'),
|
||||
|
||||
// Replace again with even more content (same ID)
|
||||
createParagraphChunk(
|
||||
'dynamic-paragraph',
|
||||
'This paragraph was updated multiple times with different content. The ID-based replacement allows for dynamic updates.'
|
||||
),
|
||||
|
||||
// Create a new paragraph (different ID)
|
||||
createParagraphChunk('final-paragraph', 'This is a new paragraph that was added.')
|
||||
];
|
||||
|
||||
for (const chunk of intelligentChunks) {
|
||||
// Stream the intelligent chunk
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk);
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
}
|
||||
|
||||
handleStopStreaming();
|
||||
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
|
||||
|
||||
// Simple test for intelligent replacement
|
||||
const handleSimpleIntelligentTest = useCallback(async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
handleStartStreaming();
|
||||
|
||||
// Test 1: First chunk
|
||||
const chunk1 = createParagraphChunk('test-paragraph', 'Hello world');
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk1);
|
||||
|
||||
// Test 2: Update same chunk
|
||||
setTimeout(() => {
|
||||
const chunk2 = createParagraphChunk('test-paragraph', 'Hello world! This is updated.');
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk2);
|
||||
}, 1000);
|
||||
|
||||
// Test 3: Update same chunk again
|
||||
setTimeout(() => {
|
||||
const chunk3 = createParagraphChunk(
|
||||
'test-paragraph',
|
||||
'Hello world! This is the final version.'
|
||||
);
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk3);
|
||||
}, 2000);
|
||||
|
||||
// Test 4: Add new chunk
|
||||
setTimeout(() => {
|
||||
const chunk4 = createParagraphChunk('new-paragraph', 'This is a new paragraph.');
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk4);
|
||||
}, 3000);
|
||||
|
||||
setTimeout(() => {
|
||||
handleStopStreaming();
|
||||
}, 4000);
|
||||
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
|
||||
|
||||
const handleStreamFullTest = useCallback(async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
handleStartStreaming();
|
||||
const arrayOfContent = [
|
||||
createParagraphChunk('para-1', 'First paragraph'),
|
||||
createParagraphChunk('para-1', 'First paragraph this is a test'),
|
||||
createParagraphChunk('para-2', 'Second paragraph'),
|
||||
createParagraphChunk('para-3', 'Third paragraph'),
|
||||
createParagraphChunk('para-4', 'Fourth paragraph'),
|
||||
createParagraphChunk('para-5', 'Fifth paragraph'),
|
||||
createParagraphChunk('para-6', 'Sixth paragraph'),
|
||||
createParagraphChunk('para-7', 'Seventh paragraph')
|
||||
];
|
||||
|
||||
for await (const chunk of arrayOfContent) {
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
|
||||
handleStopStreaming();
|
||||
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
|
||||
|
||||
// Stream custom Value content with IDs
|
||||
const handleStreamCustomValue = useCallback(async () => {
|
||||
if (!isStreaming) {
|
||||
handleStartStreaming();
|
||||
}
|
||||
|
||||
// Simulate custom Value chunks from server with IDs
|
||||
const customChunks: StreamChunk[] = [
|
||||
createStreamChunk(
|
||||
'custom-heading',
|
||||
[{ type: 'h1', children: [{ text: 'Custom Value Streaming' }] }],
|
||||
'heading'
|
||||
),
|
||||
createStreamChunk(
|
||||
'custom-paragraph-1',
|
||||
[
|
||||
{
|
||||
type: 'p',
|
||||
children: [
|
||||
{ text: 'This is streaming content in ' },
|
||||
{ text: 'Value', bold: true },
|
||||
{ text: ' format directly from the server.' }
|
||||
]
|
||||
}
|
||||
],
|
||||
'paragraph'
|
||||
),
|
||||
createStreamChunk(
|
||||
'custom-paragraph-2',
|
||||
[
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text: 'This paragraph will be replaced: ' }]
|
||||
}
|
||||
],
|
||||
'paragraph'
|
||||
),
|
||||
createStreamChunk(
|
||||
'custom-paragraph-2',
|
||||
[
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text: 'This paragraph was replaced with new content. ' }]
|
||||
}
|
||||
],
|
||||
'paragraph'
|
||||
),
|
||||
createStreamChunk(
|
||||
'custom-paragraph-2',
|
||||
[
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text: 'This paragraph was replaced again with final content!' }]
|
||||
}
|
||||
],
|
||||
'paragraph'
|
||||
)
|
||||
];
|
||||
|
||||
for (const chunk of customChunks) {
|
||||
// Stream the custom Value chunk
|
||||
getStreamingPlugin().api.streamContent.streamChunk(chunk);
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
handleStopStreaming();
|
||||
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
|
||||
|
||||
// Clear the editor
|
||||
const handleClear = useCallback(() => {
|
||||
editor.tf.selectAll();
|
||||
editor.tf.deleteBackward();
|
||||
editor.tf.insertNodes(
|
||||
[
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text: 'Editor cleared. Start typing or use streaming...' }]
|
||||
}
|
||||
],
|
||||
{ at: [0] }
|
||||
);
|
||||
// Add a default paragraph back
|
||||
// editor.tf.insertNodes(
|
||||
// [
|
||||
// {
|
||||
// type: 'p',
|
||||
// children: [{ text: 'Editor cleared. Start typing or use streaming...' }]
|
||||
// }
|
||||
// ],
|
||||
// { at: [0] }
|
||||
// );
|
||||
}, []);
|
||||
|
||||
// Get current streaming status
|
||||
const currentStreamingStatus = getStreamingPlugin().api.streamContent.isStreaming();
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<h1>Enhanced Streaming Content Plugin Example</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<h3>Controls</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
<button
|
||||
onClick={handleStreamIntelligentContent}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#ffc107',
|
||||
color: 'black',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isStreaming ? 'not-allowed' : 'pointer'
|
||||
}}>
|
||||
Stream Intelligent Content
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleStreamCustomValue}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isStreaming ? 'not-allowed' : 'pointer'
|
||||
}}>
|
||||
Stream Custom Value
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSimpleIntelligentTest}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isStreaming ? 'not-allowed' : 'pointer'
|
||||
}}>
|
||||
Simple Intelligent Test
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleStreamFullTest}
|
||||
disabled={isStreaming}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: isStreaming ? 'not-allowed' : 'pointer'
|
||||
}}>
|
||||
Stream Full Test
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#6c757d',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer'
|
||||
}}>
|
||||
Clear Editor
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
<strong>Status:</strong> {currentStreamingStatus ? 'Streaming...' : 'Ready'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: '4px',
|
||||
minHeight: '400px',
|
||||
backgroundColor: '#fff'
|
||||
}}>
|
||||
<Plate editor={editor}>
|
||||
<PlateContent
|
||||
style={{
|
||||
padding: '16px',
|
||||
minHeight: '400px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
</Plate>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
|
||||
<h3>How it works:</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Stream Text Content:</strong> Simple text streaming (backward compatibility)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stream Markdown:</strong> Markdown to Value conversion and streaming
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stream Intelligent Content:</strong> Content with IDs that can append to
|
||||
existing nodes
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stream Custom Value:</strong> Pre-formatted Value content with intelligent
|
||||
appending
|
||||
</li>
|
||||
<li>
|
||||
<strong>Stream Full:</strong> Efficient length-based updates for complete document state
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Enhanced Features:</h3>
|
||||
<ul>
|
||||
<li>✅ Content ID tracking for intelligent appending</li>
|
||||
<li>✅ Append to existing nodes with same ID</li>
|
||||
<li>✅ Create new nodes when ID doesn't exist</li>
|
||||
<li>✅ Support for paragraphs, headings, and other block types</li>
|
||||
<li>✅ Real-time streaming with intelligent content management</li>
|
||||
<li>✅ TypeScript support with proper typing</li>
|
||||
<li>✅ Backward compatibility with simple streaming</li>
|
||||
<li>✅ Utility functions for creating different chunk types</li>
|
||||
<li>
|
||||
✅ <strong>NEW:</strong> Efficient length-based updates with <code>streamFull</code>
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>NEW:</strong> Complete document state management
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>NEW:</strong> Smart truncation and expansion handling
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>Intelligent Streaming Logic:</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Content ID Matching:</strong> When streaming content with an ID, the plugin
|
||||
checks if a node with that ID already exists
|
||||
</li>
|
||||
<li>
|
||||
<strong>Appending to Existing:</strong> If the node exists and{' '}
|
||||
<code>appendToExisting</code> is true, content is appended to the existing node
|
||||
</li>
|
||||
<li>
|
||||
<strong>Creating New Nodes:</strong> If no matching ID is found, a new node is created
|
||||
</li>
|
||||
<li>
|
||||
<strong>Text Appending:</strong> For text nodes (paragraphs, headings), text content is
|
||||
intelligently appended
|
||||
</li>
|
||||
<li>
|
||||
<strong>Block Appending:</strong> For other node types, new blocks are inserted after
|
||||
the existing node
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Utility function to create a stream chunk with ID */
|
||||
export const createStreamChunk = (
|
||||
id: string,
|
||||
content: Value,
|
||||
type: string = 'paragraph'
|
||||
): StreamChunk => {
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
/** Utility function to create a paragraph chunk */
|
||||
const createParagraphChunk = (id: string, text: string): StreamChunk => {
|
||||
return createStreamChunk(id, [{ type: 'p', children: [{ text }] }], 'paragraph');
|
||||
};
|
||||
|
||||
/** Utility function to create a heading chunk */
|
||||
const createHeadingChunk = (id: string, text: string, level: 1 | 2 | 3 = 1): StreamChunk => {
|
||||
return createStreamChunk(id, [{ type: `h${level}`, children: [{ text }] }], 'heading');
|
||||
};
|
||||
|
||||
/** Utility function to convert string content to Value format */
|
||||
const stringToValue = (text: string): Value => {
|
||||
return [
|
||||
{
|
||||
type: 'p',
|
||||
children: [{ text }]
|
||||
}
|
||||
];
|
||||
};
|
|
@ -0,0 +1,266 @@
|
|||
import type { PlateEditor } from 'platejs/react';
|
||||
import type { Value, Element, Text } from 'platejs';
|
||||
|
||||
import { type PluginConfig, KEYS } from 'platejs';
|
||||
import { createPlatePlugin } from 'platejs/react';
|
||||
|
||||
export interface StreamChunkOptions {
|
||||
// No options needed - always uses simple logic
|
||||
}
|
||||
|
||||
export interface StreamChunk {
|
||||
/** Unique identifier for the content chunk */
|
||||
id: string;
|
||||
/** The content to stream */
|
||||
content: Value;
|
||||
/** Type of content (e.g., 'paragraph', 'heading', 'code') */
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export const StreamContentPlugin = createPlatePlugin({
|
||||
key: 'streamContent',
|
||||
options: {
|
||||
isStreaming: false,
|
||||
previousChunkId: null as string | null
|
||||
}
|
||||
}).extendEditorApi((ctx) => ({
|
||||
streamContent: {
|
||||
/**
|
||||
* Start streaming mode
|
||||
*/
|
||||
start: () => {
|
||||
ctx.setOption('isStreaming', true);
|
||||
ctx.setOption('previousChunkId', null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop streaming mode
|
||||
*/
|
||||
stop: () => {
|
||||
ctx.setOption('isStreaming', false);
|
||||
ctx.setOption('previousChunkId', null);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if currently streaming
|
||||
*/
|
||||
isStreaming: () => {
|
||||
return ctx.getOption('isStreaming') as boolean;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stream a single chunk with intelligent replacement logic
|
||||
*/
|
||||
streamChunk: (chunk: StreamChunk) => {
|
||||
const editor = ctx.editor as PlateEditor;
|
||||
const previousChunkId = ctx.getOption('previousChunkId') as string | null;
|
||||
|
||||
if (previousChunkId === chunk.id) {
|
||||
// Replace the last node with new content
|
||||
replaceLastNode(editor, chunk.content);
|
||||
} else {
|
||||
// Append new chunk to the end
|
||||
insertContentAtEndWithId(editor, chunk.content, chunk.id);
|
||||
}
|
||||
|
||||
// Update the previous chunk ID
|
||||
ctx.setOption('previousChunkId', chunk.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stream complete array of chunks with efficient length-based updates
|
||||
*/
|
||||
streamFull: (chunks: StreamChunk[]) => {
|
||||
const editor = ctx.editor as PlateEditor;
|
||||
|
||||
if (!chunks || chunks.length === 0) {
|
||||
console.warn('streamFull: No chunks provided');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentLength = editor.children.length;
|
||||
const incomingLength = chunks.length;
|
||||
|
||||
console.log('=== STREAM FULL DEBUG ===');
|
||||
console.log('Current editor length:', currentLength);
|
||||
console.log('Incoming chunks length:', incomingLength);
|
||||
console.log('Incoming chunks:', chunks);
|
||||
|
||||
if (currentLength === incomingLength) {
|
||||
// Same length: update the last node only
|
||||
console.log('🔄 Same length - updating last node');
|
||||
const lastChunk = chunks[chunks.length - 1];
|
||||
replaceLastNode(editor, lastChunk.content);
|
||||
} else if (incomingLength > currentLength) {
|
||||
// Incoming longer: update last node + append new nodes
|
||||
console.log('➕ Incoming longer - updating last + appending new');
|
||||
|
||||
// First, update the last existing node
|
||||
if (currentLength > 0) {
|
||||
const lastChunk = chunks[currentLength - 1];
|
||||
replaceLastNode(editor, lastChunk.content);
|
||||
}
|
||||
|
||||
// Then append any additional chunks
|
||||
const additionalChunks = chunks.slice(currentLength);
|
||||
for (const chunk of additionalChunks) {
|
||||
insertContentAtEndWithId(editor, chunk.content, chunk.id);
|
||||
}
|
||||
} else {
|
||||
// Incoming shorter: truncate and update
|
||||
console.log('➖ Incoming shorter - truncating and updating');
|
||||
|
||||
// Remove excess nodes
|
||||
const nodesToRemove = currentLength - incomingLength;
|
||||
for (let i = 0; i < nodesToRemove; i++) {
|
||||
const lastPoint = editor.api.end([]);
|
||||
const lastPath = lastPoint?.path;
|
||||
if (lastPath && Array.isArray(lastPath) && lastPath.length > 0) {
|
||||
const pathToRemove = [...lastPath];
|
||||
pathToRemove[pathToRemove.length - 1] -= 1;
|
||||
if (pathToRemove[pathToRemove.length - 1] >= 0) {
|
||||
editor.tf.removeNodes({ at: pathToRemove });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the last remaining node
|
||||
if (incomingLength > 0) {
|
||||
const lastChunk = chunks[incomingLength - 1];
|
||||
replaceLastNode(editor, lastChunk.content);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Editor children after streamFull:', JSON.stringify(editor.children, null, 2));
|
||||
console.log('=== END STREAM FULL DEBUG ===');
|
||||
},
|
||||
|
||||
/**
|
||||
* Find a node with a specific ID
|
||||
*/
|
||||
findNodeWithId: (id: string) => {
|
||||
const editor = ctx.editor as PlateEditor;
|
||||
return findNodeWithId(editor, id);
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Find a node with a specific ID in the editor (only checks the last node)
|
||||
*/
|
||||
const findNodeWithId = (editor: PlateEditor, id: string): number[] | null => {
|
||||
// Only check the last node since we always append to the end
|
||||
const lastIndex = editor.children.length - 1;
|
||||
if (lastIndex >= 0) {
|
||||
const lastNode = editor.children[lastIndex];
|
||||
|
||||
if (lastNode.id === id) {
|
||||
return [lastIndex];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert content at the end of the document with ID
|
||||
*/
|
||||
const insertContentAtEndWithId = (editor: PlateEditor, content: Value, id: string) => {
|
||||
if (!content || content.length === 0) return;
|
||||
|
||||
// Add ID to the content nodes
|
||||
const contentWithId = addIdToContent(content, id);
|
||||
|
||||
// Get the last path in the document
|
||||
const lastPath = editor.api.end([]);
|
||||
|
||||
// Insert content at the end
|
||||
editor.tf.insertNodes(contentWithId, {
|
||||
at: lastPath,
|
||||
select: false
|
||||
});
|
||||
|
||||
// Move cursor to the end of the inserted content
|
||||
const newEndPath = editor.api.end([]);
|
||||
editor.tf.select(newEndPath);
|
||||
};
|
||||
|
||||
/**
|
||||
* Add ID to content nodes
|
||||
*/
|
||||
const addIdToContent = (content: Value, id: string): Value => {
|
||||
const result = content.map((node) => ({
|
||||
...node,
|
||||
id: id
|
||||
}));
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the last node in the document with new content
|
||||
*/
|
||||
const replaceLastNode = (editor: PlateEditor, newContent: Value) => {
|
||||
if (!newContent || newContent.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the last path in the document
|
||||
const lastPoint = editor.api.end([]);
|
||||
|
||||
// Extract the path from the point
|
||||
const lastPath = lastPoint?.path;
|
||||
|
||||
// Remove the last node
|
||||
if (lastPath && Array.isArray(lastPath) && lastPath.length > 0) {
|
||||
const pathToRemove = [...lastPath];
|
||||
pathToRemove[pathToRemove.length - 1] -= 1; // Go back one node
|
||||
|
||||
if (pathToRemove[pathToRemove.length - 1] >= 0) {
|
||||
editor.tf.removeNodes({ at: pathToRemove });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new content at the end
|
||||
if (lastPath && Array.isArray(lastPath)) {
|
||||
editor.tf.insertNodes(newContent, {
|
||||
at: lastPath,
|
||||
select: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate a unique chunk ID
|
||||
*/
|
||||
const generateChunkId = (): string => {
|
||||
return `chunk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to create a stream chunk with ID
|
||||
*/
|
||||
export const createStreamChunk = (
|
||||
id: string,
|
||||
content: Value,
|
||||
type: string = 'paragraph'
|
||||
): StreamChunk => {
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
type
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to create a paragraph chunk
|
||||
*/
|
||||
export const createParagraphChunk = (id: string, text: string): StreamChunk => {
|
||||
return createStreamChunk(id, [{ type: 'p', children: [{ text }] }], 'paragraph');
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility function to create a heading chunk
|
||||
*/
|
||||
export const createHeadingChunk = (id: string, text: string, level: 1 | 2 | 3 = 1): StreamChunk => {
|
||||
return createStreamChunk(id, [{ type: `h${level}`, children: [{ text }] }], 'heading');
|
||||
};
|
|
@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react';
|
|||
import { EditorKit } from './editor-kit';
|
||||
import { FIXED_TOOLBAR_KIT_KEY } from './plugins/fixed-toolbar-kit';
|
||||
import { GlobalVariablePlugin } from './plugins/global-variable-kit';
|
||||
import { useMount } from '@/hooks';
|
||||
|
||||
export const useReportEditor = ({
|
||||
value,
|
||||
|
@ -41,6 +42,35 @@ export const useReportEditor = ({
|
|||
}
|
||||
}, [value]);
|
||||
|
||||
useMount(() => {
|
||||
setTimeout(() => {
|
||||
const lastPath = editor.api.end([]);
|
||||
console.log(lastPath, editor);
|
||||
|
||||
editor.tf.insertNodes([{ type: 'p', children: [{ text: 'test' }] }], { at: lastPath });
|
||||
|
||||
// Wait 500ms and then append additional text to the same node
|
||||
setTimeout(() => {
|
||||
const lastPath = editor.api.end([]);
|
||||
|
||||
// Find the last block (paragraph) at that location
|
||||
const lastBlock = editor.api.block({ at: lastPath });
|
||||
|
||||
if (lastBlock && lastBlock[0].type === 'p') {
|
||||
const [blockNode, blockPath] = lastBlock;
|
||||
|
||||
// Get the end point of the block
|
||||
const endPoint = editor.api.end(blockPath);
|
||||
|
||||
// Insert "WOW!" at the end
|
||||
editor.tf.insertText('WOW!', {
|
||||
at: endPoint
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
const editor = usePlateEditor({
|
||||
plugins,
|
||||
value,
|
||||
|
|
Loading…
Reference in New Issue