import type { DOMConversionMap, DOMConversionOutput, DOMExportOutput, EditorConfig, LexicalNode, NodeKey, SerializedLexicalNode, Spread, } from 'lexical'; import type { JSX } from 'react'; import { $applyNodeReplacement, DecoratorNode, } from 'lexical'; import { Suspense, lazy } from 'react'; const ImageComponent = lazy(() => import('./ImageComponent')); export interface ImagePayload { altText: string; height?: number; key?: NodeKey; maxWidth?: number; src: string; width?: number; fileKey?: string; // S3 file key uploadProgress?: number; // Upload progress (0-100) uploadError?: string; // Upload error message } function $convertImageElement(domNode: Node): null | DOMConversionOutput { const img = domNode as HTMLImageElement; const src = img.getAttribute('src'); if (!src || src.startsWith('file:///')) { return null; } const { alt: altText, width, height } = img; const fileKey = img.getAttribute('data-file-key') || undefined; const node = $createImageNode({ altText, height, src, width, fileKey }); return { node }; } export type SerializedImageNode = Spread< { altText: string; height?: number; maxWidth: number; width?: number; fileKey?: string; // uploadProgress and uploadError are runtime-only, not serialized }, SerializedLexicalNode >; export class ImageNode extends DecoratorNode { __src: string; __altText: string; __width: 'inherit' | number; __height: 'inherit' | number; __maxWidth: number; __fileKey?: string; __uploadProgress?: number; __uploadError?: string; static getType(): string { return 'image'; } static clone(node: ImageNode): ImageNode { return new ImageNode( node.__src, node.__altText, node.__maxWidth, node.__width, node.__height, node.__fileKey, node.__uploadProgress, node.__uploadError, node.__key, ); } static importJSON(serializedNode: SerializedImageNode): ImageNode { const { altText, height, width, maxWidth, fileKey } = serializedNode; // Generate src from fileKey if available, otherwise use empty string const src = fileKey ? '' : ''; // src will be generated from fileKey when rendering return $createImageNode({ altText, height, maxWidth, src, width, fileKey, // uploadProgress and uploadError are not imported - they're runtime-only }); } exportDOM(): DOMExportOutput { const element = document.createElement('img'); element.setAttribute('src', this.__src); element.setAttribute('alt', this.__altText); if (this.__fileKey) { element.setAttribute('data-file-key', this.__fileKey); } if (this.__width !== 'inherit') { element.setAttribute('width', this.__width.toString()); } if (this.__height !== 'inherit') { element.setAttribute('height', this.__height.toString()); } return { element }; } static importDOM(): DOMConversionMap | null { return { img: () => ({ conversion: $convertImageElement, priority: 0, }), }; } constructor( src: string, altText: string, maxWidth: number, width?: 'inherit' | number, height?: 'inherit' | number, fileKey?: string, uploadProgress?: number, uploadError?: string, key?: NodeKey, ) { super(key); this.__src = src; this.__altText = altText; this.__maxWidth = maxWidth; this.__width = width || 'inherit'; this.__height = height || 'inherit'; this.__fileKey = fileKey; this.__uploadProgress = uploadProgress; this.__uploadError = uploadError; } exportJSON(): SerializedImageNode { return { altText: this.getAltText(), height: this.__height === 'inherit' ? 0 : this.__height, maxWidth: this.__maxWidth, // Don't export src - it's mutable and will be generated from fileKey type: 'image', version: 1, width: this.__width === 'inherit' ? 0 : this.__width, fileKey: this.__fileKey, // Don't export uploadProgress or uploadError - they're runtime-only UI state }; } setWidthAndHeight( width: 'inherit' | number, height: 'inherit' | number, ): void { const writable = this.getWritable(); writable.__width = width; writable.__height = height; } setUploadProgress(progress: number): void { const writable = this.getWritable(); writable.__uploadProgress = progress; } setUploadError(error: string | undefined): void { const writable = this.getWritable(); writable.__uploadError = error; } setFileKey(fileKey: string): void { const writable = this.getWritable(); writable.__fileKey = fileKey; } setSrc(src: string): void { const writable = this.getWritable(); writable.__src = src; } createDOM(config: EditorConfig): HTMLElement { const span = document.createElement('span'); const theme = config.theme; const className = theme.image; if (className !== undefined) { span.className = className; } return span; } updateDOM(): false { return false; } getSrc(): string { return this.__src; } getAltText(): string { return this.__altText; } getFileKey(): string | undefined { return this.__fileKey; } getUploadProgress(): number | undefined { return this.__uploadProgress; } getUploadError(): string | undefined { return this.__uploadError; } decorate(): JSX.Element { return ( ); } } export function $createImageNode({ altText, height, maxWidth = 800, src, width, key, fileKey, uploadProgress, uploadError, }: ImagePayload): ImageNode { return $applyNodeReplacement( new ImageNode( src, altText, maxWidth, width, height, fileKey, uploadProgress, uploadError, key, ), ); } export function $isImageNode( node: LexicalNode | null | undefined, ): node is ImageNode { return node instanceof ImageNode; }