feat: add toolbar styles and functionality for blog editor
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

feat: implement EditorTheme for consistent styling across editor components

feat: define types for blog-related operations including image uploads and post creation

feat: create DropdownColorPicker component for color selection in blog editor

feat: implement ImageResizer component for resizing images in the blog editor

feat: add export and import functionality for blog posts in JSON format

feat: update main application routes to include CreatePost page

feat: enhance Blog page with a button to navigate to CreatePost

feat: implement CreatePost page with title, cover image upload, and content editor
This commit is contained in:
2025-10-25 21:03:58 +08:00
parent 4829c53355
commit e299694f22
26 changed files with 4095 additions and 1 deletions

View File

@@ -0,0 +1,276 @@
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;
src: string;
width?: number;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
},
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, src, fileKey, uploadProgress, uploadError } = serializedNode;
return $createImageNode({
altText,
height,
maxWidth,
src,
width,
fileKey,
uploadProgress,
uploadError,
});
}
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,
src: this.getSrc(),
type: 'image',
version: 1,
width: this.__width === 'inherit' ? 0 : this.__width,
fileKey: this.__fileKey,
uploadProgress: this.__uploadProgress,
uploadError: this.__uploadError,
};
}
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;
}