All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 17s
277 lines
6.4 KiB
TypeScript
277 lines
6.4 KiB
TypeScript
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<JSX.Element> {
|
|
__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 (
|
|
<Suspense fallback={null}>
|
|
<ImageComponent
|
|
src={this.__src}
|
|
altText={this.__altText}
|
|
width={this.__width}
|
|
height={this.__height}
|
|
maxWidth={this.__maxWidth}
|
|
nodeKey={this.getKey()}
|
|
resizable={true}
|
|
fileKey={this.__fileKey}
|
|
uploadProgress={this.__uploadProgress}
|
|
uploadError={this.__uploadError}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|