feat: implement ArchiveNode and ArchiveComponent for file attachment support in the editor
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s

This commit is contained in:
2025-10-27 18:54:49 +08:00
parent 98bfb2ec1b
commit fd0ec5a1d3
9 changed files with 766 additions and 35 deletions

View File

@@ -15,6 +15,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import editorTheme from './themes/EditorTheme';
import { ImageNode } from './nodes/ImageNode';
import { ArchiveNode } from './nodes/ArchiveNode';
import { MentionNode } from './nodes/MentionNode';
import './styles/editor.css';
@@ -63,6 +64,7 @@ const initialConfig: InitialConfigType = {
LinkNode,
AutoLinkNode,
ImageNode,
ArchiveNode,
HashtagNode,
MentionNode,
],

View File

@@ -20,11 +20,13 @@ import { forwardRef, useImperativeHandle, useEffect, useRef } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { ImageNode } from './nodes/ImageNode';
import { ArchiveNode } from './nodes/ArchiveNode';
import { MentionNode } from './nodes/MentionNode';
import ToolbarPlugin from './plugins/ToolbarPlugin';
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
import ImagesPlugin from './plugins/ImagesPlugin';
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
import ArchivesPlugin from './plugins/ArchivesPlugin';
import HashtagPlugin from './plugins/HashtagPlugin';
import MentionsPlugin from './plugins/MentionsPlugin';
import editorTheme from './themes/EditorTheme';
@@ -87,6 +89,7 @@ const editorConfig: InitialConfigType = {
LinkNode,
AutoLinkNode,
ImageNode,
ArchiveNode,
HashtagNode,
MentionNode,
],
@@ -173,6 +176,7 @@ const BlogEditor = forwardRef<BlogEditorRef, BlogEditorProps>(({ initialContent
<AutoLinkPlugin matchers={MATCHERS} />
<TablePlugin />
<ImagesPlugin />
<ArchivesPlugin />
<DragDropPastePlugin />
<HashtagPlugin />
<MentionsPlugin />

View File

@@ -76,9 +76,9 @@ export async function uploadFileToS3(
}
/**
* Complete image upload workflow
* Complete generic file upload workflow for blog assets
*/
export async function uploadImage(
export async function uploadBlogFile(
file: File,
onProgress?: (progress: number) => void
): Promise<{ fileKey: string; url: string }> {
@@ -94,6 +94,16 @@ export async function uploadImage(
return { fileKey, url };
}
/**
* Backwards-compatible image upload helper
*/
export async function uploadImage(
file: File,
onProgress?: (progress: number) => void
): Promise<{ fileKey: string; url: string }> {
return uploadBlogFile(file, onProgress);
}
/**
* Create a new blog post
*/

View File

@@ -0,0 +1,179 @@
import type { NodeKey } from 'lexical';
import type { JSX } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import {
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { getS3Url } from '../s3Config';
function formatFileSize(bytes?: number): string | undefined {
if (bytes === undefined) {
return undefined;
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, order);
return `${size.toFixed(size >= 10 || order === 0 ? 0 : 1)} ${units[order]}`;
}
interface ArchiveComponentProps {
nodeKey: NodeKey;
fileName: string;
fileSize?: number;
fileType?: string;
fileKey?: string;
downloadUrl?: string;
uploadProgress?: number;
uploadError?: string;
uploadId?: string;
}
export default function ArchiveComponent({
nodeKey,
fileName,
fileSize,
fileType,
fileKey,
downloadUrl,
uploadProgress,
uploadError,
}: ArchiveComponentProps): JSX.Element {
const [editor] = useLexicalComposerContext();
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
const containerRef = useRef<HTMLDivElement | null>(null);
const resolvedUrl = useMemo(() => {
if (downloadUrl) {
return downloadUrl;
}
if (fileKey) {
return getS3Url(fileKey);
}
return undefined;
}, [downloadUrl, fileKey]);
const readableSize = useMemo(() => formatFileSize(fileSize), [fileSize]);
const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError;
const handleEscape = useCallback(() => {
if (!isSelected) {
return false;
}
$setSelection(null);
editor.update(() => {
setSelected(true);
editor.getRootElement()?.focus();
});
return true;
}, [editor, isSelected, setSelected]);
const handleClick = useCallback(
(event: MouseEvent) => {
if (event.target === null) {
return false;
}
if (containerRef.current && containerRef.current.contains(event.target as Node)) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
[clearSelection, isSelected, setSelected],
);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => false,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CLICK_COMMAND,
handleClick,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
handleEscape,
COMMAND_PRIORITY_LOW,
),
);
}, [editor, handleClick, handleEscape]);
const borderColor = uploadError
? 'rgba(244, 67, 54, 0.6)'
: isSelected
? 'var(--accent-color, #4CAF50)'
: 'var(--border-color, #ccc)';
return (
<div
ref={containerRef}
className="editor-archive-card"
style={{
borderColor,
}}
tabIndex={0}
onFocus={() => {
clearSelection();
setSelected(true);
}}
>
<div className="editor-archive-icon" aria-hidden>ZIP</div>
<div className="editor-archive-details">
<div className="editor-archive-title">{fileName}</div>
<div className="editor-archive-meta">
{fileType || 'Archive'}
{readableSize ? `${readableSize}` : ''}
</div>
{resolvedUrl ? (
<a
className="editor-archive-link"
href={resolvedUrl}
download={fileName}
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
) : (
<span className="editor-archive-link disabled">Preparing link...</span>
)}
{isUploading && (
<div className="editor-archive-progress">
<div className="editor-archive-progress-bar">
<div
className="editor-archive-progress-value"
style={{ width: `${uploadProgress || 0}%` }}
/>
</div>
<div className="editor-archive-progress-label">
Uploading... {Math.round(uploadProgress || 0)}%
</div>
</div>
)}
{uploadError && (
<div className="editor-archive-error"> {uploadError}</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,287 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import type { JSX } from 'react';
import { DecoratorNode, $applyNodeReplacement } from 'lexical';
import { Suspense, lazy } from 'react';
const ArchiveComponent = lazy(() => import('./ArchiveComponent'));
export interface ArchivePayload {
fileName: string;
fileSize?: number;
fileType?: string;
fileKey?: string;
downloadUrl?: string;
uploadProgress?: number;
uploadError?: string;
uploadId?: string;
key?: NodeKey;
}
function $convertArchiveElement(domNode: Node): DOMConversionOutput | null {
const element = domNode as HTMLElement;
if (!element.hasAttribute('data-lexical-archive')) {
return null;
}
const fileName = element.textContent?.trim() || 'attachment';
const fileSizeAttr = element.getAttribute('data-file-size');
const fileType = element.getAttribute('data-file-type') || undefined;
const fileKey = element.getAttribute('data-file-key') || undefined;
const downloadUrl = element.getAttribute('href') || undefined;
const fileSize = fileSizeAttr ? Number(fileSizeAttr) : undefined;
const node = $createArchiveNode({
fileName,
fileSize: Number.isFinite(fileSize) ? fileSize : undefined,
fileType,
fileKey,
downloadUrl,
});
return { node };
}
export type SerializedArchiveNode = Spread<
{
fileName: string;
fileSize?: number;
fileType?: string;
fileKey?: string;
downloadUrl?: string;
},
SerializedLexicalNode
>;
export class ArchiveNode extends DecoratorNode<JSX.Element> {
__fileName: string;
__fileSize?: number;
__fileType?: string;
__fileKey?: string;
__downloadUrl?: string;
__uploadProgress?: number;
__uploadError?: string;
__uploadId?: string;
static getType(): string {
return 'archive';
}
static clone(node: ArchiveNode): ArchiveNode {
return new ArchiveNode(
node.__fileName,
node.__fileSize,
node.__fileType,
node.__fileKey,
node.__downloadUrl,
node.__uploadProgress,
node.__uploadError,
node.__uploadId,
node.__key,
);
}
static importJSON(serializedNode: SerializedArchiveNode): ArchiveNode {
const { fileName, fileSize, fileType, fileKey, downloadUrl } = serializedNode;
return $createArchiveNode({
fileName,
fileSize,
fileType,
fileKey,
downloadUrl,
});
}
exportJSON(): SerializedArchiveNode {
return {
fileName: this.__fileName,
fileSize: this.__fileSize,
fileType: this.__fileType,
fileKey: this.__fileKey,
downloadUrl: this.__downloadUrl,
type: 'archive',
version: 1,
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement('a');
element.textContent = this.__fileName;
if (this.__downloadUrl) {
element.setAttribute('href', this.__downloadUrl);
}
element.setAttribute('data-lexical-archive', 'true');
if (this.__fileType) {
element.setAttribute('data-file-type', this.__fileType);
}
if (typeof this.__fileSize === 'number') {
element.setAttribute('data-file-size', String(this.__fileSize));
}
if (this.__fileKey) {
element.setAttribute('data-file-key', this.__fileKey);
}
return { element };
}
constructor(
fileName: string,
fileSize?: number,
fileType?: string,
fileKey?: string,
downloadUrl?: string,
uploadProgress?: number,
uploadError?: string,
uploadId?: string,
key?: NodeKey,
) {
super(key);
this.__fileName = fileName;
this.__fileSize = fileSize;
this.__fileType = fileType;
this.__fileKey = fileKey;
this.__downloadUrl = downloadUrl;
this.__uploadProgress = uploadProgress;
this.__uploadError = uploadError;
this.__uploadId = uploadId;
}
createDOM(config: EditorConfig): HTMLElement {
const span = document.createElement('span');
const className = config.theme?.archive;
if (className) {
span.className = className;
}
return span;
}
updateDOM(): false {
return false;
}
getFileName(): string {
return this.__fileName;
}
getFileSize(): number | undefined {
return this.__fileSize;
}
getFileType(): string | undefined {
return this.__fileType;
}
getFileKey(): string | undefined {
return this.__fileKey;
}
getDownloadUrl(): string | undefined {
return this.__downloadUrl;
}
getUploadProgress(): number | undefined {
return this.__uploadProgress;
}
getUploadError(): string | undefined {
return this.__uploadError;
}
getUploadId(): string | undefined {
return this.__uploadId;
}
setFileKey(fileKey: string): void {
const writable = this.getWritable();
writable.__fileKey = fileKey;
}
setDownloadUrl(downloadUrl: string): void {
const writable = this.getWritable();
writable.__downloadUrl = downloadUrl;
}
setUploadProgress(progress: number): void {
const writable = this.getWritable();
writable.__uploadProgress = progress;
}
setUploadError(error: string | undefined): void {
const writable = this.getWritable();
writable.__uploadError = error;
}
setUploadId(uploadId: string | undefined): void {
const writable = this.getWritable();
writable.__uploadId = uploadId;
}
static importDOM(): DOMConversionMap | null {
return {
a: () => ({
conversion: $convertArchiveElement,
priority: 1,
}),
};
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<ArchiveComponent
nodeKey={this.getKey()}
fileName={this.__fileName}
fileSize={this.__fileSize}
fileType={this.__fileType}
fileKey={this.__fileKey}
downloadUrl={this.__downloadUrl}
uploadProgress={this.__uploadProgress}
uploadError={this.__uploadError}
uploadId={this.__uploadId}
/>
</Suspense>
);
}
}
export function $createArchiveNode(payload: ArchivePayload): ArchiveNode {
const {
fileName,
fileSize,
fileType,
fileKey,
downloadUrl,
uploadProgress,
uploadError,
uploadId,
key,
} = payload;
return $applyNodeReplacement(
new ArchiveNode(
fileName,
fileSize,
fileType,
fileKey,
downloadUrl,
uploadProgress,
uploadError,
uploadId,
key,
),
);
}
export function $isArchiveNode(
node: LexicalNode | null | undefined,
): node is ArchiveNode {
return node instanceof ArchiveNode;
}

View File

@@ -0,0 +1,48 @@
import type { LexicalCommand } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $wrapNodeInElement, mergeRegister } from '@lexical/utils';
import {
$createParagraphNode,
$insertNodes,
$isRootOrShadowRoot,
COMMAND_PRIORITY_EDITOR,
createCommand,
} from 'lexical';
import { useEffect } from 'react';
import { $createArchiveNode, ArchiveNode, type ArchivePayload } from '../nodes/ArchiveNode';
export type InsertArchivePayload = Readonly<ArchivePayload>;
export const INSERT_ARCHIVE_COMMAND: LexicalCommand<InsertArchivePayload> =
createCommand('INSERT_ARCHIVE_COMMAND');
export default function ArchivesPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([ArchiveNode])) {
throw new Error('ArchivesPlugin: ArchiveNode not registered on editor');
}
return mergeRegister(
editor.registerCommand<InsertArchivePayload>(
INSERT_ARCHIVE_COMMAND,
(payload) => {
const archiveNode = $createArchiveNode(payload);
$insertNodes([archiveNode]);
if ($isRootOrShadowRoot(archiveNode.getParentOrThrow())) {
$wrapNodeInElement(archiveNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
);
}, [editor]);
return null;
}

View File

@@ -1,11 +1,14 @@
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
import { isMimeType, mediaFileReader } from '@lexical/utils';
import { COMMAND_PRIORITY_LOW } from 'lexical';
import { $nodesOfType, COMMAND_PRIORITY_LOW } from 'lexical';
import { useEffect } from 'react';
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
import { uploadImage } from '../api';
import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin';
import { uploadImage, uploadBlogFile } from '../api';
import { ImageNode } from '../nodes/ImageNode';
import { ArchiveNode } from '../nodes/ArchiveNode';
// Toast notification helper (global)
let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
@@ -39,49 +42,100 @@ const ACCEPTABLE_IMAGE_TYPES = [
'image/svg+xml',
];
const ACCEPTABLE_ARCHIVE_TYPES = [
'application/zip',
'application/x-zip-compressed',
'application/x-7z-compressed',
'application/x-rar-compressed',
'application/x-tar',
'application/gzip',
'application/x-gzip',
'application/x-bzip2',
'application/x-bzip',
];
const ARCHIVE_EXTENSIONS = new Set([
'zip',
'rar',
'7z',
'tar',
'gz',
'tgz',
'bz2',
'tbz',
'xz',
]);
function isArchiveFile(file: File): boolean {
const mimeMatches = isMimeType(file, ACCEPTABLE_ARCHIVE_TYPES);
if (mimeMatches) {
return true;
}
const name = file.name.toLowerCase();
const ext = name.includes('.') ? name.split('.').pop() : undefined;
return ext !== undefined && ARCHIVE_EXTENSIONS.has(ext);
}
function createUploadId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
export default function DragDropPastePlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerCommand(
DRAG_DROP_PASTE,
(files) => {
(files: File[]) => {
(async () => {
const filesResult = await mediaFileReader(
files,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
);
const fileArray = Array.from(files);
for (const { file, result } of filesResult) {
const imageFiles: File[] = [];
const archiveFiles: File[] = [];
for (const file of fileArray) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
// Insert image with preview first
imageFiles.push(file);
} else if (isArchiveFile(file)) {
archiveFiles.push(file);
}
}
if (imageFiles.length > 0) {
const filesResult = await mediaFileReader(
imageFiles,
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
);
for (const { file, result } of filesResult) {
if (!isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
continue;
}
const imagePayload = {
altText: file.name,
src: result, // data URL for preview
src: result,
uploadProgress: 0,
};
editor.dispatchCommand(INSERT_IMAGE_COMMAND, imagePayload);
// Upload to S3 in background
try {
const { fileKey, url } = await uploadImage(file, (progress) => {
// Update progress
const { fileKey, url } = await uploadImage(file, (progress: number) => {
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
const nodes = $nodesOfType(ImageNode);
nodes.forEach((node: ImageNode) => {
if (node.getSrc() === result) {
node.setUploadProgress(progress);
}
});
});
});
// Update with final URL and fileKey
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
const nodes = $nodesOfType(ImageNode);
nodes.forEach((node: ImageNode) => {
if (node.getSrc() === result) {
node.setSrc(url);
node.setFileKey(fileKey);
node.setUploadProgress(100);
@@ -90,27 +144,77 @@ export default function DragDropPastePlugin(): null {
});
});
// Show success notification
showToast('Image uploaded successfully!', 'success');
} catch (error) {
// Update with error
editor.update(() => {
const nodes = editor._editorState._nodeMap;
nodes.forEach((node: any) => {
if (node.__type === 'image' && node.__src === result) {
const nodes = $nodesOfType(ImageNode);
nodes.forEach((node: ImageNode) => {
if (node.getSrc() === result) {
node.setUploadError(
error instanceof Error ? error.message : 'Upload failed'
error instanceof Error ? error.message : 'Upload failed',
);
}
});
});
// Show error notification
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
showToast(`Image upload failed: ${errorMessage}`, 'error');
}
}
}
for (const file of archiveFiles) {
const uploadId = createUploadId();
editor.dispatchCommand(INSERT_ARCHIVE_COMMAND, {
fileName: file.name,
fileSize: file.size,
fileType: file.type || undefined,
uploadProgress: 0,
uploadId,
});
try {
const { fileKey, url } = await uploadBlogFile(file, (progress: number) => {
editor.update(() => {
const nodes = $nodesOfType(ArchiveNode);
nodes.forEach((node: ArchiveNode) => {
if (node.getUploadId() === uploadId) {
node.setUploadProgress(progress);
}
});
});
});
editor.update(() => {
const nodes = $nodesOfType(ArchiveNode);
nodes.forEach((node: ArchiveNode) => {
if (node.getUploadId() === uploadId) {
node.setFileKey(fileKey);
node.setDownloadUrl(url);
node.setUploadProgress(100);
node.setUploadError(undefined);
}
});
});
showToast('File uploaded successfully!', 'success');
} catch (error) {
editor.update(() => {
const nodes = $nodesOfType(ArchiveNode);
nodes.forEach((node: ArchiveNode) => {
if (node.getUploadId() === uploadId) {
node.setUploadError(
error instanceof Error ? error.message : 'Upload failed',
);
}
});
});
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
showToast(`File upload failed: ${errorMessage}`, 'error');
}
}
})();
return true;
},

View File

@@ -277,6 +277,102 @@
touch-action: none;
}
/* Archive/File attachment */
.editor-archive {
display: inline-block;
user-select: none;
}
.editor-archive-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 8px;
background: var(--bg-card, #fafafa);
min-width: 260px;
max-width: 420px;
transition: border-color 0.2s ease;
outline: none;
}
.editor-archive-card:focus {
border-color: var(--accent-color, #4caf50);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.editor-archive-icon {
font-size: 28px;
line-height: 1;
}
.editor-archive-details {
flex: 1;
min-width: 0;
font-size: 14px;
}
.editor-archive-title {
font-weight: 600;
color: var(--text-primary, #222);
word-break: break-word;
margin-bottom: 4px;
}
.editor-archive-meta {
color: var(--text-secondary, #666);
font-size: 13px;
margin-bottom: 6px;
}
.editor-archive-link {
display: inline-block;
color: var(--accent-color, #4caf50);
text-decoration: none;
font-weight: 500;
margin-bottom: 8px;
}
.editor-archive-link:hover {
text-decoration: underline;
}
.editor-archive-link.disabled {
color: var(--text-secondary, #888);
pointer-events: none;
}
.editor-archive-progress {
margin-top: 4px;
}
.editor-archive-progress-bar {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
overflow: hidden;
height: 4px;
}
.editor-archive-progress-value {
height: 100%;
background: var(--accent-color, #4caf50);
width: 0;
transition: width 0.3s ease;
}
.editor-archive-progress-label {
font-size: 12px;
color: var(--text-secondary, #666);
margin-top: 4px;
}
.editor-archive-error {
margin-top: 6px;
font-size: 12px;
color: #f44336;
}
/* Image Resizer */
.image-resizer {
display: block;

View File

@@ -40,6 +40,7 @@ const theme: EditorThemeClasses = {
tableCellHeader: 'editor-table-cell-header',
hr: 'editor-hr',
image: 'editor-image',
archive: 'editor-archive',
hashtag: 'editor-hashtag',
};