Skip to content

Support additional embedded blocks#359

Merged
appflowy merged 2 commits into
mainfrom
support_other_blocks
May 22, 2026
Merged

Support additional embedded blocks#359
appflowy merged 2 commits into
mainfrom
support_other_blocks

Conversation

@appflowy
Copy link
Copy Markdown
Contributor

@appflowy appflowy commented May 21, 2026

Summary

  • add support for inserting additional embedded blocks from the slash menu
  • defer block popover anchoring until newly inserted blocks mount
  • harden Google Drive URL validation and embedding, including query-id Drive links

Tests

  • pnpm jest src/components/editor/components/blocks/google-drive/tests/google-drive-utils.test.ts --runInBand --no-coverage
  • pnpm type-check

Summary by Sourcery

Add support for new media and embed block types and improve how block popovers are anchored and configured for these embeds.

New Features:

  • Introduce audio blocks with upload and URL-embed support, including playback and retry handling for failed uploads.
  • Introduce Google Drive embed blocks with validation, normalization, and iframe-based previews for various Drive resource types, plus a dedicated slash command.
  • Extend the gallery block to support an upload/embed/Unsplash popover and provide a new photo gallery option in the slash menu.
  • Allow creating editable web bookmark/link preview blocks from the slash menu and via empty-state prompts.

Enhancements:

  • Defer block popover anchoring until target blocks are mounted, using a mount notification mechanism to resolve DOM nodes safely.
  • Wire newly added embed-capable blocks (audio, gallery, Google Drive, link preview) into the central block popover system with appropriate sizing and behaviors.
  • Improve empty states and interactions for gallery, audio, Google Drive, and link preview blocks so clicking them opens their configuration popovers instead of requiring a prefilled URL.

Tests:

  • Add unit tests for Google Drive URL detection and embed URL construction utilities.

@sourcery-ai
Copy link
Copy Markdown

sourcery-ai Bot commented May 21, 2026

Reviewer's Guide

Adds new Audio, Google Drive, Gallery, and Link Preview embedded blocks that can be inserted from the slash menu and configured via block popovers, and introduces a deferred popover anchoring mechanism to handle newly-mounted blocks while hardening Google Drive URL parsing/embedding.

Sequence diagram for deferred block popover anchoring for new embedded blocks

sequenceDiagram
  actor User
  participant SlashPanel
  participant Editor
  participant BlockPopoverProvider
  participant Element
  participant EmbeddedBlock as EmbeddedBlock(AudioBlock/GalleryBlock/LinkPreview/GoogleDriveBlock)
  participant BlockPopover

  User->>SlashPanel: select slash item (e.g. Audio / Gallery / Bookmark / Google Drive)
  SlashPanel->>Editor: turnInto(type, initialData)
  Note over Editor: New block with blockId is added to document
  SlashPanel->>BlockPopoverProvider: openPopover(newBlockId, type)
  BlockPopoverProvider->>BlockPopoverProvider: resolveAnchor(newBlockId)
  alt block not yet mounted
    BlockPopoverProvider->>BlockPopoverProvider: pendingRef.current = { blockId, type }
  else block already mounted
    BlockPopoverProvider->>BlockPopoverProvider: setBlockId, setType, setAnchorEl(dom)
    BlockPopoverProvider-->>BlockPopover: context updated (open = true)
  end

  Editor-->>Element: render Element for blockId
  Element->>Element: usePopoverMountSignal(blockId)
  Element->>BlockPopoverProvider: notifyMount(blockId)
  BlockPopoverProvider->>BlockPopoverProvider: const pending = pendingRef.current
  alt pending matches mounted blockId
    BlockPopoverProvider->>BlockPopoverProvider: resolveAnchor(pending.blockId)
    BlockPopoverProvider->>BlockPopoverProvider: setBlockId, setType, setAnchorEl(dom)
    BlockPopoverProvider-->>BlockPopover: context updated (open = true)
  end

  BlockPopover->>BlockPopover: render content by type
  BlockPopover-->>EmbeddedBlock: e.g. AudioBlockPopoverContent / GalleryBlockPopoverContent / LinkPreviewPopoverContent / GoogleDriveBlockPopoverContent
Loading

File-Level Changes

