From ce8215ecc770ea1b6028b594bd1fcc076585872b Mon Sep 17 00:00:00 2001 From: cialloo Date: Wed, 29 Oct 2025 07:47:00 +0800 Subject: [PATCH] feat: add audio support with AudioNode and AudioComponent, including drag-and-drop functionality --- src/blog/BlogContentViewer.tsx | 2 + src/blog/BlogEditor.tsx | 4 + src/blog/nodes/AudioComponent.tsx | 148 +++++++++++++++++ src/blog/nodes/AudioNode.tsx | 195 +++++++++++++++++++++++ src/blog/plugins/AudiosPlugin.tsx | 48 ++++++ src/blog/plugins/DragDropPastePlugin.tsx | 72 +++++++++ src/blog/styles/editor.css | 85 ++++++++++ src/blog/themes/EditorTheme.ts | 1 + 8 files changed, 555 insertions(+) create mode 100644 src/blog/nodes/AudioComponent.tsx create mode 100644 src/blog/nodes/AudioNode.tsx create mode 100644 src/blog/plugins/AudiosPlugin.tsx diff --git a/src/blog/BlogContentViewer.tsx b/src/blog/BlogContentViewer.tsx index 11b0d40..c611eac 100644 --- a/src/blog/BlogContentViewer.tsx +++ b/src/blog/BlogContentViewer.tsx @@ -17,6 +17,7 @@ import editorTheme from './themes/EditorTheme'; import { ImageNode } from './nodes/ImageNode'; import { ArchiveNode } from './nodes/ArchiveNode'; import { VideoNode } from './nodes/VideoNode'; +import { AudioNode } from './nodes/AudioNode'; import { MentionNode } from './nodes/MentionNode'; import './styles/editor.css'; @@ -67,6 +68,7 @@ const initialConfig: InitialConfigType = { ImageNode, ArchiveNode, VideoNode, + AudioNode, HashtagNode, MentionNode, ], diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx index daeffa5..cc5641e 100644 --- a/src/blog/BlogEditor.tsx +++ b/src/blog/BlogEditor.tsx @@ -22,11 +22,13 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { ImageNode } from './nodes/ImageNode'; import { ArchiveNode } from './nodes/ArchiveNode'; import { VideoNode } from './nodes/VideoNode'; +import { AudioNode } from './nodes/AudioNode'; import { MentionNode } from './nodes/MentionNode'; import ToolbarPlugin from './plugins/ToolbarPlugin'; import MarkdownPlugin from './plugins/MarkdownShortcutPlugin'; import ImagesPlugin from './plugins/ImagesPlugin'; import VideosPlugin from './plugins/VideosPlugin'; +import AudiosPlugin from './plugins/AudiosPlugin'; import DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin'; import ArchivesPlugin from './plugins/ArchivesPlugin'; import HashtagPlugin from './plugins/HashtagPlugin'; @@ -93,6 +95,7 @@ const editorConfig: InitialConfigType = { ImageNode, ArchiveNode, VideoNode, + AudioNode, HashtagNode, MentionNode, ], @@ -180,6 +183,7 @@ const BlogEditor = forwardRef(({ initialContent + diff --git a/src/blog/nodes/AudioComponent.tsx b/src/blog/nodes/AudioComponent.tsx new file mode 100644 index 0000000..cdfb404 --- /dev/null +++ b/src/blog/nodes/AudioComponent.tsx @@ -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(null); + const audioRef = useRef(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 ( +
{ + clearSelection(); + setSelected(true); + }} + > +
+ {resolvedSrc ? ( +
+ ); +} diff --git a/src/blog/nodes/AudioNode.tsx b/src/blog/nodes/AudioNode.tsx new file mode 100644 index 0000000..22692c2 --- /dev/null +++ b/src/blog/nodes/AudioNode.tsx @@ -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 { + __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 ( + + + + ); + } +} + +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; +} diff --git a/src/blog/plugins/AudiosPlugin.tsx b/src/blog/plugins/AudiosPlugin.tsx new file mode 100644 index 0000000..e26879b --- /dev/null +++ b/src/blog/plugins/AudiosPlugin.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 { $createAudioNode, AudioNode, type AudioPayload } from '../nodes/AudioNode'; + +export type InsertAudioPayload = Readonly; + +export const INSERT_AUDIO_COMMAND: LexicalCommand = + 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( + 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; +} diff --git a/src/blog/plugins/DragDropPastePlugin.tsx b/src/blog/plugins/DragDropPastePlugin.tsx index 6ce501f..bc5257f 100644 --- a/src/blog/plugins/DragDropPastePlugin.tsx +++ b/src/blog/plugins/DragDropPastePlugin.tsx @@ -7,10 +7,12 @@ import { useEffect } from 'react'; import { INSERT_IMAGE_COMMAND } from './ImagesPlugin'; import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin'; import { INSERT_VIDEO_COMMAND } from './VideosPlugin'; +import { INSERT_AUDIO_COMMAND } from './AudiosPlugin'; import { uploadImage, uploadBlogFile } from '../api'; import { ImageNode } from '../nodes/ImageNode'; import { ArchiveNode } from '../nodes/ArchiveNode'; import { VideoNode } from '../nodes/VideoNode'; +import { AudioNode } from '../nodes/AudioNode'; // Toast notification helper (global) let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null; @@ -76,6 +78,18 @@ const ARCHIVE_EXTENSIONS = new Set([ '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 { const mimeMatches = isMimeType(file, ACCEPTABLE_ARCHIVE_TYPES); if (mimeMatches) { @@ -103,6 +117,7 @@ export default function DragDropPastePlugin(): null { const imageFiles: File[] = []; const archiveFiles: File[] = []; const videoFiles: File[] = []; + const audioFiles: File[] = []; for (const file of fileArray) { if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { @@ -111,6 +126,8 @@ export default function DragDropPastePlugin(): null { archiveFiles.push(file); } else if (isMimeType(file, ACCEPTABLE_VIDEO_TYPES)) { 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) { const uploadId = createUploadId(); diff --git a/src/blog/styles/editor.css b/src/blog/styles/editor.css index e763f61..99fde02 100644 --- a/src/blog/styles/editor.css +++ b/src/blog/styles/editor.css @@ -461,6 +461,91 @@ 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 { display: block; diff --git a/src/blog/themes/EditorTheme.ts b/src/blog/themes/EditorTheme.ts index c1cfb88..d38e790 100644 --- a/src/blog/themes/EditorTheme.ts +++ b/src/blog/themes/EditorTheme.ts @@ -42,6 +42,7 @@ const theme: EditorThemeClasses = { image: 'editor-image', archive: 'editor-archive', video: 'editor-video', + audio: 'editor-audio', hashtag: 'editor-hashtag', };