From ce3d49cc02cae843f55c8cc0ea35025d12152693 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 20 May 2025 16:40:53 +0200 Subject: [PATCH] fix(editor): make pasting a file work again It seems like only one paste handler is possible - with the change inf52a321acf19b8925a5285abf09ae3ed51ea4ca8 the paste handler for the image paste did not work anymore. Resolves https://community.vikunja.io/t/feature-suggestion-paste-images-directly-into-description-comment-from-clipboard/3656 --- frontend/cypress/e2e/task/task.spec.ts | 23 ++++++++ frontend/cypress/support/commands.ts | 26 +++++++++- frontend/cypress/support/index.d.ts | 12 +++++ .../src/components/input/editor/TipTap.vue | 52 ++++++++----------- 4 files changed, 81 insertions(+), 32 deletions(-) create mode 100644 frontend/cypress/support/index.d.ts diff --git a/frontend/cypress/e2e/task/task.spec.ts b/frontend/cypress/e2e/task/task.spec.ts index ef0f40282..4ea077338 100644 --- a/frontend/cypress/e2e/task/task.spec.ts +++ b/frontend/cypress/e2e/task/task.spec.ts @@ -630,6 +630,29 @@ describe('Task', () => { .should('contain', 'Success') }) + it('Can paste an image into the description editor which uploads it as an attachment', () => { + TaskAttachmentFactory.truncate() + const tasks = TaskFactory.create(1, { + id: 1, + }) as Task[] + cy.visit(`/tasks/${tasks[0].id}`) + + cy.intercept('**/tasks/*/attachments').as('uploadAttachment') + + cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror', {timeout: 30_000}) + .pasteFile('image.jpg', 'image/jpeg') + + cy.wait('@uploadAttachment') + cy.get('.attachments .attachments .files button.attachment') + .should('exist') + cy.get('.task-view .details.content.description .tiptap__editor .tiptap.ProseMirror img') + .should('be.visible') + .and(($img) => { + // "naturalWidth" and "naturalHeight" are set when the image loads + expect($img[0].naturalWidth).to.be.greaterThan(0) + }) + }) + it('Can set a reminder', () => { TaskReminderFactory.truncate() const tasks = TaskFactory.create(1, { diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 698b01a42..4538bf297 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -34,4 +34,28 @@ // visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable // } // } -// } \ No newline at end of file +// } + +Cypress.Commands.add('pasteFile', {prevSubject: true}, (subject, fileName, fileType = 'image/png') => { + // Load the file fixture as base64 + cy.fixture(fileName, 'base64').then((fileContent) => { + // Convert base64 to a Blob + const blob = Cypress.Blob.base64StringToBlob(fileContent, fileType) + // Create a File object + const testFile = new File([blob], fileName, {type: fileType}) + // Create a DataTransfer and add the file + const dataTransfer = new DataTransfer() + dataTransfer.items.add(testFile) + + // Create the paste event with clipboardData containing the file + const pasteEvent = new ClipboardEvent('paste', { + bubbles: true, + cancelable: true, + clipboardData: dataTransfer, + }) + + // Dispatch the paste event on the target element + subject[0].dispatchEvent(pasteEvent) + }) +}) + diff --git a/frontend/cypress/support/index.d.ts b/frontend/cypress/support/index.d.ts new file mode 100644 index 000000000..22498ad26 --- /dev/null +++ b/frontend/cypress/support/index.d.ts @@ -0,0 +1,12 @@ +/// + +declare namespace Cypress { + interface Chainable { + /** + * Pastes a file onto the subject element. + * @param fileName The name of the file to paste + * @param fileType The MIME type of the file (defaults to 'image/png') + */ + pasteFile(fileName: string, fileType?: string): Chainable; + } +} \ No newline at end of file diff --git a/frontend/src/components/input/editor/TipTap.vue b/frontend/src/components/input/editor/TipTap.vue index 32c4c5f0c..7f36fb75b 100644 --- a/frontend/src/components/input/editor/TipTap.vue +++ b/frontend/src/components/input/editor/TipTap.vue @@ -333,15 +333,32 @@ const additionalLinkProtocols = [ 'notion', ] -const MarkdownPasteHandler = Extension.create({ - name: 'markdownPasteHandler', +const PasteHandler = Extension.create({ + name: 'pasteHandler', addProseMirrorPlugins() { return [ new Plugin({ - key: new PluginKey('markdownPasteHandler'), + key: new PluginKey('pasteHandler'), props: { handlePaste: (view, event) => { + + // Handle images pasted from clipboard + if (typeof props.uploadCallback !== 'undefined' && event.clipboardData?.items?.length > 0) { + + for (const item of event.clipboardData.items) { + console.log({item}) + if (item.kind === 'file' && item.type.startsWith('image/')) { + const file = item.getAsFile() + if (file) { + uploadAndInsertFiles([file]) + return true + } + } + } + } + + // Handle markdown text const text = event.clipboardData?.getData('text/plain') if (!text) return false @@ -451,7 +468,7 @@ const extensions : Extensions = [ suggestion: suggestionSetup(t), }), - MarkdownPasteHandler, + PasteHandler, ] // Add a custom extension for the Escape key @@ -616,21 +633,10 @@ onMounted(async () => { await nextTick() - if (typeof props.uploadCallback !== 'undefined') { - const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0] - input?.addEventListener('paste', handleImagePaste) - } - setModeAndValue(props.modelValue) }) onBeforeUnmount(() => { - nextTick(() => { - if (typeof props.uploadCallback !== 'undefined') { - const input = tiptapInstanceRef.value?.querySelectorAll('.tiptap__editor')[0]?.children[0] - input?.removeEventListener('paste', handleImagePaste) - } - }) if (props.editShortcut !== '') { document.removeEventListener('keydown', setFocusToEditor) } @@ -641,22 +647,6 @@ function setModeAndValue(value: string) { editor.value?.commands.setContent(value, false) } -function handleImagePaste(event) { - if (event?.clipboardData?.items?.length === 0) { - return - } - - event.preventDefault() - - const image = event.clipboardData.items[0] - if (image.kind === 'file' && image.type.startsWith('image/')) { - if (typeof props.uploadCallback !== 'undefined') { - return - } - - uploadAndInsertFiles([image.getAsFile()]) - } -} // See https://github.com/github/hotkey/discussions/85#discussioncomment-5214660 function setFocusToEditor(event) {