diff --git a/src/application/types.ts b/src/application/types.ts index 9c8edbd69..413313f8e 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -33,6 +33,8 @@ export enum BlockType { DividerBlock = 'divider', ImageBlock = 'image', VideoBlock = 'video', + AudioBlock = 'audio', + GoogleDriveBlock = 'google_drive', GridBlock = 'grid', BoardBlock = 'board', CalendarBlock = 'calendar', @@ -172,6 +174,32 @@ export interface VideoBlockData extends BlockData { name?: string; } +export enum AudioUrlType { + Local = 'local', + Network = 'network', + Cloud = 'cloud', +} + +export interface AudioBlockData extends BlockData { + url?: string; + url_type?: AudioUrlType | string; + name?: string; + uploaded_at?: number; + uploaded_by?: string; + duration_in_second?: number; + retry_local_url?: string; + pending_upload_id?: string; +} + +export interface GoogleDriveBlockData extends BlockData { + url?: string; + name?: string; + email?: string; + uploaded_at?: number; + width_factor?: number; + height_factor?: number; +} + export interface AIMeetingBlockData extends BlockData { title?: string; date?: string | number; diff --git a/src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx b/src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx new file mode 100644 index 000000000..d1300c25d --- /dev/null +++ b/src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx @@ -0,0 +1,221 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { AudioBlockData, AudioUrlType, BlockType } from '@/application/types'; +import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; +import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; +import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { FileHandler } from '@/utils/file'; +import { createPendingUploadId } from '@/utils/pending-upload'; +import { processUrl } from '@/utils/url'; + +const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.aac', '.wma', '.alac', '.aiff', '.m4a']; +const AUDIO_EXTENSION_REGEX = /\.(mp3|wav|ogg|flac|aac|wma|alac|aiff|m4a)($|\?)/i; + +function getAudioName(rawUrl: string) { + try { + const url = new URL(rawUrl); + const name = url.pathname.split('/').filter(Boolean).pop(); + + return name || rawUrl; + } catch { + return rawUrl; + } +} + +function isAudioUrl(rawUrl: string) { + const url = processUrl(rawUrl) || rawUrl; + + return AUDIO_EXTENSION_REGEX.test(url); +} + +function AudioBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) { + const editor = useSlateStatic() as YjsEditor; + const { uploadFile } = useEditorContext(); + const { t } = useTranslation(); + const [tabValue, setTabValue] = React.useState('upload'); + const [uploading, setUploading] = React.useState(false); + const entry = useMemo(() => { + try { + return findSlateEntryByBlockId(editor, blockId); + } catch { + return null; + } + }, [blockId, editor]); + + const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => { + setTabValue(newValue); + }, []); + + const handleInsertEmbedLink = useCallback( + (rawUrl: string) => { + const url = processUrl(rawUrl) || rawUrl; + + CustomEditor.setBlockData(editor, blockId, { + url, + name: getAudioName(url), + uploaded_at: Date.now(), + url_type: AudioUrlType.Network, + } as AudioBlockData); + onClose(); + }, + [blockId, editor, onClose] + ); + + const uploadFileRemote = useCallback( + async (file: File) => { + try { + return await uploadFile?.(file); + } catch { + return undefined; + } + }, + [uploadFile] + ); + + const createPendingAudioData = useCallback(async (file: File): Promise => { + const data: AudioBlockData = { + url: undefined, + name: file.name, + uploaded_at: Date.now(), + url_type: AudioUrlType.Cloud, + pending_upload_id: createPendingUploadId(), + }; + + try { + const fileHandler = new FileHandler(); + const res = await fileHandler.handleFileUpload(file); + + URL.revokeObjectURL(res.url); + data.retry_local_url = res.id; + } catch { + data.retry_local_url = ''; + } + + return data; + }, []); + + const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => { + if (!retryLocalUrl) return; + + const fileHandler = new FileHandler(); + + await fileHandler.cleanup(retryLocalUrl).catch(() => undefined); + }, []); + + const uploadIntoAudioBlock = useCallback( + async (targetBlockId: string, file: File, pendingData: AudioBlockData) => { + const url = await uploadFileRemote(file); + + if (!url) return; + + await cleanupLocalFile(pendingData.retry_local_url); + + let currentData: AudioBlockData | undefined; + + try { + const entry = findSlateEntryByBlockId(editor, targetBlockId); + + currentData = entry ? (entry[0] as { data?: AudioBlockData }).data ?? undefined : undefined; + } catch { + return; + } + + if (!currentData) return; + if (currentData.url) return; + if (!pendingData.pending_upload_id || currentData.pending_upload_id !== pendingData.pending_upload_id) return; + + CustomEditor.setBlockData(editor, targetBlockId, { + url, + name: file.name, + uploaded_at: Date.now(), + url_type: AudioUrlType.Cloud, + retry_local_url: '', + pending_upload_id: '', + } as AudioBlockData); + }, + [cleanupLocalFile, editor, uploadFileRemote] + ); + + const handleChangeUploadFiles = useCallback( + async (files: File[]) => { + if (!files.length) return; + + setUploading(true); + try { + const [primaryData, ...otherDatas] = await Promise.all(files.map((file) => createPendingAudioData(file))); + const [file, ...otherFiles] = files; + + CustomEditor.setBlockData(editor, blockId, primaryData); + + const pendingUploads: Promise[] = [uploadIntoAudioBlock(blockId, file, primaryData)]; + const reversedPairs = otherFiles.map((f, i) => [f, otherDatas[i]] as const).reverse(); + + for (const [f, data] of reversedPairs) { + const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.AudioBlock, data); + + if (newId) { + pendingUploads.push(uploadIntoAudioBlock(newId, f, data)); + } + } + + onClose(); + await Promise.all(pendingUploads); + } finally { + setUploading(false); + } + }, + [blockId, createPendingAudioData, editor, onClose, uploadIntoAudioBlock] + ); + + const defaultLink = useMemo(() => { + return (entry?.[0]?.data as AudioBlockData | undefined)?.url; + }, [entry]); + const selectedIndex = tabValue === 'upload' ? 0 : 1; + + return ( +
+ + + + +
+ + + {t('document.plugins.audio.uploadHint', { + defaultValue: 'Click to upload or drag and drop audio files', + })} + {t('document.plugins.file.fileUploadHintSuffix')} + + } + onChange={handleChangeUploadFiles} + loading={uploading} + /> + + + + +
+
+ ); +} + +export default AudioBlockPopoverContent; diff --git a/src/components/editor/components/block-popover/BlockPopoverContext.tsx b/src/components/editor/components/block-popover/BlockPopoverContext.tsx index c94318348..8e11eda25 100644 --- a/src/components/editor/components/block-popover/BlockPopoverContext.tsx +++ b/src/components/editor/components/block-popover/BlockPopoverContext.tsx @@ -1,4 +1,4 @@ -import React, { createContext, useState, useCallback, useContext, useMemo } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { ReactEditor } from 'slate-react'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; @@ -10,7 +10,8 @@ export interface BlockPopoverContextType { anchorEl?: HTMLElement | null; open: boolean; close: () => void; - openPopover: (blockId: string, type: BlockType, anchorEl: HTMLElement) => void; + openPopover: (blockId: string, type: BlockType, anchorEl?: HTMLElement | null) => void; + notifyMount: (blockId: string) => void; isOpen: (type: BlockType) => boolean; } @@ -26,47 +27,90 @@ export function usePopoverContext() { return context; } +export function usePopoverMountSignal(blockId: string | undefined) { + const { notifyMount } = usePopoverContext(); + + useEffect(() => { + if (!blockId) return; + notifyMount(blockId); + }, [blockId, notifyMount]); +} + export const BlockPopoverProvider = ({ children, editor }: { children: React.ReactNode; editor: ReactEditor }) => { const [type, setType] = useState(); const [blockId, setBlockId] = useState(); const [anchorEl, setAnchorEl] = useState(null); + const pendingRef = useRef<{ blockId: string; type: BlockType } | null>(null); const open = Boolean(anchorEl); const close = useCallback(() => { setAnchorEl(null); setBlockId(undefined); setType(undefined); + pendingRef.current = null; }, []); - const openPopover = useCallback((blockId: string, type: BlockType) => { - const entry = findSlateEntryByBlockId(editor, blockId); + const resolveAnchor = useCallback( + (targetBlockId: string): HTMLElement | null => { + const entry = findSlateEntryByBlockId(editor, targetBlockId); - if (!entry) { - console.error('Block not found'); - return; - } + if (!entry) return null; - const [node] = entry; - const dom = ReactEditor.toDOMNode(editor, node); + try { + return ReactEditor.toDOMNode(editor, entry[0]); + } catch { + return null; + } + }, + [editor] + ); - setBlockId(blockId); - setType(type); - setAnchorEl(dom); - }, [editor]); + const openPopover = useCallback( + (targetBlockId: string, targetType: BlockType) => { + const dom = resolveAnchor(targetBlockId); + + if (dom) { + pendingRef.current = null; + setBlockId(targetBlockId); + setType(targetType); + setAnchorEl(dom); + return; + } + + pendingRef.current = { blockId: targetBlockId, type: targetType }; + }, + [resolveAnchor] + ); - const isOpen = useCallback((popover: BlockType) => { - return popover === type; - }, [type]); + const notifyMount = useCallback( + (mountedBlockId: string) => { + const pending = pendingRef.current; - const contextValue = useMemo( - () => ({ blockId, type, anchorEl, open, close, openPopover, isOpen }), - [blockId, type, anchorEl, open, close, openPopover, isOpen] + if (!pending || pending.blockId !== mountedBlockId) return; + + const dom = resolveAnchor(pending.blockId); + + if (!dom) return; + + pendingRef.current = null; + setBlockId(pending.blockId); + setType(pending.type); + setAnchorEl(dom); + }, + [resolveAnchor] ); - return ( - - {children} - + const isOpen = useCallback( + (popover: BlockType) => { + return popover === type; + }, + [type] + ); + + const contextValue = useMemo( + () => ({ blockId, type, anchorEl, open, close, openPopover, notifyMount, isOpen }), + [blockId, type, anchorEl, open, close, openPopover, notifyMount, isOpen] ); + return {children}; }; diff --git a/src/components/editor/components/block-popover/GalleryBlockPopoverContent.tsx b/src/components/editor/components/block-popover/GalleryBlockPopoverContent.tsx new file mode 100644 index 000000000..31fbdbf03 --- /dev/null +++ b/src/components/editor/components/block-popover/GalleryBlockPopoverContent.tsx @@ -0,0 +1,124 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { GalleryBlockData, GalleryLayout, ImageType } from '@/application/types'; +import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; +import { ALLOWED_IMAGE_EXTENSIONS, Unsplash } from '@/components/_shared/image-upload'; +import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; +import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { processUrl } from '@/utils/url'; + +function GalleryBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) { + const editor = useSlateStatic() as YjsEditor; + const { uploadFile } = useEditorContext(); + const { t } = useTranslation(); + const [tabValue, setTabValue] = React.useState('upload'); + const [uploading, setUploading] = React.useState(false); + const entry = useMemo(() => { + try { + return findSlateEntryByBlockId(editor, blockId); + } catch { + return null; + } + }, [blockId, editor]); + const data = entry?.[0]?.data as GalleryBlockData | undefined; + + const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => { + setTabValue(newValue); + }, []); + + const appendImages = useCallback( + (images: GalleryBlockData['images']) => { + if (!images.length) return; + + CustomEditor.setBlockData(editor, blockId, { + images: [...(data?.images ?? []), ...images], + layout: data?.layout ?? GalleryLayout.Carousel, + } as GalleryBlockData); + onClose(); + }, + [blockId, data?.images, data?.layout, editor, onClose] + ); + + const handleChangeUploadFiles = useCallback( + async (files: File[]) => { + if (!files.length) return; + + setUploading(true); + try { + const uploadedImages = ( + await Promise.all( + files.map(async (file) => { + try { + const url = await uploadFile?.(file); + + return url + ? { + url, + type: ImageType.External, + } + : null; + } catch { + return null; + } + }) + ) + ).filter(Boolean) as GalleryBlockData['images']; + + appendImages(uploadedImages); + } finally { + setUploading(false); + } + }, + [appendImages, uploadFile] + ); + + const handleInsertEmbedLink = useCallback( + (url: string, type?: ImageType) => { + const resolvedType = type ?? ImageType.External; + const normalizedUrl = resolvedType === ImageType.External ? processUrl(url) || url : url; + + appendImages([{ url: normalizedUrl, type: resolvedType }]); + }, + [appendImages] + ); + + const selectedIndex = tabValue === 'upload' ? 0 : tabValue === 'embed' ? 1 : 2; + + return ( +
+ + + + + +
+ + + + + + + + + +
+
+ ); +} + +export default GalleryBlockPopoverContent; diff --git a/src/components/editor/components/block-popover/GoogleDriveBlockPopoverContent.tsx b/src/components/editor/components/block-popover/GoogleDriveBlockPopoverContent.tsx new file mode 100644 index 000000000..e5f9b5d04 --- /dev/null +++ b/src/components/editor/components/block-popover/GoogleDriveBlockPopoverContent.tsx @@ -0,0 +1,63 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { GoogleDriveBlockData } from '@/application/types'; +import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; +import { + isGoogleDriveUrl, + resolveGoogleDriveName, +} from '@/components/editor/components/blocks/google-drive/google-drive-utils'; +import { processUrl } from '@/utils/url'; + +function GoogleDriveBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) { + const editor = useSlateStatic() as YjsEditor; + const { t } = useTranslation(); + const entry = useMemo(() => { + try { + return findSlateEntryByBlockId(editor, blockId); + } catch { + return null; + } + }, [blockId, editor]); + const data = entry?.[0]?.data as GoogleDriveBlockData | undefined; + + const handleInsertEmbedLink = useCallback( + (rawUrl: string) => { + const url = processUrl(rawUrl) || rawUrl; + + CustomEditor.setBlockData(editor, blockId, { + url, + name: resolveGoogleDriveName(url), + uploaded_at: Date.now(), + width_factor: data?.width_factor ?? 1, + height_factor: data?.height_factor ?? 1, + } as GoogleDriveBlockData); + onClose(); + }, + [blockId, data?.height_factor, data?.width_factor, editor, onClose] + ); + + return ( +
+ +
+ {t('document.plugins.googleDrive.worksWithLinksOfGoogleDrive', { + defaultValue: 'Works with Google Docs, Sheets, Slides, Forms, files, and folders.', + })} +
+
+ ); +} + +export default GoogleDriveBlockPopoverContent; diff --git a/src/components/editor/components/block-popover/LinkPreviewPopoverContent.tsx b/src/components/editor/components/block-popover/LinkPreviewPopoverContent.tsx new file mode 100644 index 000000000..5560c879e --- /dev/null +++ b/src/components/editor/components/block-popover/LinkPreviewPopoverContent.tsx @@ -0,0 +1,48 @@ +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; +import { LinkPreviewBlockData, LinkPreviewType } from '@/application/types'; +import EmbedLink from '@/components/_shared/image-upload/EmbedLink'; +import { processUrl } from '@/utils/url'; + +function LinkPreviewPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) { + const editor = useSlateStatic() as YjsEditor; + const { t } = useTranslation(); + const entry = useMemo(() => { + try { + return findSlateEntryByBlockId(editor, blockId); + } catch { + return null; + } + }, [blockId, editor]); + const data = entry?.[0]?.data as LinkPreviewBlockData | undefined; + + const handleInsertEmbedLink = useCallback( + (rawUrl: string) => { + const url = processUrl(rawUrl) || rawUrl; + + CustomEditor.setBlockData(editor, blockId, { + url, + preview_type: data?.preview_type ?? LinkPreviewType.Bookmark, + } as LinkPreviewBlockData); + onClose(); + }, + [blockId, data?.preview_type, editor, onClose] + ); + + return ( +
+ +
+ ); +} + +export default LinkPreviewPopoverContent; diff --git a/src/components/editor/components/block-popover/index.tsx b/src/components/editor/components/block-popover/index.tsx index 1d1227f80..23d411b04 100644 --- a/src/components/editor/components/block-popover/index.tsx +++ b/src/components/editor/components/block-popover/index.tsx @@ -5,9 +5,13 @@ import { YjsEditor } from '@/application/slate-yjs'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; import { BlockType } from '@/application/types'; import { calculateOptimalOrigins, Origins, Popover } from '@/components/_shared/popover'; +import AudioBlockPopoverContent from '@/components/editor/components/block-popover/AudioBlockPopoverContent'; import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; import FileBlockPopoverContent from '@/components/editor/components/block-popover/FileBlockPopoverContent'; +import GalleryBlockPopoverContent from '@/components/editor/components/block-popover/GalleryBlockPopoverContent'; +import GoogleDriveBlockPopoverContent from '@/components/editor/components/block-popover/GoogleDriveBlockPopoverContent'; import ImageBlockPopoverContent from '@/components/editor/components/block-popover/ImageBlockPopoverContent'; +import LinkPreviewPopoverContent from '@/components/editor/components/block-popover/LinkPreviewPopoverContent'; import PDFBlockPopoverContent from '@/components/editor/components/block-popover/PDFBlockPopoverContent'; import { useEditorLocalState } from '@/components/editor/EditorContext'; @@ -37,7 +41,7 @@ function BlockPopover() { const entry = findSlateEntryByBlockId(editor, blockId); - if(!entry) return; + if (!entry) return; const [, path] = entry; @@ -59,6 +63,14 @@ function BlockPopover() { return ; case BlockType.VideoBlock: return ; + case BlockType.LinkPreview: + return ; + case BlockType.GalleryBlock: + return ; + case BlockType.AudioBlock: + return ; + case BlockType.GoogleDriveBlock: + return ; default: return null; } @@ -89,7 +101,15 @@ function BlockPopover() { left: panelPosition.left, }, 400, - type === BlockType.ImageBlock || type === BlockType.VideoBlock ? 366 : 200, + [ + BlockType.ImageBlock, + BlockType.VideoBlock, + BlockType.GalleryBlock, + BlockType.AudioBlock, + BlockType.GoogleDriveBlock, + ].includes(type as BlockType) + ? 366 + : 200, defaultOrigins, 16 ); diff --git a/src/components/editor/components/blocks/audio/AudioBlock.tsx b/src/components/editor/components/blocks/audio/AudioBlock.tsx new file mode 100644 index 000000000..052d6b6b9 --- /dev/null +++ b/src/components/editor/components/blocks/audio/AudioBlock.tsx @@ -0,0 +1,217 @@ +import { CircularProgress, IconButton, Tooltip } from '@mui/material'; +import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { AudioBlockData, AudioUrlType, BlockType } from '@/application/types'; +import { ReactComponent as AudioIcon } from '@/assets/icons/audio.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/icons/regenerate.svg'; +import { notify } from '@/components/_shared/notify'; +import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; +import FileToolbar from '@/components/editor/components/blocks/file/FileToolbar'; +import { AudioBlockNode, EditorElementProps, FileNode } from '@/components/editor/editor.type'; +import { useEditorContext } from '@/components/editor/EditorContext'; +import { constructFileUrl } from '@/components/editor/utils/file-url'; +import { FileHandler } from '@/utils/file'; + +export const AudioBlock = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const { t } = useTranslation(); + const { blockId, data } = node; + const { uploadFile, workspaceId, viewId } = useEditorContext(); + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const { openPopover } = usePopoverContext(); + const emptyRef = useRef(null); + const fileHandlerRef = useRef(new FileHandler()); + const [localUrl, setLocalUrl] = useState(); + const [needRetry, setNeedRetry] = useState(false); + const [loading, setLoading] = useState(false); + const [showToolbar, setShowToolbar] = useState(false); + + const { url: dataUrl, name, retry_local_url, duration_in_second } = data || {}; + const remoteUrl = useMemo( + () => (dataUrl ? constructFileUrl(dataUrl, workspaceId, viewId) : ''), + [dataUrl, workspaceId, viewId] + ); + const sourceUrl = remoteUrl || localUrl || ''; + const hasContent = Boolean(sourceUrl); + + useEffect(() => { + if (readOnly) return; + void (async () => { + if (!retry_local_url || dataUrl) { + setLocalUrl(undefined); + setNeedRetry(false); + return; + } + + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + + setLocalUrl(fileData?.url); + setNeedRetry(!!fileData); + })(); + }, [dataUrl, readOnly, retry_local_url]); + + const openUploadPopover = useCallback(() => { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.AudioBlock, emptyRef.current); + } + }, [blockId, openPopover, readOnly]); + + const uploadFileRemote = useCallback( + async (file: File) => { + try { + return await uploadFile?.(file); + } catch { + return undefined; + } + }, + [uploadFile] + ); + + const handleRetry = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + if (!retry_local_url) return; + + setLoading(true); + try { + const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url); + const file = fileData?.file; + + if (!file) { + notify.error(t('web.fileBlock.uploadFailed')); + return; + } + + const url = await uploadFileRemote(file); + + if (!url) { + notify.error(t('web.fileBlock.uploadFailed')); + return; + } + + await fileHandlerRef.current.cleanup(retry_local_url); + CustomEditor.setBlockData(editor, blockId, { + url, + name, + uploaded_at: Date.now(), + url_type: AudioUrlType.Cloud, + retry_local_url: '', + pending_upload_id: '', + } as AudioBlockData); + } finally { + setLoading(false); + } + }, + [blockId, editor, name, retry_local_url, t, uploadFileRemote] + ); + + const handleLoadedMetadata = useCallback( + (event: React.SyntheticEvent) => { + if (readOnly) return; + + const seconds = Math.round(event.currentTarget.duration); + + if (!Number.isFinite(seconds) || seconds <= 0 || seconds === duration_in_second) return; + + CustomEditor.setBlockData(editor, blockId, { + duration_in_second: seconds, + } as AudioBlockData); + }, + [blockId, duration_in_second, editor, readOnly] + ); + + const className = [ + 'w-full', + !readOnly || hasContent ? 'cursor-pointer' : 'text-text-secondary', + attributes.className, + ] + .filter(Boolean) + .join(' '); + + return ( +
{ + if (!hasContent) { + openUploadPopover(); + } + }} + onMouseEnter={() => { + if (hasContent) setShowToolbar(true); + }} + onMouseLeave={() => setShowToolbar(false)} + > +
+
+ +
+ {hasContent ? ( +
+ {name?.trim() || t('document.selectionMenu.audio', { defaultValue: 'Audio' })} +
+ ) : ( +
+ {t('document.plugins.audio.addAudio', { defaultValue: 'Upload or embed audio' })} +
+ )} + {needRetry && ( +
{t('web.fileBlock.uploadFailed')}
+ )} +
+ {needRetry && + (loading ? ( + + ) : ( + + + + + + ))} +
+ + {hasContent && ( +
+
+ {children} +
+
+ ); + }) +); + +AudioBlock.displayName = 'AudioBlock'; + +export default AudioBlock; diff --git a/src/components/editor/components/blocks/audio/index.ts b/src/components/editor/components/blocks/audio/index.ts new file mode 100644 index 000000000..aa87bfc5a --- /dev/null +++ b/src/components/editor/components/blocks/audio/index.ts @@ -0,0 +1 @@ +export * from './AudioBlock'; diff --git a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx index f250f3c4f..c1eba592c 100644 --- a/src/components/editor/components/blocks/gallery/GalleryBlock.tsx +++ b/src/components/editor/components/blocks/gallery/GalleryBlock.tsx @@ -1,11 +1,14 @@ import React, { forwardRef, memo, Suspense, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useReadOnly } from 'slate-react'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; -import { GalleryLayout } from '@/application/types'; +import { YjsEditor } from '@/application/slate-yjs'; +import { BlockType, GalleryLayout } from '@/application/types'; import { ReactComponent as GalleryIcon } from '@/assets/icons/gallery.svg'; import { GalleryPreview } from '@/components/_shared/gallery-preview'; import { notify } from '@/components/_shared/notify'; +import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; import Carousel from '@/components/editor/components/blocks/gallery/Carousel'; import GalleryToolbar from '@/components/editor/components/blocks/gallery/GalleryToolbar'; import ImageGallery from '@/components/editor/components/blocks/gallery/ImageGallery'; @@ -18,10 +21,14 @@ const GalleryBlock = memo( forwardRef>(({ node, children, ...attributes }, ref) => { const { t } = useTranslation(); const { workspaceId, viewId } = useEditorContext(); - const { images, layout } = useMemo(() => node.data || {}, [node.data]); + const { images = [], layout = GalleryLayout.Carousel } = useMemo(() => node.data || {}, [node.data]); const [openPreview, setOpenPreview] = React.useState(false); const previewIndexRef = React.useRef(0); const [hovered, setHovered] = useState(false); + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const emptyRef = React.useRef(null); + const { openPopover } = usePopoverContext(); const className = useMemo(() => { const classList = ['gallery-block', 'relative', 'w-full', 'cursor-default', attributes.className || '']; @@ -47,10 +54,10 @@ const GalleryBlock = memo( }; }) .filter(Boolean) as { - src: string; - thumb: string; - responsive: string; - }[]; + src: string; + thumb: string; + responsive: string; + }[]; }, [images, workspaceId, viewId]); const handleOpenPreview = useCallback(() => { @@ -81,7 +88,11 @@ const GalleryBlock = memo( const handlePreviewIndex = useCallback((index: number) => { previewIndexRef.current = index; }, []); - const readOnly = useReadOnly(); + const handleOpenUploadPopover = useCallback(() => { + if (readOnly || !emptyRef.current) return; + + openPopover(node.blockId, BlockType.GalleryBlock, emptyRef.current); + }, [node.blockId, openPopover, readOnly]); return (
setHovered(false)} + onClick={() => { + if (!photos.length) { + handleOpenUploadPopover(); + } + }} >
{children} @@ -114,7 +130,7 @@ const GalleryBlock = memo( /> ) ) : ( -
+
{t('document.plugins.image.addAnImageMobile')}
diff --git a/src/components/editor/components/blocks/google-drive/GoogleDriveBlock.tsx b/src/components/editor/components/blocks/google-drive/GoogleDriveBlock.tsx new file mode 100644 index 000000000..b9ca4aa70 --- /dev/null +++ b/src/components/editor/components/blocks/google-drive/GoogleDriveBlock.tsx @@ -0,0 +1,134 @@ +import { Divider } from '@mui/material'; +import { forwardRef, memo, useCallback, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Element } from 'slate'; +import { useReadOnly, useSlateStatic } from 'slate-react'; + +import { YjsEditor } from '@/application/slate-yjs'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { BlockType } from '@/application/types'; +import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg'; +import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; +import { ReactComponent as OpenIcon } from '@/assets/icons/link_arrow.svg'; +import { ReactComponent as GoogleIcon } from '@/assets/login/google.svg'; +import { notify } from '@/components/_shared/notify'; +import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; +import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton'; +import { EditorElementProps, GoogleDriveBlockNode } from '@/components/editor/editor.type'; +import { copyTextToClipboard } from '@/utils/copy'; +import { openUrl } from '@/utils/url'; + +import { buildGoogleDriveEmbeddedUrl } from './google-drive-utils'; + +export const GoogleDriveBlock = memo( + forwardRef>(({ node, children, ...attributes }, ref) => { + const { t } = useTranslation(); + const { blockId, data } = node; + const { url, name } = data || {}; + const editor = useSlateStatic() as YjsEditor; + const readOnly = useReadOnly() || editor.isElementReadOnly(node as unknown as Element); + const { openPopover } = usePopoverContext(); + const emptyRef = useRef(null); + const [showToolbar, setShowToolbar] = useState(false); + const embeddedUrl = useMemo(() => (url ? buildGoogleDriveEmbeddedUrl(url) : ''), [url]); + + const openEditPopover = useCallback(() => { + if (emptyRef.current && !readOnly) { + openPopover(blockId, BlockType.GoogleDriveBlock, emptyRef.current); + } + }, [blockId, openPopover, readOnly]); + + const onCopy = useCallback(async () => { + if (!url) return; + + await copyTextToClipboard(url); + notify.success(t('button.copyLinkOriginal')); + }, [t, url]); + + const onOpen = useCallback(() => { + if (!url) return; + void openUrl(url, '_blank'); + }, [url]); + + const onDelete = useCallback(() => { + CustomEditor.deleteBlock(editor, blockId); + }, [blockId, editor]); + + return ( +
{ + if (!embeddedUrl) { + openEditPopover(); + } + }} + onMouseEnter={() => { + if (embeddedUrl) setShowToolbar(true); + }} + onMouseLeave={() => setShowToolbar(false)} + > +
+ {embeddedUrl ? ( +
+