feat: add video support with VideoNode and VideoComponent, including drag-and-drop functionality
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 19s
This commit is contained in:
@@ -16,6 +16,7 @@ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext
|
|||||||
import editorTheme from './themes/EditorTheme';
|
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 { MentionNode } from './nodes/MentionNode';
|
import { MentionNode } from './nodes/MentionNode';
|
||||||
import './styles/editor.css';
|
import './styles/editor.css';
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ const initialConfig: InitialConfigType = {
|
|||||||
AutoLinkNode,
|
AutoLinkNode,
|
||||||
ImageNode,
|
ImageNode,
|
||||||
ArchiveNode,
|
ArchiveNode,
|
||||||
|
VideoNode,
|
||||||
HashtagNode,
|
HashtagNode,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ 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 { 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 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';
|
||||||
@@ -90,6 +92,7 @@ const editorConfig: InitialConfigType = {
|
|||||||
AutoLinkNode,
|
AutoLinkNode,
|
||||||
ImageNode,
|
ImageNode,
|
||||||
ArchiveNode,
|
ArchiveNode,
|
||||||
|
VideoNode,
|
||||||
HashtagNode,
|
HashtagNode,
|
||||||
MentionNode,
|
MentionNode,
|
||||||
],
|
],
|
||||||
@@ -176,6 +179,7 @@ const BlogEditor = forwardRef<BlogEditorRef, BlogEditorProps>(({ initialContent
|
|||||||
<AutoLinkPlugin matchers={MATCHERS} />
|
<AutoLinkPlugin matchers={MATCHERS} />
|
||||||
<TablePlugin />
|
<TablePlugin />
|
||||||
<ImagesPlugin />
|
<ImagesPlugin />
|
||||||
|
<VideosPlugin />
|
||||||
<ArchivesPlugin />
|
<ArchivesPlugin />
|
||||||
<DragDropPastePlugin />
|
<DragDropPastePlugin />
|
||||||
<HashtagPlugin />
|
<HashtagPlugin />
|
||||||
|
|||||||
159
src/blog/nodes/VideoComponent.tsx
Normal file
159
src/blog/nodes/VideoComponent.tsx
Normal file
@@ -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<HTMLDivElement | null>(null);
|
||||||
|
const videoRef = useRef<HTMLVideoElement | 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-video-card"
|
||||||
|
style={{
|
||||||
|
borderColor,
|
||||||
|
}}
|
||||||
|
tabIndex={0}
|
||||||
|
onFocus={() => {
|
||||||
|
clearSelection();
|
||||||
|
setSelected(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="editor-video-wrapper">
|
||||||
|
{resolvedSrc ? (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="editor-video-player"
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
style={{
|
||||||
|
width: width === 'inherit' ? '100%' : width,
|
||||||
|
height: height === 'inherit' ? 'auto' : height,
|
||||||
|
maxWidth,
|
||||||
|
}}
|
||||||
|
src={resolvedSrc}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="editor-video-placeholder">Preparing video...</div>
|
||||||
|
)}
|
||||||
|
{isUploading && (
|
||||||
|
<div className="editor-video-overlay">
|
||||||
|
<div className="editor-video-progress">
|
||||||
|
<div
|
||||||
|
className="editor-video-progress-value"
|
||||||
|
style={{ width: `${uploadProgress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="editor-video-progress-label">
|
||||||
|
Uploading... {Math.round(uploadProgress || 0)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{uploadError && (
|
||||||
|
<div className="editor-video-error">❌ {uploadError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
src/blog/nodes/VideoNode.tsx
Normal file
276
src/blog/nodes/VideoNode.tsx
Normal file
@@ -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<JSX.Element> {
|
||||||
|
__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 (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<VideoComponent
|
||||||
|
nodeKey={this.getKey()}
|
||||||
|
src={this.__src}
|
||||||
|
fileKey={this.__fileKey}
|
||||||
|
width={this.__width}
|
||||||
|
height={this.__height}
|
||||||
|
maxWidth={this.__maxWidth}
|
||||||
|
uploadProgress={this.__uploadProgress}
|
||||||
|
uploadError={this.__uploadError}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ 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 { 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';
|
||||||
|
|
||||||
// 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;
|
||||||
@@ -54,6 +56,14 @@ const ACCEPTABLE_ARCHIVE_TYPES = [
|
|||||||
'application/x-bzip',
|
'application/x-bzip',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ACCEPTABLE_VIDEO_TYPES = [
|
||||||
|
'video/mp4',
|
||||||
|
'video/quicktime',
|
||||||
|
'video/webm',
|
||||||
|
'video/ogg',
|
||||||
|
'video/x-matroska',
|
||||||
|
];
|
||||||
|
|
||||||
const ARCHIVE_EXTENSIONS = new Set([
|
const ARCHIVE_EXTENSIONS = new Set([
|
||||||
'zip',
|
'zip',
|
||||||
'rar',
|
'rar',
|
||||||
@@ -92,12 +102,15 @@ export default function DragDropPastePlugin(): null {
|
|||||||
|
|
||||||
const imageFiles: File[] = [];
|
const imageFiles: File[] = [];
|
||||||
const archiveFiles: File[] = [];
|
const archiveFiles: File[] = [];
|
||||||
|
const videoFiles: File[] = [];
|
||||||
|
|
||||||
for (const file of fileArray) {
|
for (const file of fileArray) {
|
||||||
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
|
||||||
imageFiles.push(file);
|
imageFiles.push(file);
|
||||||
} else if (isArchiveFile(file)) {
|
} else if (isArchiveFile(file)) {
|
||||||
archiveFiles.push(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) {
|
for (const file of archiveFiles) {
|
||||||
const uploadId = createUploadId();
|
const uploadId = createUploadId();
|
||||||
|
|
||||||
|
|||||||
48
src/blog/plugins/VideosPlugin.tsx
Normal file
48
src/blog/plugins/VideosPlugin.tsx
Normal 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 { $createVideoNode, VideoNode, type VideoPayload } from '../nodes/VideoNode';
|
||||||
|
|
||||||
|
export type InsertVideoPayload = Readonly<VideoPayload>;
|
||||||
|
|
||||||
|
export const INSERT_VIDEO_COMMAND: LexicalCommand<InsertVideoPayload> =
|
||||||
|
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<InsertVideoPayload>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -373,6 +373,94 @@
|
|||||||
color: #f44336;
|
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 */
|
||||||
.image-resizer {
|
.image-resizer {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ const theme: EditorThemeClasses = {
|
|||||||
hr: 'editor-hr',
|
hr: 'editor-hr',
|
||||||
image: 'editor-image',
|
image: 'editor-image',
|
||||||
archive: 'editor-archive',
|
archive: 'editor-archive',
|
||||||
|
video: 'editor-video',
|
||||||
hashtag: 'editor-hashtag',
|
hashtag: 'editor-hashtag',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user