From e241f789eddda9561b5b6299efbe01a218f1a80b Mon Sep 17 00:00:00 2001 From: cialloo Date: Mon, 27 Oct 2025 21:56:32 +0800 Subject: [PATCH] feat: add video support with VideoNode and VideoComponent, including drag-and-drop functionality --- src/blog/BlogContentViewer.tsx | 2 + src/blog/BlogEditor.tsx | 4 + src/blog/nodes/VideoComponent.tsx | 159 +++++++++++++ src/blog/nodes/VideoNode.tsx | 276 +++++++++++++++++++++++ src/blog/plugins/DragDropPastePlugin.tsx | 68 ++++++ src/blog/plugins/VideosPlugin.tsx | 48 ++++ src/blog/styles/editor.css | 88 ++++++++ src/blog/themes/EditorTheme.ts | 1 + 8 files changed, 646 insertions(+) create mode 100644 src/blog/nodes/VideoComponent.tsx create mode 100644 src/blog/nodes/VideoNode.tsx create mode 100644 src/blog/plugins/VideosPlugin.tsx diff --git a/src/blog/BlogContentViewer.tsx b/src/blog/BlogContentViewer.tsx index fdb8074..11b0d40 100644 --- a/src/blog/BlogContentViewer.tsx +++ b/src/blog/BlogContentViewer.tsx @@ -16,6 +16,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import editorTheme from './themes/EditorTheme'; import { ImageNode } from './nodes/ImageNode'; import { ArchiveNode } from './nodes/ArchiveNode'; +import { VideoNode } from './nodes/VideoNode'; import { MentionNode } from './nodes/MentionNode'; import './styles/editor.css'; @@ -65,6 +66,7 @@ const initialConfig: InitialConfigType = { AutoLinkNode, ImageNode, ArchiveNode, + VideoNode, HashtagNode, MentionNode, ], diff --git a/src/blog/BlogEditor.tsx b/src/blog/BlogEditor.tsx index 2800c17..daeffa5 100644 --- a/src/blog/BlogEditor.tsx +++ b/src/blog/BlogEditor.tsx @@ -21,10 +21,12 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext import { ImageNode } from './nodes/ImageNode'; import { ArchiveNode } from './nodes/ArchiveNode'; +import { VideoNode } from './nodes/VideoNode'; 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 DragDropPastePlugin, { setDragDropToastHandler } from './plugins/DragDropPastePlugin'; import ArchivesPlugin from './plugins/ArchivesPlugin'; import HashtagPlugin from './plugins/HashtagPlugin'; @@ -90,6 +92,7 @@ const editorConfig: InitialConfigType = { AutoLinkNode, ImageNode, ArchiveNode, + VideoNode, HashtagNode, MentionNode, ], @@ -176,6 +179,7 @@ const BlogEditor = forwardRef(({ initialContent + diff --git a/src/blog/nodes/VideoComponent.tsx b/src/blog/nodes/VideoComponent.tsx new file mode 100644 index 0000000..87f86a6 --- /dev/null +++ b/src/blog/nodes/VideoComponent.tsx @@ -0,0 +1,159 @@ +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 VideoComponentProps { + nodeKey: NodeKey; + src: string; + fileKey?: string; + width: number | 'inherit'; + height: number | 'inherit'; + maxWidth: number; + uploadProgress?: number; + uploadError?: string; +} + +export default function VideoComponent({ + nodeKey, + src, + fileKey, + width, + height, + maxWidth, + uploadProgress, + uploadError, +}: VideoComponentProps): JSX.Element { + const [editor] = useLexicalComposerContext(); + const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); + const containerRef = useRef(null); + const videoRef = 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/VideoNode.tsx b/src/blog/nodes/VideoNode.tsx new file mode 100644 index 0000000..4d099b8 --- /dev/null +++ b/src/blog/nodes/VideoNode.tsx @@ -0,0 +1,276 @@ +import type { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import type { JSX } from 'react'; + +import { DecoratorNode, $applyNodeReplacement } from 'lexical'; +import { Suspense, lazy } from 'react'; + +const VideoComponent = lazy(() => import('./VideoComponent')); + +export interface VideoPayload { + src: string; + fileKey?: string; + width?: number | 'inherit'; + height?: number | 'inherit'; + maxWidth?: number; + uploadProgress?: number; + uploadError?: string; + key?: NodeKey; +} + +function $convertVideoElement(domNode: Node): DOMConversionOutput | null { + const element = domNode as HTMLElement; + if (!element.hasAttribute('data-lexical-video')) { + return null; + } + + const src = element.getAttribute('src') || ''; + const fileKey = element.getAttribute('data-file-key') || undefined; + const widthAttr = element.getAttribute('width'); + const heightAttr = element.getAttribute('height'); + const maxWidthAttr = element.getAttribute('data-max-width'); + + const width = widthAttr ? Number(widthAttr) : undefined; + const height = heightAttr ? Number(heightAttr) : undefined; + const maxWidth = maxWidthAttr ? Number(maxWidthAttr) : undefined; + + return { + node: $createVideoNode({ + src, + fileKey, + width: Number.isFinite(width) ? width : undefined, + height: Number.isFinite(height) ? height : undefined, + maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined, + }), + }; +} + +export type SerializedVideoNode = Spread< + { + src: string; + fileKey?: string; + width?: number; + height?: number; + maxWidth?: number; + }, + SerializedLexicalNode +>; + +export class VideoNode extends DecoratorNode { + __src: string; + __fileKey?: string; + __width: number | 'inherit'; + __height: number | 'inherit'; + __maxWidth: number; + __uploadProgress?: number; + __uploadError?: string; + + static getType(): string { + return 'video'; + } + + static clone(node: VideoNode): VideoNode { + return new VideoNode( + node.__src, + node.__fileKey, + node.__width, + node.__height, + node.__maxWidth, + node.__uploadProgress, + node.__uploadError, + node.__key, + ); + } + + static importJSON(serializedNode: SerializedVideoNode): VideoNode { + const { src, fileKey, width, height, maxWidth } = serializedNode; + return $createVideoNode({ + src: fileKey ? '' : src, + fileKey, + width, + height, + maxWidth, + }); + } + + exportJSON(): SerializedVideoNode { + return { + src: this.__fileKey ? '' : this.__src, + fileKey: this.__fileKey, + width: this.__width === 'inherit' ? undefined : this.__width, + height: this.__height === 'inherit' ? undefined : this.__height, + maxWidth: this.__maxWidth, + type: 'video', + version: 1, + }; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('video'); + element.setAttribute('data-lexical-video', 'true'); + element.setAttribute('controls', ''); + element.setAttribute('src', this.__src); + if (this.__fileKey) { + element.setAttribute('data-file-key', this.__fileKey); + } + if (this.__width !== 'inherit') { + element.setAttribute('width', String(this.__width)); + } + if (this.__height !== 'inherit') { + element.setAttribute('height', String(this.__height)); + } + element.setAttribute('data-max-width', String(this.__maxWidth)); + return { element }; + } + + constructor( + src: string, + fileKey?: string, + width: number | 'inherit' = 'inherit', + height: number | 'inherit' = 'inherit', + maxWidth = 800, + uploadProgress?: number, + uploadError?: string, + key?: NodeKey, + ) { + super(key); + this.__src = src; + this.__fileKey = fileKey; + this.__width = width; + this.__height = height; + this.__maxWidth = maxWidth; + this.__uploadProgress = uploadProgress; + this.__uploadError = uploadError; + } + + static importDOM(): DOMConversionMap | null { + return { + video: () => ({ + conversion: $convertVideoElement, + priority: 1, + }), + }; + } + + createDOM(config: EditorConfig): HTMLElement { + const div = document.createElement('div'); + const className = config.theme?.video; + if (className) { + div.className = className; + } + return div; + } + + updateDOM(): false { + return false; + } + + getSrc(): string { + return this.__src; + } + + getFileKey(): string | undefined { + return this.__fileKey; + } + + getWidth(): number | 'inherit' { + return this.__width; + } + + getHeight(): number | 'inherit' { + return this.__height; + } + + getMaxWidth(): number { + return this.__maxWidth; + } + + getUploadProgress(): number | undefined { + return this.__uploadProgress; + } + + getUploadError(): string | undefined { + return this.__uploadError; + } + + setWidthAndHeight(width: number | 'inherit', height: number | 'inherit'): void { + const writable = this.getWritable(); + writable.__width = width; + writable.__height = height; + } + + 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 $createVideoNode(payload: VideoPayload): VideoNode { + const { + src, + fileKey, + width, + height, + maxWidth, + uploadProgress, + uploadError, + key, + } = payload; + + return $applyNodeReplacement( + new VideoNode( + src, + fileKey, + width ?? 'inherit', + height ?? 'inherit', + maxWidth ?? 800, + uploadProgress, + uploadError, + key, + ), + ); +} + +export function $isVideoNode(node: LexicalNode | null | undefined): node is VideoNode { + return node instanceof VideoNode; +} diff --git a/src/blog/plugins/DragDropPastePlugin.tsx b/src/blog/plugins/DragDropPastePlugin.tsx index e35d908..6ce501f 100644 --- a/src/blog/plugins/DragDropPastePlugin.tsx +++ b/src/blog/plugins/DragDropPastePlugin.tsx @@ -6,9 +6,11 @@ import { useEffect } from 'react'; import { INSERT_IMAGE_COMMAND } from './ImagesPlugin'; import { INSERT_ARCHIVE_COMMAND } from './ArchivesPlugin'; +import { INSERT_VIDEO_COMMAND } from './VideosPlugin'; import { uploadImage, uploadBlogFile } from '../api'; import { ImageNode } from '../nodes/ImageNode'; import { ArchiveNode } from '../nodes/ArchiveNode'; +import { VideoNode } from '../nodes/VideoNode'; // Toast notification helper (global) let showToastFn: ((message: string, type: 'success' | 'error') => void) | null = null; @@ -54,6 +56,14 @@ const ACCEPTABLE_ARCHIVE_TYPES = [ 'application/x-bzip', ]; +const ACCEPTABLE_VIDEO_TYPES = [ + 'video/mp4', + 'video/quicktime', + 'video/webm', + 'video/ogg', + 'video/x-matroska', +]; + const ARCHIVE_EXTENSIONS = new Set([ 'zip', 'rar', @@ -92,12 +102,15 @@ export default function DragDropPastePlugin(): null { const imageFiles: File[] = []; const archiveFiles: File[] = []; + const videoFiles: File[] = []; for (const file of fileArray) { if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) { imageFiles.push(file); } else if (isArchiveFile(file)) { archiveFiles.push(file); + } else if (isMimeType(file, ACCEPTABLE_VIDEO_TYPES)) { + videoFiles.push(file); } } @@ -163,6 +176,61 @@ export default function DragDropPastePlugin(): null { } } + for (const file of videoFiles) { + const previewUrl = URL.createObjectURL(file); + + editor.dispatchCommand(INSERT_VIDEO_COMMAND, { + src: previewUrl, + uploadProgress: 0, + uploadError: undefined, + }); + + try { + const { fileKey, url } = await uploadBlogFile(file, (progress: number) => { + editor.update(() => { + const nodes = $nodesOfType(VideoNode); + nodes.forEach((node: VideoNode) => { + if (node.getSrc() === previewUrl) { + node.setUploadProgress(progress); + } + }); + }); + }); + + URL.revokeObjectURL(previewUrl); + + editor.update(() => { + const nodes = $nodesOfType(VideoNode); + nodes.forEach((node: VideoNode) => { + if (node.getSrc() === previewUrl || node.getFileKey() === fileKey) { + node.setSrc(url); + node.setFileKey(fileKey); + node.setUploadProgress(100); + node.setUploadError(undefined); + } + }); + }); + + showToast('Video uploaded successfully!', 'success'); + } catch (error) { + URL.revokeObjectURL(previewUrl); + + editor.update(() => { + const nodes = $nodesOfType(VideoNode); + nodes.forEach((node: VideoNode) => { + if (node.getSrc() === previewUrl) { + node.setUploadError( + error instanceof Error ? error.message : 'Upload failed', + ); + } + }); + }); + + const errorMessage = error instanceof Error ? error.message : 'Upload failed'; + showToast(`Video upload failed: ${errorMessage}`, 'error'); + } + } + for (const file of archiveFiles) { const uploadId = createUploadId(); diff --git a/src/blog/plugins/VideosPlugin.tsx b/src/blog/plugins/VideosPlugin.tsx new file mode 100644 index 0000000..1ffd0cc --- /dev/null +++ b/src/blog/plugins/VideosPlugin.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 { $createVideoNode, VideoNode, type VideoPayload } from '../nodes/VideoNode'; + +export type InsertVideoPayload = Readonly; + +export const INSERT_VIDEO_COMMAND: LexicalCommand = + createCommand('INSERT_VIDEO_COMMAND'); + +export default function VideosPlugin(): null { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + if (!editor.hasNodes([VideoNode])) { + throw new Error('VideosPlugin: VideoNode not registered on editor'); + } + + return mergeRegister( + editor.registerCommand( + INSERT_VIDEO_COMMAND, + (payload) => { + const videoNode = $createVideoNode(payload); + $insertNodes([videoNode]); + + if ($isRootOrShadowRoot(videoNode.getParentOrThrow())) { + $wrapNodeInElement(videoNode, $createParagraphNode).selectEnd(); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR, + ), + ); + }, [editor]); + + return null; +} diff --git a/src/blog/styles/editor.css b/src/blog/styles/editor.css index 3152e4e..e763f61 100644 --- a/src/blog/styles/editor.css +++ b/src/blog/styles/editor.css @@ -373,6 +373,94 @@ color: #f44336; } +/* Video */ +.editor-video { + display: block; + margin: 18px 0; +} + +.editor-video-card { + position: relative; + border: 1px solid var(--border-color, #d0d0d0); + border-radius: 12px; + overflow: hidden; + background: var(--bg-card, #fafafa); + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; +} + +.editor-video-card:focus { + border-color: var(--accent-color, #4caf50); + box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.15); +} + +.editor-video-wrapper { + position: relative; + background: #000; +} + +.editor-video-player { + display: block; + width: 100%; + height: auto; + background: #000; +} + +.editor-video-placeholder { + padding: 24px; + text-align: center; + color: var(--text-secondary, #777); + font-size: 14px; +} + +.editor-video-overlay { + position: absolute; + left: 0; + right: 0; + bottom: 0; + padding: 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-video-progress { + height: 4px; + background: rgba(255, 255, 255, 0.35); + border-radius: 999px; + overflow: hidden; +} + +.editor-video-progress-value { + height: 100%; + background: var(--accent-color, #4caf50); + width: 0; + transition: width 0.3s ease; +} + +.editor-video-progress-label { + font-size: 12px; + font-weight: 500; +} + +.editor-video-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 2ffae2e..c1cfb88 100644 --- a/src/blog/themes/EditorTheme.ts +++ b/src/blog/themes/EditorTheme.ts @@ -41,6 +41,7 @@ const theme: EditorThemeClasses = { hr: 'editor-hr', image: 'editor-image', archive: 'editor-archive', + video: 'editor-video', hashtag: 'editor-hashtag', };