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

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