Skip to content

Commit 53a2b9d

Browse files
authored
Support additional embedded blocks (#359)
* chore: support other blocks * fix: handle embedded block popovers
1 parent 9704635 commit 53a2b9d

18 files changed

Lines changed: 1405 additions & 94 deletions

src/application/types.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ export enum BlockType {
3333
DividerBlock = 'divider',
3434
ImageBlock = 'image',
3535
VideoBlock = 'video',
36+
AudioBlock = 'audio',
37+
GoogleDriveBlock = 'google_drive',
3638
GridBlock = 'grid',
3739
BoardBlock = 'board',
3840
CalendarBlock = 'calendar',
@@ -172,6 +174,32 @@ export interface VideoBlockData extends BlockData {
172174
name?: string;
173175
}
174176

177+
export enum AudioUrlType {
178+
Local = 'local',
179+
Network = 'network',
180+
Cloud = 'cloud',
181+
}
182+
183+
export interface AudioBlockData extends BlockData {
184+
url?: string;
185+
url_type?: AudioUrlType | string;
186+
name?: string;
187+
uploaded_at?: number;
188+
uploaded_by?: string;
189+
duration_in_second?: number;
190+
retry_local_url?: string;
191+
pending_upload_id?: string;
192+
}
193+
194+
export interface GoogleDriveBlockData extends BlockData {
195+
url?: string;
196+
name?: string;
197+
email?: string;
198+
uploaded_at?: number;
199+
width_factor?: number;
200+
height_factor?: number;
201+
}
202+
175203
export interface AIMeetingBlockData extends BlockData {
176204
title?: string;
177205
date?: string | number;
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import React, { useCallback, useMemo } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { useSlateStatic } from 'slate-react';
4+
5+
import { YjsEditor } from '@/application/slate-yjs';
6+
import { CustomEditor } from '@/application/slate-yjs/command';
7+
import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor';
8+
import { AudioBlockData, AudioUrlType, BlockType } from '@/application/types';
9+
import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone';
10+
import EmbedLink from '@/components/_shared/image-upload/EmbedLink';
11+
import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs';
12+
import { useEditorContext } from '@/components/editor/EditorContext';
13+
import { FileHandler } from '@/utils/file';
14+
import { createPendingUploadId } from '@/utils/pending-upload';
15+
import { processUrl } from '@/utils/url';
16+
17+
const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.flac', '.aac', '.wma', '.alac', '.aiff', '.m4a'];
18+
const AUDIO_EXTENSION_REGEX = /\.(mp3|wav|ogg|flac|aac|wma|alac|aiff|m4a)($|\?)/i;
19+
20+
function getAudioName(rawUrl: string) {
21+
try {
22+
const url = new URL(rawUrl);
23+
const name = url.pathname.split('/').filter(Boolean).pop();
24+
25+
return name || rawUrl;
26+
} catch {
27+
return rawUrl;
28+
}
29+
}
30+
31+
function isAudioUrl(rawUrl: string) {
32+
const url = processUrl(rawUrl) || rawUrl;
33+
34+
return AUDIO_EXTENSION_REGEX.test(url);
35+
}
36+
37+
function AudioBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) {
38+
const editor = useSlateStatic() as YjsEditor;
39+
const { uploadFile } = useEditorContext();
40+
const { t } = useTranslation();
41+
const [tabValue, setTabValue] = React.useState('upload');
42+
const [uploading, setUploading] = React.useState(false);
43+
const entry = useMemo(() => {
44+
try {
45+
return findSlateEntryByBlockId(editor, blockId);
46+
} catch {
47+
return null;
48+
}
49+
}, [blockId, editor]);
50+
51+
const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => {
52+
setTabValue(newValue);
53+
}, []);
54+
55+
const handleInsertEmbedLink = useCallback(
56+
(rawUrl: string) => {
57+
const url = processUrl(rawUrl) || rawUrl;
58+
59+
CustomEditor.setBlockData(editor, blockId, {
60+
url,
61+
name: getAudioName(url),
62+
uploaded_at: Date.now(),
63+
url_type: AudioUrlType.Network,
64+
} as AudioBlockData);
65+
onClose();
66+
},
67+
[blockId, editor, onClose]
68+
);
69+
70+
const uploadFileRemote = useCallback(
71+
async (file: File) => {
72+
try {
73+
return await uploadFile?.(file);
74+
} catch {
75+
return undefined;
76+
}
77+
},
78+
[uploadFile]
79+
);
80+
81+
const createPendingAudioData = useCallback(async (file: File): Promise<AudioBlockData> => {
82+
const data: AudioBlockData = {
83+
url: undefined,
84+
name: file.name,
85+
uploaded_at: Date.now(),
86+
url_type: AudioUrlType.Cloud,
87+
pending_upload_id: createPendingUploadId(),
88+
};
89+
90+
try {
91+
const fileHandler = new FileHandler();
92+
const res = await fileHandler.handleFileUpload(file);
93+
94+
URL.revokeObjectURL(res.url);
95+
data.retry_local_url = res.id;
96+
} catch {
97+
data.retry_local_url = '';
98+
}
99+
100+
return data;
101+
}, []);
102+
103+
const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => {
104+
if (!retryLocalUrl) return;
105+
106+
const fileHandler = new FileHandler();
107+
108+
await fileHandler.cleanup(retryLocalUrl).catch(() => undefined);
109+
}, []);
110+
111+
const uploadIntoAudioBlock = useCallback(
112+
async (targetBlockId: string, file: File, pendingData: AudioBlockData) => {
113+
const url = await uploadFileRemote(file);
114+
115+
if (!url) return;
116+
117+
await cleanupLocalFile(pendingData.retry_local_url);
118+
119+
let currentData: AudioBlockData | undefined;
120+
121+
try {
122+
const entry = findSlateEntryByBlockId(editor, targetBlockId);
123+
124+
currentData = entry ? (entry[0] as { data?: AudioBlockData }).data ?? undefined : undefined;
125+
} catch {
126+
return;
127+
}
128+
129+
if (!currentData) return;
130+
if (currentData.url) return;
131+
if (!pendingData.pending_upload_id || currentData.pending_upload_id !== pendingData.pending_upload_id) return;
132+
133+
CustomEditor.setBlockData(editor, targetBlockId, {
134+
url,
135+
name: file.name,
136+
uploaded_at: Date.now(),
137+
url_type: AudioUrlType.Cloud,
138+
retry_local_url: '',
139+
pending_upload_id: '',
140+
} as AudioBlockData);
141+
},
142+
[cleanupLocalFile, editor, uploadFileRemote]
143+
);
144+
145+
const handleChangeUploadFiles = useCallback(
146+
async (files: File[]) => {
147+
if (!files.length) return;
148+
149+
setUploading(true);
150+
try {
151+
const [primaryData, ...otherDatas] = await Promise.all(files.map((file) => createPendingAudioData(file)));
152+
const [file, ...otherFiles] = files;
153+
154+
CustomEditor.setBlockData(editor, blockId, primaryData);
155+
156+
const pendingUploads: Promise<void>[] = [uploadIntoAudioBlock(blockId, file, primaryData)];
157+
const reversedPairs = otherFiles.map((f, i) => [f, otherDatas[i]] as const).reverse();
158+
159+
for (const [f, data] of reversedPairs) {
160+
const newId = CustomEditor.addBelowBlock(editor, blockId, BlockType.AudioBlock, data);
161+
162+
if (newId) {
163+
pendingUploads.push(uploadIntoAudioBlock(newId, f, data));
164+
}
165+
}
166+
167+
onClose();
168+
await Promise.all(pendingUploads);
169+
} finally {
170+
setUploading(false);
171+
}
172+
},
173+
[blockId, createPendingAudioData, editor, onClose, uploadIntoAudioBlock]
174+
);
175+
176+
const defaultLink = useMemo(() => {
177+
return (entry?.[0]?.data as AudioBlockData | undefined)?.url;
178+
}, [entry]);
179+
const selectedIndex = tabValue === 'upload' ? 0 : 1;
180+
181+
return (
182+
<div className={'flex flex-col gap-2 p-2'}>
183+
<ViewTabs
184+
value={tabValue}
185+
onChange={handleTabChange}
186+
className={'min-h-[38px] w-[560px] max-w-[964px] border-b border-border-primary px-2'}
187+
>
188+
<ViewTab iconPosition='start' color='inherit' label={t('button.upload')} value='upload' />
189+
<ViewTab iconPosition='start' color='inherit' label={t('document.plugins.file.networkTab')} value='embed' />
190+
</ViewTabs>
191+
<div className={'appflowy-scroller max-h-[400px] overflow-y-auto p-2'}>
192+
<TabPanel className={'flex h-full w-full flex-col'} index={0} value={selectedIndex}>
193+
<FileDropzone
194+
accept={AUDIO_EXTENSIONS.join(',')}
195+
multiple={true}
196+
placeholder={
197+
<span>
198+
{t('document.plugins.audio.uploadHint', {
199+
defaultValue: 'Click to upload or drag and drop audio files',
200+
})}
201+
<span className={'text-text-action'}> {t('document.plugins.file.fileUploadHintSuffix')}</span>
202+
</span>
203+
}
204+
onChange={handleChangeUploadFiles}
205+
loading={uploading}
206+
/>
207+
</TabPanel>
208+
<TabPanel className={'flex h-full w-full flex-col'} index={1} value={selectedIndex}>
209+
<EmbedLink
210+
onDone={handleInsertEmbedLink}
211+
defaultLink={defaultLink}
212+
placeholder={t('document.plugins.audio.embedPlaceholder', { defaultValue: 'Paste an audio link' })}
213+
validator={isAudioUrl}
214+
/>
215+
</TabPanel>
216+
</div>
217+
</div>
218+
);
219+
}
220+
221+
export default AudioBlockPopoverContent;
Lines changed: 68 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useState, useCallback, useContext, useMemo } from 'react';
1+
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
22
import { ReactEditor } from 'slate-react';
33

