From fd0ec5a1d350123ced7ad8b514c7b730c51ee719 Mon Sep 17 00:00:00 2001 From: cialloo Date: Mon, 27 Oct 2025 18:54:49 +0800 Subject: [PATCH] feat: implement ArchiveNode and ArchiveComponent for file attachment support in the editor --- src/blog/BlogContentViewer.tsx | 2 + src/blog/BlogEditor.tsx | 4 + src/blog/api.ts | 14 +- src/blog/nodes/ArchiveComponent.tsx | 179 ++++++++++++++ src/blog/nodes/ArchiveNode.tsx | 287 +++++++++++++++++++++++ src/blog/plugins/ArchivesPlugin.tsx | 48 ++++ src/blog/plugins/DragDropPastePlugin.tsx | 170 +++++++++++--- src/blog/styles/editor.css | 96 ++++++++ src/blog/themes/EditorTheme.ts | 1 + 9 files changed, 766 insertions(+), 35 deletions(-) create mode 100644 src/blog/nodes/ArchiveComponent.tsx create mode 100644 src/blog/nodes/ArchiveNode.tsx create mode 100644 src/blog/plugins/ArchivesPlugin.tsx diff --git a/src/blog/BlogContentViewer.tsx b/src/blog/BlogContentViewer.tsx index b03da53..fdb8074 100644 --- a/src/blog/BlogContentViewer.tsx +++ b/src/blog/BlogContentViewer.tsx @@ -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, ], diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx index 987a8fe..2800c17 100644 --- a/src/blog/BlogEditor.tsx +++ b/src/blog/BlogEditor.tsx @@ -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(({ initialContent + diff --git a/src/blog/api.ts b/src/blog/api.ts index 1fefba2..ab61706 100644 --- a/src/blog/api.ts +++ b/src/blog/api.ts @@ -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 */ diff --git a/src/blog/nodes/ArchiveComponent.tsx b/src/blog/nodes/ArchiveComponent.tsx new file mode 100644 index 0000000..79ca27d --- /dev/null +++ b/src/blog/nodes/ArchiveComponent.tsx @@ -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(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 ( +
{ + clearSelection(); + setSelected(true); + }} + > +
ZIP
+
+
{fileName}
+
+ {fileType || 'Archive'} + {readableSize ? ` • ${readableSize}` : ''} +
+ {resolvedUrl ? ( + + Download + + ) : ( + Preparing link... + )} + {isUploading && ( +
+
+
+
+
+ Uploading... {Math.round(uploadProgress || 0)}% +
+
+ )} + {uploadError && ( +
❌ {uploadError}
+ )} +
+
+ ); +} diff --git a/src/blog/nodes/ArchiveNode.tsx b/src/blog/nodes/ArchiveNode.tsx new file mode 100644 index 0000000..974f571 --- /dev/null +++ b/src/blog/nodes/ArchiveNode.tsx @@ -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 { + __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 ( + + + + ); + } +} + +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; +} diff --git a/src/blog/plugins/ArchivesPlugin.tsx b/src/blog/plugins/ArchivesPlugin.tsx new file mode 100644 index 0000000..c78f0e9 --- /dev/null +++ b/src/blog/plugins/ArchivesPlugin.tsx @@ -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; + +export const INSERT_ARCHIVE_COMMAND: LexicalCommand = + 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( + 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; +} diff --git a/src/blog/plugins/DragDropPastePlugin.tsx b/src/blog/plugins/DragDropPastePlugin.tsx index 86f41fb..e7a00a9 100644 --- a/src/blog/plugins/DragDropPastePlugin.tsx +++ b/src/blog/plugins/DragDropPastePlugin.tsx @@ -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), - ); - - for (const { file, result } of filesResult) { + const fileArray = Array.from(files); + + 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); @@ -89,28 +143,78 @@ 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; }, diff --git a/src/blog/styles/editor.css b/src/blog/styles/editor.css index e6a62a6..3152e4e 100644 --- a/src/blog/styles/editor.css +++ b/src/blog/styles/editor.css @@ -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; diff --git a/src/blog/themes/EditorTheme.ts b/src/blog/themes/EditorTheme.ts index 94b1a80..2ffae2e 100644 --- a/src/blog/themes/EditorTheme.ts +++ b/src/blog/themes/EditorTheme.ts @@ -40,6 +40,7 @@ const theme: EditorThemeClasses = { tableCellHeader: 'editor-table-cell-header', hr: 'editor-hr', image: 'editor-image', + archive: 'editor-archive', hashtag: 'editor-hashtag', };