Skip to content

Conversation

@appflowy
Copy link
Contributor

@appflowy appflowy commented Nov 25, 2025

Description


Checklist

General

  • I've included relevant documentation or comments for the changes introduced.
  • I've tested the changes in multiple environments (e.g., different browsers, operating systems).

Testing

  • I've added or updated tests to validate the changes introduced for AppFlowy Web.

Feature-Specific

  • For feature additions, I've added a preview (video, screenshot, or demo) in the "Feature Preview" section.
  • I've verified that this feature integrates seamlessly with existing functionality.

Summary by Sourcery

Add support for copying image content to the clipboard from the image block toolbar while simplifying the file block toolbar actions.

New Features:

  • Enable copying embedded images directly to the clipboard from the image toolbar, including fetching blobs from protected storage URLs.

Enhancements:

  • Introduce a shared utility to fetch image blobs from both authenticated and public URLs.
  • Remove redundant copy-link controls from the file toolbar to streamline file block actions.

Tests:

  • Add end-to-end coverage for copying images from embedded image blocks via the editor toolbar.

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 25, 2025

Reviewer's Guide

Adds support for copying actual image data from the image block toolbar (rather than just the URL), including handling authenticated AppFlowy storage URLs, and cleans up unused copy-link behavior from the file block toolbar while introducing an end-to-end test for the image copy feature.

Sequence diagram for copying an image from the image toolbar

sequenceDiagram
  actor User
  participant ImageToolbar
  participant ImageUtils as fetchImageBlob
  participant AuthUtils
  participant AppFlowyStorage
  participant PublicImageHost
  participant ClipboardAPI as navigator_clipboard
  participant Notify

  User ->> ImageToolbar: Click copy image button
  ImageToolbar ->> ImageUtils: fetchImageBlob(imageUrl)

  alt URL is AppFlowy storage
    ImageUtils ->> AuthUtils: isAppFlowyFileStorageUrl(imageUrl)
    AuthUtils -->> ImageUtils: true
    ImageUtils ->> AuthUtils: getTokenParsed()
    AuthUtils -->> ImageUtils: token or null
    alt token is available
      ImageUtils ->> AuthUtils: resolveImageUrl(imageUrl)
      AuthUtils -->> ImageUtils: fullUrl
      ImageUtils ->> AppFlowyStorage: fetch(fullUrl, Authorization Bearer access_token)
      AppFlowyStorage -->> ImageUtils: Response(blob or error)
      alt response ok
        ImageUtils -->> ImageToolbar: Blob
      else response not ok or error
        ImageUtils -->> ImageToolbar: null
      end
    else token missing
      ImageUtils -->> ImageToolbar: null
    end
  else URL is public
    ImageUtils ->> AuthUtils: isAppFlowyFileStorageUrl(imageUrl)
    AuthUtils -->> ImageUtils: false
    ImageUtils ->> PublicImageHost: fetch(imageUrl)
    PublicImageHost -->> ImageUtils: Response(blob or error)
    alt response ok
      ImageUtils -->> ImageToolbar: Blob
    else response not ok or error
      ImageUtils -->> ImageToolbar: null
    end
  end

  alt Blob is available
    ImageToolbar ->> ClipboardAPI: write([ClipboardItem({ blobType: blob })])
    alt Clipboard write success
      ClipboardAPI -->> ImageToolbar: success
      ImageToolbar ->> Notify: success(document.plugins.image.copiedToPasteBoard)
    else Clipboard write failure
      ClipboardAPI -->> ImageToolbar: error
      ImageToolbar ->> Notify: error(Failed to copy image)
    end
  else Blob is null
    ImageToolbar -->> User: Copy silently fails (no notification)
  end
Loading

Class diagram for updated image utilities and toolbars

