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
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 18s
This commit is contained in:
@@ -15,6 +15,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
|||||||
|
|
||||||
import editorTheme from './themes/EditorTheme';
|
import editorTheme from './themes/EditorTheme';
|
||||||
import { ImageNode } from './nodes/ImageNode';
|
import { ImageNode } from './nodes/ImageNode';
|
||||||
|
import { ArchiveNode } from './nodes/ArchiveNode';
|
||||||
import { MentionNode } from './nodes/MentionNode';
|
import { MentionNode } from './nodes/MentionNode';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
@@ -63,6 +64,7 @@ const initialConfig: InitialConfigType = {
|
|||||||
LinkNode,
|
LinkNode,
|
||||||
AutoLinkNode,
|
AutoLinkNode,
|
||||||
ImageNode,
|
ImageNode,
|
||||||
|
ArchiveNode,
|
||||||
HashtagNode,
|
HashtagNode,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -20,11 +20,13 @@ import { forwardRef, useImperativeHandle, useEffect, useRef } from 'react';
|
|||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
|
||||||
import { ImageNode } from './nodes/ImageNode';
|
import { ImageNode } from './nodes/ImageNode';
|
||||||
|
import { ArchiveNode } from './nodes/ArchiveNode';
|
||||||
import { MentionNode } from './nodes/MentionNode';
|
import { MentionNode } from './nodes/MentionNode';
|
||||||
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
import ToolbarPlugin from './plugins/ToolbarPlugin';
|
||||||
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
import MarkdownPlugin from './plugins/MarkdownShortcutPlugin';
|
||||||
import ImagesPlugin from './plugins/ImagesPlugin';
|
import ImagesPlugin from './plugins/ImagesPlugin';
|
||||||
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
|
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
|
||||||
|
import ArchivesPlugin from './plugins/ArchivesPlugin';
|
||||||
import HashtagPlugin from './plugins/HashtagPlugin';
|
import HashtagPlugin from './plugins/HashtagPlugin';
|
||||||
import MentionsPlugin from './plugins/MentionsPlugin';
|
import MentionsPlugin from './plugins/MentionsPlugin';
|
||||||
import editorTheme from './themes/EditorTheme';
|
import editorTheme from './themes/EditorTheme';
|
||||||
@@ -87,6 +89,7 @@ const editorConfig: InitialConfigType = {
|
|||||||
LinkNode,
|
LinkNode,
|
||||||
AutoLinkNode,
|
AutoLinkNode,
|
||||||
ImageNode,
|
ImageNode,
|
||||||
|
ArchiveNode,
|
||||||
HashtagNode,
|
HashtagNode,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
],
|
],
|
||||||
@@ -173,6 +176,7 @@ const BlogEditor = forwardRef<BlogEditorRef, BlogEditorProps>(({ initialContent
|
|||||||
<AutoLinkPlugin matchers={MATCHERS} />
|
<AutoLinkPlugin matchers={MATCHERS} />
|
||||||
<TablePlugin />
|
<TablePlugin />
|
||||||
<ImagesPlugin />
|
<ImagesPlugin />
|
||||||
|
<ArchivesPlugin />
|
||||||
<DragDropPastePlugin />
|
<DragDropPastePlugin />
|
||||||
<HashtagPlugin />
|
<HashtagPlugin />
|
||||||
<MentionsPlugin />
|
<MentionsPlugin />
|
||||||
|
|||||||
@@ -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,
|
file: File,
|
||||||
onProgress?: (progress: number) => void
|
onProgress?: (progress: number) => void
|
||||||
): Promise<{ fileKey: string; url: string }> {
|
): Promise<{ fileKey: string; url: string }> {
|
||||||
@@ -94,6 +94,16 @@ export async function uploadImage(
|
|||||||
return { fileKey, url };
|
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
|
* Create a new blog post
|
||||||
*/
|
*/
|
||||||
|
|||||||
179
src/blog/nodes/ArchiveComponent.tsx
Normal file
179
src/blog/nodes/ArchiveComponent.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
src/blog/nodes/ArchiveNode.tsx
Normal file
287
src/blog/nodes/ArchiveNode.tsx
Normal 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;
|
||||||
|
}
|
||||||
48
src/blog/plugins/ArchivesPlugin.tsx
Normal file
48
src/blog/plugins/ArchivesPlugin.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
|
import { DRAG_DROP_PASTE } from '@lexical/rich-text';
|
||||||
import { isMimeType, mediaFileReader } from '@lexical/utils';
|
import { isMimeType, mediaFileReader } from '@lexical/utils';
|
||||||
import { COMMAND_PRIORITY_LOW } from 'lexical';
|
import { $nodesOfType, COMMAND_PRIORITY_LOW } from 'lexical';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
|
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)
|
// Toast notification helper (global)
|
||||||
let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
|
let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null;
|
||||||
@@ -39,49 +42,100 @@ const ACCEPTABLE_IMAGE_TYPES = [
|
|||||||
'image/svg+xml',
|
'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 {
|
export default function DragDropPastePlugin(): null {
|
||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return editor.registerCommand(
|
return editor.registerCommand(
|
||||||
DRAG_DROP_PASTE,
|
DRAG_DROP_PASTE,
|
||||||
(files) => {
|
(files: File[]) => {
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
const imageFiles: File[] = [];
|
||||||
|
const archiveFiles: File[] = [];
|
||||||
|
|
||||||
|
for (const file of fileArray) {
|
||||||
|
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
||||||
|
imageFiles.push(file);
|
||||||
|
} else if (isArchiveFile(file)) {
|
||||||
|
archiveFiles.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageFiles.length > 0) {
|
||||||
const filesResult = await mediaFileReader(
|
const filesResult = await mediaFileReader(
|
||||||
files,
|
imageFiles,
|
||||||
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
|
[ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const { file, result } of filesResult) {
|
for (const { file, result } of filesResult) {
|
||||||
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
if (!isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
||||||
// Insert image with preview first
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const imagePayload = {
|
const imagePayload = {
|
||||||
altText: file.name,
|
altText: file.name,
|
||||||
src: result, // data URL for preview
|
src: result,
|
||||||
uploadProgress: 0,
|
uploadProgress: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
editor.dispatchCommand(INSERT_IMAGE_COMMAND, imagePayload);
|
editor.dispatchCommand(INSERT_IMAGE_COMMAND, imagePayload);
|
||||||
|
|
||||||
// Upload to S3 in background
|
|
||||||
try {
|
try {
|
||||||
const { fileKey, url } = await uploadImage(file, (progress) => {
|
const { fileKey, url } = await uploadImage(file, (progress: number) => {
|
||||||
// Update progress
|
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const nodes = editor._editorState._nodeMap;
|
const nodes = $nodesOfType(ImageNode);
|
||||||
nodes.forEach((node: any) => {
|
nodes.forEach((node: ImageNode) => {
|
||||||
if (node.__type === 'image' && node.__src === result) {
|
if (node.getSrc() === result) {
|
||||||
node.setUploadProgress(progress);
|
node.setUploadProgress(progress);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update with final URL and fileKey
|
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const nodes = editor._editorState._nodeMap;
|
const nodes = $nodesOfType(ImageNode);
|
||||||
nodes.forEach((node: any) => {
|
nodes.forEach((node: ImageNode) => {
|
||||||
if (node.__type === 'image' && node.__src === result) {
|
if (node.getSrc() === result) {
|
||||||
node.setSrc(url);
|
node.setSrc(url);
|
||||||
node.setFileKey(fileKey);
|
node.setFileKey(fileKey);
|
||||||
node.setUploadProgress(100);
|
node.setUploadProgress(100);
|
||||||
@@ -90,27 +144,77 @@ export default function DragDropPastePlugin(): null {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Show success notification
|
|
||||||
showToast('Image uploaded successfully!', 'success');
|
showToast('Image uploaded successfully!', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Update with error
|
|
||||||
editor.update(() => {
|
editor.update(() => {
|
||||||
const nodes = editor._editorState._nodeMap;
|
const nodes = $nodesOfType(ImageNode);
|
||||||
nodes.forEach((node: any) => {
|
nodes.forEach((node: ImageNode) => {
|
||||||
if (node.__type === 'image' && node.__src === result) {
|
if (node.getSrc() === result) {
|
||||||
node.setUploadError(
|
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';
|
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
|
||||||
showToast(`Image upload failed: ${errorMessage}`, 'error');
|
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;
|
return true;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -277,6 +277,102 @@
|
|||||||
touch-action: none;
|
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 */
|
||||||
.image-resizer {
|
.image-resizer {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const theme: EditorThemeClasses = {
|
|||||||
tableCellHeader: 'editor-table-cell-header',
|
tableCellHeader: 'editor-table-cell-header',
|
||||||
hr: 'editor-hr',
|
hr: 'editor-hr',
|
||||||
image: 'editor-image',
|
image: 'editor-image',
|
||||||
|
archive: 'editor-archive',
|
||||||
hashtag: 'editor-hashtag',
|
hashtag: 'editor-hashtag',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user