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
This commit is contained in:
kolaente
2025-05-20 16:40:53 +02:00
parent ec324f8c5a
commit ce3d49cc02
4 changed files with 81 additions and 32 deletions

View File

@@ -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, {

View File

@@ -34,4 +34,28 @@
// visit(originalFn: CommandOriginalFn, url: string, options: Partial<VisitOptions>): Chainable<Element>
// }
// }
// }
// }
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)
})
})

12
frontend/cypress/support/index.d.ts vendored Normal file
View File

@@ -0,0 +1,12 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable<Subject = any> {
/**
* 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<Subject>;
}
}

View File

@@ -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) {