classDiagram
  class ImageUtils {
    +checkImage(url)
    +fetchImageBlob(url) Blob
  }

  class ImageToolbar {
    +node ImageBlockNode
    +onOpenPreview()
    +onCopyImage()
    +onDelete()
  }

  class FileToolbar {
    +node FileNode
    +onDelete()
    +onChangeName(event)
    +onDownload()
  }

  class ImageBlockNode {
    +data ImageBlockData
    +blockId string
  }

  class FileNode {
    +data FileBlockData
    +blockId string
  }

  class ImageBlockData {
    +url string
  }

  class FileBlockData {
    +url string
    +name string
  }

  class ClipboardAPI {
    +write(items)
  }

  class AuthUtils {
    +isAppFlowyFileStorageUrl(url) boolean
    +getTokenParsed() Token
    +resolveImageUrl(url) string
  }

  class Token {
    +access_token string
  }

  ImageToolbar --> ImageBlockNode
  ImageToolbar --> ImageUtils : uses
  ImageToolbar --> ClipboardAPI : uses
  ImageToolbar --> Notify : uses

  FileToolbar --> FileNode
  FileToolbar --> DownloadUtils : uses

  ImageBlockNode --> ImageBlockData
  FileNode --> FileBlockData

  ImageUtils --> AuthUtils
  AuthUtils --> Token

  class Notify {
    +success(message)
    +error(message)
  }

  class DownloadUtils {
    +downloadFile(url name)
  }
Loading

Flow diagram for fetchImageBlob image retrieval logic

flowchart TD
  A_start[Start fetchImageBlob url]
  B_checkStorage{isAppFlowyFileStorageUrl url}
  C_getToken[getTokenParsed]
  D_tokenNull{token is null}
  E_resolveUrl[resolveImageUrl url]
  F_fetchStorage[fetch fullUrl with Authorization Bearer access_token]
  G_fetchPublic[fetch url]
  H_respOkStorage{response.ok}
  I_respOkPublic{response.ok}
  J_returnBlobStorage[return response.blob]
  K_returnBlobPublic[return response.blob]
  L_returnNullError[return null]
  M_returnNullEnd[return null]

  A_start --> B_checkStorage

  B_checkStorage -- yes --> C_getToken
  C_getToken --> D_tokenNull
  D_tokenNull -- yes --> M_returnNullEnd
  D_tokenNull -- no --> E_resolveUrl
  E_resolveUrl --> F_fetchStorage
  F_fetchStorage --> H_respOkStorage
  H_respOkStorage -- yes --> J_returnBlobStorage
  H_respOkStorage -- no --> L_returnNullError

  B_checkStorage -- no --> G_fetchPublic
  G_fetchPublic --> I_respOkPublic
  I_respOkPublic -- yes --> K_returnBlobPublic
  I_respOkPublic -- no --> L_returnNullError

  J_returnBlobStorage -->|success| End_success_storage((End))
  K_returnBlobPublic -->|success| End_success_public((End))
  L_returnNullError --> M_returnNullEnd
  M_returnNullEnd --> End_null((End null))
Loading

File-Level Changes

Change Details Files
Add a utility to fetch image data as a Blob, supporting both authenticated AppFlowy storage URLs and public URLs.
  • Introduce fetchImageBlob that resolves AppFlowy storage URLs, attaches the current access token as a Bearer header, and fetches the image as a Blob.
  • Fallback to unauthenticated fetch for non-AppFlowy storage URLs and return null on failure or non-ok responses.
  • Ensure all failure paths return null to simplify caller handling of missing/failed fetches.
src/utils/image.ts
Change the image toolbar copy action to copy the actual image to the system clipboard instead of copying its URL.
  • Replace the previous text-URL copy logic with a new onCopyImage handler that uses fetchImageBlob to retrieve the image data.
  • Use navigator.clipboard.write with a ClipboardItem keyed by the Blob MIME type to place the image on the clipboard.
  • Show a success notification on successful copy and an error notification when clipboard write fails, and update the button tooltip and test id to reflect the new behavior.
src/components/editor/components/blocks/image/ImageToolbar.tsx
Remove now-unused copy-link behavior from the file toolbar to keep the UI and code consistent.
  • Delete the onCopy handler and its use in the file toolbar, removing the Copy icon button from the file block controls.
  • Clean up now-unused imports related to copy-to-clipboard and notifications in the file toolbar component.
