/** * Blog-specific Image Plugin * Handles image upload to S3 when images are dragged/pasted into the editor */ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' import { useEffect, useState } from 'react' import { $insertNodes, COMMAND_PRIORITY_EDITOR, createCommand } from 'lexical' import type { LexicalCommand } from 'lexical' import { $createImageNode, ImageNode } from '../../editor/nodes/ImageNode' import { uploadFile, getDownloadPresignedURL } from '../api' export const INSERT_BLOG_IMAGE_COMMAND: LexicalCommand = createCommand('INSERT_BLOG_IMAGE_COMMAND') interface UploadingImage { id: string file: File localUrl: string } export function BlogImagePlugin() { const [editor] = useLexicalComposerContext() const [, setUploadingImages] = useState>(new Map()) useEffect(() => { if (!editor.hasNodes([ImageNode])) { throw new Error('BlogImagePlugin: ImageNode not registered on editor') } return editor.registerCommand( INSERT_BLOG_IMAGE_COMMAND, (file: File) => { const id = `uploading-${Date.now()}-${Math.random()}` const localUrl = URL.createObjectURL(file) // Add to uploading state setUploadingImages(prev => new Map(prev.set(id, { id, file, localUrl }))) // Insert placeholder image node with loading state editor.update(() => { const imageNode = $createImageNode({ src: localUrl, altText: file.name, maxWidth: 800, }) $insertNodes([imageNode]) }) // Start upload handleImageUpload(file, id, localUrl) return true }, COMMAND_PRIORITY_EDITOR ) }, [editor]) const handleImageUpload = async (file: File, uploadId: string, localUrl: string) => { try { // Upload file and get fileKey const fileKey = await uploadFile(file) // Get download URL const { url: downloadUrl } = await getDownloadPresignedURL({ fileKey }) // Update the image node with the actual S3 URL editor.update(() => { const nodes = editor.getEditorState().read(() => { const allNodes: any[] = [] editor.getEditorState()._nodeMap.forEach((node) => { if (node instanceof ImageNode && node.getSrc() === localUrl) { allNodes.push(node) } }) return allNodes }) nodes.forEach(node => { if (node instanceof ImageNode) { const writable = node.getWritable() writable.__src = downloadUrl } }) }) // Clean up URL.revokeObjectURL(localUrl) setUploadingImages(prev => { const next = new Map(prev) next.delete(uploadId) return next }) // Show success notification showNotification('Image uploaded successfully', 'success') } catch (error) { console.error('Failed to upload image:', error) // Show error notification showNotification('Failed to upload image', 'error') // Clean up URL.revokeObjectURL(localUrl) setUploadingImages(prev => { const next = new Map(prev) next.delete(uploadId) return next }) } } // 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 } // Simple notification helper function showNotification(message: string, type: 'success' | 'error') { // You can replace this with your preferred notification library // For now, just using a simple alert-like approach const event = new CustomEvent('app:notification', { detail: { message, type } }) window.dispatchEvent(event) console.log(`[${type.toUpperCase()}] ${message}`) }