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

@@ -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>
);
}