feat: add audio support with AudioNode and AudioComponent, including drag-and-drop functionality
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s

This commit is contained in:
2025-10-29 07:47:00 +08:00
parent a970a8ce25
commit ce8215ecc7
8 changed files with 555 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import editorTheme from './themes/EditorTheme';
import { ImageNode } from './nodes/ImageNode'; import { ImageNode } from './nodes/ImageNode';
import { ArchiveNode } from './nodes/ArchiveNode'; import { ArchiveNode } from './nodes/ArchiveNode';
import { VideoNode } from './nodes/VideoNode'; import { VideoNode } from './nodes/VideoNode';
import { AudioNode } from './nodes/AudioNode';
import { MentionNode } from './nodes/MentionNode'; import { MentionNode } from './nodes/MentionNode';
import './styles/editor.css'; import './styles/editor.css';
@@ -67,6 +68,7 @@ const initialConfig: InitialConfigType = {
ImageNode, ImageNode,
ArchiveNode, ArchiveNode,
VideoNode, VideoNode,
AudioNode,
HashtagNode, HashtagNode,
MentionNode, MentionNode,
], ],

View File

@@ -22,11 +22,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
import { ImageNode } from './nodes/ImageNode'; import { ImageNode } from './nodes/ImageNode';
import { ArchiveNode } from './nodes/ArchiveNode'; import { ArchiveNode } from './nodes/ArchiveNode';
import { VideoNode } from './nodes/VideoNode'; import { VideoNode } from './nodes/VideoNode';
import { AudioNode } from './nodes/AudioNode';
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 VideosPlugin from './plugins/VideosPlugin'; import VideosPlugin from './plugins/VideosPlugin';
import AudiosPlugin from './plugins/AudiosPlugin';
import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin'; import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin';
import ArchivesPlugin from './plugins/ArchivesPlugin'; import ArchivesPlugin from './plugins/ArchivesPlugin';
import HashtagPlugin from './plugins/HashtagPlugin'; import HashtagPlugin from './plugins/HashtagPlugin';
@@ -93,6 +95,7 @@ const editorConfig: InitialConfigType = {
ImageNode, ImageNode,
ArchiveNode, ArchiveNode,
VideoNode, VideoNode,
AudioNode,
HashtagNode, HashtagNode,
MentionNode, MentionNode,
], ],
@@ -180,6 +183,7 @@ const BlogEditor = forwardRef<BlogEditorRef, BlogEditorProps>(({ initialContent
<TablePlugin /> <TablePlugin />
<ImagesPlugin /> <ImagesPlugin />
<VideosPlugin /> <VideosPlugin />
<AudiosPlugin />
<ArchivesPlugin /> <ArchivesPlugin />
<DragDropPastePlugin /> <DragDropPastePlugin />
<HashtagPlugin /> <HashtagPlugin />

View File

@@ -0,0 +1,148 @@
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';
interface AudioComponentProps {
nodeKey: NodeKey;
src: string;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
}
export default function AudioComponent({
nodeKey,
src,
fileKey,
uploadProgress,
uploadError,
}: AudioComponentProps): JSX.Element {
const [editor] = useLexicalComposerContext();
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
const containerRef = useRef<HTMLDivElement | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const resolvedSrc = useMemo(() => {
if (fileKey) {
return getS3Url(fileKey);
}
return src;
}, [fileKey, src]);
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 (!containerRef.current) {
return false;
}
if (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-audio-card"
style={{
borderColor,
}}
tabIndex={0}
onFocus={() => {
clearSelection();
setSelected(true);
}}
>
<div className="editor-audio-wrapper">
{resolvedSrc ? (
<audio
ref={audioRef}
className="editor-audio-player"
controls
preload="metadata"
src={resolvedSrc}
/>
) : (
<div className="editor-audio-placeholder">Preparing audio...</div>
)}
{isUploading && (
<div className="editor-audio-overlay">
<div className="editor-audio-progress">
<div
className="editor-audio-progress-value"
style={{ width: `${uploadProgress || 0}%` }}
/>
</div>
<div className="editor-audio-progress-label">
Uploading... {Math.round(uploadProgress || 0)}%
</div>
</div>
)}
{uploadError && (
<div className="editor-audio-error"> {uploadError}</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,195 @@
import type {
DOMConversionMap,
DOMConversionOutput,
DOMExportOutput,
EditorConfig,
LexicalNode,
NodeKey,
SerializedLexicalNode,
Spread,
} from 'lexical';
import type { JSX } from 'react';
import { $applyNodeReplacement, DecoratorNode } from 'lexical';
import { Suspense, lazy } from 'react';
const AudioComponent = lazy(() => import('./AudioComponent'));
export interface AudioPayload {
src: string;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
key?: NodeKey;
}
function $convertAudioElement(domNode: Node): DOMConversionOutput | null {
const element = domNode as HTMLElement;
if (!element.hasAttribute('data-lexical-audio')) {
return null;
}
const src = element.getAttribute('src') || '';
const fileKey = element.getAttribute('data-file-key') || undefined;
return {
node: $createAudioNode({
src,
fileKey,
}),
};
}
export type SerializedAudioNode = Spread<
{
src: string;
fileKey?: string;
},
SerializedLexicalNode
>;
export class AudioNode extends DecoratorNode<JSX.Element> {
__src: string;
__fileKey?: string;
__uploadProgress?: number;
__uploadError?: string;
static getType(): string {
return 'audio';
}
static clone(node: AudioNode): AudioNode {
return new AudioNode(
node.__src,
node.__fileKey,
node.__uploadProgress,
node.__uploadError,
node.__key,
);
}
static importJSON(serializedNode: SerializedAudioNode): AudioNode {
const { src, fileKey } = serializedNode;
return $createAudioNode({
src: fileKey ? '' : src,
fileKey,
});
}
exportJSON(): SerializedAudioNode {
return {
src: this.__fileKey ? '' : this.__src,
fileKey: this.__fileKey,
type: 'audio',
version: 1,
};
}
exportDOM(): DOMExportOutput {
const element = document.createElement('audio');
element.setAttribute('data-lexical-audio', 'true');
element.setAttribute('controls', '');
element.setAttribute('src', this.__src);
if (this.__fileKey) {
element.setAttribute('data-file-key', this.__fileKey);
}
return { element };
}
constructor(
src: string,
fileKey?: string,
uploadProgress?: number,
uploadError?: string,
key?: NodeKey,
) {
super(key);
this.__src = src;
this.__fileKey = fileKey;
this.__uploadProgress = uploadProgress;
this.__uploadError = uploadError;
}
static importDOM(): DOMConversionMap | null {
return {
audio: () => ({
conversion: $convertAudioElement,
priority: 1,
}),
};
}
createDOM(config: EditorConfig): HTMLElement {
const div = document.createElement('div');
const className = config.theme?.audio;
if (className) {
div.className = className;
}
return div;
}
updateDOM(): false {
return false;
}
getSrc(): string {
return this.__src;
}
getFileKey(): string | undefined {
return this.__fileKey;
}
getUploadProgress(): number | undefined {
return this.__uploadProgress;
}
getUploadError(): string | undefined {
return this.__uploadError;
}
setSrc(src: string): void {
const writable = this.getWritable();
writable.__src = src;
}
setFileKey(fileKey: string): void {
const writable = this.getWritable();
writable.__fileKey = fileKey;
}
setUploadProgress(progress: number): void {
const writable = this.getWritable();
writable.__uploadProgress = progress;
}
setUploadError(error: string | undefined): void {
const writable = this.getWritable();
writable.__uploadError = error;
}
decorate(): JSX.Element {
return (
<Suspense fallback={null}>
<AudioComponent
nodeKey={this.getKey()}
src={this.__src}
fileKey={this.__fileKey}
uploadProgress={this.__uploadProgress}
uploadError={this.__uploadError}
/>
</Suspense>
);
}
}
export function $createAudioNode(payload: AudioPayload): AudioNode {
const { src, fileKey, uploadProgress, uploadError, key } = payload;
return $applyNodeReplacement(
new AudioNode(src, fileKey, uploadProgress, uploadError, key),
);
}
export function $isAudioNode(node: LexicalNode | null | undefined): node is AudioNode {
return node instanceof AudioNode;
}

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 { $createAudioNode, AudioNode, type AudioPayload } from '../nodes/AudioNode';
export type InsertAudioPayload = Readonly<AudioPayload>;
export const INSERT_AUDIO_COMMAND: LexicalCommand<InsertAudioPayload> =
createCommand('INSERT_AUDIO_COMMAND');
export default function AudiosPlugin(): null {
const [editor] = useLexicalComposerContext();
useEffect(() => {
if (!editor.hasNodes([AudioNode])) {
throw new Error('AudiosPlugin: AudioNode not registered on editor');
}
return mergeRegister(
editor.registerCommand<InsertAudioPayload>(
INSERT_AUDIO_COMMAND,
(payload) => {
const audioNode = $createAudioNode(payload);
$insertNodes([audioNode]);
if ($isRootOrShadowRoot(audioNode.getParentOrThrow())) {
$wrapNodeInElement(audioNode, $createParagraphNode).selectEnd();
}
return true;
},
COMMAND_PRIORITY_EDITOR,
),
);
}, [editor]);
return null;
}

View File

@@ -7,10 +7,12 @@ import { useEffect } from 'react';
import { INSERT_IMAGE_COMMAND } from './ImagesPlugin'; import { INSERT_IMAGE_COMMAND } from './ImagesPlugin';
import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin'; import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin';
import { INSERT_VIDEO_COMMAND } from './VideosPlugin'; import { INSERT_VIDEO_COMMAND } from './VideosPlugin';
import { INSERT_AUDIO_COMMAND } from './AudiosPlugin';
import { uploadImage, uploadBlogFile } from '../api'; import { uploadImage, uploadBlogFile } from '../api';
import { ImageNode } from '../nodes/ImageNode'; import { ImageNode } from '../nodes/ImageNode';
import { ArchiveNode } from '../nodes/ArchiveNode'; import { ArchiveNode } from '../nodes/ArchiveNode';
import { VideoNode } from '../nodes/VideoNode'; import { VideoNode } from '../nodes/VideoNode';
import { AudioNode } from '../nodes/AudioNode';
// 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;
@@ -76,6 +78,18 @@ const ARCHIVE_EXTENSIONS = new Set([
'xz', 'xz',
]); ]);
const ACCEPTABLE_AUDIO_TYPES = [
'audio/mpeg',
'audio/mp3',
'audio/mp4',
'audio/aac',
'audio/ogg',
'audio/wav',
'audio/x-wav',
'audio/webm',
'audio/flac',
];
function isArchiveFile(file: File): boolean { function isArchiveFile(file: File): boolean {
const mimeMatches = isMimeType(file, ACCEPTABLE_ARCHIVE_TYPES); const mimeMatches = isMimeType(file, ACCEPTABLE_ARCHIVE_TYPES);
if (mimeMatches) { if (mimeMatches) {
@@ -103,6 +117,7 @@ export default function DragDropPastePlugin(): null {
const imageFiles: File[] = []; const imageFiles: File[] = [];
const archiveFiles: File[] = []; const archiveFiles: File[] = [];
const videoFiles: File[] = []; const videoFiles: File[] = [];
const audioFiles: File[] = [];
for (const file of fileArray) { for (const file of fileArray) {
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
@@ -111,6 +126,8 @@ export default function DragDropPastePlugin(): null {
archiveFiles.push(file); archiveFiles.push(file);
} else if (isMimeType(file, ACCEPTABLE_VIDEO_TYPES)) { } else if (isMimeType(file, ACCEPTABLE_VIDEO_TYPES)) {
videoFiles.push(file); videoFiles.push(file);
} else if (isMimeType(file, ACCEPTABLE_AUDIO_TYPES)) {
audioFiles.push(file);
} }
} }
@@ -231,6 +248,61 @@ export default function DragDropPastePlugin(): null {
} }
} }
for (const file of audioFiles) {
const previewUrl = URL.createObjectURL(file);
editor.dispatchCommand(INSERT_AUDIO_COMMAND, {
src: previewUrl,
uploadProgress: 0,
uploadError: undefined,
});
try {
const { fileKey, url } = await uploadBlogFile(file, (progress: number) => {
editor.update(() => {
const nodes = $nodesOfType(AudioNode);
nodes.forEach((node: AudioNode) => {
if (node.getSrc() === previewUrl) {
node.setUploadProgress(progress);
}
});
});
});
URL.revokeObjectURL(previewUrl);
editor.update(() => {
const nodes = $nodesOfType(AudioNode);
nodes.forEach((node: AudioNode) => {
if (node.getSrc() === previewUrl || node.getFileKey() === fileKey) {
node.setSrc(url);
node.setFileKey(fileKey);
node.setUploadProgress(100);
node.setUploadError(undefined);
}
});
});
showToast('Audio uploaded successfully!', 'success');
} catch (error) {
URL.revokeObjectURL(previewUrl);
editor.update(() => {
const nodes = $nodesOfType(AudioNode);
nodes.forEach((node: AudioNode) => {
if (node.getSrc() === previewUrl) {
node.setUploadError(
error instanceof Error ? error.message : 'Upload failed',
);
}
});
});
const errorMessage = error instanceof Error ? error.message : 'Upload failed';
showToast(`Audio upload failed: ${errorMessage}`, 'error');
}
}
for (const file of archiveFiles) { for (const file of archiveFiles) {
const uploadId = createUploadId(); const uploadId = createUploadId();

View File

@@ -461,6 +461,91 @@
gap: 6px; gap: 6px;
} }
/* Audio */
.editor-audio {
display: block;
margin: 16px 0;
}
.editor-audio-card {
position: relative;
border: 1px solid var(--border-color, #d0d0d0);
border-radius: 12px;
background: var(--bg-card, #fafafa);
overflow: hidden;
padding: 12px 16px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
outline: none;
}
.editor-audio-card:focus {
border-color: var(--accent-color, #4caf50);
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15);
}
.editor-audio-wrapper {
position: relative;
}
.editor-audio-player {
width: 100%;
}
.editor-audio-placeholder {
padding: 12px;
text-align: center;
color: var(--text-secondary, #777);
font-size: 14px;
}
.editor-audio-overlay {
position: absolute;
left: 0;
right: 0;
bottom: 0;
padding: 10px 12px;
background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0.75) 100%);
color: #fff;
display: flex;
flex-direction: column;
gap: 6px;
}
.editor-audio-progress {
height: 4px;
background: rgba(255, 255, 255, 0.35);
border-radius: 999px;
overflow: hidden;
}
.editor-audio-progress-value {
height: 100%;
background: var(--accent-color, #4caf50);
width: 0;
transition: width 0.3s ease;
}
.editor-audio-progress-label {
font-size: 12px;
font-weight: 500;
}
.editor-audio-error {
position: absolute;
bottom: 12px;
left: 12px;
right: 12px;
padding: 10px;
border-radius: 8px;
background: rgba(244, 67, 54, 0.92);
color: #fff;
font-size: 12px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
}
/* Image Resizer */ /* Image Resizer */
.image-resizer { .image-resizer {
display: block; display: block;

View File

@@ -42,6 +42,7 @@ const theme: EditorThemeClasses = {
image: 'editor-image', image: 'editor-image',
archive: 'editor-archive', archive: 'editor-archive',
video: 'editor-video', video: 'editor-video',
audio: 'editor-audio',
hashtag: 'editor-hashtag', hashtag: 'editor-hashtag',
}; };