feat(blog): add onChange functionality to BlogEditor and enhance BlogImagePlugin with drag-and-drop support
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 20s

This commit is contained in:
2025-10-25 20:04:14 +08:00
parent 1f2cb3268f
commit 2a46c1423b
2 changed files with 61 additions and 31 deletions

View File

@@ -8,6 +8,8 @@ import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { ContentEditable } from '@lexical/react/LexicalContentEditable' import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin' import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary' import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { HeadingNode, QuoteNode } from '@lexical/rich-text' import { HeadingNode, QuoteNode } from '@lexical/rich-text'
import { CodeNode, CodeHighlightNode } from '@lexical/code' import { CodeNode, CodeHighlightNode } from '@lexical/code'
import { ListItemNode, ListNode } from '@lexical/list' import { ListItemNode, ListNode } from '@lexical/list'
@@ -20,12 +22,13 @@ import { LinkNode, AutoLinkNode } from '@lexical/link'
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin' import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'
import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin' import { AutoLinkPlugin } from '@lexical/react/LexicalAutoLinkPlugin'
import { HashtagNode } from '@lexical/hashtag' import { HashtagNode } from '@lexical/hashtag'
import { $generateHtmlFromNodes } from '@lexical/html'
import type { EditorState } from 'lexical'
import { ImageNode } from '../../editor/nodes/ImageNode' import { ImageNode } from '../../editor/nodes/ImageNode'
import { MentionNode } from '../../editor/nodes/MentionNode' import { MentionNode } from '../../editor/nodes/MentionNode'
import ToolbarPlugin from '../../editor/plugins/ToolbarPlugin' import ToolbarPlugin from '../../editor/plugins/ToolbarPlugin'
import MarkdownPlugin from '../../editor/plugins/MarkdownShortcutPlugin' import MarkdownPlugin from '../../editor/plugins/MarkdownShortcutPlugin'
import DragDropPastePlugin from '../../editor/plugins/DragDropPastePlugin'
import HashtagPlugin from '../../editor/plugins/HashtagPlugin' import HashtagPlugin from '../../editor/plugins/HashtagPlugin'
import MentionsPlugin from '../../editor/plugins/MentionsPlugin' import MentionsPlugin from '../../editor/plugins/MentionsPlugin'
import editorTheme from '../../editor/themes/EditorTheme' import editorTheme from '../../editor/themes/EditorTheme'
@@ -69,9 +72,26 @@ const MATCHERS = [
interface BlogEditorProps { interface BlogEditorProps {
placeholder?: string placeholder?: string
onChange?: (html: string) => void
} }
export default function BlogEditor({ placeholder }: BlogEditorProps) { /**
* OnChange wrapper component that has access to editor context
*/
function OnChangeWrapper({ onChange }: { onChange?: (html: string) => void }) {
const [editor] = useLexicalComposerContext()
const handleChange = (editorState: EditorState) => {
editorState.read(() => {
const html = $generateHtmlFromNodes(editor)
onChange?.(html)
})
}
return <OnChangePlugin onChange={handleChange} />
}
export default function BlogEditor({ placeholder, onChange }: BlogEditorProps) {
const editorConfig = { const editorConfig = {
namespace: 'BlogEditor', namespace: 'BlogEditor',
theme: editorTheme, theme: editorTheme,
@@ -111,6 +131,7 @@ export default function BlogEditor({ placeholder }: BlogEditorProps) {
} }
ErrorBoundary={LexicalErrorBoundary} ErrorBoundary={LexicalErrorBoundary}
/> />
<OnChangeWrapper onChange={onChange} />
<HistoryPlugin /> <HistoryPlugin />
<ListPlugin /> <ListPlugin />
<CheckListPlugin /> <CheckListPlugin />
@@ -118,7 +139,6 @@ export default function BlogEditor({ placeholder }: BlogEditorProps) {
<AutoLinkPlugin matchers={MATCHERS} /> <AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin /> <TablePlugin />
<BlogImagePlugin /> <BlogImagePlugin />
<DragDropPastePlugin />
<HashtagPlugin /> <HashtagPlugin />
<MentionsPlugin /> <MentionsPlugin />
<MarkdownPlugin /> <MarkdownPlugin />

View File

@@ -4,14 +4,28 @@
*/ */
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { DRAG_DROP_PASTE } from '@lexical/rich-text'
import { isMimeType, mediaFileReader } from '@lexical/utils'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' import { $insertNodes, COMMAND_PRIORITY_EDITOR, COMMAND_PRIORITY_LOW, createCommand } from 'lexical'
import type { LexicalCommand } from 'lexical' import type { LexicalCommand } from 'lexical'
import { $createImageNode, ImageNode } from '../../editor/nodes/ImageNode' import { $createImageNode, ImageNode } from '../../editor/nodes/ImageNode'
import { uploadFile, getDownloadPresignedURL } from '../api' import { uploadFile, getDownloadPresignedURL } from '../api'
export const INSERT_BLOG_IMAGE_COMMAND: LexicalCommand<File> = createCommand('INSERT_BLOG_IMAGE_COMMAND') export const INSERT_BLOG_IMAGE_COMMAND: LexicalCommand<File> = createCommand('INSERT_BLOG_IMAGE_COMMAND')
const ACCEPTABLE_IMAGE_TYPES = [
'image/',
'image/heic',
'image/heif',
'image/gif',
'image/webp',
'image/png',
'image/jpeg',
'image/jpg',
'image/svg+xml',
]
interface UploadingImage { interface UploadingImage {
id: string id: string
file: File file: File
@@ -27,6 +41,7 @@ export function BlogImagePlugin() {
throw new Error('BlogImagePlugin: ImageNode not registered on editor') throw new Error('BlogImagePlugin: ImageNode not registered on editor')
} }
// Register command to insert blog images
return editor.registerCommand( return editor.registerCommand(
INSERT_BLOG_IMAGE_COMMAND, INSERT_BLOG_IMAGE_COMMAND,
(file: File) => { (file: File) => {
@@ -55,6 +70,28 @@ export function BlogImagePlugin() {
) )
}, [editor]) }, [editor])
// Handle drag and drop / paste events
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(async () => {
const filesResult = await mediaFileReader(
files,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
)
for (const { file } of filesResult) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
editor.dispatchCommand(INSERT_BLOG_IMAGE_COMMAND, file)
}
}
})()
return true
},
COMMAND_PRIORITY_LOW,
)
}, [editor])
const handleImageUpload = async (file: File, uploadId: string, localUrl: string) => { const handleImageUpload = async (file: File, uploadId: string, localUrl: string) => {
try { try {
// Upload file and get fileKey // Upload file and get fileKey
@@ -109,33 +146,6 @@ export function BlogImagePlugin() {
} }
} }
// Handle paste events
useEffect(() => {
return editor.registerCommand(
// @ts-ignore - PASTE_COMMAND exists
editor.PASTE_COMMAND || 'PASTE_COMMAND',
(event: ClipboardEvent) => {
const items = event.clipboardData?.items
if (!items) return false
for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item.type.indexOf('image') !== -1) {
event.preventDefault()
const file = item.getAsFile()
if (file) {
editor.dispatchCommand(INSERT_BLOG_IMAGE_COMMAND, file)
}
return true
}
}
return false
},
COMMAND_PRIORITY_EDITOR
)
}, [editor])
return null return null
} }