From 627f805f221bcbcd06635058dcae28445b094923 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Fri, 15 Nov 2024 23:37:09 +0100 Subject: [PATCH] Tiptap: refactor to allow image upload from button Work in Progress --- .../src/components/ui/FileUpload.vue | 4 +- .../src/components/ui/Tiptap.vue | 38 +- confiture-web-app/src/enums.ts | 4 + .../src/tiptap/ImageUploadTiptapExtension.ts | 461 ++++++++++-------- 4 files changed, 302 insertions(+), 205 deletions(-) diff --git a/confiture-web-app/src/components/ui/FileUpload.vue b/confiture-web-app/src/components/ui/FileUpload.vue index 86890819..2d80e179 100644 --- a/confiture-web-app/src/components/ui/FileUpload.vue +++ b/confiture-web-app/src/components/ui/FileUpload.vue @@ -3,7 +3,7 @@ import { computed, Ref, ref } from "vue"; import { useIsOffline } from "../../composables/useIsOffline"; import { useUniqueId } from "../../composables/useUniqueId"; -import { FileErrorMessage } from "../../enums"; +import { FileErrorMessage, Limitations } from "../../enums"; import { AuditFile } from "../../types"; import { formatBytes, getUploadUrl } from "../../utils"; @@ -71,7 +71,7 @@ const acceptedFormatsAttr = computed(() => { function handleFileChange() { if (fileInputRef.value?.files && fileInputRef.value?.files[0]) { const file = fileInputRef.value?.files[0]; - if (file.size > 2000000) { + if (file.size > Limitations.FILE_SIZE) { localErrorMessage.value = FileErrorMessage.UPLOAD_SIZE; return; } diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue index 0178871c..8d01492c 100644 --- a/confiture-web-app/src/components/ui/Tiptap.vue +++ b/confiture-web-app/src/components/ui/Tiptap.vue @@ -16,11 +16,14 @@ import html from "highlight.js/lib/languages/xml"; // load common languages import { common, createLowlight } from "lowlight"; import { Markdown } from "tiptap-markdown"; -import { computed, ShallowRef } from "vue"; +import { computed, onMounted, ShallowRef } from "vue"; import { useRoute } from "vue-router"; import { useNotifications } from "../../composables/useNotifications"; -import { ImageUploadTiptapExtension } from "../../tiptap/ImageUploadTiptapExtension"; +import { + ImageUploadTiptapExtension, + insertFilesAtSelection +} from "../../tiptap/ImageUploadTiptapExtension"; // create a lowlight instance const lowlight = createLowlight(common); @@ -105,6 +108,27 @@ const editor = useEditor({ emit("update:content", JSON.stringify(editor.getJSON())); } }) as ShallowRef; + +onMounted(() => { + const browseButton = document.getElementById("tiptap-browse-btn")!; + const browseInput = document.getElementById("tiptap-browse-input")!; + browseButton.addEventListener( + "click", + () => { + browseInput.click(); + }, + false + ); + browseInput.addEventListener( + "change", + (e) => { + const inputElement = e?.target as HTMLInputElement; + const files = inputElement.files!; + insertFilesAtSelection(uniqueId.value, editor.value, files); + }, + false + ); +}); diff --git a/confiture-web-app/src/enums.ts b/confiture-web-app/src/enums.ts index f9d9bc9b..08265be9 100644 --- a/confiture-web-app/src/enums.ts +++ b/confiture-web-app/src/enums.ts @@ -24,6 +24,10 @@ export enum Browsers { EDGE = "Microsoft Edge" } +export enum Limitations { + FILE_SIZE = 2000000 +} + export enum FileErrorMessage { UPLOAD_SIZE = "Votre fichier dépasse la limite de 2 Mo. Veuillez choisir un fichier plus léger.", UPLOAD_FORMAT = "Format de fichier non supporté.", diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts index baa800e0..e6b6de3d 100644 --- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts +++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts @@ -1,10 +1,10 @@ -import { Extension } from "@tiptap/core"; +import { Editor, Extension } from "@tiptap/core"; import { Slice } from "@tiptap/pm/model"; import { EditorState, Plugin, Selection, Transaction } from "@tiptap/pm/state"; import { canSplit } from "@tiptap/pm/transform"; import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view"; -import { FileErrorMessage } from "../enums"; +import { FileErrorMessage, Limitations } from "../enums"; import { useAuditStore } from "../store/audit"; import { AuditFile, FileDisplay } from "../types"; import { getUploadUrl, handleFileUploadError } from "../utils"; @@ -101,7 +101,12 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => { return false; } - return handleDataTransfer(view, dragEvent.dataTransfer, position.pos); + return handleDataTransfer( + uniqueId, + view, + dragEvent.dataTransfer, + position.pos + ); }, /** @@ -126,229 +131,265 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => { } const pos = view.state.selection.from; - return handleDataTransfer(view, clipboardEvent.clipboardData, pos, { - replaceSelection: true - }); + return handleDataTransfer( + uniqueId, + view, + clipboardEvent.clipboardData, + pos, + { + replaceSelection: true + } + ); } } }); +}; - /** - * handleDataTransfer: called for both drop and paste. - * - * @param {EditorView} view - * @param {DataTransfer} dataTransfer - * @param {number} pos - * @param {{replaceSelection: boolean}} options - * @returns true if event is handled, otherwise false - */ - function handleDataTransfer( - view: EditorView, - dataTransfer: DataTransfer, - pos: number, - options?: { replaceSelection: boolean } - ) { - if (dataTransfer.files.length === 0) { - // Handle a single URL (ex: when an external image is dragged from another window) - // TODO multiple URLs - // See: "text/uri-list" and - // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types - const url = dataTransfer.getData("URL"); - if (url) { - createFileFromImageUrl(url).then((file) => { - if (file) { - handleFileImport(view, pos, file, options); - } - }); - } - return true; +/** + * handleDataTransfer: called for both drop and paste. + * + * @param {string} uniqueId + * @param {EditorView} view + * @param {DataTransfer} dataTransfer + * @param {number} pos + * @param {{replaceSelection: boolean}} options + * @returns true if event is handled, otherwise false + */ +function handleDataTransfer( + uniqueId: string, + view: EditorView, + dataTransfer: DataTransfer, + pos: number, + options?: { replaceSelection: boolean } +) { + if (dataTransfer.files.length === 0) { + // Handle a single URL (ex: when an external image is dragged from another window) + // TODO multiple URLs + // See: "text/uri-list" and + // https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API/Recommended_drag_types + const url = dataTransfer.getData("URL"); + if (url) { + createFileFromImageUrl(url).then((file) => { + if (file) { + handleFileImport(uniqueId, view, pos, file, options); + } + }); } + return true; + } - // Handle multiple files - // FIXME: sometimes placeholders order differs from final images order - const files: FileList = dataTransfer.files; - for (let i = 0, il = files.length, file: File; i < il; i++) { - file = files.item(i)!; - if (!handleFileImport(view, pos, file, options)) { - return false; - } + // Handle multiple files + return handleFilesImport(uniqueId, view, pos, dataTransfer.files, options); +} + +/** + * Handles multiple files import (drop or paste) + * + * @param {string} uniqueId + * @param {EditorView} view + * @param {number} pos + * @param {FileList} files + * @param {{replaceSelection: boolean}} options + * @returns {boolean} true or false if: + * - files array is empty + * - any file is not dropped inside of the editor (should not happen) + */ +function handleFilesImport( + uniqueId: string, + view: EditorView, + pos: number, + files: FileList, + options?: { replaceSelection: boolean } +): boolean { + // FIXME: sometimes placeholders order differs from final images order + for (let i = 0, il = files.length, file: File; i < il; i++) { + file = files.item(i)!; + if (!handleFileImport(uniqueId, view, pos, file, options)) { + return false; } + } + return true; +} +/** + * Handles file import (drop or paste) + * + * @param {string} uniqueId + * @param {EditorView} view + * @param {number} pos + * @param {File} file + * @param {{replaceSelection: boolean}} options + * @returns {boolean} true or false if file is not dropped inside of the editor (should not happen) + */ +function handleFileImport( + uniqueId: string, + view: EditorView, + pos: number, + file: File, + options?: { replaceSelection: boolean } +): boolean { + if (file.size > Limitations.FILE_SIZE) { + //FIXME: use a notification + window.alert(FileErrorMessage.UPLOAD_SIZE); return true; } - /** - * Handles file import (drop or paste) - * - * @param {EditorView} view - * @param {number} pos - * @param {File} file - * @param {{replaceSelection: boolean}} options - * @returns {boolean} true or false if file is not dropped inside of the editor (should not happen) - */ - function handleFileImport( - view: EditorView, - pos: number, - file: File, - options?: { replaceSelection: boolean } - ): boolean { - if (file.size > 2000000) { - //FIXME: use a notification - window.alert(FileErrorMessage.UPLOAD_SIZE); - return true; - } - - // A fresh object to act as the ID for this upload - const id = {}; + // A fresh object to act as the ID for this upload + const id = {}; - const _URL = window.URL || window.webkitURL; - const localURL = _URL.createObjectURL(file); - // const container: HTMLParagraphElement = document.createElement("p"); - let element: HTMLImageElement | HTMLVideoElement; - if (file.type.startsWith("image")) { - element = document.createElement("img"); - // container.appendChild(element); - element.onerror = () => { - //FIXME: use a notification - window.alert(FileErrorMessage.UPLOAD_FORMAT); - }; - element.onload = () => { - URL.revokeObjectURL(element.src); - element.setAttribute("width", element.width.toString()); - element.setAttribute("height", element.height.toString()); - const state: EditorState = view.state; - const tr: Transaction = state.tr; + const _URL = window.URL || window.webkitURL; + const localURL = _URL.createObjectURL(file); + // const container: HTMLParagraphElement = document.createElement("p"); + let element: HTMLImageElement | HTMLVideoElement; + if (file.type.startsWith("image")) { + element = document.createElement("img"); + // container.appendChild(element); + element.onerror = () => { + //FIXME: use a notification + window.alert(FileErrorMessage.UPLOAD_FORMAT); + }; + element.onload = () => { + URL.revokeObjectURL(element.src); + element.setAttribute("width", element.width.toString()); + element.setAttribute("height", element.height.toString()); + const state: EditorState = view.state; + const tr: Transaction = state.tr; - if (options?.replaceSelection) { - tr.deleteSelection(); + if (options?.replaceSelection && !state.selection.empty) { + tr.deleteSelection(); - // Delete the paragraph if it becomes empty - if (tr.doc.resolve(pos).parent.textContent === "") { - tr.deleteRange(pos - 1, pos + 1); - } + // Delete the paragraph if it becomes empty + if (tr.doc.resolve(pos).parent.textContent === "") { + tr.deleteRange(pos - 1, pos + 1); } + } - const $pos = tr.doc.resolve(pos); - if (canSplit(state.tr.doc, pos)) { - if (pos === $pos.start()) { - pos -= 1; - } else { - if (pos < $pos.end()) { - tr.split(pos); - } - pos += 1; + const $pos = tr.doc.resolve(pos); + if (canSplit(state.tr.doc, pos)) { + if (pos === $pos.start()) { + pos -= 1; + } else { + if (pos < $pos.end()) { + tr.split(pos); } + pos += 1; } - tr.setMeta(PlaceholderPlugin, { - add: { id, container: null, element, pos } - }); - tr.setSelection(Selection.near(tr.doc.resolve(pos), 1)); - view.dispatch(tr); - uploadAndReplacePlaceholder(view, file, id); - }; - element.src = localURL; - } else if (file.type.startsWith("video")) { - //FIXME: Handle videos - // element = document.createElement("video"); - // … - //FIXME: use a notification - window.alert(FileErrorMessage.UPLOAD_FORMAT); - } else { - //FIXME: use a notification - window.alert(FileErrorMessage.UPLOAD_FORMAT); - } - - return true; + } + tr.setMeta(PlaceholderPlugin, { + add: { id, container: null, element, pos } + }); + tr.setSelection(Selection.near(tr.doc.resolve(pos), 1)); + view.dispatch(tr); + uploadAndReplacePlaceholder(uniqueId, view, file, id); + }; + element.src = localURL; + } else if (file.type.startsWith("video")) { + //FIXME: Handle videos + // element = document.createElement("video"); + // … + //FIXME: use a notification + window.alert(FileErrorMessage.UPLOAD_FORMAT); + } else { + //FIXME: use a notification + window.alert(FileErrorMessage.UPLOAD_FORMAT); } - /** - * Uploads and then replaces the placeholder - * - * @param {EditorView} view - * @param {DragEvent} dragEvent - * @param {File} file - * @param {{replaceSelection: boolean}} options - */ - function uploadAndReplacePlaceholder(view: EditorView, file: File, id: any) { - const auditStore = useAuditStore(); - auditStore.uploadAuditFile(uniqueId, file, FileDisplay.EDITOR).then( - (response: AuditFile) => { - const placeholder = findPlaceholderDecoration(view.state, id); - const pos: number | undefined = placeholder?.from; - - // If the content around the placeholder has been deleted, drop - // the image - if (pos === undefined) { - // TODO remove image from server - return; - } + return true; +} - // Otherwise, insert it at the placeholder's position, and remove - // the placeholder - const imgUrl: string = getUploadUrl(response.key); - const state = view.state; - const tr = state.tr; - const node = state.schema.nodes.image.create({ - width: placeholder?.spec.width, - height: placeholder?.spec.height, - src: imgUrl - }); - tr.replaceWith(pos, pos, node); - tr.setMeta(PlaceholderPlugin, { remove: { id } }); +/** + * Uploads and then replaces the placeholder + * + * @param {string} uniqueId + * @param {EditorView} view + * @param {DragEvent} dragEvent + * @param {File} file + * @param {{replaceSelection: boolean}} options + */ +function uploadAndReplacePlaceholder( + uniqueId: string, + view: EditorView, + file: File, + id: any +) { + const auditStore = useAuditStore(); + auditStore.uploadAuditFile(uniqueId, file, FileDisplay.EDITOR).then( + (response: AuditFile) => { + const placeholder = findPlaceholderDecoration(view.state, id); + const pos: number | undefined = placeholder?.from; - // Selects the image - // tr.setSelection(new NodeSelection(tr.doc.resolve(pos))); - view.dispatch(tr); - }, - async (reason: any) => { - // On failure, just clean up the placeholder - view.dispatch( - view.state.tr.setMeta(PlaceholderPlugin, { remove: { id } }) - ); - //FIXME: use a notification - window.alert(await handleFileUploadError(reason)); + // If the content around the placeholder has been deleted, drop + // the image + if (pos === undefined) { + // TODO remove image from server + return; } - ); - } - /** - * Finds the given placeholder (by id) within the given editor state. - * - * @param {EditorState} state - * @param {any} id - * @returns {Decoration} the placeholder (a ProseMirror decoration) - */ - function findPlaceholderDecoration( - state: EditorState, - id: any - ): Decoration | undefined { - const decos = PlaceholderPlugin.getState(state); - const found = decos?.find(undefined, undefined, (spec) => spec.id == id); - return found?.[0]; - } - - /** - * Creates a File object from a given URL - * - * @param {string} url - * @returns {Promise} the created File or null if any error - */ - function createFileFromImageUrl(url: string): Promise { - let mimeType: string | undefined = undefined; - return fetch(url) - .then((res: Response) => { - mimeType = res.headers.get("content-type") || undefined; - return res.arrayBuffer(); - }) - .then((buf: ArrayBuffer) => { - return new File([buf], "external", { type: mimeType }); - }) - .catch(() => { - window.alert(FileErrorMessage.FETCH_ERROR); - return null; + // Otherwise, insert it at the placeholder's position, and remove + // the placeholder + const imgUrl: string = getUploadUrl(response.key); + const state = view.state; + const tr = state.tr; + const node = state.schema.nodes.image.create({ + width: placeholder?.spec.width, + height: placeholder?.spec.height, + src: imgUrl }); - } -}; + tr.replaceWith(pos, pos, node); + tr.setMeta(PlaceholderPlugin, { remove: { id } }); + + // Selects the image + // tr.setSelection(new NodeSelection(tr.doc.resolve(pos))); + view.dispatch(tr); + }, + async (reason: any) => { + // On failure, just clean up the placeholder + view.dispatch( + view.state.tr.setMeta(PlaceholderPlugin, { remove: { id } }) + ); + //FIXME: use a notification + window.alert(await handleFileUploadError(reason)); + } + ); +} + +/** + * Finds the given placeholder (by id) within the given editor state. + * + * @param {EditorState} state + * @param {any} id + * @returns {Decoration} the placeholder (a ProseMirror decoration) + */ +function findPlaceholderDecoration( + state: EditorState, + id: any +): Decoration | undefined { + const decos = PlaceholderPlugin.getState(state); + const found = decos?.find(undefined, undefined, (spec) => spec.id == id); + return found?.[0]; +} + +/** + * Creates a File object from a given URL + * + * @param {string} url + * @returns {Promise} the created File or null if any error + */ +function createFileFromImageUrl(url: string): Promise { + let mimeType: string | undefined = undefined; + return fetch(url) + .then((res: Response) => { + mimeType = res.headers.get("content-type") || undefined; + return res.arrayBuffer(); + }) + .then((buf: ArrayBuffer) => { + return new File([buf], "external", { type: mimeType }); + }) + .catch(() => { + window.alert(FileErrorMessage.FETCH_ERROR); + return null; + }); +} /** * Extension ImageUploadTiptapExtension @@ -383,3 +424,21 @@ export const ImageUploadTiptapExtension = // }; // } }); + +export function insertFilesAtSelection( + uniqueId: string, + editor: Editor, + files: FileList +) { + const view: EditorView = editor.view; + const state: EditorState = view.state; + const tr: Transaction = state.tr; + const pos = state.selection.from; + + view.focus(); + tr.deleteSelection(); + + return handleFilesImport(uniqueId, view, pos, files, { + replaceSelection: true + }); +}