From 23bba7e45c7f3e77035cf2f08ec303625a2b381d Mon Sep 17 00:00:00 2001 From: Found-L <50953254+Found-L@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:00:05 +0800 Subject: [PATCH 1/4] fix(video): show upload state and handle failures --- docs/extensions/Video/index.md | 57 +++++++++++- .../RenderDialogUploadVideo.tsx | 91 ++++++++++++++----- src/extensions/Video/Video.ts | 3 + .../Video/components/RichTextVideo.tsx | 80 +++++++++++----- 4 files changed, 186 insertions(+), 45 deletions(-) diff --git a/docs/extensions/Video/index.md b/docs/extensions/Video/index.md index 9f5d18f0..30a33183 100644 --- a/docs/extensions/Video/index.md +++ b/docs/extensions/Video/index.md @@ -45,7 +45,28 @@ const extensions = [ ... // Import Extensions Here - Video// [!code ++] + Video.configure({// [!code ++] + resourceVideo: 'both',// [!code ++] + upload: async (file) => {// [!code ++] + const formData = new FormData();// [!code ++] + formData.append('file', file);// [!code ++] +// [!code ++] + const response = await fetch('/api/videos', {// [!code ++] + method: 'POST',// [!code ++] + body: formData,// [!code ++] + });// [!code ++] +// [!code ++] + if (!response.ok) {// [!code ++] + throw new Error('Video upload failed');// [!code ++] + }// [!code ++] +// [!code ++] + const { url } = await response.json();// [!code ++] + return url;// [!code ++] + },// [!code ++] + onError: ({ message, file }) => {// [!code ++] + console.error(message, file?.name);// [!code ++] + },// [!code ++] + })// [!code ++] ]; const RichTextToolbar = () => { @@ -103,7 +124,41 @@ interface VideoOptions extends GeneralOptions { /** Function for uploading files */ upload?: (file: File) => Promise; + /** Callback invoked when a video upload fails */ + onError?: (error: { 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | +| `upload` | `(file: File) => Promise` | Uploads a local video and resolves with the URL inserted into the editor. | No | None | +| `onError` | `(error: { type: 'upload'; message: string; file?: File }) => void` | Handles 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, disable the upload button, and show a localized loading indicator. When the promise resolves, +the returned URL is inserted into the editor and the dialog closes. + +If the promise rejects, no video is inserted. The dialog remains open so the user can retry the same +file. Configure `onError` to provide custom error handling; otherwise, the editor shows its default +upload error toast. diff --git a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx index c501f45c..9bae2586 100644 --- a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx +++ b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx @@ -1,6 +1,15 @@ -import { useMemo, useRef, useState } from 'react'; - -import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components'; +import { type ChangeEvent, useMemo, useRef, useState } from 'react'; + +import { + Button, + IconComponent, + Input, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + useToast, +} from '@/components'; import { useListener } from '@/components/ReactBus'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; import { Video } from '@/extensions/Video/Video'; @@ -13,6 +22,7 @@ import { EVENTS } from '@/utils/customEvents/events.constant'; export function RenderDialogUploadVideo() { const { t } = useLocale(); + const { toast } = useToast(); const editor = useEditorInstance(); // const buttonProps = useButtonProps(Video.name); @@ -30,6 +40,7 @@ export function RenderDialogUploadVideo() { const [error, setError] = useState(''); 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); @@ -42,29 +53,50 @@ export function RenderDialogUploadVideo() { return uploadOptions; }, [extension]); - async function handleFile(event: any) { - const files = event?.target?.files; - if (!editor || editor.isDestroyed || files.length === 0) { + async function handleFile(event: ChangeEvent) { + const files = event.target.files; + if (!editor || editor.isDestroyed || !files?.length || isUploading) { + event.target.value = ''; return; } const file = files[0]; - let src = ''; - if (uploadOptions.upload) { - src = await uploadOptions.upload(file); - } else { - src = URL.createObjectURL(file); + setIsUploading(true); + try { + 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); + } catch (error) { + console.error('Error uploading video', error); + if (uploadOptions.onError) { + uploadOptions.onError({ + type: 'upload', + message: t('editor.upload.error'), + file, + }); + } else { + toast({ + variant: 'destructive', + title: t('editor.upload.error'), + }); + } + } finally { + setIsUploading(false); + event.target.value = ''; } - - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); - setOpen(false); } function handleLink(e: any) { e.preventDefault(); @@ -122,8 +154,21 @@ export function RenderDialogUploadVideo() {
-
diff --git a/src/extensions/Video/Video.ts b/src/extensions/Video/Video.ts index d6341070..ad9a2267 100644 --- a/src/extensions/Video/Video.ts +++ b/src/extensions/Video/Video.ts @@ -36,6 +36,9 @@ export interface VideoOptions extends GeneralOptions { /** Function for uploading files */ upload?: (file: File) => Promise; + /** Callback invoked when a video upload fails */ + onError?: (error: { type: 'upload'; message: string; file?: File }) => void; + /** The source URL of the video */ resourceVideo: 'upload' | 'link' | 'both'; diff --git a/src/extensions/Video/components/RichTextVideo.tsx b/src/extensions/Video/components/RichTextVideo.tsx index ee36bef2..76e8b463 100644 --- a/src/extensions/Video/components/RichTextVideo.tsx +++ b/src/extensions/Video/components/RichTextVideo.tsx @@ -1,13 +1,15 @@ -import { useMemo, useRef, useState } from 'react'; +import { type ChangeEvent, useMemo, useRef, useState } from 'react'; import { ActionButton, Button, + IconComponent, Input, Tabs, TabsContent, TabsList, TabsTrigger, + useToast, } from '@/components'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { Video } from '@/extensions/Video/Video'; @@ -20,6 +22,7 @@ import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; export function RichTextVideo() { const { t } = useLocale(); + const { toast } = useToast(); const editor = useEditorInstance(); const buttonProps = useButtonProps(Video.name); @@ -34,6 +37,7 @@ export function RichTextVideo() { const [error, setError] = useState(''); const [open, setOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); const extension = useExtension(Video.name); const uploadOptions = useMemo(() => { @@ -42,29 +46,50 @@ export function RichTextVideo() { return uploadOptions; }, [extension]); - async function handleFile(event: any) { - const files = event?.target?.files; - if (!editor || editor.isDestroyed || files.length === 0) { + async function handleFile(event: ChangeEvent) { + const files = event.target.files; + if (!editor || editor.isDestroyed || !files?.length || isUploading) { + event.target.value = ''; return; } const file = files[0]; - let src = ''; - if (uploadOptions.upload) { - src = await uploadOptions.upload(file); - } else { - src = URL.createObjectURL(file); + setIsUploading(true); + try { + 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); + } catch (error) { + console.error('Error uploading video', error); + if (uploadOptions.onError) { + uploadOptions.onError({ + type: 'upload', + message: t('editor.upload.error'), + file, + }); + } else { + toast({ + variant: 'destructive', + title: t('editor.upload.error'), + }); + } + } finally { + setIsUploading(false); + event.target.value = ''; } - - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); - setOpen(false); } function handleLink(e: any) { e.preventDefault(); @@ -130,8 +155,21 @@ export function RichTextVideo() {
-
From 7e09856bdd59886cb5f757e1d19275747b245a0e Mon Sep 17 00:00:00 2001 From: Found-L <50953254+Found-L@users.noreply.github.com> Date: Sun, 26 Jul 2026 19:48:41 +0800 Subject: [PATCH 2/4] feat(video): add upload validation and multiple file support --- docs/extensions/Video/index.md | 42 ++++++++--- .../RenderDialogUploadVideo.tsx | 73 ++++++++++++++----- src/extensions/Video/Video.ts | 24 +++++- .../Video/components/RichTextVideo.tsx | 73 ++++++++++++++----- 4 files changed, 158 insertions(+), 54 deletions(-) diff --git a/docs/extensions/Video/index.md b/docs/extensions/Video/index.md index 30a33183..ce277a70 100644 --- a/docs/extensions/Video/index.md +++ b/docs/extensions/Video/index.md @@ -47,6 +47,9 @@ const extensions = [ // Import Extensions Here Video.configure({// [!code ++] resourceVideo: 'both',// [!code ++] + acceptMimes: ['video/mp4', 'video/webm'],// [!code ++] + maxSize: 100 * 1024 * 1024,// [!code ++] + multiple: false,// [!code ++] upload: async (file) => {// [!code ++] const formData = new FormData();// [!code ++] formData.append('file', file);// [!code ++] @@ -124,8 +127,17 @@ interface VideoOptions extends GeneralOptions { /** Function for uploading files */ upload?: (file: File) => Promise; - /** Callback invoked when a video upload fails */ - onError?: (error: { type: 'upload'; message: string; file?: File }) => void; + /** Whether multiple videos can be selected and uploaded at once */ + multiple?: 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'; @@ -142,16 +154,19 @@ interface VideoOptions extends GeneralOptions { ## 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | -| `upload` | `(file: File) => Promise` | Uploads a local video and resolves with the URL inserted into the editor. | No | None | -| `onError` | `(error: { type: 'upload'; message: string; file?: File }) => void` | Handles 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 | `['.']` | +| 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | +| `upload` | `(file: File) => Promise` | Uploads a local video and resolves with the URL inserted into the editor. | No | None | +| `multiple` | `boolean` | Allows selecting and uploading multiple videos. | 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 @@ -162,3 +177,6 @@ the returned URL is inserted into the editor and the dialog closes. If the promise rejects, no video is inserted. The dialog remains open so the user can retry the same file. 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 upload in parallel and are inserted in selection order after every upload succeeds. diff --git a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx index 9bae2586..9f3842a5 100644 --- a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx +++ b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx @@ -12,13 +12,14 @@ import { } from '@/components'; import { useListener } from '@/components/ReactBus'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; -import { Video } from '@/extensions/Video/Video'; +import { DEFAULT_VIDEO_OPTIONS, Video } from '@/extensions/Video/Video'; import { useToggleActive } from '@/hooks/useActive'; import { useExtension } from '@/hooks/useExtension'; import { useLocale } from '@/locales'; import { useEditorInstance } from '@/store/editor'; import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; import { EVENTS } from '@/utils/customEvents/events.constant'; +import { validateFiles } from '@/utils/validateFile'; export function RenderDialogUploadVideo() { const { t } = useLocale(); @@ -59,25 +60,48 @@ export function RenderDialogUploadVideo() { event.target.value = ''; return; } - const file = files[0]; + const validFiles = validateFiles(Array.from(files), { + acceptMimes: uploadOptions.acceptMimes ?? DEFAULT_VIDEO_OPTIONS.acceptMimes, + maxSize: uploadOptions.maxSize ?? Number.POSITIVE_INFINITY, + t, + toast, + onError: uploadOptions.onError, + }); + + if (validFiles.length === 0) { + event.target.value = ''; + return; + } + + const filesToUpload = + (uploadOptions.multiple ?? DEFAULT_VIDEO_OPTIONS.multiple) + ? validFiles + : validFiles.slice(0, 1); setIsUploading(true); try { - let src = ''; - if (uploadOptions.upload) { - src = await uploadOptions.upload(file); - } else { - src = URL.createObjectURL(file); + const srcs = await Promise.all( + filesToUpload.map((file) => { + return uploadOptions.upload + ? uploadOptions.upload(file) + : Promise.resolve(URL.createObjectURL(file)); + }) + ); + + if (editor.isDestroyed) { + return; } - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); + srcs.forEach((src) => { + editor + .chain() + .focus() + .setVideo({ + src, + width: '100%', + }) + .run(); + }); setOpen(false); } catch (error) { console.error('Error uploading video', error); @@ -85,7 +109,6 @@ export function RenderDialogUploadVideo() { uploadOptions.onError({ type: 'upload', message: t('editor.upload.error'), - file, }); } else { toast({ @@ -128,7 +151,16 @@ export function RenderDialogUploadVideo() { } return ( - + { + if (isUploading && !nextOpen) { + return; + } + + setOpen(nextOpen); + }} + open={open} + > {t('editor.video.dialog.title')} @@ -173,8 +205,11 @@ export function RenderDialogUploadVideo() { { /** Function for uploading files */ upload?: (file: File) => Promise; - /** Callback invoked when a video upload fails */ - onError?: (error: { type: 'upload'; message: string; file?: File }) => void; + /** Whether multiple videos can be selected and uploaded at once */ + multiple?: 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'; @@ -51,6 +60,13 @@ export interface VideoOptions extends GeneralOptions { videoProviders?: string[]; } +export const DEFAULT_VIDEO_OPTIONS = { + acceptMimes: ['video/*'], + multiple: true, + resourceVideo: 'both', + videoProviders: ['.'], +} satisfies Pick; + /** * Represents the type for setting video options */ @@ -133,8 +149,8 @@ export const Video = /* @__PURE__ */ Node.create({ spacer: false, allowFullscreen: true, upload: undefined, + ...DEFAULT_VIDEO_OPTIONS, frameborder: false, - resourceVideo: 'both', width: VIDEO_SIZE['size-medium'], HTMLAttributes: { class: 'iframe-wrapper', @@ -151,7 +167,7 @@ export const Video = /* @__PURE__ */ Node.create({ disabled: !editor.can().setVideo?.({}), icon: 'Video', tooltip: t('editor.video.tooltip'), - videoProviders: ['.'], + videoProviders: DEFAULT_VIDEO_OPTIONS.videoProviders, editor, }, }; diff --git a/src/extensions/Video/components/RichTextVideo.tsx b/src/extensions/Video/components/RichTextVideo.tsx index 76e8b463..edcb52ec 100644 --- a/src/extensions/Video/components/RichTextVideo.tsx +++ b/src/extensions/Video/components/RichTextVideo.tsx @@ -12,13 +12,14 @@ import { useToast, } from '@/components'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { Video } from '@/extensions/Video/Video'; +import { DEFAULT_VIDEO_OPTIONS, Video } from '@/extensions/Video/Video'; import { useToggleActive } from '@/hooks/useActive'; import { useButtonProps } from '@/hooks/useButtonProps'; import { useExtension } from '@/hooks/useExtension'; import { useLocale } from '@/locales'; import { useEditorInstance } from '@/store/editor'; import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; +import { validateFiles } from '@/utils/validateFile'; export function RichTextVideo() { const { t } = useLocale(); @@ -52,25 +53,48 @@ export function RichTextVideo() { event.target.value = ''; return; } - const file = files[0]; + const validFiles = validateFiles(Array.from(files), { + acceptMimes: uploadOptions.acceptMimes ?? DEFAULT_VIDEO_OPTIONS.acceptMimes, + maxSize: uploadOptions.maxSize ?? Number.POSITIVE_INFINITY, + t, + toast, + onError: uploadOptions.onError, + }); + + if (validFiles.length === 0) { + event.target.value = ''; + return; + } + + const filesToUpload = + (uploadOptions.multiple ?? DEFAULT_VIDEO_OPTIONS.multiple) + ? validFiles + : validFiles.slice(0, 1); setIsUploading(true); try { - let src = ''; - if (uploadOptions.upload) { - src = await uploadOptions.upload(file); - } else { - src = URL.createObjectURL(file); + const srcs = await Promise.all( + filesToUpload.map((file) => { + return uploadOptions.upload + ? uploadOptions.upload(file) + : Promise.resolve(URL.createObjectURL(file)); + }) + ); + + if (editor.isDestroyed) { + return; } - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); + srcs.forEach((src) => { + editor + .chain() + .focus() + .setVideo({ + src, + width: '100%', + }) + .run(); + }); setOpen(false); } catch (error) { console.error('Error uploading video', error); @@ -78,7 +102,6 @@ export function RichTextVideo() { uploadOptions.onError({ type: 'upload', message: t('editor.upload.error'), - file, }); } else { toast({ @@ -117,7 +140,16 @@ export function RichTextVideo() { } return ( - + { + if (isUploading && !nextOpen) { + return; + } + + setOpen(nextOpen); + }} + open={open} + > Date: Sun, 26 Jul 2026 20:52:12 +0800 Subject: [PATCH 3/4] feat(video): add real upload progress tracking --- docs/extensions/Video/index.md | 110 ++++-- .../RenderDialogUploadVideo.tsx | 127 +------ src/extensions/Video/Video.ts | 23 +- .../Video/components/RichTextVideo.tsx | 119 +----- .../Video/components/VideoUploadTab.tsx | 353 ++++++++++++++++++ src/locales/en.ts | 1 + src/locales/fi.ts | 1 + src/locales/hu.ts | 1 + src/locales/ja.ts | 1 + src/locales/pt-br.ts | 1 + src/locales/vi.ts | 1 + src/locales/zh-cn.ts | 1 + 12 files changed, 472 insertions(+), 267 deletions(-) create mode 100644 src/extensions/Video/components/VideoUploadTab.tsx diff --git a/docs/extensions/Video/index.md b/docs/extensions/Video/index.md index ce277a70..0c6e563c 100644 --- a/docs/extensions/Video/index.md +++ b/docs/extensions/Video/index.md @@ -49,23 +49,9 @@ const extensions = [ resourceVideo: 'both',// [!code ++] acceptMimes: ['video/mp4', 'video/webm'],// [!code ++] maxSize: 100 * 1024 * 1024,// [!code ++] - multiple: false,// [!code ++] - upload: async (file) => {// [!code ++] - const formData = new FormData();// [!code ++] - formData.append('file', file);// [!code ++] -// [!code ++] - const response = await fetch('/api/videos', {// [!code ++] - method: 'POST',// [!code ++] - body: formData,// [!code ++] - });// [!code ++] -// [!code ++] - if (!response.ok) {// [!code ++] - throw new Error('Video upload failed');// [!code ++] - }// [!code ++] -// [!code ++] - const { url } = await response.json();// [!code ++] - return url;// [!code ++] - },// [!code ++] + multiple: true,// [!code ++] + uploadConcurrency: 3,// [!code ++] + upload: (file, { onProgress } = {}) => uploadVideo(file, onProgress),// [!code ++] onError: ({ message, file }) => {// [!code ++] console.error(message, file?.name);// [!code ++] },// [!code ++] @@ -98,9 +84,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((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 { /** * Indicates whether fullscreen play is allowed @@ -125,11 +153,14 @@ interface VideoOptions extends GeneralOptions { [key: string]: any; }; /** Function for uploading files */ - upload?: (file: File) => Promise; + upload?: (file: File, context?: VideoUploadContext) => Promise; /** Whether multiple videos can be selected and uploaded at once */ multiple?: boolean; + /** Maximum number of videos uploaded concurrently */ + uploadConcurrency?: number; + /** Accepted video MIME types or file extensions */ acceptMimes?: string[]; @@ -154,29 +185,34 @@ interface VideoOptions extends GeneralOptions { ## 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | -| `upload` | `(file: File) => Promise` | Uploads a local video and resolves with the URL inserted into the editor. | No | None | -| `multiple` | `boolean` | Allows selecting and uploading multiple videos. | 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 | `['.']` | +| 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | +| `upload` | `(file: File, context?: VideoUploadContext) => Promise` | 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` | +| `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, disable the upload button, and show a localized loading indicator. When the promise resolves, -the returned URL is inserted into the editor and the dialog closes. +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. + +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 the promise rejects, no video is inserted. The dialog remains open so the user can retry the same -file. Configure `onError` to provide custom error handling; otherwise, the editor shows its default -upload error toast. +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 upload in parallel and are inserted in selection order after every upload succeeds. +valid files are queued and up to `uploadConcurrency` files upload at once. diff --git a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx index 9f3842a5..aae6319a 100644 --- a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx +++ b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx @@ -1,29 +1,19 @@ -import { type ChangeEvent, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; -import { - Button, - IconComponent, - Input, - Tabs, - TabsContent, - TabsList, - TabsTrigger, - useToast, -} from '@/components'; +import { Button, Input, Tabs, TabsContent, TabsList, TabsTrigger } from '@/components'; import { useListener } from '@/components/ReactBus'; import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; -import { DEFAULT_VIDEO_OPTIONS, Video } from '@/extensions/Video/Video'; +import { VideoUploadTab } from '@/extensions/Video/components/VideoUploadTab'; +import { Video } from '@/extensions/Video/Video'; import { useToggleActive } from '@/hooks/useActive'; import { useExtension } from '@/hooks/useExtension'; import { useLocale } from '@/locales'; import { useEditorInstance } from '@/store/editor'; import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; import { EVENTS } from '@/utils/customEvents/events.constant'; -import { validateFiles } from '@/utils/validateFile'; export function RenderDialogUploadVideo() { const { t } = useLocale(); - const { toast } = useToast(); const editor = useEditorInstance(); // const buttonProps = useButtonProps(Video.name); @@ -36,7 +26,6 @@ export function RenderDialogUploadVideo() { const { editorDisabled } = useToggleActive(); const [link, setLink] = useState(''); - const fileInput = useRef(null); const [error, setError] = useState(''); @@ -54,73 +43,6 @@ export function RenderDialogUploadVideo() { return uploadOptions; }, [extension]); - async function handleFile(event: ChangeEvent) { - const files = event.target.files; - if (!editor || editor.isDestroyed || !files?.length || isUploading) { - event.target.value = ''; - return; - } - const validFiles = validateFiles(Array.from(files), { - acceptMimes: uploadOptions.acceptMimes ?? DEFAULT_VIDEO_OPTIONS.acceptMimes, - maxSize: uploadOptions.maxSize ?? Number.POSITIVE_INFINITY, - t, - toast, - onError: uploadOptions.onError, - }); - - if (validFiles.length === 0) { - event.target.value = ''; - return; - } - - const filesToUpload = - (uploadOptions.multiple ?? DEFAULT_VIDEO_OPTIONS.multiple) - ? validFiles - : validFiles.slice(0, 1); - - setIsUploading(true); - try { - const srcs = await Promise.all( - filesToUpload.map((file) => { - return uploadOptions.upload - ? uploadOptions.upload(file) - : Promise.resolve(URL.createObjectURL(file)); - }) - ); - - if (editor.isDestroyed) { - return; - } - - srcs.forEach((src) => { - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); - }); - setOpen(false); - } catch (error) { - console.error('Error uploading video', error); - if (uploadOptions.onError) { - uploadOptions.onError({ - type: 'upload', - message: t('editor.upload.error'), - }); - } else { - toast({ - variant: 'destructive', - title: t('editor.upload.error'), - }); - } - } finally { - setIsUploading(false); - event.target.value = ''; - } - } function handleLink(e: any) { e.preventDefault(); e.stopPropagation(); @@ -141,11 +63,6 @@ export function RenderDialogUploadVideo() { setLink(''); } - function handleClick(e: any) { - e.preventDefault(); - fileInput.current?.click(); - } - if (editorDisabled) { return <>; } @@ -185,37 +102,11 @@ export function RenderDialogUploadVideo() { -
- -
- - setOpen(false)} + onUploadingChange={setIsUploading} + uploadOptions={uploadOptions} />
diff --git a/src/extensions/Video/Video.ts b/src/extensions/Video/Video.ts index 53c8cdac..599d47be 100644 --- a/src/extensions/Video/Video.ts +++ b/src/extensions/Video/Video.ts @@ -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. */ @@ -34,11 +46,14 @@ export interface VideoOptions extends GeneralOptions { [key: string]: any; }; /** Function for uploading files */ - upload?: (file: File) => Promise; + upload?: (file: File, context?: VideoUploadContext) => Promise; /** Whether multiple videos can be selected and uploaded at once */ multiple?: boolean; + /** Maximum number of videos uploaded concurrently */ + uploadConcurrency?: number; + /** Accepted video MIME types or file extensions */ acceptMimes?: string[]; @@ -64,8 +79,12 @@ export const DEFAULT_VIDEO_OPTIONS = { acceptMimes: ['video/*'], multiple: true, resourceVideo: 'both', + uploadConcurrency: 3, videoProviders: ['.'], -} satisfies Pick; +} satisfies Pick< + VideoOptions, + 'acceptMimes' | 'multiple' | 'resourceVideo' | 'uploadConcurrency' | 'videoProviders' +>; /** * Represents the type for setting video options diff --git a/src/extensions/Video/components/RichTextVideo.tsx b/src/extensions/Video/components/RichTextVideo.tsx index edcb52ec..41501db6 100644 --- a/src/extensions/Video/components/RichTextVideo.tsx +++ b/src/extensions/Video/components/RichTextVideo.tsx @@ -1,29 +1,27 @@ -import { type ChangeEvent, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { ActionButton, Button, - IconComponent, Input, Tabs, TabsContent, TabsList, TabsTrigger, - useToast, } from '@/components'; import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; -import { DEFAULT_VIDEO_OPTIONS, Video } from '@/extensions/Video/Video'; +import { Video } from '@/extensions/Video/Video'; import { useToggleActive } from '@/hooks/useActive'; import { useButtonProps } from '@/hooks/useButtonProps'; import { useExtension } from '@/hooks/useExtension'; import { useLocale } from '@/locales'; import { useEditorInstance } from '@/store/editor'; import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; -import { validateFiles } from '@/utils/validateFile'; + +import { VideoUploadTab } from './VideoUploadTab'; export function RichTextVideo() { const { t } = useLocale(); - const { toast } = useToast(); const editor = useEditorInstance(); const buttonProps = useButtonProps(Video.name); @@ -33,7 +31,6 @@ export function RichTextVideo() { const { editorDisabled } = useToggleActive(); const [link, setLink] = useState(''); - const fileInput = useRef(null); const [error, setError] = useState(''); @@ -47,73 +44,6 @@ export function RichTextVideo() { return uploadOptions; }, [extension]); - async function handleFile(event: ChangeEvent) { - const files = event.target.files; - if (!editor || editor.isDestroyed || !files?.length || isUploading) { - event.target.value = ''; - return; - } - const validFiles = validateFiles(Array.from(files), { - acceptMimes: uploadOptions.acceptMimes ?? DEFAULT_VIDEO_OPTIONS.acceptMimes, - maxSize: uploadOptions.maxSize ?? Number.POSITIVE_INFINITY, - t, - toast, - onError: uploadOptions.onError, - }); - - if (validFiles.length === 0) { - event.target.value = ''; - return; - } - - const filesToUpload = - (uploadOptions.multiple ?? DEFAULT_VIDEO_OPTIONS.multiple) - ? validFiles - : validFiles.slice(0, 1); - - setIsUploading(true); - try { - const srcs = await Promise.all( - filesToUpload.map((file) => { - return uploadOptions.upload - ? uploadOptions.upload(file) - : Promise.resolve(URL.createObjectURL(file)); - }) - ); - - if (editor.isDestroyed) { - return; - } - - srcs.forEach((src) => { - editor - .chain() - .focus() - .setVideo({ - src, - width: '100%', - }) - .run(); - }); - setOpen(false); - } catch (error) { - console.error('Error uploading video', error); - if (uploadOptions.onError) { - uploadOptions.onError({ - type: 'upload', - message: t('editor.upload.error'), - }); - } else { - toast({ - variant: 'destructive', - title: t('editor.upload.error'), - }); - } - } finally { - setIsUploading(false); - event.target.value = ''; - } - } function handleLink(e: any) { e.preventDefault(); e.stopPropagation(); @@ -134,11 +64,6 @@ export function RichTextVideo() { setLink(''); } - function handleClick(e: any) { - e.preventDefault(); - fileInput.current?.click(); - } - return ( { @@ -186,37 +111,11 @@ export function RichTextVideo() { -
- -
- - setOpen(false)} + onUploadingChange={setIsUploading} + uploadOptions={uploadOptions} />
diff --git a/src/extensions/Video/components/VideoUploadTab.tsx b/src/extensions/Video/components/VideoUploadTab.tsx new file mode 100644 index 00000000..595e21bf --- /dev/null +++ b/src/extensions/Video/components/VideoUploadTab.tsx @@ -0,0 +1,353 @@ +import { type ChangeEvent, useMemo, useRef, useState } from 'react'; + +import { Button, IconComponent, useToast } from '@/components'; +import { + DEFAULT_VIDEO_OPTIONS, + type VideoOptions, + type VideoUploadProgress, +} from '@/extensions/Video/Video'; +import { useLocale } from '@/locales'; +import { validateFiles } from '@/utils/validateFile'; + +import type { Editor } from '@tiptap/core'; + +type UploadStatus = 'error' | 'pending' | 'processing' | 'success' | 'uploading'; + +interface UploadItem { + file: File; + loaded: number; + progressReported: boolean; + status: UploadStatus; + total: number; +} + +interface VideoUploadTabProps { + editor: Editor | null; + onUploadComplete: () => void; + onUploadingChange: (isUploading: boolean) => void; + uploadOptions: VideoOptions; +} + +function normalizeProgress(progress: VideoUploadProgress, file: File) { + const fallbackTotal = file.size || 1; + const total = + Number.isFinite(progress.total) && progress.total > 0 ? progress.total : fallbackTotal; + const loaded = Number.isFinite(progress.loaded) + ? Math.min(Math.max(progress.loaded, 0), total) + : 0; + + return { loaded, total }; +} + +function getConcurrency(value: number | undefined) { + if (!Number.isFinite(value)) { + return DEFAULT_VIDEO_OPTIONS.uploadConcurrency; + } + + return Math.max(1, Math.floor(value as number)); +} + +export function VideoUploadTab({ + editor, + onUploadComplete, + onUploadingChange, + uploadOptions, +}: VideoUploadTabProps) { + const { t } = useLocale(); + const { toast } = useToast(); + const fileInput = useRef(null); + const [isUploading, setIsUploading] = useState(false); + const [uploadItems, setUploadItems] = useState([]); + + const successfulCount = useMemo( + () => uploadItems.filter(({ status }) => status === 'success').length, + [uploadItems] + ); + const hasMeasuredProgress = useMemo(() => { + const hasProgress = uploadItems.some(({ progressReported }) => progressReported); + const activeUploadsAreMeasured = uploadItems.every( + ({ progressReported, status }) => status !== 'uploading' || progressReported + ); + + return hasProgress && activeUploadsAreMeasured; + }, [uploadItems]); + const totalProgress = useMemo(() => { + const totalBytes = uploadItems.reduce((total, item) => total + item.total, 0); + if (totalBytes === 0) { + return 0; + } + + const loadedBytes = uploadItems.reduce((total, item) => { + return total + (item.status === 'success' ? item.total : item.loaded); + }, 0); + + return Math.round((loadedBytes / totalBytes) * 100); + }, [uploadItems]); + + function updateUploadItem(index: number, update: Partial) { + setUploadItems((items) => + items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...update } : item)) + ); + } + + function reportUploadError(file: File) { + const message = t('editor.upload.error'); + + if (uploadOptions.onError) { + try { + uploadOptions.onError({ + type: 'upload', + message, + file, + }); + } catch (error) { + console.error('Error in video upload error handler', error); + } + return; + } + + toast({ + variant: 'destructive', + title: message, + }); + } + + async function handleFile(event: ChangeEvent) { + const files = event.target.files; + if (!editor || editor.isDestroyed || !files?.length || isUploading) { + event.target.value = ''; + return; + } + + const validFiles = validateFiles(Array.from(files), { + acceptMimes: uploadOptions.acceptMimes ?? DEFAULT_VIDEO_OPTIONS.acceptMimes, + maxSize: uploadOptions.maxSize ?? Number.POSITIVE_INFINITY, + t, + toast, + onError: uploadOptions.onError, + }); + + if (validFiles.length === 0) { + event.target.value = ''; + return; + } + + const filesToUpload = + (uploadOptions.multiple ?? DEFAULT_VIDEO_OPTIONS.multiple) + ? validFiles + : validFiles.slice(0, 1); + const results: Array = Array.from({ length: filesToUpload.length }); + + setUploadItems( + filesToUpload.map((file) => ({ + file, + loaded: 0, + progressReported: false, + status: 'pending', + total: file.size || 1, + })) + ); + setIsUploading(true); + onUploadingChange(true); + + let nextIndex = 0; + const uploadNext = async () => { + while (nextIndex < filesToUpload.length) { + const index = nextIndex; + nextIndex += 1; + const file = filesToUpload[index]; + let uploadTotal = file.size || 1; + + updateUploadItem(index, { status: 'uploading' }); + + try { + const src = uploadOptions.upload + ? await uploadOptions.upload(file, { + onProgress: (progress) => { + const normalized = normalizeProgress(progress, file); + uploadTotal = normalized.total; + updateUploadItem(index, { + ...normalized, + progressReported: true, + status: normalized.loaded >= normalized.total ? 'processing' : 'uploading', + }); + }, + }) + : URL.createObjectURL(file); + + results[index] = src; + updateUploadItem(index, { + loaded: uploadTotal, + status: 'success', + total: uploadTotal, + }); + } catch (error) { + console.error('Error uploading video', error); + updateUploadItem(index, { status: 'error' }); + reportUploadError(file); + } + } + }; + + try { + const concurrency = Math.min( + getConcurrency(uploadOptions.uploadConcurrency), + filesToUpload.length + ); + await Promise.all(Array.from({ length: concurrency }, () => uploadNext())); + + if (editor.isDestroyed) { + return; + } + + results.forEach((src) => { + if (!src) { + return; + } + + editor.chain().focus().setVideo({ src, width: '100%' }).run(); + }); + + if (results.every(Boolean)) { + onUploadComplete(); + } + } finally { + setIsUploading(false); + onUploadingChange(false); + event.target.value = ''; + } + } + + return ( + <> +
+ +
+ + {uploadItems.length > 0 && ( +
+
+ + {isUploading + ? t('editor.video.dialog.uploading') + : `${successfulCount}/${uploadItems.length}`} + + {isUploading && hasMeasuredProgress && {totalProgress}%} +
+ + {isUploading && hasMeasuredProgress && ( +
+
+
+ )} + +
+ {uploadItems.map((item, index) => { + const progress = item.total > 0 ? Math.round((item.loaded / item.total) * 100) : 0; + const isIndeterminate = + (item.status === 'pending' || item.status === 'uploading') && + !item.progressReported; + const statusText = + item.status === 'error' + ? t('editor.upload.error') + : item.status === 'processing' + ? t('editor.video.dialog.processing') + : item.status === 'success' + ? '100%' + : item.progressReported + ? `${progress}%` + : t('editor.video.dialog.uploading'); + + return ( +
+
+ + {item.file.name} + + + {statusText} + {(isIndeterminate || item.status === 'processing') && ( + + )} + +
+ + {(item.progressReported || item.status === 'success') && ( +
+
+
+ )} +
+ ); + })} +
+
+ )} + + + + ); +} diff --git a/src/locales/en.ts b/src/locales/en.ts index a15eb154..9fd2e0b8 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': 'Video', 'editor.video.dialog.tab.upload': 'Upload', 'editor.video.dialog.uploading': 'Uploading', + 'editor.video.dialog.processing': 'Processing', 'editor.video.dialog.title': 'Embed or upload a video', 'editor.video.dialog.link': 'Link', 'editor.video.dialog.placeholder': 'Link', diff --git a/src/locales/fi.ts b/src/locales/fi.ts index dcb38d7e..7c2fdf1f 100644 --- a/src/locales/fi.ts +++ b/src/locales/fi.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': 'Video', 'editor.video.dialog.tab.upload': 'Lataa', 'editor.video.dialog.uploading': 'Ladataan', + 'editor.video.dialog.processing': 'Käsitellään', 'editor.video.dialog.title': 'Upota tai lataa video', 'editor.video.dialog.link': 'Linkki', 'editor.video.dialog.placeholder': 'Linkki', diff --git a/src/locales/hu.ts b/src/locales/hu.ts index f4d0f407..6b1c922c 100644 --- a/src/locales/hu.ts +++ b/src/locales/hu.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': 'Videó', 'editor.video.dialog.tab.upload': 'Feltöltés', 'editor.video.dialog.uploading': 'Feltöltés alatt', + 'editor.video.dialog.processing': 'Feldolgozás', 'editor.video.dialog.title': 'Videó beágyazása vagy feltöltése', 'editor.video.dialog.link': 'Link', 'editor.video.dialog.placeholder': 'Link', diff --git a/src/locales/ja.ts b/src/locales/ja.ts index 85292f81..5996f70c 100644 --- a/src/locales/ja.ts +++ b/src/locales/ja.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': '動画', 'editor.video.dialog.tab.upload': 'アップロード', 'editor.video.dialog.uploading': 'アップロード中', + 'editor.video.dialog.processing': '処理中', 'editor.video.dialog.title': '動画を埋め込むかアップロード', 'editor.video.dialog.link': 'リンク', 'editor.video.dialog.placeholder': 'リンク', diff --git a/src/locales/pt-br.ts b/src/locales/pt-br.ts index 6865177b..7cebb1fe 100644 --- a/src/locales/pt-br.ts +++ b/src/locales/pt-br.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': 'Vídeo', 'editor.video.dialog.tab.upload': 'Enviar', 'editor.video.dialog.uploading': 'Enviando', + 'editor.video.dialog.processing': 'Processando', 'editor.video.dialog.title': 'Incorporar ou enviar um vídeo', 'editor.video.dialog.link': 'Link', 'editor.video.dialog.placeholder': 'Link', diff --git a/src/locales/vi.ts b/src/locales/vi.ts index eca70acd..dcc503d8 100644 --- a/src/locales/vi.ts +++ b/src/locales/vi.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.tooltip': 'Video', 'editor.video.dialog.tab.upload': 'Tải lên', 'editor.video.dialog.uploading': 'Đang tải lên', + 'editor.video.dialog.processing': 'Đang xử lý', 'editor.video.dialog.title': 'Nhúng hoặc tải lên video', 'editor.video.dialog.link': 'Liên kết', 'editor.video.dialog.placeholder': 'Liên kết', diff --git a/src/locales/zh-cn.ts b/src/locales/zh-cn.ts index 0ba49b31..6abe6b9f 100644 --- a/src/locales/zh-cn.ts +++ b/src/locales/zh-cn.ts @@ -93,6 +93,7 @@ const locale = { 'editor.video.dialog.tab.upload': '上传', 'editor.image.dialog.tab.uploadCrop': '上传并裁剪', 'editor.video.dialog.uploading': '上传中', + 'editor.video.dialog.processing': '处理中', 'editor.video.dialog.title': '嵌入或上传视频', 'editor.video.dialog.link': '链接', 'editor.video.dialog.placeholder': '链接', From 284c88a8a77e7a28d902b6d9b4c7400f9c723dd5 Mon Sep 17 00:00:00 2001 From: Found-L <50953254+Found-L@users.noreply.github.com> Date: Sun, 26 Jul 2026 21:26:27 +0800 Subject: [PATCH 4/4] feat(video): add upload progress visibility option --- docs/extensions/Video/index.md | 39 ++++++++++++------- src/extensions/Video/Video.ts | 15 ++++++- .../Video/components/VideoUploadTab.tsx | 28 +++++++------ 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/docs/extensions/Video/index.md b/docs/extensions/Video/index.md index 0c6e563c..bdd4954d 100644 --- a/docs/extensions/Video/index.md +++ b/docs/extensions/Video/index.md @@ -51,6 +51,7 @@ const extensions = [ 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 ++] @@ -161,6 +162,13 @@ interface VideoOptions extends GeneralOptions { /** 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[]; @@ -185,20 +193,21 @@ interface VideoOptions extends GeneralOptions { ## 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | -| `upload` | `(file: File, context?: VideoUploadContext) => Promise` | 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` | -| `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 | `['.']` | +| 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` | Adds HTML attributes to the video wrapper. | No | `{ class: 'iframe-wrapper' }` | +| `upload` | `(file: File, context?: VideoUploadContext) => Promise` | 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 @@ -206,6 +215,8 @@ While the `upload` promise is pending, both the toolbar dialog and the slash-com 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. diff --git a/src/extensions/Video/Video.ts b/src/extensions/Video/Video.ts index 599d47be..c7f3fe67 100644 --- a/src/extensions/Video/Video.ts +++ b/src/extensions/Video/Video.ts @@ -54,6 +54,13 @@ export interface VideoOptions extends GeneralOptions { /** 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[]; @@ -79,11 +86,17 @@ export const DEFAULT_VIDEO_OPTIONS = { acceptMimes: ['video/*'], multiple: true, resourceVideo: 'both', + showUploadProgress: true, uploadConcurrency: 3, videoProviders: ['.'], } satisfies Pick< VideoOptions, - 'acceptMimes' | 'multiple' | 'resourceVideo' | 'uploadConcurrency' | 'videoProviders' + | 'acceptMimes' + | 'multiple' + | 'resourceVideo' + | 'showUploadProgress' + | 'uploadConcurrency' + | 'videoProviders' >; /** diff --git a/src/extensions/Video/components/VideoUploadTab.tsx b/src/extensions/Video/components/VideoUploadTab.tsx index 595e21bf..280bbca9 100644 --- a/src/extensions/Video/components/VideoUploadTab.tsx +++ b/src/extensions/Video/components/VideoUploadTab.tsx @@ -58,6 +58,8 @@ export function VideoUploadTab({ const fileInput = useRef(null); const [isUploading, setIsUploading] = useState(false); const [uploadItems, setUploadItems] = useState([]); + const showUploadProgress = + uploadOptions.showUploadProgress ?? DEFAULT_VIDEO_OPTIONS.showUploadProgress; const successfulCount = useMemo( () => uploadItems.filter(({ status }) => status === 'success').length, @@ -163,15 +165,17 @@ export function VideoUploadTab({ try { const src = uploadOptions.upload ? await uploadOptions.upload(file, { - onProgress: (progress) => { - const normalized = normalizeProgress(progress, file); - uploadTotal = normalized.total; - updateUploadItem(index, { - ...normalized, - progressReported: true, - status: normalized.loaded >= normalized.total ? 'processing' : 'uploading', - }); - }, + onProgress: showUploadProgress + ? (progress) => { + const normalized = normalizeProgress(progress, file); + uploadTotal = normalized.total; + updateUploadItem(index, { + ...normalized, + progressReported: true, + status: normalized.loaded >= normalized.total ? 'processing' : 'uploading', + }); + } + : undefined, }) : URL.createObjectURL(file); @@ -231,9 +235,9 @@ export function VideoUploadTab({ {isUploading ? ( <> {t('editor.video.dialog.uploading')} - {hasMeasuredProgress ? ` ${totalProgress}%` : ''} + {showUploadProgress && hasMeasuredProgress ? ` ${totalProgress}%` : ''} - {!hasMeasuredProgress && ( + {(!showUploadProgress || !hasMeasuredProgress) && ( )} @@ -243,7 +247,7 @@ export function VideoUploadTab({
- {uploadItems.length > 0 && ( + {showUploadProgress && uploadItems.length > 0 && (