Skip to content

Commit

Permalink
Tiptap: handle images drag and drop
Browse files Browse the repository at this point in the history
Add a new extension: ImageUploadTiptapExtension
  • Loading branch information
yaaax committed Sep 13, 2024
1 parent d9932b1 commit 9f3a245
Show file tree
Hide file tree
Showing 7 changed files with 236 additions and 7 deletions.
1 change: 1 addition & 0 deletions confiture-web-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@sentry/vue": "^7.37.2",
"@tiptap/extension-code-block-lowlight": "^2.5.9",
"@tiptap/extension-highlight": "^2.5.9",
"@tiptap/extension-image": "^2.6.6",
"@tiptap/extension-link": "^2.5.9",
"@tiptap/extension-task-item": "^2.5.9",
"@tiptap/extension-task-list": "^2.5.9",
Expand Down
12 changes: 7 additions & 5 deletions confiture-web-app/src/components/audit/NotesModal.vue
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ const files = computed(
) || []
);
const handleNotesChange = debounce(() => emit("confirm", notes.value), 500);
const handleNotesChange = debounce((notesContent: string) => {
notes.value = notesContent;
emit("confirm", notes.value);
}, 500);
function handleUploadFile(file: File) {
auditStore
Expand Down Expand Up @@ -116,8 +119,7 @@ function handleDeleteFile(file: AuditFile) {
rows="10"
:disabled="isOffline"
aria-describedby="notes-markdown"
@input="handleNotesChange"
@update:content="($content) => (notes = $content)"
@update:content="handleNotesChange"
/>
</div>
<MarkdownHelpButton
Expand Down Expand Up @@ -154,8 +156,8 @@ function handleDeleteFile(file: AuditFile) {
flex-wrap: wrap;
column-gap: 1rem;
align-items: center;
margin: 2rem 0 1.5rem 0;
padding: 0.75rem 0;
margin: 0 -3rem 1.5rem -3rem;
padding: 2rem 3rem 0.75rem;
position: sticky;
top: 0;
z-index: 9;
Expand Down
19 changes: 17 additions & 2 deletions confiture-web-app/src/components/ui/Tiptap.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,25 @@
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import Heading from "@tiptap/extension-heading";
import Highlight from "@tiptap/extension-highlight";
import { Image as ImageExtension } from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import TaskItem from "@tiptap/extension-task-item";
import TaskList from "@tiptap/extension-task-list";
import Typography from "@tiptap/extension-typography";
import StarterKit from "@tiptap/starter-kit";
import { EditorContent, useEditor } from "@tiptap/vue-3";
import { Editor, EditorContent, useEditor } from "@tiptap/vue-3";
import css from "highlight.js/lib/languages/css";
import js from "highlight.js/lib/languages/javascript";
import ts from "highlight.js/lib/languages/typescript";
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 { useRoute } from "vue-router";
import { useNotifications } from "../../composables/useNotifications";
import { ImageUploadTiptapExtension } from "../../tiptap/ImageUploadTiptapExtension";
// create a lowlight instance
const lowlight = createLowlight(common);
Expand All @@ -25,11 +31,16 @@ lowlight.register("css", css);
lowlight.register("js", js);
lowlight.register("ts", ts);
const route = useRoute();
const notify = useNotifications();

Check failure on line 35 in confiture-web-app/src/components/ui/Tiptap.vue

View workflow job for this annotation

GitHub Actions / Run linters

'notify' is assigned a value but never used
const props = defineProps<{
content: string;
}>();
const emit = defineEmits(["update:content"]);
const uniqueId = computed(() => route.params.uniqueId as string);
function getContent() {
let jsonContent;
try {
Expand Down Expand Up @@ -57,6 +68,10 @@ const editor = useEditor({
}),
TaskItem,
TaskList,
ImageExtension.configure({ inline: false }),
ImageUploadTiptapExtension.configure({
uniqueId: uniqueId.value
}),
Typography.configure({
openDoubleQuote: "« ",
closeDoubleQuote: " »"
Expand All @@ -66,7 +81,7 @@ const editor = useEditor({
// The content has changed.
emit("update:content", JSON.stringify(editor.getJSON()));
}
});
}) as ShallowRef<Editor>;
</script>

<template>
Expand Down
1 change: 1 addition & 0 deletions confiture-web-app/src/store/audit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ export const useAuditStore = defineStore("audit", {

const notesFiles = this.entities[uniqueId].notesFiles || [];
notesFiles.push(notesFile);
return notesFile;
},

async deleteAuditFile(uniqueId: string, fileId: number) {
Expand Down
39 changes: 39 additions & 0 deletions confiture-web-app/src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,40 @@ from DSFR links with `target="_blank"` */
}

.tiptap {
background-color: var(--background-default-grey);
padding: 1rem;
border: 1px solid var(--border-default-grey);
min-height: 10rem;
height: 70vh;
height: 70dvh;
max-height: 80vh;
max-height: 80dvh;
overflow-y: auto;
}

.tiptap img {
cursor: pointer;
max-width: 100%;
}

.tiptap p {
vertical-align: middle;
}

/* Testing some different UI for TipTap editor: */
/* .tiptap[contenteditable]:not([contenteditable="false"]),
.tiptap[tabindex] {
color: rgba(10, 118, 246, 0);
transition: outline-color 0.3s ease-in;
}
.tiptap[contenteditable]:not([contenteditable="false"]):focus,
.tiptap[tabindex]:focus {
outline-color: rgba(10, 118, 246, 0.2);
outline-offset: 2px;
outline-width: 500px;
} */

.tiptap pre {
padding: 0.75rem;
}
Expand Down Expand Up @@ -146,6 +175,16 @@ from DSFR links with `target="_blank"` */
margin: 0;
}

.ProseMirror-selectednode {
outline-style: dotted;
outline-width: 2px;
outline-color: var(--dsfr-outline);
}

.ProseMirror-widget {
opacity: 0.5;
}

/* File upload styling */
/* input[type="file" i]::-webkit-file-upload-button {
background-color: transparent;
Expand Down
166 changes: 166 additions & 0 deletions confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { Extension } from "@tiptap/core";
import { Slice } from "@tiptap/pm/model";
import { EditorState, Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";

import { FileErrorMessage } from "../enums";
import { useAuditStore } from "../store/audit";
import { AuditFile, FileDisplay } from "../types";
import { getUploadUrl, handleFileUploadError } from "../utils";

export interface ImageUploadTiptapExtensionOptions {
uniqueId: string;
}

/**
* Placeholder: the image blob (local to browser), with 50% opacity
*/
const placeholderPlugin = new Plugin({
state: {
init() {
return DecorationSet.empty;
},
apply(tr, set) {
// Adjust decoration positions to changes made by the transaction
set = set.map(tr.mapping, tr.doc);
// See if the transaction adds or removes any placeholders
const action = tr.getMeta(placeholderPlugin);
if (action && action.add) {
const deco = Decoration.widget(
action.add.pos,
() => {
const phImg: HTMLImageElement = document.createElement("img");
phImg.setAttribute("src", action.add.blobUrl);
phImg.onload = () => {
phImg.setAttribute("width", phImg.width.toString());
phImg.setAttribute("height", phImg.height.toString());
};
return phImg;
},
{
id: action.add.id
}
);
set = set.add(tr.doc, [deco]);
} else if (action && action.remove) {
set = set.remove(
set.find(undefined, undefined, (spec) => spec.id == action.remove.id)
);
}
return set;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
});

const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
const { uniqueId } = options;
return new Plugin({
props: {
handleDrop(
this: Plugin<any>,
view: EditorView,
dragEvent: DragEvent,
slice: Slice,
moved: boolean
): boolean | void {
if (
!moved &&
dragEvent.dataTransfer &&
dragEvent.dataTransfer.files &&
dragEvent.dataTransfer.files[0]
) {
// If dropping external files
const file = dragEvent.dataTransfer.files[0];
if (file.size < 2000000) {
// A fresh object to act as the ID for this upload
const id = {};

// Place the now uploaded image in the editor where it was dropped
const { tr } = view.state;
const coordinates = view.posAtCoords({
left: dragEvent.clientX,
top: dragEvent.clientY
});
if (!coordinates) {
console.log("No coordinates?!");
return;
}
const _URL = window.URL || window.webkitURL;
const blobUrl = _URL.createObjectURL(file);
tr.setMeta(placeholderPlugin, {
add: { id, blobUrl, pos: coordinates.pos }
});
view.dispatch(tr);

uploadAndReplacePlaceHolder(view, file, id);
} else {
//FIXME: use a notification
window.alert(FileErrorMessage.UPLOAD_SIZE);
}

// handled
return true;
}
}
}
});

function uploadAndReplacePlaceHolder(view: EditorView, file: File, id: any) {
const auditStore = useAuditStore();
auditStore.uploadAuditFile(uniqueId, file, FileDisplay.EDITOR).then(
(response: AuditFile) => {
const pos = findPlaceholder(view.state, id);
// If the content around the placeholder has been deleted, drop
// the image
if (pos === undefined) {
//TODO remove image from server
return;
}
// Otherwise, insert it at the placeholder's position, and remove
// the placeholder
view.dispatch(
view.state.tr
.replaceWith(
pos,
pos,
//FIXME: add `width` and `height` to avoid layout shift
view.state.schema.nodes.image.create({
src: getUploadUrl(response.key)
})
)
.setMeta(placeholderPlugin, { remove: { id } })
);
},
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));
}
);
}

function findPlaceholder(state: EditorState, id: any) {
const decos = placeholderPlugin.getState(state);
const found = decos?.find(undefined, undefined, (spec) => spec.id == id);
return found?.[0].from;
}
};

export const ImageUploadTiptapExtension =
Extension.create<ImageUploadTiptapExtensionOptions>({
name: "imageUpload",
addProseMirrorPlugins() {
return [
HandleDropPlugin({ uniqueId: this.options.uniqueId }),
placeholderPlugin
];
}
});
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2094,6 +2094,11 @@
resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.5.9.tgz#9f91a17b80700670e53e241fcee40365c57aa994"
integrity sha512-/ES5NdxCndBmZAgIXSpCJH8YzENcpxR0S8w34coSWyv+iW0Sq7rW/mksQw8ZIVsj8a7ntpoY5OoRFpSlqcvyGw==

"@tiptap/extension-image@^2.6.6":
version "2.6.6"
resolved "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.6.6.tgz#d3c2b4c6234dc8d475a5ee534447605c4e1408d5"
integrity sha512-dwJKvoqsr72B4tcTH8hXhfBJzUMs/jXUEE9MnfzYnSXf+CYALLjF8r/IkGYbxce62GP/bMDoj8BgpF8saeHtqA==

"@tiptap/extension-italic@^2.5.9":
version "2.5.9"
resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.5.9.tgz#8ea0e19e650f0f1d6fc30425ec28291511143dda"
Expand Down

0 comments on commit 9f3a245

Please sign in to comment.