-
Notifications
You must be signed in to change notification settings - Fork 136
Support additional embedded blocks #359
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
221 changes: 221 additions & 0 deletions
221
src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<AudioBlockData> => { | ||
| 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<void>[] = [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 ( | ||
| <div className={'flex flex-col gap-2 p-2'}> | ||
| <ViewTabs | ||
| value={tabValue} | ||
| onChange={handleTabChange} | ||
| className={'min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2'} | ||
| > | ||
| <ViewTab iconPosition='start' color='inherit' label={t('button.upload')} value='upload' /> | ||
| <ViewTab iconPosition='start' color='inherit' label={t('document.plugins.file.networkTab')} value='embed' /> | ||
| </ViewTabs> | ||
| <div className={'appflowy-scroller max-h-[400px] overflow-y-auto p-2'}> | ||
| <TabPanel className={'flex h-full w-full flex-col'} index={0} value={selectedIndex}> | ||
| <FileDropzone | ||
| accept={AUDIO_EXTENSIONS.join(',')} | ||
| multiple={true} | ||
| placeholder={ | ||
| <span> | ||
| {t('document.plugins.audio.uploadHint', { | ||
| defaultValue: 'Click to upload or drag and drop audio files', | ||
| })} | ||
| <span className={'text-text-action'}> {t('document.plugins.file.fileUploadHintSuffix')}</span> | ||
| </span> | ||
| } | ||
| onChange={handleChangeUploadFiles} | ||
| loading={uploading} | ||
| /> | ||
| </TabPanel> | ||
| <TabPanel className={'flex h-full w-full flex-col'} index={1} value={selectedIndex}> | ||
| <EmbedLink | ||
| onDone={handleInsertEmbedLink} | ||
| defaultLink={defaultLink} | ||
| placeholder={t('document.plugins.audio.embedPlaceholder', { defaultValue: 'Paste an audio link' })} | ||
| validator={isAudioUrl} | ||
| /> | ||
| </TabPanel> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| export default AudioBlockPopoverContent; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider extracting the multi-file upload orchestration from
AudioBlockPopoverContentinto a reusable hook so the component focuses on audio-specific UI and wiring only.You can reduce the cognitive load in
AudioBlockPopoverContentby extracting the multi‑file upload orchestration into a dedicated hook and keeping the popover focused on UI + wiring. That will also make it reusable for image/video, etc.1. Extract upload orchestration into a hook
Move the generic bits (
createPendingAudioData,cleanupLocalFile,uploadIntoAudioBlock,handleChangeUploadFiles) into a hook that is parameterized by how to build/update block data.2. Keep
AudioBlockPopoverContentfocused on audio specifics + UIWith the hook, the popover becomes mostly wiring + audio‑specific helpers:
This keeps:
createPendingUploadId,AudioUrlType,getAudioName) in the audio component.It also flattens the nested async callbacks in
AudioBlockPopoverContentinto onehandleUploadcall, making the popover much easier to read.