Change Details Files
Introduce deferred block popover anchoring so popovers can open reliably for newly-inserted blocks without needing manual DOM delays.
  • Extend BlockPopoverContext to accept an optional anchor element in openPopover, add notifyMount, and track pending block/type in a ref until the DOM node is available.
  • Add resolveAnchor helper to safely map a blockId to its DOM node using Slate, handling failures without throwing.
  • Wire notifyMount via new usePopoverMountSignal hook in Element so each block notifies the provider on mount, which then resolves the anchor and opens any pending popover.
  • Simplify slash-panel media insertion to call openPopover with just blockId and BlockType, relying on deferred anchoring instead of a setTimeout DOM lookup.
src/components/editor/components/block-popover/BlockPopoverContext.tsx
src/components/editor/components/element/Element.tsx
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
Add Audio block support including UI, upload/rehydration behavior, and configuration popover.
  • Define AudioUrlType and AudioBlockData in the shared types and add AudioBlockNode to editor node types.
  • Render AudioBlock from the generic Element switch and expose AudioBlock via a new index module.
  • Implement AudioBlock component with empty-state prompting, retryable local-file upload via FileHandler, remote URL construction, audio metadata (duration) capture, and optional FileToolbar when content exists.
  • Implement AudioBlockPopoverContent that supports uploading multiple audio files or embedding a network audio URL with validation, setting pending upload metadata and spawning additional Audio blocks as needed.
src/application/types.ts
src/components/editor/editor.type.ts
src/components/editor/components/element/Element.tsx
src/components/editor/components/blocks/audio/AudioBlock.tsx
src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx
src/components/editor/components/blocks/audio/index.ts
Add Google Drive embed block with robust URL validation, ID extraction, and configurable popover-driven editing.
  • Introduce GoogleDriveBlockData and GoogleDriveBlockNode types and wire GoogleDriveBlock into the Element renderer and block exports.
  • Add slash menu entry to insert an empty GoogleDriveBlock pre-populated with basic metadata and default sizing factors.
  • Implement google-drive-utils with helpers to validate Google Drive URLs, infer a human-readable name, and build an embeddable URL (including support for query-id links, folders, resource keys, and various Docs/Sheets/Slides/Forms/file paths).
  • Implement GoogleDriveBlock component that renders an iframe embed from the normalized URL, shows an empty state that opens an edit popover, and offers toolbar actions to copy link, open in a new tab, or delete the block.
  • Implement GoogleDriveBlockPopoverContent that validates/normalizes pasted Drive URLs, persists them into block data, and preserves existing width/height factors.
  • Add a test scaffold file for google-drive-utils behavior (tests referenced in PR description).
src/application/types.ts
src/components/editor/editor.type.ts
src/components/editor/components/element/Element.tsx
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
src/components/editor/components/blocks/google-drive/google-drive-utils.ts
src/components/editor/components/blocks/google-drive/GoogleDriveBlock.tsx
src/components/editor/components/block-popover/GoogleDriveBlockPopoverContent.tsx
src/components/editor/components/blocks/google-drive/index.ts
src/components/editor/components/blocks/google-drive/__tests__/google-drive-utils.test.ts
Enhance Gallery block to support insertion from slash menu and configuration via an upload/embed/Unsplash popover, with safer defaults.
  • Update GalleryBlock to default missing data to an empty images array and Carousel layout, treat missing content as an empty state that can trigger the upload popover, and respect editor read-only state.
  • Wire GalleryBlock into BlockPopover routing and adjust BlockPopover sizing rules to treat Gallery popovers like other large media popovers.
  • Add a slash menu entry for a Photo gallery block that inserts a GalleryBlock with initial empty images and default layout.
  • Implement GalleryBlockPopoverContent which allows uploading multiple images, embedding URLs, or choosing from Unsplash, appending images to the block while preserving existing layout.
src/application/types.ts
src/components/editor/editor.type.ts
src/components/editor/components/blocks/gallery/GalleryBlock.tsx
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
src/components/editor/components/block-popover/index.tsx
src/components/editor/components/block-popover/GalleryBlockPopoverContent.tsx
Improve Link Preview block UX with editable empty state and dedicated popover for entering URLs.
  • Register LinkPreviewBlock handling in BlockPopover so LinkPreviewPopoverContent can be shown, and route LinkPreviewBlock through the popover height logic as a smaller panel.
  • Extend LinkPreview to use Slate’s read-only/element-read-only semantics, show an inline empty state with an icon and hint when URL is missing, and on click either open the popover (no URL) or open the URL in a new tab (with URL).
  • Implement LinkPreviewPopoverContent which normalizes the pasted URL, preserves the preview_type, and updates block data via CustomEditor.
  • Add a slash menu entry for Web bookmark that inserts a LinkPreview block preconfigured as a bookmark with an empty URL, triggering the new empty-state behavior.
