diff --git a/docs/extensions/Video/index.md b/docs/extensions/Video/index.md index 9f5d18f0..bdd4954d 100644 --- a/docs/extensions/Video/index.md +++ b/docs/extensions/Video/index.md @@ -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 = () => { @@ -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((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 @@ -101,9 +154,76 @@ 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; + + /** + * 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` | 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 + +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. diff --git a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx index c501f45c..aae6319a 100644 --- a/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx +++ b/src/components/SlashDialogTrigger/RenderDialogUploadVideo.tsx @@ -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'; @@ -25,11 +26,11 @@ export function RenderDialogUploadVideo() { const { editorDisabled } = useToggleActive(); const [link, setLink] = useState(''); - const fileInput = useRef(null); 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,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(); @@ -86,17 +63,21 @@ export function RenderDialogUploadVideo() { setLink(''); } - function handleClick(e: any) { - e.preventDefault(); - fileInput.current?.click(); - } - if (editorDisabled) { return <>; } return ( - + { + if (isUploading && !nextOpen) { + return; + } + + setOpen(nextOpen); + }} + open={open} + > {t('editor.video.dialog.title')} @@ -121,21 +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 d6341070..c7f3fe67 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,7 +46,29 @@ 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; + + /** + * 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'; @@ -48,6 +82,23 @@ export interface VideoOptions extends GeneralOptions { 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 */ @@ -130,8 +181,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', @@ -148,7 +199,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 ee36bef2..41501db6 100644 --- a/src/extensions/Video/components/RichTextVideo.tsx +++ b/src/extensions/Video/components/RichTextVideo.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { ActionButton, @@ -18,6 +18,8 @@ import { useLocale } from '@/locales'; import { useEditorInstance } from '@/store/editor'; import { checkIsVideoUrl } from '@/utils/checkIsVideoUrl'; +import { VideoUploadTab } from './VideoUploadTab'; + export function RichTextVideo() { const { t } = useLocale(); @@ -29,11 +31,11 @@ export function RichTextVideo() { const { editorDisabled } = useToggleActive(); const [link, setLink] = useState(''); - const fileInput = useRef(null); const [error, setError] = useState(''); const [open, setOpen] = useState(false); + const [isUploading, setIsUploading] = useState(false); const extension = useExtension(Video.name); const uploadOptions = useMemo(() => { @@ -42,30 +44,6 @@ export function RichTextVideo() { 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(); @@ -86,13 +64,17 @@ export function RichTextVideo() { setLink(''); } - function handleClick(e: any) { - e.preventDefault(); - fileInput.current?.click(); - } - return ( - + { + if (isUploading && !nextOpen) { + return; + } + + setOpen(nextOpen); + }} + open={open} + > -
- -
- - 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..280bbca9 --- /dev/null +++ b/src/extensions/Video/components/VideoUploadTab.tsx @@ -0,0 +1,357 @@ +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 showUploadProgress = + uploadOptions.showUploadProgress ?? DEFAULT_VIDEO_OPTIONS.showUploadProgress; + + 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: 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); + + 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 ( + <> +
+ +
+ + {showUploadProgress && 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': '链接',