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
108 changes: 108 additions & 0 deletions cypress/e2e/embeded/image/copy_image.cy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { v4 as uuidv4 } from 'uuid';
import { AuthTestUtils } from '../../../support/auth-utils';
import { EditorSelectors, waitForReactUpdate, SlashCommandSelectors, AddPageSelectors } from '../../../support/selectors';

describe('Copy Image Test', () => {
const authUtils = new AuthTestUtils();
const testEmail = `${uuidv4()}@appflowy.io`;

beforeEach(() => {
cy.on('uncaught:exception', () => false);

// Mock the image fetch
cy.intercept('GET', '**/logo.png', {
statusCode: 200,
fixture: 'appflowy.png',
headers: {
'content-type': 'image/png',
},
}).as('getImage');

// We need to mock the clipboard write
cy.window().then((win) => {
// Check if clipboard exists
if (win.navigator.clipboard) {
cy.stub(win.navigator.clipboard, 'write').as('clipboardWrite');
} else {
// Mock clipboard if it doesn't exist or is not writable directly
// In some browsers, we might need to redefine the property
const clipboardMock = {
write: cy.stub().as('clipboardWrite')
};
try {
// @ts-ignore
win.navigator.clipboard = clipboardMock;
} catch (e) {
Object.defineProperty(win.navigator, 'clipboard', {
value: clipboardMock,
configurable: true,
writable: true
});
}
}
});

cy.visit('/login', { failOnStatusCode: false });
authUtils.signInWithTestUrl(testEmail).then(() => {
cy.url({ timeout: 30000 }).should('include', '/app');
waitForReactUpdate(1000);
});
});

it('should copy image to clipboard when clicking copy button', () => {
// Create a new page
AddPageSelectors.inlineAddButton().first().click();
waitForReactUpdate(500);
cy.get('[role="menuitem"]').first().click(); // Create Doc
waitForReactUpdate(1000);

// Focus editor
EditorSelectors.firstEditor().should('exist').click({ force: true });
waitForReactUpdate(1000);

// Ensure focus
EditorSelectors.firstEditor().focus();
waitForReactUpdate(500);

// Type '/' to open slash menu
EditorSelectors.firstEditor().type('/', { force: true });
waitForReactUpdate(1000);

// Check if slash panel exists
cy.get('[data-testid="slash-panel"]').should('exist').should('be.visible');

// Type 'image' to filter
EditorSelectors.firstEditor().type('image', { force: true });
waitForReactUpdate(1000);

// Click Image item
cy.get('[data-testid^="slash-menu-"]').contains(/^Image$/).click({ force: true });
waitForReactUpdate(1000);

// Upload image directly
cy.get('input[type="file"]').attachFile('appflowy.png');
waitForReactUpdate(2000);

waitForReactUpdate(2000);

// The image should now be rendered.
// We need to hover or click it to see the toolbar.
// The toolbar is only visible when the block is selected/focused or hovered.
// ImageToolbar.tsx uses useSlateStatic, suggesting it's part of the slate render.

// Find the image block.
cy.get('[data-block-type="image"]').first().should('exist').trigger('mouseover', { force: true }).click({ force: true });
waitForReactUpdate(1000);

// Click the copy button
cy.get('[data-testid="copy-image-button"]').should('exist').click({ force: true });

// Verify clipboard write
cy.get('@clipboardWrite').should('have.been.called');
cy.get('@clipboardWrite').should((stub: any) => {
const clipboardItem = stub.args[0][0][0];
expect(clipboardItem).to.be.instanceOf(ClipboardItem);
expect(clipboardItem.types).to.include('image/png');
});
});
});
Binary file added cypress/fixtures/appflowy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
11 changes: 0 additions & 11 deletions src/components/editor/components/blocks/file/FileToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,12 @@ import { useReadOnly, useSlateStatic } from 'slate-react';

import { YjsEditor } from '@/application/slate-yjs';
import { CustomEditor } from '@/application/slate-yjs/command';
import { ReactComponent as CopyIcon } from '@/assets/icons/copy.svg';
import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg';
import { ReactComponent as EditIcon } from '@/assets/icons/edit.svg';
import { ReactComponent as DownloadIcon } from '@/assets/icons/save_as.svg';
import { NormalModal } from '@/components/_shared/modal';
import { notify } from '@/components/_shared/notify';
import ActionButton from '@/components/editor/components/toolbar/selection-toolbar/actions/ActionButton';
import { FileNode } from '@/components/editor/editor.type';
import { copyTextToClipboard } from '@/utils/copy';
import { downloadFile } from '@/utils/download';

function FileToolbar({ node }: { node: FileNode }) {
Expand All @@ -25,10 +22,6 @@ function FileToolbar({ node }: { node: FileNode }) {
const name = node.data.name || '';
const [open, setOpen] = useState<boolean>(false);
const [fileName, setFileName] = useState<string>(name);
const onCopy = async () => {
await copyTextToClipboard(node.data.url || '');
notify.success(t('publish.copy.fileBlock'));
};

const onDelete = () => {
CustomEditor.deleteBlock(editor, node.blockId);
Expand Down Expand Up @@ -61,10 +54,6 @@ function FileToolbar({ node }: { node: FileNode }) {
<DownloadIcon />
</ActionButton>

<ActionButton onClick={onCopy} tooltip={t('button.copyLinkOriginal')}>
<CopyIcon />
</ActionButton>

{!readOnly && (
<>
<ActionButton
Expand Down
22 changes: 17 additions & 5 deletions src/components/editor/components/blocks/image/ImageToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import ActionButton from '@/components/editor/components/toolbar/selection-toolb
import Align from '@/components/editor/components/toolbar/selection-toolbar/actions/Align';
import { ImageBlockNode } from '@/components/editor/editor.type';
import { useEditorContext } from '@/components/editor/EditorContext';
import { copyTextToClipboard } from '@/utils/copy';
import { fetchImageBlob } from '@/utils/image';

function ImageToolbar({ node }: { node: ImageBlockNode }) {
const editor = useSlateStatic() as YjsEditor;
Expand All @@ -27,9 +27,21 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) {
setOpenPreview(true);
};

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,
}),
]);
Comment on lines +30 to +39
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).

notify.success(t('document.plugins.image.copiedToPasteBoard'));
} catch (error) {
notify.error('Failed to copy image');
Comment on lines +34 to +42
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.

}
}
};

const onDelete = () => {
Expand All @@ -45,7 +57,7 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) {
</ActionButton>
)}

<ActionButton onClick={onCopy} tooltip={t('button.copyLinkOriginal')}>
<ActionButton onClick={onCopyImage} tooltip={t('button.copy')} data-testid="copy-image-button">
<CopyIcon />
</ActionButton>

Expand Down
36 changes: 36 additions & 0 deletions src/utils/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,40 @@ export const checkImage = async (url: string) => {

img.src = url;
});
};

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.

if (isAppFlowyFileStorageUrl(url)) {
const token = getTokenParsed();

if (!token) return null;

const fullUrl = resolveImageUrl(url);

try {
const response = await fetch(fullUrl, {
headers: {
Authorization: `Bearer ${token.access_token}`,
},
});

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

if (response.ok) {
return await response.blob();
}
} catch (error) {
return null;
}
}

return null;
};