src/components/editor/components/block-popover/index.tsx
src/components/editor/components/blocks/link-preview/LinkPreview.tsx
src/components/editor/components/block-popover/LinkPreviewPopoverContent.tsx
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
src/application/types.ts
Extend slash menu media options and central media-popover wiring so newly-inserted media blocks immediately open their configuration UIs.
  • Extend the slash panel’s media group with entries for Photo gallery, Audio, Google Drive, and Web bookmark that create their respective block types with sensible initial data.
  • Update the slash panel’s post-insert handling to detect a broader set of media/embedded block types and invoke openPopover with just the new blockId and type, letting the provider resolve anchor elements after mount.
  • Adjust BlockPopover’s content switch and height determination to include new media block types alongside existing Image/Video/PDF/Equation handling.
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
src/components/editor/components/block-popover/index.tsx

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Copy Markdown

@sourcery-ai sourcery-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues, and left some high level feedback:

  • In google-drive-utils.ts, isGoogleDriveUrl accepts hosts like sheets.google.com and slides.google.com, but buildGoogleDriveEmbeddedUrl only has special handling for docs.google.com and drive.google.com; consider either normalizing those hosts to docs.google.com or aligning the validation/embedding behavior so accepted URLs are always embedded in a consistent way.
  • In AudioBlock.tsx, clicks on the block are always given a cursor-pointer when hasContent or !readOnly, but in read-only mode with content the click handler is effectively a no-op (stops at the audio tag); consider adjusting the cursor or click behavior to better reflect interactivity in read-only views.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `google-drive-utils.ts`, `isGoogleDriveUrl` accepts hosts like `sheets.google.com` and `slides.google.com`, but `buildGoogleDriveEmbeddedUrl` only has special handling for `docs.google.com` and `drive.google.com`; consider either normalizing those hosts to `docs.google.com` or aligning the validation/embedding behavior so accepted URLs are always embedded in a consistent way.
- In `AudioBlock.tsx`, clicks on the block are always given a `cursor-pointer` when `hasContent` or `!readOnly`, but in read-only mode with content the click handler is effectively a no-op (stops at the audio tag); consider adjusting the cursor or click behavior to better reflect interactivity in read-only views.

## Individual Comments

