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
+ );
+});
@@ -114,5 +138,15 @@ const editor = useEditor({
utiliser les raccourcis clavier.
+
+
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
+ });
+}