Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/application/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ export enum BlockType {
DividerBlock = 'divider',
ImageBlock = 'image',
VideoBlock = 'video',
AudioBlock = 'audio',
GoogleDriveBlock = 'google_drive',
GridBlock = 'grid',
BoardBlock = 'board',
CalendarBlock = 'calendar',
Expand Down Expand Up @@ -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;
Expand Down
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 }) {
Copy link
Copy Markdown

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 AudioBlockPopoverContent into a reusable hook so the component focuses on audio-specific UI and wiring only.

You can reduce the cognitive load in AudioBlockPopoverContent by 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.

// useMultiBlockUpload.ts
import { useCallback } from 'react';
import { CustomEditor } from '@/application/slate-yjs/command';
import { BlockType } from '@/application/types';
import { FileHandler } from '@/utils/file';

type PendingData<T> = T & { pending_upload_id?: string; retry_local_url?: string };

interface UseMultiBlockUploadParams<T> {
  editor: YjsEditor;
  blockId: string;
  blockType: BlockType;
  uploadFileRemote: (file: File) => Promise<string | undefined>;
  createPendingData: (file: File) => Promise<PendingData<T>>;
  finalizeData: (file: File, url: string) => T;
}

export function useMultiBlockUpload<T>({
  editor,
  blockId,
  blockType,
  uploadFileRemote,
  createPendingData,
  finalizeData,
}: UseMultiBlockUploadParams<T>) {
  const cleanupLocalFile = useCallback(async (retryLocalUrl?: string) => {
    if (!retryLocalUrl) return;
    const fileHandler = new FileHandler();
    await fileHandler.cleanup(retryLocalUrl).catch(() => undefined);
  }, []);

  const uploadIntoBlock = useCallback(
    async (targetBlockId: string, file: File, pendingData: PendingData<T>) => {
      const url = await uploadFileRemote(file);
      if (!url) return;

      await cleanupLocalFile(pendingData.retry_local_url);

      let currentData: PendingData<T> | undefined;
      try {
        const entry = findSlateEntryByBlockId(editor, targetBlockId);
        currentData = entry ? (entry[0] as { data?: PendingData<T> }).data ?? undefined : undefined;
      } catch {
        return;
      }

      if (!currentData?.pending_upload_id) return;
      if (currentData.pending_upload_id !== pendingData.pending_upload_id) return;
      if ((currentData as any).url) return;

      CustomEditor.setBlockData(editor, targetBlockId, finalizeData(file, url));
    },
    [cleanupLocalFile, editor, uploadFileRemote, finalizeData],
  );

  const handleChangeUploadFiles = useCallback(
    async (files: File[]) => {
      if (!files.length) return;

      const [primaryData, ...otherDatas] = await Promise.all(files.map((f) => createPendingData(f)));
      const [file, ...otherFiles] = files;

      CustomEditor.setBlockData(editor, blockId, primaryData as any);

      const pendingUploads: Promise<void>[] = [uploadIntoBlock(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, data as any);
        if (newId) pendingUploads.push(uploadIntoBlock(newId, f, data));
      }

      await Promise.all(pendingUploads);
    },
    [blockId, createPendingData, uploadIntoBlock, editor, blockType],
  );

  return { handleChangeUploadFiles };
}

2. Keep AudioBlockPopoverContent focused on audio specifics + UI

With the hook, the popover becomes mostly wiring + audio‑specific helpers:

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 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 finalizeAudioData = useCallback(
    (file: File, url: string): AudioBlockData => ({
      url,
      name: file.name,
      uploaded_at: Date.now(),
      url_type: AudioUrlType.Cloud,
      retry_local_url: '',
      pending_upload_id: '',
    }),
    [],
  );

  const { handleChangeUploadFiles } = useMultiBlockUpload<AudioBlockData>({
    editor,
    blockId,
    blockType: BlockType.AudioBlock,
    uploadFileRemote,
    createPendingData: createPendingAudioData,
    finalizeData: finalizeAudioData,
  });

  const handleTabChange = useCallback((_e: React.SyntheticEvent, newValue: string) => {
    setTabValue(newValue);
  }, []);

  // wrap uploaded handler for UI state & closing
  const handleUpload = useCallback(
    async (files: File[]) => {
      if (!files.length) return;
      setUploading(true);
      try {
        await handleChangeUploadFiles(files);
        onClose();
      } finally {
        setUploading(false);
      }
    },
    [handleChangeUploadFiles, onClose],
  );

  // ...embed link + UI unchanged, just use handleUpload for FileDropzone.onChange
}

This keeps:

  • All existing behavior (pending upload id guard, reverse insertion, cleanup).
  • Audio specifics (types, createPendingUploadId, AudioUrlType, getAudioName) in the audio component.
  • The multi‑file upload orchestration in one reusable, testable unit.

It also flattens the nested async callbacks in AudioBlockPopoverContent into one handleUpload call, making the popover much easier to read.

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;
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}

Expand All @@ -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<BlockType | undefined>();
const [blockId, setBlockId] = useState<string | undefined>();
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(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 (
<BlockPopoverContext.Provider value={contextValue}>
{children}
</BlockPopoverContext.Provider>
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 <BlockPopoverContext.Provider value={contextValue}>{children}</BlockPopoverContext.Provider>;
};
Loading
Loading