diff --git a/cypress/e2e/embeded/image/copy_image.cy.ts b/cypress/e2e/embeded/image/copy_image.cy.ts new file mode 100644 index 00000000..4f90532b --- /dev/null +++ b/cypress/e2e/embeded/image/copy_image.cy.ts @@ -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'); + }); + }); +}); diff --git a/cypress/fixtures/appflowy.png b/cypress/fixtures/appflowy.png new file mode 100644 index 00000000..f37764b1 Binary files /dev/null and b/cypress/fixtures/appflowy.png differ diff --git a/src/components/editor/components/blocks/file/FileToolbar.tsx b/src/components/editor/components/blocks/file/FileToolbar.tsx index b1bc1b4d..17537b54 100644 --- a/src/components/editor/components/blocks/file/FileToolbar.tsx +++ b/src/components/editor/components/blocks/file/FileToolbar.tsx @@ -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 }) { @@ -25,10 +22,6 @@ function FileToolbar({ node }: { node: FileNode }) { const name = node.data.name || ''; const [open, setOpen] = useState(false); const [fileName, setFileName] = useState(name); - const onCopy = async () => { - await copyTextToClipboard(node.data.url || ''); - notify.success(t('publish.copy.fileBlock')); - }; const onDelete = () => { CustomEditor.deleteBlock(editor, node.blockId); @@ -61,10 +54,6 @@ function FileToolbar({ node }: { node: FileNode }) { - - - - {!readOnly && ( <> { - 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'); + } + } }; const onDelete = () => { @@ -45,7 +57,7 @@ function ImageToolbar({ node }: { node: ImageBlockNode }) { )} - + diff --git a/src/utils/image.ts b/src/utils/image.ts index 84e08ad1..e013d8b9 100644 --- a/src/utils/image.ts +++ b/src/utils/image.ts @@ -104,4 +104,40 @@ export const checkImage = async (url: string) => { img.src = url; }); +}; + +export const fetchImageBlob = async (url: string): Promise => { + 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; }; \ No newline at end of file