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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user