44
import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor';
@@ -10,7 +10,8 @@ export interface BlockPopoverContextType {
1010
anchorEl?: HTMLElement | null;
1111
open: boolean;
1212
close: () => void;
13-
openPopover: (blockId: string, type: BlockType, anchorEl: HTMLElement) => void;
13+
openPopover: (blockId: string, type: BlockType, anchorEl?: HTMLElement | null) => void;
14+
notifyMount: (blockId: string) => void;
1415
isOpen: (type: BlockType) => boolean;
1516
}
1617

@@ -26,47 +27,90 @@ export function usePopoverContext() {
2627
return context;
2728
}
2829

30+
export function usePopoverMountSignal(blockId: string | undefined) {
31+
const { notifyMount } = usePopoverContext();
32+
33+
useEffect(() => {
34+
if (!blockId) return;
35+
notifyMount(blockId);
36+
}, [blockId, notifyMount]);
37+
}
38+
2939
export const BlockPopoverProvider = ({ children, editor }: { children: React.ReactNode; editor: ReactEditor }) => {
3040
const [type, setType] = useState<BlockType | undefined>();
3141
const [blockId, setBlockId] = useState<string | undefined>();
3242
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
43+
const pendingRef = useRef<{ blockId: string; type: BlockType } | null>(null);
3344
const open = Boolean(anchorEl);
3445

3546
const close = useCallback(() => {
3647
setAnchorEl(null);
3748
setBlockId(undefined);
3849
setType(undefined);
50+
pendingRef.current = null;
3951
}, []);
4052

41-
const openPopover = useCallback((blockId: string, type: BlockType) => {
42-
const entry = findSlateEntryByBlockId(editor, blockId);
53+
const resolveAnchor = useCallback(
54+
(targetBlockId: string): HTMLElement | null => {
55+
const entry = findSlateEntryByBlockId(editor, targetBlockId);
4356

44-
if (!entry) {
45-
console.error('Block not found');
46-
return;
47-
}
57+
if (!entry) return null;
4858

49-
const [node] = entry;
50-
const dom = ReactEditor.toDOMNode(editor, node);
59+
try {
60+
return ReactEditor.toDOMNode(editor, entry[0]);
61+
} catch {
62+
return null;
63+
}
64+
},
65+
[editor]
66+
);
5167

52-
setBlockId(blockId);
53-
setType(type);
54-
setAnchorEl(dom);
55-
}, [editor]);
68+
const openPopover = useCallback(
69+
(targetBlockId: string, targetType: BlockType) => {
70+
const dom = resolveAnchor(targetBlockId);
71+
72+
if (dom) {
73+
pendingRef.current = null;
74+
setBlockId(targetBlockId);
75+
setType(targetType);
76+
setAnchorEl(dom);
77+
return;
78+
}
79+
80+
pendingRef.current = { blockId: targetBlockId, type: targetType };
81+
},
82+
[resolveAnchor]
83+
);
5684

57-
const isOpen = useCallback((popover: BlockType) => {
58-
return popover === type;
59-
}, [type]);
85+
const notifyMount = useCallback(
86+
(mountedBlockId: string) => {
87+
const pending = pendingRef.current;
6088

61-
const contextValue = useMemo(
62-
() => ({ blockId, type, anchorEl, open, close, openPopover, isOpen }),
63-
[blockId, type, anchorEl, open, close, openPopover, isOpen]
89+
if (!pending || pending.blockId !== mountedBlockId) return;
90+
91+
const dom = resolveAnchor(pending.blockId);
92+
93+
if (!dom) return;
94+
95+
pendingRef.current = null;
96+
setBlockId(pending.blockId);
97+
setType(pending.type);
98+
setAnchorEl(dom);
99+
},
100+
[resolveAnchor]
64101
);
65102

66-
return (
67-
<BlockPopoverContext.Provider value={contextValue}>
68-
{children}
69-
</BlockPopoverContext.Provider>
103+
const isOpen = useCallback(
104+
(popover: BlockType) => {
105+
return popover === type;
106+
},
107+
[type]
108+
);
109+
110+
const contextValue = useMemo(
111+
() => ({ blockId, type, anchorEl, open, close, openPopover, notifyMount, isOpen }),
112+
[blockId, type, anchorEl, open, close, openPopover, notifyMount, isOpen]
70113
);
71114

115+
return <BlockPopoverContext.Provider value={contextValue}>{children}</BlockPopoverContext.Provider>;
72116
};

0 commit comments

Comments
 (0)