src/components/editor/components/blocks/file/FileToolbar.tsx
Add an end-to-end test covering the image copy-to-clipboard behavior in the embedded editor.
  • Introduce a new Cypress spec file under the embedded image tests directory to validate the new image copy button behavior (details to be reviewed in the spec).
cypress/e2e/embeded/image/copy_image.cy.ts

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

@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 there - I've reviewed your changes - here's some feedback:

  • In fetchImageBlob, the authenticated and unauthenticated branches largely duplicate the fetch logic and swallow errors; consider extracting a shared helper/returning more detailed error information (or at least logging) to make failures easier to diagnose.
  • The new onCopyImage handler assumes navigator.clipboard.write and ClipboardItem are available; add feature detection and a graceful fallback or user message for browsers that don't support the async Clipboard API.
  • The error notification message 'Failed to copy image' is hard-coded; switch this to use the existing i18n translation mechanism to keep messaging consistent with the rest of the UI.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In fetchImageBlob, the authenticated and unauthenticated branches largely duplicate the fetch logic and swallow errors; consider extracting a shared helper/returning more detailed error information (or at least logging) to make failures easier to diagnose.
- The new onCopyImage handler assumes navigator.clipboard.write and ClipboardItem are available; add feature detection and a graceful fallback or user message for browsers that don't support the async Clipboard API.
- The error notification message 'Failed to copy image' is hard-coded; switch this to use the existing i18n translation mechanism to keep messaging consistent with the rest of the UI.

## Individual Comments

### Comment 1
<location> `src/components/editor/components/blocks/image/ImageToolbar.tsx:30-39` </location>
<code_context>
-  const onCopy = async () => {
-    await copyTextToClipboard(node.data.url || '');
-    notify.success(t('document.plugins.image.copiedToPasteBoard'));
+  const onCopyImage = async () => {
+    const blob = await fetchImageBlob(node.data.url || '');
+
+    if (blob) {
+      try {
+        await navigator.clipboard.write([
+          new ClipboardItem({
+            [blob.type]: blob,
+          }),
+        ]);
+        notify.success(t('document.plugins.image.copiedToPasteBoard'));
+      } catch (error) {
+        notify.error('Failed to copy image');
+      }
+    }
   };
</code_context>

<issue_to_address>
**issue:** When `fetchImageBlob` returns null, the handler fails silently without user feedback.

In that case, the function just returns and the button appears unresponsive to the user. Consider showing an error toast when `blob` is null so users know the copy failed (and ideally why).
</issue_to_address>

### Comment 2
<location> `src/components/editor/components/blocks/image/ImageToolbar.tsx:34-42` </location>
<code_context>
+             const clipboardMock = {
+                 write: cy.stub().as('clipboardWrite')
+             };
+             try {
+                 // @ts-ignore
+                 win.navigator.clipboard = clipboardMock;
</code_context>

<issue_to_address>
**nitpick:** The error message for failed image copy is hardcoded and not localized.

The success path already uses `document.plugins.image.copiedToPasteBoard`, but the error path still uses a hardcoded English string. Please also source the error text from your i18n system (e.g., `t('document.plugins.image.copyFailed')`) to keep behavior consistent and localizable.
</issue_to_address>

### Comment 3
<location> `src/utils/image.ts:109` </location>
<code_context>
   });