### Comment 1
<location path="src/components/editor/components/blocks/audio/AudioBlock.tsx" line_range="43-52" />
<code_context>
+export function usePopoverMountSignal(blockId: string | undefined) {
+  const { notifyMount } = usePopoverContext();
+
+  useEffect(() => {
+    if (!blockId) return;
+    notifyMount(blockId);
</code_context>
<issue_to_address>
**issue (bug_risk):** In read-only mode, pending local audio files are not surfaced, so unfinished uploads become invisible to viewers.

Because the effect that restores pending local files exits when `readOnly` is true, `localUrl` and `needRetry` are never set in read-only views. If an upload failed or is pending and only `retry_local_url` exists, viewers get an empty block with no audio or retry state. It would be better to always run this effect so `localUrl`/`needRetry` are derived, and instead gate only the mutating handlers (e.g. `handleRetry`, `handleLoadedMetadata`) on `readOnly`, so read-only views can still show or play cached audio and display failures correctly.
</issue_to_address>

### Comment 2
<location path="src/components/editor/components/blocks/audio/AudioBlock.tsx" line_range="76-85" />
<code_context>
+    const handleRetry = useCallback(
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Retry logic silently bails out when the local cache is missing, leaving the block stuck in a failed state.

In `handleRetry`, when `getStoredFile(retry_local_url)` returns `undefined`, we just show another generic `uploadFailed` toast and leave the block in the same failed state with the retry button. If the cached file was evicted, the user has no path forward. Please either clear `needRetry`/`retry_local_url` and prompt for a fresh upload, or show a specific message that the file is no longer available with an option to re-select it, so the block doesn’t get stuck permanently.

Suggested implementation:

```typescript
          const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url);
          const file = fileData?.file;

          if (!file) {
            // Cached file is gone: clear retry state and prompt user to re-upload
            setNeedRetry(false);
            setRetryLocalUrl(undefined);

            toast({
              status: 'error',
              title: t('audioBlock.retryFileMissingTitle', 'Original file no longer available'),
              description: t(
                'audioBlock.retryFileMissingDescription',
                'The previously uploaded audio file is no longer available. Please select the file again to upload.'
              ),
            });

            setLoading(false);
            return;
          }

```

1. Ensure `setNeedRetry` and `setRetryLocalUrl` are defined state setters in this component and that `retry_local_url` is nullable (e.g. `string | undefined`).
2. If the component uses a different toast/notification mechanism than `toast({ ... })`, adapt the call accordingly and ensure `toast` is imported/initialized (e.g. via `const toast = useToast()`).
3. Make sure `t` is available in scope (from your i18n solution) or replace `t(...)` with hard-coded strings or your existing translation helper.
4. Optionally, if you have a "select file" action (e.g. `openFilePicker()`), you could call it right after clearing retry state to streamline the re-upload flow.
</issue_to_address>

### Comment 3
<location path="src/components/editor/components/blocks/audio/AudioBlock.tsx" line_range="20" />
<code_context>
+import { constructFileUrl } from '@/components/editor/utils/file-url';
+import { FileHandler } from '@/utils/file';
+
+export const AudioBlock = memo(
+  forwardRef<HTMLDivElement, EditorElementProps<AudioBlockNode>>(({ node, children, ...attributes }, ref) => {
+    const { t } = useTranslation();
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the upload/retry logic, header UI, and optional toolbar hover wiring from `AudioBlock` into separate hooks/components to keep the main block focused on composition and rendering.

You can reduce the complexity of `AudioBlock` by extracting the upload/retry plumbing and header rendering into small, focused units. This keeps behavior intact while making the main component mostly about rendering.

### 1. Extract upload/retry logic into a hook

Move `localUrl`, `needRetry`, `loading`, the `useEffect` for `retry_local_url`, and `handleRetry` into a reusable hook:

```ts
// usePendingCloudUpload.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { FileHandler } from '@/utils/file';
import { notify } from '@/components/_shared/notify';
import { CustomEditor } from '@/application/slate-yjs/command';
import { AudioBlockData, AudioUrlType } from '@/application/types';

export function usePendingCloudUpload({
  readOnly,
  dataUrl,
  retryLocalUrl,
  name,
  editor,
  blockId,
  uploadFileRemote,
  t,
}: {
  readOnly: boolean;
  dataUrl?: string;
  retryLocalUrl?: string;
  name?: string;
  editor: any;
  blockId: string;
  uploadFileRemote?: (file: File) => Promise<string | undefined>;
  t: (key: string, opts?: any) => string;
}) {
  const fileHandlerRef = useRef(new FileHandler());
  const [localUrl, setLocalUrl] = useState<string | undefined>();
  const [needRetry, setNeedRetry] = useState(false);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (readOnly) return;
    void (async () => {
      if (!retryLocalUrl || dataUrl) {
        setLocalUrl(undefined);
        setNeedRetry(false);
        return;
      }

      const fileData = await fileHandlerRef.current.getStoredFile(retryLocalUrl);
      setLocalUrl(fileData?.url);
      setNeedRetry(!!fileData);
    })();
  }, [dataUrl, readOnly, retryLocalUrl]);

  const handleRetry = useCallback(
    async (e: React.MouseEvent) => {
      e.stopPropagation();
      if (!retryLocalUrl) return;

      setLoading(true);
      try {
        const fileData = await fileHandlerRef.current.getStoredFile(retryLocalUrl);
        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(retryLocalUrl);
        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, retryLocalUrl, t, uploadFileRemote]
  );

  return { localUrl, needRetry, loading, handleRetry };
}
```

Then `AudioBlock` becomes simpler:

```tsx
const { url: dataUrl, name, retry_local_url, duration_in_second } = data || {};

const uploadFileRemote = useCallback(
  async (file: File) => {
    try {
      return await uploadFile?.(file);
    } catch {
      return undefined;
    }
  },
  [uploadFile]
);

const { localUrl, needRetry, loading, handleRetry } = usePendingCloudUpload({
  readOnly,
  dataUrl,
  retryLocalUrl: retry_local_url,
  name,
  editor,
  blockId,
  uploadFileRemote,
  t,
});
```

This removes the `FileHandler` ref, `useEffect`, and most retry-related state from the component.

### 2. Extract a small header component

Pull the header (icon + title + error + retry UI) into a presentational component to remove branching from the main JSX:

```tsx
// AudioBlockHeader.tsx
import { CircularProgress, IconButton, Tooltip } from '@mui/material';
import { ReactComponent as AudioIcon } from '@/assets/icons/audio.svg';
import { ReactComponent as ReloadIcon } from '@/assets/icons/regenerate.svg';

export function AudioBlockHeader({
  hasContent,
  name,
  needRetry,
  loading,
  onRetry,
  t,
  emptyRef,
}: {
  hasContent: boolean;
  name?: string;
  needRetry: boolean;
  loading: boolean;
  onRetry: (e: React.MouseEvent) => void;
  t: (key: string, opts?: any) => string;
  emptyRef: React.RefObject<HTMLDivElement>;
}) {
  return (
    <div className="flex w-full items-center gap-3">
      <AudioIcon className="h-6 w-6 flex-none" />
      <div ref={emptyRef} className="min-w-0 flex-1 text-base font-medium">
        {hasContent ? (
          <div className="truncate">
            {name?.trim() || t('document.selectionMenu.audio', { defaultValue: 'Audio' })}
          </div>
        ) : (
          <div className="text-text-secondary">
            {t('document.plugins.audio.addAudio', { defaultValue: 'Upload or embed audio' })}
          </div>
        )}
        {needRetry && (
          <div className="text-sm font-normal text-function-error">
            {t('web.fileBlock.uploadFailed')}
          </div>
        )}
      </div>
      {needRetry &&
        (loading ? (
          <CircularProgress size={16} />
        ) : (
          <Tooltip placement="top" title={t('web.fileBlock.retry')}>
            <IconButton onClick={onRetry} size="small" color="error">
              <ReloadIcon />
            </IconButton>
          </Tooltip>
        ))}
    </div>
  );
}
```

Usage in `AudioBlock`:

```tsx
<AudioBlockHeader
  hasContent={hasContent}
  name={name}
  needRetry={needRetry}
  loading={loading}
  onRetry={handleRetry}
  t={t}
  emptyRef={emptyRef}
/>
```

### 3. Optional: reuse toolbar wiring

If `FileToolbar` is used similarly in other blocks, wrap the `showToolbar` hover logic and `remoteUrl` mapping in a tiny helper/hook:

```tsx
// useFileToolbar.ts
import { useState, useCallback } from 'react';

export function useFileToolbar(hasContent: boolean) {
  const [showToolbar, setShowToolbar] = useState(false);

  const onMouseEnter = useCallback(() => {
    if (hasContent) setShowToolbar(true);
  }, [hasContent]);

  const onMouseLeave = useCallback(() => setShowToolbar(false), []);

  return { showToolbar, onMouseEnter, onMouseLeave };
}
```

Then in `AudioBlock`:

```tsx
const { showToolbar, onMouseEnter, onMouseLeave } = useFileToolbar(hasContent);

<div
  {...attributes}
  onMouseEnter={onMouseEnter}
  onMouseLeave={onMouseLeave}
  // ...
>
  {/* ... */}
  {showToolbar && remoteUrl && (
    <FileToolbar
      node={
        {
          ...node,
          data: { ...data, url: remoteUrl },
        } as unknown as FileNode
      }
    />
  )}
</div>
```

These extractions keep all behavior but make `AudioBlock` mostly compose existing pieces, reducing cognitive load and making the upload/retry logic reusable across `FileBlock`/`ImageBlock`.
</issue_to_address>

### Comment 4
<location path="src/components/editor/components/block-popover/AudioBlockPopoverContent.tsx" line_range="37" />
<code_context>
+  return AUDIO_EXTENSION_REGEX.test(url);
+}
+
+function AudioBlockPopoverContent({ blockId, onClose }: { blockId: string; onClose: () => void }) {
+  const editor = useSlateStatic() as YjsEditor;
+  const { uploadFile } = useEditorContext();
</code_context>
<issue_to_address>
**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.

```ts
// 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:

```tsx
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.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +43 to +52
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);
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 (bug_risk): In read-only mode, pending local audio files are not surfaced, so unfinished uploads become invisible to viewers.

Because the effect that restores pending local files exits when readOnly is true, localUrl and needRetry are never set in read-only views. If an upload failed or is pending and only retry_local_url exists, viewers get an empty block with no audio or retry state. It would be better to always run this effect so localUrl/needRetry are derived, and instead gate only the mutating handlers (e.g. handleRetry, handleLoadedMetadata) on readOnly, so read-only views can still show or play cached audio and display failures correctly.

Comment on lines +76 to +85
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): Retry logic silently bails out when the local cache is missing, leaving the block stuck in a failed state.

In handleRetry, when getStoredFile(retry_local_url) returns undefined, we just show another generic uploadFailed toast and leave the block in the same failed state with the retry button. If the cached file was evicted, the user has no path forward. Please either clear needRetry/retry_local_url and prompt for a fresh upload, or show a specific message that the file is no longer available with an option to re-select it, so the block doesn’t get stuck permanently.

Suggested implementation:

          const fileData = await fileHandlerRef.current.getStoredFile(retry_local_url);
          const file = fileData?.file;

          if (!file) {
            // Cached file is gone: clear retry state and prompt user to re-upload
            setNeedRetry(false);
            setRetryLocalUrl(undefined);

            toast({
              status: 'error',
              title: t('audioBlock.retryFileMissingTitle', 'Original file no longer available'),
              description: t(
                'audioBlock.retryFileMissingDescription',
                'The previously uploaded audio file is no longer available. Please select the file again to upload.'
              ),
            });

            setLoading(false);
            return;
          }
  1. Ensure setNeedRetry and setRetryLocalUrl are defined state setters in this component and that retry_local_url is nullable (e.g. string | undefined).
  2. If the component uses a different toast/notification mechanism than toast({ ... }), adapt the call accordingly and ensure toast is imported/initialized (e.g. via const toast = useToast()).
  3. Make sure t is available in scope (from your i18n solution) or replace t(...) with hard-coded strings or your existing translation helper.
  4. Optionally, if you have a "select file" action (e.g. openFilePicker()), you could call it right after clearing retry state to streamline the re-upload flow.

import { constructFileUrl } from '@/components/editor/utils/file-url';
import { FileHandler } from '@/utils/file';

export const AudioBlock = memo(
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 upload/retry logic, header UI, and optional toolbar hover wiring from AudioBlock into separate hooks/components to keep the main block focused on composition and rendering.

You can reduce the complexity of AudioBlock by extracting the upload/retry plumbing and header rendering into small, focused units. This keeps behavior intact while making the main component mostly about rendering.

1. Extract upload/retry logic into a hook

Move localUrl, needRetry, loading, the useEffect for retry_local_url, and handleRetry into a reusable hook:

// usePendingCloudUpload.ts
import { useCallback, useEffect, useRef, useState } from 'react';
import { FileHandler } from '@/utils/file';
import { notify } from '@/components/_shared/notify';
import { CustomEditor } from '@/application/slate-yjs/command';
import { AudioBlockData, AudioUrlType } from '@/application/types';

export function usePendingCloudUpload({
  readOnly,
  dataUrl,
  retryLocalUrl,
  name,
  editor,
  blockId,
  uploadFileRemote,
  t,
}: {
  readOnly: boolean;
  dataUrl?: string;
  retryLocalUrl?: string;
  name?: string;
  editor: any;
  blockId: string;
  uploadFileRemote?: (file: File) => Promise<string | undefined>;
  t: (key: string, opts?: any) => string;
}) {
  const fileHandlerRef = useRef(new FileHandler());
  const [localUrl, setLocalUrl] = useState<string | undefined>();
  const [needRetry, setNeedRetry] = useState(false);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    if (readOnly) return;
    void (async () => {
      if (!retryLocalUrl || dataUrl) {
        setLocalUrl(undefined);
        setNeedRetry(false);
        return;
      }

      const fileData = await fileHandlerRef.current.getStoredFile(retryLocalUrl);
      setLocalUrl(fileData?.url);
      setNeedRetry(!!fileData);
    })();
  }, [dataUrl, readOnly, retryLocalUrl]);

  const handleRetry = useCallback(
    async (e: React.MouseEvent) => {
      e.stopPropagation();
      if (!retryLocalUrl) return;

      setLoading(true);
      try {
        const fileData = await fileHandlerRef.current.getStoredFile(retryLocalUrl);
        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(retryLocalUrl);
        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, retryLocalUrl, t, uploadFileRemote]
  );

  return { localUrl, needRetry, loading, handleRetry };
}

Then AudioBlock becomes simpler:

const { url: dataUrl, name, retry_local_url, duration_in_second } = data || {};

const uploadFileRemote = useCallback(
  async (file: File) => {
    try {
      return await uploadFile?.(file);
    } catch {
      return undefined;
    }
  },
  [uploadFile]
);

const { localUrl, needRetry, loading, handleRetry } = usePendingCloudUpload({
  readOnly,
  dataUrl,
  retryLocalUrl: retry_local_url,
  name,
  editor,
  blockId,
  uploadFileRemote,
  t,
});

This removes the FileHandler ref, useEffect, and most retry-related state from the component.

2. Extract a small header component

Pull the header (icon + title + error + retry UI) into a presentational component to remove branching from the main JSX:

// AudioBlockHeader.tsx
import { CircularProgress, IconButton, Tooltip } from '@mui/material';
import { ReactComponent as AudioIcon } from '@/assets/icons/audio.svg';
import { ReactComponent as ReloadIcon } from '@/assets/icons/regenerate.svg';

export function AudioBlockHeader({
  hasContent,
  name,
  needRetry,
  loading,
  onRetry,
  t,
  emptyRef,
}: {
  hasContent: boolean;
  name?: string;
  needRetry: boolean;
  loading: boolean;
  onRetry: (e: React.MouseEvent) => void;
  t: (key: string, opts?: any) => string;
  emptyRef: React.RefObject<HTMLDivElement>;
}) {
  return (
    <div className="flex w-full items-center gap-3">
      <AudioIcon className="h-6 w-6 flex-none" />
      <div ref={emptyRef} className="min-w-0 flex-1 text-base font-medium">
        {hasContent ? (
          <div className="truncate">
            {name?.trim() || t('document.selectionMenu.audio', { defaultValue: 'Audio' })}
          </div>
        ) : (
          <div className="text-text-secondary">
            {t('document.plugins.audio.addAudio', { defaultValue: 'Upload or embed audio' })}
          </div>
        )}
        {needRetry && (
          <div className="text-sm font-normal text-function-error">
            {t('web.fileBlock.uploadFailed')}
          </div>
        )}
      </div>
      {needRetry &&
        (loading ? (
          <CircularProgress size={16} />
        ) : (
          <Tooltip placement="top" title={t('web.fileBlock.retry')}>
            <IconButton onClick={onRetry} size="small" color="error">
              <ReloadIcon />
            </IconButton>
          </Tooltip>
        ))}
    </div>
  );
}

Usage in AudioBlock:

<AudioBlockHeader
  hasContent={hasContent}
  name={name}
  needRetry={needRetry}
  loading={loading}
  onRetry={handleRetry}
  t={t}
  emptyRef={emptyRef}
/>

3. Optional: reuse toolbar wiring

If FileToolbar is used similarly in other blocks, wrap the showToolbar hover logic and remoteUrl mapping in a tiny helper/hook:

// useFileToolbar.ts
import { useState, useCallback } from 'react';

export function useFileToolbar(hasContent: boolean) {
  const [showToolbar, setShowToolbar] = useState(false);

  const onMouseEnter = useCallback(() => {
    if (hasContent) setShowToolbar(true);
  }, [hasContent]);

  const onMouseLeave = useCallback(() => setShowToolbar(false), []);

  return { showToolbar, onMouseEnter, onMouseLeave };
}

Then in AudioBlock:

const { showToolbar, onMouseEnter, onMouseLeave } = useFileToolbar(hasContent);

<div
  {...attributes}
  onMouseEnter={onMouseEnter}
  onMouseLeave={onMouseLeave}
  // ...
>
  {/* ... */}
  {showToolbar && remoteUrl && (
    <FileToolbar
      node={
        {
          ...node,
          data: { ...data, url: remoteUrl },
        } as unknown as FileNode
      }
    />
  )}
</div>

These extractions keep all behavior but make AudioBlock mostly compose existing pieces, reducing cognitive load and making the upload/retry logic reusable across FileBlock/ImageBlock.

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.

@appflowy appflowy merged commit 53a2b9d into main May 22, 2026
12 of 13 checks passed
@appflowy appflowy deleted the support_other_blocks branch May 22, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant