Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 122 additions & 2 deletions docs/extensions/Video/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,18 @@ const extensions = [

...
// Import Extensions Here
Video// [!code ++]
Video.configure({// [!code ++]
resourceVideo: 'both',// [!code ++]
acceptMimes: ['video/mp4', 'video/webm'],// [!code ++]
maxSize: 100 * 1024 * 1024,// [!code ++]
multiple: true,// [!code ++]
uploadConcurrency: 3,// [!code ++]
showUploadProgress: true,// [!code ++]
upload: (file, { onProgress } = {}) => uploadVideo(file, onProgress),// [!code ++]
onError: ({ message, file }) => {// [!code ++]
console.error(message, file?.name);// [!code ++]
},// [!code ++]
})// [!code ++]
];

const RichTextToolbar = () => {
Expand Down Expand Up @@ -74,9 +85,51 @@ const App = () => {
};
```

`fetch` does not expose upload byte progress. Use `XMLHttpRequest`, Axios, or a storage SDK that
reports transferred bytes when you need a real progress bar:

```ts
function uploadVideo(
file: File,
onProgress?: (progress: { loaded: number; total: number }) => void
) {
return new Promise<string>((resolve, reject) => {
const formData = new FormData();
formData.append('file', file);

const request = new XMLHttpRequest();
request.open('POST', '/api/videos');
request.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
onProgress?.({ loaded: event.loaded, total: event.total });
}
});
request.addEventListener('load', () => {
if (request.status < 200 || request.status >= 300) {
reject(new Error('Video upload failed'));
return;
}

resolve(JSON.parse(request.responseText).url);
});
request.addEventListener('error', () => reject(new Error('Video upload failed')));
request.send(formData);
});
}
```

## Props

```ts
interface VideoUploadProgress {
loaded: number;
total: number;
}

interface VideoUploadContext {
onProgress?: (progress: VideoUploadProgress) => void;
}

interface VideoOptions extends GeneralOptions<VideoOptions> {
/**
* Indicates whether fullscreen play is allowed
Expand All @@ -101,9 +154,76 @@ interface VideoOptions extends GeneralOptions<VideoOptions> {
[key: string]: any;
};
/** Function for uploading files */
upload?: (file: File) => Promise<string>;
upload?: (file: File, context?: VideoUploadContext) => Promise<string>;

/** Whether multiple videos can be selected and uploaded at once */
multiple?: boolean;

/** Maximum number of videos uploaded concurrently */
uploadConcurrency?: number;

/**
* Whether to display overall and per-file upload progress
*
* @default true
*/
showUploadProgress?: boolean;

/** Accepted video MIME types or file extensions */
acceptMimes?: string[];

/** Maximum size of a single video in bytes. No limit is applied when omitted. */
maxSize?: number;

/** Callback invoked when video validation or upload fails */
onError?: (error: { type: 'size' | 'type' | 'upload'; message: string; file?: File }) => void;

/** The source URL of the video */
resourceVideo: 'upload' | 'link' | 'both';

/**
* List of allowed video hosting providers.
* Use ['.'] to allow any URL.
*
* @default ['.']
*/
videoProviders?: string[];
}
```

## Options

| Option | Type | Description | Required | Default |
| -------------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -------- | ----------------------------- |
| `allowFullscreen` | `boolean` | Allows embedded videos to enter fullscreen mode. | No | `true` |
| `frameborder` | `boolean` | Displays a border around the embedded video frame. | No | `false` |
| `width` | `number \| string` | Sets the default video width. | No | `VIDEO_SIZE.size-medium` |
| `HTMLAttributes` | `Record<string, any>` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` |
| `upload` | `(file: File, context?: VideoUploadContext) => Promise<string>` | Uploads a local video, optionally reports byte progress, and resolves with its URL. | No | None |
| `multiple` | `boolean` | Allows selecting and uploading multiple videos. | No | `true` |
| `uploadConcurrency` | `number` | Limits the number of videos uploaded at the same time. Values below `1` are treated as `1`. | No | `3` |
| `showUploadProgress` | `boolean` | Displays overall and per-file progress when byte progress is reported. | No | `true` |
| `acceptMimes` | `string[]` | Restricts local files by MIME type or extension; wildcard values such as `video/*` are supported. | No | `['video/*']` |
| `maxSize` | `number` | Maximum size of each local video in bytes. No size limit is applied when omitted. | No | None |
| `onError` | `(error: { type: 'size' \| 'type' \| 'upload'; message: string; file?: File }) => void` | Handles validation and upload failures. When omitted, the editor displays its default error toast. | No | None |
| `resourceVideo` | `'upload' \| 'link' \| 'both'` | Controls whether users can add videos by local upload, URL, or both. | No | `'both'` |
| `videoProviders` | `string[]` | Restricts linked videos to matching providers. Use `['.']` to accept any URL. | No | `['.']` |

## Upload behavior

While the `upload` promise is pending, both the toolbar dialog and the slash-command dialog stay
open and disable the upload button. Call `context.onProgress({ loaded, total })` to show real,
byte-weighted total progress and per-file progress. If an existing upload function ignores the
optional second argument, it remains compatible and the editor shows an indeterminate spinner.
Progress details are enabled by default. Set `showUploadProgress: false` to hide the overall and
per-file progress UI while keeping the disabled upload button and indeterminate loading indicator.

When all uploads resolve, their URLs are inserted in selection order and the dialog closes. A file
that reaches 100% before its upload promise resolves is shown as processing.

If one upload rejects, other successful videos are still inserted. The failed file remains marked in
the open dialog so the user can select it again. Configure `onError` to provide custom error handling;
otherwise, the editor shows its default upload error toast.

`acceptMimes` and `maxSize` validate every selected file before uploading. With `multiple: true`, all
valid files are queued and up to `uploadConcurrency` files upload at once.
65 changes: 18 additions & 47 deletions src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';

import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components';
import { useListener } from '@/components/ReactBus';
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
import { VideoUploadTab } from '@/extensions/Video/components/VideoUploadTab';
import { Video } from '@/extensions/Video/Video';
import { useToggleActive } from '@/hooks/useActive';
import { useExtension } from '@/hooks/useExtension';
Expand All @@ -25,11 +26,11 @@ export function RenderDialogUploadVideo() {
const { editorDisabled } = useToggleActive();

const [link, setLink] = useState<string>('');
const fileInput = useRef<HTMLInputElement>(null);

const [error, setError] = useState<string>('');

const [open, setOpen] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const extension = useExtension(Video.name);

const EVENT_ID = EVENTS.UPLOAD_VIDEO((editor as any).id);
Expand All @@ -42,30 +43,6 @@ export function RenderDialogUploadVideo() {
return uploadOptions;
}, [extension]);