+};
+
+export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
+  if (isAppFlowyFileStorageUrl(url)) {
+    const token = getTokenParsed();
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring fetchImageBlob so URL/header resolution is separated from the fetch call and the network logic is shared in a single try/catch block to reduce duplication.

You can simplify `fetchImageBlob` by separating URL/header resolution from the network call and using a single `try/catch`. This removes duplicated `fetch`/`response.ok`/`blob()` logic while preserving behavior.

For example:

```ts
export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
  const isStorageUrl = isAppFlowyFileStorageUrl(url);

  // Resolve URL and headers up front
  if (isStorageUrl) {
    const token = getTokenParsed();
    if (!token) return null;

    const fullUrl = resolveImageUrl(url);
    const headers = { Authorization: `Bearer ${token.access_token}` };

    try {
      const response = await fetch(fullUrl, { headers });
      if (!response.ok) return null;
      return await response.blob();
    } catch {
      return null;
    }
  }

  try {
    const response = await fetch(url);
    if (!response.ok) return null;
    return await response.blob();
  } catch {
    return null;
  }
};
```

You can go a step further and share the `fetch` logic completely:

```ts
export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
  const isStorageUrl = isAppFlowyFileStorageUrl(url);

  let finalUrl = url;
  let headers: HeadersInit | undefined;

  if (isStorageUrl) {
    const token = getTokenParsed();
    if (!token) return null;

    finalUrl = resolveImageUrl(url);
    headers = { Authorization: `Bearer ${token.access_token}` };
  }

  try {
    const response = await fetch(finalUrl, headers ? { headers } : undefined);
    if (!response.ok) return null;
    return await response.blob();
  } catch {
    return null;
  }
};
```

This keeps:
- A single exit path for the network call.
- All branching limited to computing `finalUrl` and `headers`.
- The early return on missing token.
</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 +30 to +39
const onCopyImage = async () => {
const blob = await fetchImageBlob(node.data.url || '');

if (blob) {
try {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
Copy link

Choose a reason for hiding this comment

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

issue: When fetchImageBlob returns null, the handler fails silently without user feedback.

In that case, the function just returns and the button appears unresponsive to the user. Consider showing an error toast when blob is null so users know the copy failed (and ideally why).

Comment on lines +34 to +42
try {
await navigator.clipboard.write([
new ClipboardItem({
[blob.type]: blob,
}),
]);
notify.success(t('document.plugins.image.copiedToPasteBoard'));
} catch (error) {
notify.error('Failed to copy image');
Copy link

Choose a reason for hiding this comment

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

nitpick: The error message for failed image copy is hardcoded and not localized.

The success path already uses document.plugins.image.copiedToPasteBoard, but the error path still uses a hardcoded English string. Please also source the error text from your i18n system (e.g., t('document.plugins.image.copyFailed')) to keep behavior consistent and localizable.

});
};

export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
Copy link

Choose a reason for hiding this comment

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

issue (complexity): Consider refactoring fetchImageBlob so URL/header resolution is separated from the fetch call and the network logic is shared in a single try/catch block to reduce duplication.

You can simplify fetchImageBlob by separating URL/header resolution from the network call and using a single try/catch. This removes duplicated fetch/response.ok/blob() logic while preserving behavior.

For example:

export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
  const isStorageUrl = isAppFlowyFileStorageUrl(url);

  // Resolve URL and headers up front
  if (isStorageUrl) {
    const token = getTokenParsed();
    if (!token) return null;

    const fullUrl = resolveImageUrl(url);
    const headers = { Authorization: `Bearer ${token.access_token}` };

    try {
      const response = await fetch(fullUrl, { headers });
      if (!response.ok) return null;
      return await response.blob();
    } catch {
      return null;
    }
  }

  try {
    const response = await fetch(url);
    if (!response.ok) return null;
    return await response.blob();
  } catch {
    return null;
  }
};

You can go a step further and share the fetch logic completely:

export const fetchImageBlob = async (url: string): Promise<Blob | null> => {
  const isStorageUrl = isAppFlowyFileStorageUrl(url);

  let finalUrl = url;
  let headers: HeadersInit | undefined;

  if (isStorageUrl) {
    const token = getTokenParsed();
    if (!token) return null;

    finalUrl = resolveImageUrl(url);
    headers = { Authorization: `Bearer ${token.access_token}` };
  }

  try {
    const response = await fetch(finalUrl, headers ? { headers } : undefined);
    if (!response.ok) return null;
    return await response.blob();
  } catch {
    return null;
  }
};

This keeps:

  • A single exit path for the network call.
  • All branching limited to computing finalUrl and headers.
  • The early return on missing token.

@appflowy appflowy merged commit 4949cc3 into main Nov 25, 2025
12 checks passed
@appflowy appflowy deleted the image_button branch November 25, 2025 06:28
josue693 pushed a commit to josue693/AppFlowy-Web that referenced this pull request Dec 21, 2025
* refactor: remove 'Copy to original' button from Image and File toolbars

* feat: add 'Copy image' button to ImageToolbar

* chore: add test
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.

2 participants