async function handleFile(event: any) {
const files = event?.target?.files;
if (!editor || editor.isDestroyed || files.length === 0) {
return;
}
const file = files[0];

let src = '';
if (uploadOptions.upload) {
src = await uploadOptions.upload(file);
} else {
src = URL.createObjectURL(file);
}

editor
.chain()
.focus()
.setVideo({
src,
width: '100%',
})
.run();
setOpen(false);
}
function handleLink(e: any) {
e.preventDefault();
e.stopPropagation();
Expand All @@ -86,17 +63,21 @@ export function RenderDialogUploadVideo() {
setLink('');
}

function handleClick(e: any) {
e.preventDefault();
fileInput.current?.click();
}

if (editorDisabled) {
return <></>;
}

return (
<Dialog onOpenChange={setOpen} open={open}>
<Dialog
onOpenChange={(nextOpen) => {
if (isUploading && !nextOpen) {
return;
}

setOpen(nextOpen);
}}
open={open}
>
<DialogContent>
<DialogTitle>{t('editor.video.dialog.title')}</DialogTitle>

Expand All @@ -121,21 +102,11 @@ export function RenderDialogUploadVideo() {
</TabsList>

<TabsContent value='upload'>
<div className='richtext-flex richtext-items-center richtext-gap-[10px]'>
<Button className='richtext-mt-1 richtext-w-full' onClick={handleClick} size='sm'>
{t('editor.video.dialog.tab.upload')}
</Button>
</div>

<input
accept='video/*'
multiple
onChange={handleFile}
ref={fileInput}
type='file'
style={{
display: 'none',
}}
<VideoUploadTab
editor={editor}
onUploadComplete={() => setOpen(false)}
onUploadingChange={setIsUploading}
uploadOptions={uploadOptions}
/>
</TabsContent>

Expand Down
57 changes: 54 additions & 3 deletions src/extensions/Video/Video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ import type { GeneralOptions, VideoAlignment } from '@/types';

export * from '@/extensions/Video/components/RichTextVideo';

export interface VideoUploadProgress {
/** Number of bytes uploaded so far */
loaded: number;
/** Total number of bytes to upload */
total: number;
}

export interface VideoUploadContext {
/** Reports upload byte progress. Omit calls when progress is unavailable. */
onProgress?: (progress: VideoUploadProgress) => void;
}

/**
* Represents the interface for video options, extending GeneralOptions.
*/
Expand Down Expand Up @@ -34,7 +46,29 @@ export interface VideoOptions extends GeneralOptions<VideoOptions> {
[key: string]: any;
};
/** Function for uploading files */
upload?: (file: File) => Promise<string>;
upload?: (file: File, context?: VideoUploadContext) => Promise<string>;

/** Whether multiple videos can be selected and uploaded at once */
multiple?: boolean;

/** Maximum number of videos uploaded concurrently */
uploadConcurrency?: number;

/**
* Whether to display overall and per-file upload progress
*
* @default true
*/
showUploadProgress?: boolean;

/** Accepted video MIME types or file extensions */
acceptMimes?: string[];

/** Maximum size of a single video in bytes. No limit is applied when omitted. */
maxSize?: number;

/** Callback invoked when video validation or upload fails */
onError?: (error: { type: 'size' | 'type' | 'upload'; message: string; file?: File }) => void;

/** The source URL of the video */
resourceVideo: 'upload' | 'link' | 'both';
Expand All @@ -48,6 +82,23 @@ export interface VideoOptions extends GeneralOptions<VideoOptions> {
videoProviders?: string[];
}

export const DEFAULT_VIDEO_OPTIONS = {
acceptMimes: ['video/*'],
multiple: true,
resourceVideo: 'both',
showUploadProgress: true,
uploadConcurrency: 3,
videoProviders: ['.'],
} satisfies Pick<
VideoOptions,
| 'acceptMimes'
| 'multiple'
| 'resourceVideo'
| 'showUploadProgress'
| 'uploadConcurrency'
| 'videoProviders'
>;

/**
* Represents the type for setting video options
*/
Expand Down Expand Up @@ -130,8 +181,8 @@ export const Video = /* @__PURE__ */ Node.create<VideoOptions>({
spacer: false,
allowFullscreen: true,
upload: undefined,
...DEFAULT_VIDEO_OPTIONS,
frameborder: false,
resourceVideo: 'both',
width: VIDEO_SIZE['size-medium'],
HTMLAttributes: {
class: 'iframe-wrapper',
Expand All @@ -148,7 +199,7 @@ export const Video = /* @__PURE__ */ Node.create<VideoOptions>({
disabled: !editor.can().setVideo?.({}),
icon: 'Video',
tooltip: t('editor.video.tooltip'),
videoProviders: ['.'],
videoProviders: DEFAULT_VIDEO_OPTIONS.videoProviders,
editor,
},
};
Expand Down
Loading
Loading