From 0da9c0ecc77765bc09489f227a326c1bf08ddfb2 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 9 Aug 2024 00:53:56 +0200
Subject: [PATCH 01/55] Add Tiptap and a first integration in NotesModal
* Should handle existing markdown content
* The editor is configured with extensions:
* all what's in StarterKit
* code block lowlight
* highlight
* link
* task
* typography (with french double quotes)
* Some styles have been added to fit DSFR styling
Still work in progress, but a nice start!
To do:
* image uploading and placement inside editor
* menu buttons
* accessibility tests and improvements
---
confiture-web-app/package.json | 20 +-
.../src/components/audit/NotesModal.vue | 13 +-
.../src/components/ui/Tiptap.vue | 75 +++
confiture-web-app/src/main.ts | 1 +
confiture-web-app/src/styles/main.css | 79 +++
yarn.lock | 503 ++++++++++++++++++
6 files changed, 681 insertions(+), 10 deletions(-)
create mode 100644 confiture-web-app/src/components/ui/Tiptap.vue
diff --git a/confiture-web-app/package.json b/confiture-web-app/package.json
index 1d5c66b10..5ddddb7f2 100644
--- a/confiture-web-app/package.json
+++ b/confiture-web-app/package.json
@@ -15,21 +15,33 @@
"dependencies": {
"@gouvfr/dsfr": "1.12.1",
"@sentry/tracing": "^7.37.2",
+ "@sentry/vite-plugin": "^0.3.0",
"@sentry/vue": "^7.37.2",
+ "@tiptap/extension-code-block-lowlight": "^2.5.9",
+ "@tiptap/extension-highlight": "^2.5.9",
+ "@tiptap/extension-link": "^2.5.9",
+ "@tiptap/extension-task-item": "^2.5.9",
+ "@tiptap/extension-task-list": "^2.5.9",
+ "@tiptap/extension-typography": "^2.5.9",
+ "@tiptap/pm": "^2.5.9",
+ "@tiptap/starter-kit": "^2.5.9",
+ "@tiptap/vue-3": "^2.5.9",
"@unhead/vue": "^1.5.3",
+ "@vitejs/plugin-vue": "^4.4.1",
"dompurify": "^2.4.1",
+ "highlight.js": "^11.10.0",
"jwt-decode": "^3.1.2",
"ky": "^0.33.0",
"lodash-es": "^4.17.21",
+ "lowlight": "^3.1.0",
"marked": "^4.2.4",
"pinia": "^2.0.28",
"slugify": "^1.6.5",
+ "tiptap-markdown": "^0.8.10",
+ "vite": "^4.5.0",
"vue": "^3.3.8",
"vue-matomo": "^4.2.0",
- "vue-router": "^4.2.5",
- "vite": "^4.5.0",
- "@vitejs/plugin-vue": "^4.4.1",
- "@sentry/vite-plugin": "^0.3.0"
+ "vue-router": "^4.2.5"
},
"devDependencies": {
"@types/dompurify": "^2.4.0",
diff --git a/confiture-web-app/src/components/audit/NotesModal.vue b/confiture-web-app/src/components/audit/NotesModal.vue
index b84eee718..0e7d98c8e 100644
--- a/confiture-web-app/src/components/audit/NotesModal.vue
+++ b/confiture-web-app/src/components/audit/NotesModal.vue
@@ -10,6 +10,7 @@ import { AuditFile, StoreName } from "../../types";
import { handleFileDeleteError, handleFileUploadError } from "../../utils";
import DsfrModal from "../ui/DsfrModal.vue";
import FileUpload from "../ui/FileUpload.vue";
+import Tiptap from "../ui/Tiptap.vue";
import MarkdownHelpButton from "./MarkdownHelpButton.vue";
import SaveIndicator from "./SaveIndicator.vue";
@@ -104,15 +105,15 @@ function handleDeleteFile(file: AuditFile) {
-
+ @update:content="($content) => (notes = $content)"
+ />
+import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
+import Heading from "@tiptap/extension-heading";
+import Highlight from "@tiptap/extension-highlight";
+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 { useEditor, EditorContent } from "@tiptap/vue-3";
+import StarterKit from "@tiptap/starter-kit";
+
+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";
+
+import { Markdown } from "tiptap-markdown";
+
+// load common languages
+import { common, createLowlight } from "lowlight";
+
+// create a lowlight instance
+const lowlight = createLowlight(common);
+
+// you can also register languages
+lowlight.register("html", html);
+lowlight.register("css", css);
+lowlight.register("js", js);
+lowlight.register("ts", ts);
+
+const props = defineProps(["content"]);
+const emit = defineEmits(["update:content"]);
+//JSON.stringify(document.querySelector(".tiptap").editor.getJSON())
+
+function getContent() {
+ let jsonContent;
+ try {
+ jsonContent = JSON.parse(props.content);
+ } catch (e) {
+ return "";
+ }
+ return jsonContent;
+}
+
+const editor = useEditor({
+ content: getContent(),
+ extensions: [
+ CodeBlockLowlight.configure({ lowlight }),
+ Highlight,
+ Heading.configure({
+ levels: [2, 3, 4, 5, 6]
+ }),
+ Link,
+ Markdown,
+ StarterKit.configure({
+ codeBlock: false,
+ heading: false
+ }),
+ TaskItem,
+ TaskList,
+ Typography.configure({
+ openDoubleQuote: "« ",
+ closeDoubleQuote: " »"
+ })
+ ],
+ onUpdate({ editor }) {
+ // The content has changed.
+ emit("update:content", JSON.stringify(editor.getJSON()));
+ }
+});
+
+
+
+
+
diff --git a/confiture-web-app/src/main.ts b/confiture-web-app/src/main.ts
index 787d9fa13..1dcc140b4 100644
--- a/confiture-web-app/src/main.ts
+++ b/confiture-web-app/src/main.ts
@@ -2,6 +2,7 @@ import "./styles/main.css";
import "@gouvfr/dsfr/dist/dsfr.min.css";
import "@gouvfr/dsfr/dist/dsfr.module.min.js";
import "@gouvfr/dsfr/dist/utility/icons/icons.css";
+import "highlight.js/styles/github.css";
import { BrowserTracing } from "@sentry/tracing";
import * as Sentry from "@sentry/vue";
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index fd842b7dc..5b5f2dd83 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -65,6 +65,85 @@ from DSFR links with `target="_blank"` */
color: var(--background-action-high-error) !important;
}
+.tiptap {
+ padding: 1rem;
+ border: 1px solid var(--border-default-grey);
+ min-height: 10rem;
+}
+
+.tiptap pre {
+ padding: 0.75rem;
+}
+.tiptap code {
+ padding: 0.2em 0.4em;
+ font-size: 85%;
+}
+.tiptap pre,
+.tiptap code {
+ background-color: var(--background-alt-grey);
+ border-radius: 0.25rem;
+}
+.tiptap blockquote:before {
+ --icon-size: 2rem;
+ color: var(--artwork-minor-blue-france);
+ content: "";
+ display: block;
+ margin-bottom: 0.5rem;
+ background-color: currentColor;
+ display: inline-block;
+ flex: 0 0 auto;
+ height: var(--icon-size);
+ mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTE0IDNhOCA4IDAgMSAxIDAgMTZ2My41Yy01LTItMTItNS0xMi0xMS41YTggOCAwIDAgMSA4LThoNFptMCAyaC00YTYgNiAwIDAgMC02IDZjMCAzLjYxIDIuNDYyIDUuOTY2IDggOC40OFYxN2gyYTYgNiAwIDEgMCAwLTEyWm0tMiAyLjUtMi41MDYgMy43NUwxMiAxNUg5LjI5NUw2Ljc1IDExLjI1IDkuMjk1IDcuNUgxMlptNC41IDAtMi41MDYgMy43NUwxNi41IDE1aC0yLjcwNWwtMi41NDUtMy43NSAyLjU0NS0zLjc1SDE2LjVaIi8+PC9zdmc+);
+ mask-size: 100% 100%;
+ vertical-align: calc((0.75em - var(--icon-size)) * 0.5);
+ width: var(--icon-size);
+}
+
+.tiptap blockquote p {
+ font-size: 1.25rem;
+ font-weight: 700;
+ line-height: 2rem;
+}
+
+.tiptap li > p {
+ margin-bottom: 0.25em;
+}
+
+/* FIXME: tiptap tasklist are not accessible yet. */
+/* https://github.com/ueberdosis/tiptap/issues/4774 */
+.tiptap ul[data-type="taskList"] {
+ list-style: none;
+ margin-left: 0;
+ padding: 0;
+}
+
+.tiptap ul[data-type="taskList"] li {
+ align-items: flex-start;
+ display: flex;
+}
+
+.tiptap ul[data-type="taskList"] li > label {
+ flex: 0 0 auto;
+ margin-right: 0.5rem;
+ user-select: none;
+}
+
+.tiptap ul[data-type="taskList"] li > div {
+ flex: 1 1 auto;
+}
+
+.tiptap ul[data-type="taskList"] li > div p {
+ margin-bottom: 0.25em;
+}
+
+.tiptap ul[data-type="taskList"] input[type="checkbox"] {
+ cursor: pointer;
+}
+
+.tiptap ul[data-type="taskList"] ul[data-type="taskList"] {
+ margin: 0;
+}
+
/* File upload styling */
/* input[type="file" i]::-webkit-file-upload-button {
background-color: transparent;
diff --git a/yarn.lock b/yarn.lock
index 5dd555134..072e088e7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1180,6 +1180,11 @@
dependencies:
debug "^4.3.1"
+"@popperjs/core@^2.9.0":
+ version "2.11.8"
+ resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
+ integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
+
"@prisma/client@^4.1.1":
version "4.16.2"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-4.16.2.tgz#3bb9ebd49b35c8236b3d468d0215192267016e2b"
@@ -1330,6 +1335,11 @@
tmp "0.2.1"
ts-pattern "^4.0.1"
+"@remirror/core-constants@^2.0.2":
+ version "2.0.2"
+ resolved "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-2.0.2.tgz#f05eccdc69e3a65e7d524b52548f567904a11a1a"
+ integrity sha512-dyHY+sMF0ihPus3O27ODd4+agdHMEmuRdyiZJ2CCWjPV5UFmn17ZbElvk6WOGVE4rdCJKZQCrPV2BcikOMLUGQ==
+
"@sentry-internal/feedback@7.88.0":
version "7.88.0"
resolved "https://registry.yarnpkg.com/@sentry-internal/feedback/-/feedback-7.88.0.tgz#fa4db4a27d1fa7fe51dc67af185b13519d7fbc76"
@@ -1995,6 +2005,204 @@
resolved "https://registry.yarnpkg.com/@timsuchanek/sleep-promise/-/sleep-promise-8.0.1.tgz#81c0754b345138a519b51c2059771eb5f9b97818"
integrity sha512-cxHYbrXfnCWsklydIHSw5GCMHUPqpJ/enxWSyVHNOgNe61sit/+aOXTTI+VOdWkvVaJsI2vsB9N4+YDNITawOQ==
+"@tiptap/core@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/core/-/core-2.5.9.tgz#1deb0b7c748e24ec32613263e0af8d55a3b3c2ca"
+ integrity sha512-PPUR+0tbr+wX2G8RG4FEps4qhbnAPEeXK1FUtirLXSRh8vm+TDgafu3sms7wBc4fAyw9zTO/KNNZ90GBe04guA==
+
+"@tiptap/extension-blockquote@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.5.9.tgz#d873a8496fcf572c69aaac2a7a341e035fdbae22"
+ integrity sha512-LhGyigmd/v1OjYPeoVK8UvFHbH6ffh175ZuNvseZY4PsBd7kZhrSUiuMG8xYdNX8FxamsxAzr2YpsYnOzu3W7A==
+
+"@tiptap/extension-bold@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.5.9.tgz#00c9b7b5211048b1e1c5d67e355935b9c92e3532"
+ integrity sha512-XUJdzFb31t0+bwiRquJf0btBpqOB3axQNHTKM9XADuL4S+Z6OBPj0I5rYINeElw/Q7muvdWrHWHh/ovNJA1/5A==
+
+"@tiptap/extension-bubble-menu@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.5.9.tgz#d600bbcaa1d98a99f32b3b8b8c3d35752161200c"
+ integrity sha512-NddZ8Qn5dgPPa1W4yk0jdhF4tDBh0FwzBpbnDu2Xz/0TUHrA36ugB2CvR5xS1we4zUKckgpVqOqgdelrmqqFVg==
+ dependencies:
+ tippy.js "^6.3.7"
+
+"@tiptap/extension-bullet-list@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.5.9.tgz#2852aba9a1dacbf2c673cda6a4994b1f3c33cd5c"
+ integrity sha512-hJTv1x4omFgaID4LMRT5tOZb/VKmi8Kc6jsf4JNq4Grxd2sANmr9qpmKtBZvviK+XD5PpTXHvL+1c8C1SQtuHQ==
+
+"@tiptap/extension-code-block-lowlight@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.5.9.tgz#ccd6569422d98b11813df3e0dbd09b1dcf957def"
+ integrity sha512-taIXxXQ/Lka9CegHFHQS+nx6cX9i9Ws63ZFMPbrXLMSJRhXk8+m4UAoGZQJH9CGGb5/Rv0p3Z8I59AGiyUHLEw==
+
+"@tiptap/extension-code-block@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.5.9.tgz#8cd99515b286fc62ad1215a411aea5da9a7d9701"
+ integrity sha512-+MUwp0VFFv2aFiZ/qN6q10vfIc6VhLoFFpfuETX10eIRks0Xuj2nGiqCDj7ca0/M44bRg2VvW8+tg/ZEHFNl9g==
+
+"@tiptap/extension-code@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.5.9.tgz#93c4433eca8b2aa239ea7f408b90f152b7fc4603"
+ integrity sha512-Q1PL3DUXiEe5eYUwOug1haRjSaB0doAKwx7KFVI+kSGbDwCV6BdkKAeYf3us/O2pMP9D0im8RWX4dbSnatgwBA==
+
+"@tiptap/extension-document@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.5.9.tgz#13a22b2d3bdc1463844872b1f1c926633df431a8"
+ integrity sha512-VdNZYDyCzC3W430UdeRXR9IZzPeODSbi5Xz/JEdV93THVp8AC9CrZR7/qjqdBTgbTB54VP8Yr6bKfCoIAF0BeQ==
+
+"@tiptap/extension-dropcursor@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.5.9.tgz#648f683f929056a0526620f530f73e6b052c1481"
+ integrity sha512-nEOb37UryG6bsU9JAs/HojE6Jg43LupNTAMISbnuB1CPAeAqNsFMwORd9eEPkyEwnQT7MkhsMOSJM44GoPGIFA==
+
+"@tiptap/extension-floating-menu@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.5.9.tgz#b970905f3c1af49a916dcbd477a4302086187974"
+ integrity sha512-MWJIQQT6e5MgqHny8neeH2Dx926nVPF7sv4p84nX4E0dnkRbEYUP8mCsWYhSUvxxIif6e+yY+4654f2Q9qTx1w==
+ dependencies:
+ tippy.js "^6.3.7"
+
+"@tiptap/extension-gapcursor@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.5.9.tgz#68b9e227cd7876aac353a8ac029995b4c092a763"
+ integrity sha512-yW7V2ebezsa7mWEDWCg4A1ZGsmSV5bEHKse9wzHCDkb7TutSVhLZxGo72U6hNN9PnAksv+FJQk03NuZNYvNyRQ==
+
+"@tiptap/extension-hard-break@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.5.9.tgz#4f38f06dbeb5fb3e58ff7fc0c48b9db9c4ee4ecd"
+ integrity sha512-8hQ63SgZRG4BqHOeSfeaowG2eMr2beced018pOGbpHbE3XSYoISkMVuFz4Z8UEVR3W9dTbKo4wxNufSTducocQ==
+
+"@tiptap/extension-heading@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.5.9.tgz#b9ec3b3b48dea939606d06eff56c4bdc7bed0662"
+ integrity sha512-HHowAlGUbFn1qvmY02ydM7qiPPMTGhAJn2A46enDRjNHW5UoqeMfkMpTEYaioOexyguRFSfDT3gpK68IHkQORQ==
+
+"@tiptap/extension-highlight@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.5.9.tgz#290426538abcbb2299809d3e1274ba5af1ba9f68"
+ integrity sha512-tRaSIIbCI7aBlvlmgUgBI5lVBqnMy49lc++UVAx1Pjey1j2KW031vUyvZfEwf6wk8Y7W3kVSkN0mW9IYCcOAOQ==
+
+"@tiptap/extension-history@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.5.9.tgz#f48f64ff95407f0ce27bcdd020762e49d0dd60d1"
+ integrity sha512-hGPtJgoZSwnVVqi/xipC2ET/9X2G2UI/Y+M3IYV1ZlM0tCYsv4spNi3uXlZqnXRwYcBXLk5u6e/dmsy5QFbL8g==
+
+"@tiptap/extension-horizontal-rule@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.5.9.tgz#9f91a17b80700670e53e241fcee40365c57aa994"
+ integrity sha512-/ES5NdxCndBmZAgIXSpCJH8YzENcpxR0S8w34coSWyv+iW0Sq7rW/mksQw8ZIVsj8a7ntpoY5OoRFpSlqcvyGw==
+
+"@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"
+ integrity sha512-Bw+P139L4cy+B56zpUiRjP8BZSaAUl3JFMnr/FO+FG55QhCxFMXIc6XrC3vslNy5ef3B3zv4gCttS3ee8ByMiw==
+
+"@tiptap/extension-link@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.5.9.tgz#6cb323d36b82700963ad2b9d189a7d07c81c7d6e"
+ integrity sha512-7v9yRsX7NuiY8DPslIsPIlFqcD8aGBMLqfEGXltJDvuG6kykdr+khEZeWcJ8ihHIL4yWR3/MAgeT2W72Z/nxiQ==
+ dependencies:
+ linkifyjs "^4.1.0"
+
+"@tiptap/extension-list-item@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.5.9.tgz#0805b7216371b8b54649abe5ab29bd2c2155f05f"
+ integrity sha512-d9Eo+vBz74SMxP0r25aqiErV256C+lGz+VWMjOoqJa6xWLM1keYy12JtGQWJi8UDVZrDskJKCHq81A0uLt27WA==
+
+"@tiptap/extension-ordered-list@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.5.9.tgz#44aab6ec3e19429a8e3b73e42c04156f2b0bc730"
+ integrity sha512-9MsWpvVvzILuEOd/GdroF7RI7uDuE1M6at9rzsaVGvCPVHZBvu1XR3MSVK5OdiJbbJuPGttlzEFLaN/rQdCGFg==
+
+"@tiptap/extension-paragraph@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.5.9.tgz#05210b6e7a9940b1acc09fdd4ec769fc6406da2b"
+ integrity sha512-HDXGiHTJ/V85dbDMjcFj4XfqyTQZqry6V21ucMzgBZYX60X3gIn7VpQTQnnRjvULSgtfOASSJP6BELc5TyiK0w==
+
+"@tiptap/extension-strike@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.5.9.tgz#f2d54161d24ee37dc8a41b5077c553048ed69f99"
+ integrity sha512-QezkOZpczpl09S8lp5JL7sRkwREoPY16Y/lTvBcFKm3TZbVzYZZ/KwS0zpwK9HXTfXr8os4L9AGjQf0tHonX+w==
+
+"@tiptap/extension-task-item@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.5.9.tgz#623590d549aa0e21ccd1d34396ebbeeea45886f5"
+ integrity sha512-g4HK3r3yNE0RcXQOkJHs94Ws/fhhTqa1L5iAy4gwYKNNFFnIQl8BpE6nn9d5h33kWDN9jjY+PZmq+0PvxCLODQ==
+
+"@tiptap/extension-task-list@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.5.9.tgz#9934b3ae84dbfc27804d0d54e64aeb9e8fcaf418"
+ integrity sha512-OylVo5cAh0117PzhyM8MGaUIrCskGiF7v7x6/zAHMFIqVdcbKsq+hMueVPnABfOyLcIH5Zojo3NzNOJeKeblCg==
+
+"@tiptap/extension-text@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.5.9.tgz#a5bef0b9c5324511dbc2804a3a5ac8b9b5d5dc4c"
+ integrity sha512-W0pfiQUPsMkwaV5Y/wKW4cFsyXAIkyOFt7uN5u6LrZ/iW9KZ/IsDODPJDikWp0aeQnXzT9NNQULTpCjbHzzS6g==
+
+"@tiptap/extension-typography@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/extension-typography/-/extension-typography-2.5.9.tgz#bd6a68889ab8479be593d31a930f98ea575f4f08"
+ integrity sha512-S+r4m3J0eK4qOszUcCU7NeOEUMuOwj0pGO4YYbIJs3AjWOyLrXD04grb64u8sCGcM8hiibQ7uZKSLJOmLjuoEA==
+
+"@tiptap/pm@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-2.5.9.tgz#f97889210374993a1ce78e9ecb23461d0e4644bf"
+ integrity sha512-YSUaEQVtvZnGzGjif2Tl2o9utE+6tR2Djhz0EqFUcAUEVhOMk7UYUO+r/aPfcCRraIoKKuDQzyCpjKmJicjCUA==
+ dependencies:
+ prosemirror-changeset "^2.2.1"
+ prosemirror-collab "^1.3.1"
+ prosemirror-commands "^1.5.2"
+ prosemirror-dropcursor "^1.8.1"
+ prosemirror-gapcursor "^1.3.2"
+ prosemirror-history "^1.4.1"
+ prosemirror-inputrules "^1.4.0"
+ prosemirror-keymap "^1.2.2"
+ prosemirror-markdown "^1.13.0"
+ prosemirror-menu "^1.2.4"
+ prosemirror-model "^1.22.2"
+ prosemirror-schema-basic "^1.2.3"
+ prosemirror-schema-list "^1.4.1"
+ prosemirror-state "^1.4.3"
+ prosemirror-tables "^1.4.0"
+ prosemirror-trailing-node "^2.0.9"
+ prosemirror-transform "^1.9.0"
+ prosemirror-view "^1.33.9"
+
+"@tiptap/starter-kit@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.5.9.tgz#fec0955b873ebcbdeefdfaab0e9254011df3f41b"
+ integrity sha512-nZ4V+vRayomjxUsajFMHv1iJ5SiSaEA65LAXze/CzyZXGMXfL2OLzY7wJoaVJ4BgwINuO0dOSAtpNDN6jI+6mQ==
+ dependencies:
+ "@tiptap/core" "^2.5.9"
+ "@tiptap/extension-blockquote" "^2.5.9"
+ "@tiptap/extension-bold" "^2.5.9"
+ "@tiptap/extension-bullet-list" "^2.5.9"
+ "@tiptap/extension-code" "^2.5.9"
+ "@tiptap/extension-code-block" "^2.5.9"
+ "@tiptap/extension-document" "^2.5.9"
+ "@tiptap/extension-dropcursor" "^2.5.9"
+ "@tiptap/extension-gapcursor" "^2.5.9"
+ "@tiptap/extension-hard-break" "^2.5.9"
+ "@tiptap/extension-heading" "^2.5.9"
+ "@tiptap/extension-history" "^2.5.9"
+ "@tiptap/extension-horizontal-rule" "^2.5.9"
+ "@tiptap/extension-italic" "^2.5.9"
+ "@tiptap/extension-list-item" "^2.5.9"
+ "@tiptap/extension-ordered-list" "^2.5.9"
+ "@tiptap/extension-paragraph" "^2.5.9"
+ "@tiptap/extension-strike" "^2.5.9"
+ "@tiptap/extension-text" "^2.5.9"
+
+"@tiptap/vue-3@^2.5.9":
+ version "2.5.9"
+ resolved "https://registry.npmjs.org/@tiptap/vue-3/-/vue-3-2.5.9.tgz#822e3b52c51b582b5c7238f782d543125da0f3b1"
+ integrity sha512-Iz7HMW9A0jinYnMs2wZxjI+e5fc5MQmjgmfE0kQmimpgISBregW8vJyDKDPIZVJz5LQPLL045G3mL+7V8fExrQ==
+ dependencies:
+ "@tiptap/extension-bubble-menu" "^2.5.9"
+ "@tiptap/extension-floating-menu" "^2.5.9"
+
"@tootallnate/once@2":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"
@@ -2119,6 +2327,13 @@
"@types/qs" "*"
"@types/serve-static" "*"
+"@types/hast@^3.0.0":
+ version "3.0.4"
+ resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa"
+ integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==
+ dependencies:
+ "@types/unist" "*"
+
"@types/http-cache-semantics@*":
version "4.0.4"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4"
@@ -2153,6 +2368,11 @@
dependencies:
"@types/node" "*"
+"@types/linkify-it@^3":
+ version "3.0.5"
+ resolved "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz#1e78a3ac2428e6d7e6c05c1665c242023a4601d8"
+ integrity sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==
+
"@types/lodash-es@^4.17.6":
version "4.17.12"
resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.12.tgz#65f6d1e5f80539aa7cfbfc962de5def0cf4f341b"
@@ -2165,11 +2385,24 @@
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.202.tgz#f09dbd2fb082d507178b2f2a5c7e74bd72ff98f8"
integrity sha512-OvlIYQK9tNneDlS0VN54LLd5uiPCBOp7gS5Z0f1mjoJYBrtStzgmJBxONW3U6OZqdtNzZPmn9BS/7WI7BFFcFQ==
+"@types/markdown-it@^13.0.7":
+ version "13.0.9"
+ resolved "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz#df79221eae698df5b4e982c7e91128dd8e525743"
+ integrity sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==
+ dependencies:
+ "@types/linkify-it" "^3"
+ "@types/mdurl" "^1"
+
"@types/marked@^4.0.8":
version "4.3.2"
resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.3.2.tgz#e2e0ad02ebf5626bd215c5bae2aff6aff0ce9eac"
integrity sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w==
+"@types/mdurl@^1":
+ version "1.0.5"
+ resolved "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz#3e0d2db570e9fb6ccb2dc8fde0be1d79ac810d39"
+ integrity sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==
+
"@types/mime@*":
version "3.0.4"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.4.tgz#2198ac274de6017b44d941e00261d5bc6a0e0a45"
@@ -2306,6 +2539,11 @@
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11"
integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==
+"@types/unist@*":
+ version "3.0.2"
+ resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz#6dd61e43ef60b34086287f83683a5c1b2dc53d20"
+ integrity sha512-dqId9J8K/vGi5Zr7oo212BGii5m3q5Hxlkwy3WpYuKPklmBEvsbMYYyLxAQpSffdLl/gdW0XUpKWFvYmyoWCoQ==
+
"@types/yauzl@^2.9.1":
version "2.10.3"
resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999"
@@ -3745,6 +3983,11 @@ create-require@^1.1.0:
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
+crelt@^1.0.0:
+ version "1.0.6"
+ resolved "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72"
+ integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==
+
croner@~4.1.92:
version "4.1.97"
resolved "https://registry.yarnpkg.com/croner/-/croner-4.1.97.tgz#6e373dc7bb3026fab2deb0d82685feef20796766"
@@ -4008,6 +4251,11 @@ depd@2.0.0, depd@~2.0.0:
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+dequal@^2.0.0:
+ version "2.0.3"
+ resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be"
+ integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==
+
destroy@1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
@@ -4028,6 +4276,13 @@ detect-node@^2.0.4:
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
+devlop@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018"
+ integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==
+ dependencies:
+ dequal "^2.0.0"
+
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
@@ -5228,6 +5483,16 @@ he@^1.2.0:
resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+highlight.js@^11.10.0:
+ version "11.10.0"
+ resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-11.10.0.tgz#6e3600dc4b33d6dc23d5bd94fbf72405f5892b92"
+ integrity sha512-SYVnVFswQER+zu1laSya563s+F8VDGt7o35d4utbamowvUNLLMovFqwCLSocpZTz3MgaSRA1IbqRWZv97dtErQ==
+
+highlight.js@~11.9.0:
+ version "11.9.0"
+ resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0"
+ integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==
+
hookable@^5.5.3:
version "5.5.3"
resolved "https://registry.yarnpkg.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d"
@@ -5885,6 +6150,18 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
+linkify-it@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz#9ef238bfa6dc70bd8e7f9572b52d369af569b421"
+ integrity sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==
+ dependencies:
+ uc.micro "^2.0.0"
+
+linkifyjs@^4.1.0:
+ version "4.1.3"
+ resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.1.3.tgz#0edbc346428a7390a23ea2e5939f76112c9ae07f"
+ integrity sha512-auMesunaJ8yfkHvK4gfg1K0SaKX/6Wn9g2Aac/NwX+l5VdmFZzo/hdPGxEOETj+ryRa4/fiOPjeeKURSAJx1sg==
+
lint-staged@^15.2.0:
version "15.2.0"
resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-15.2.0.tgz#3111534ca58096a3c8f70b044b6e7fe21b36f859"
@@ -6055,6 +6332,15 @@ lowercase-keys@^2.0.0:
resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479"
integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==
+lowlight@^3.1.0:
+ version "3.1.0"
+ resolved "https://registry.npmjs.org/lowlight/-/lowlight-3.1.0.tgz#aa394c5f3a7689fce35fa49a7c850ba3ead4f590"
+ integrity sha512-CEbNVoSikAxwDMDPjXlqlFYiZLkDJHwyGu/MfOsJnF3d7f3tds5J3z8s/l9TMXhzfsJCCJEAsD78842mwmg0PQ==
+ dependencies:
+ "@types/hast" "^3.0.0"
+ devlop "^1.0.0"
+ highlight.js "~11.9.0"
+
lru-cache@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
@@ -6110,11 +6396,33 @@ make-error@^1.1.1:
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
+markdown-it-task-lists@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz#f68f4d2ac2bad5a2c373ba93081a1a6848417088"
+ integrity sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==
+
+markdown-it@^14.0.0, markdown-it@^14.1.0:
+ version "14.1.0"
+ resolved "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz#3c3c5992883c633db4714ccb4d7b5935d98b7d45"
+ integrity sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==
+ dependencies:
+ argparse "^2.0.1"
+ entities "^4.4.0"
+ linkify-it "^5.0.0"
+ mdurl "^2.0.0"
+ punycode.js "^2.3.1"
+ uc.micro "^2.1.0"
+
marked@^4.2.4:
version "4.3.0"
resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3"
integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==
+mdurl@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0"
+ integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==
+
media-typer@0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -6947,6 +7255,11 @@ ora@5.4.1, ora@^5.4.1:
strip-ansi "^6.0.0"
wcwidth "^1.0.1"
+orderedmap@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2"
+ integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
+
os-name@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/os-name/-/os-name-4.0.1.tgz#32cee7823de85a8897647ba4d76db46bf845e555"
@@ -7397,6 +7710,159 @@ prompts@2.4.2:
kleur "^3.0.3"
sisteransi "^1.0.5"
+prosemirror-changeset@^2.2.1:
+ version "2.2.1"
+ resolved "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.2.1.tgz#dae94b63aec618fac7bb9061648e6e2a79988383"
+ integrity sha512-J7msc6wbxB4ekDFj+n9gTW/jav/p53kdlivvuppHsrZXCaQdVgRghoZbSS3kwrRyAstRVQ4/+u5k7YfLgkkQvQ==
+ dependencies:
+ prosemirror-transform "^1.0.0"
+
+prosemirror-collab@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz#0e8c91e76e009b53457eb3b3051fb68dad029a33"
+ integrity sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+
+prosemirror-commands@^1.0.0, prosemirror-commands@^1.5.2:
+ version "1.6.0"
+ resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.6.0.tgz#b79f034ed371576e7bf83ddd4ede689c8ccbd9ab"
+ integrity sha512-xn1U/g36OqXn2tn5nGmvnnimAj/g1pUx2ypJJIe8WkVX83WyJVC5LTARaxZa2AtQRwntu9Jc5zXs9gL9svp/mg==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-dropcursor@^1.8.1:
+ version "1.8.1"
+ resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.1.tgz#49b9fb2f583e0d0f4021ff87db825faa2be2832d"
+ integrity sha512-M30WJdJZLyXHi3N8vxN6Zh5O8ZBbQCz0gURTfPmTIBNQ5pxrdU7A58QkNqfa98YEjSAL1HUyyU34f6Pm5xBSGw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+ prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.3.2:
+ version "1.3.2"
+ resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.3.2.tgz#5fa336b83789c6199a7341c9493587e249215cb4"
+ integrity sha512-wtjswVBd2vaQRrnYZaBCbyDqr232Ed4p2QPtRIUK5FuqHYKGWkEwl08oQM4Tw7DOR0FsasARV5uJFvMZWxdNxQ==
+ dependencies:
+ prosemirror-keymap "^1.0.0"
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.0.0, prosemirror-history@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.4.1.tgz#cc370a46fb629e83a33946a0e12612e934ab8b98"
+ integrity sha512-2JZD8z2JviJrboD9cPuX/Sv/1ChFng+xh2tChQ2X4bB2HeK+rra/bmJ3xGntCcjhOqIzSDG6Id7e8RJ9QPXLEQ==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.31.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-inputrules@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.4.0.tgz#ef1519bb2cb0d1e0cec74bad1a97f1c1555068bb"
+ integrity sha512-6ygpPRuTJ2lcOXs9JkefieMst63wVJBgHZGl5QOytN7oSZs3Co/BYbc3Yx9zm9H37Bxw8kVzCnDsihsVsL4yEg==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.0.0"
+
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.1.2, prosemirror-keymap@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz#14a54763a29c7b2704f561088ccf3384d14eb77e"
+ integrity sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-markdown@^1.11.1, prosemirror-markdown@^1.13.0:
+ version "1.13.0"
+ resolved "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.0.tgz#67ebfa40af48a22d1e4ed6cad2e29851eb61e649"
+ integrity sha512-UziddX3ZYSYibgx8042hfGKmukq5Aljp2qoBiJRejD/8MH70siQNz5RB1TrdTPheqLMy4aCe4GYNF10/3lQS5g==
+ dependencies:
+ markdown-it "^14.0.0"
+ prosemirror-model "^1.20.0"
+
+prosemirror-menu@^1.2.4:
+ version "1.2.4"
+ resolved "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.2.4.tgz#3cfdc7c06d10f9fbd1bce29082c498bd11a0a79a"
+ integrity sha512-S/bXlc0ODQup6aiBbWVsX/eM+xJgCTAfMq/nLqaO5ID/am4wS0tTCIkzwytmao7ypEtjj39i7YbJjAgO20mIqA==
+ dependencies:
+ crelt "^1.0.0"
+ prosemirror-commands "^1.0.0"
+ prosemirror-history "^1.0.0"
+ prosemirror-state "^1.0.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.19.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.22.2, prosemirror-model@^1.8.1:
+ version "1.22.3"
+ resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.22.3.tgz#52fdf5897f348b0f07f64bea89156d90afdf645a"
+ integrity sha512-V4XCysitErI+i0rKFILGt/xClnFJaohe/wrrlT2NSZ+zk8ggQfDH4x2wNK7Gm0Hp4CIoWizvXFP7L9KMaCuI0Q==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-schema-basic@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.3.tgz#649c349bb21c61a56febf9deb71ac68fca4cedf2"
+ integrity sha512-h+H0OQwZVqMon1PNn0AG9cTfx513zgIG2DY00eJ00Yvgb3UD+GQ/VlWW5rcaxacpCGT1Yx8nuhwXk4+QbXUfJA==
+ dependencies:
+ prosemirror-model "^1.19.0"
+
+prosemirror-schema-list@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.4.1.tgz#78b8d25531db48ca9688836dbde50e13ac19a4a1"
+ integrity sha512-jbDyaP/6AFfDfu70VzySsD75Om2t3sXTOdl5+31Wlxlg62td1haUpty/ybajSfJ1pkGadlOfwQq9kgW5IMo1Rg==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.7.3"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.3.1, prosemirror-state@^1.4.3:
+ version "1.4.3"
+ resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080"
+ integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-tables@^1.4.0:
+ version "1.4.0"
+ resolved "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.4.0.tgz#59c3dc241e03fc4ba8c093995b130d2980f0ffdc"
+ integrity sha512-fxryZZkQG12fSCNuZDrYx6Xvo2rLYZTbKLRd8rglOPgNJGMKIS8uvTt6gGC38m7UCu/ENnXIP9pEz5uDaPc+cA==
+ dependencies:
+ prosemirror-keymap "^1.1.2"
+ prosemirror-model "^1.8.1"
+ prosemirror-state "^1.3.1"
+ prosemirror-transform "^1.2.1"
+ prosemirror-view "^1.13.3"
+
+prosemirror-trailing-node@^2.0.9:
+ version "2.0.9"
+ resolved "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-2.0.9.tgz#a087e6d1372e888cd3e57c977507b6b85dc658e4"
+ integrity sha512-YvyIn3/UaLFlFKrlJB6cObvUhmwFNZVhy1Q8OpW/avoTbD/Y7H5EcjK4AZFKhmuS6/N6WkGgt7gWtBWDnmFvHg==
+ dependencies:
+ "@remirror/core-constants" "^2.0.2"
+ escape-string-regexp "^4.0.0"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.2.1, prosemirror-transform@^1.7.3, prosemirror-transform@^1.9.0:
+ version "1.9.0"
+ resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.9.0.tgz#81fd1fbd887929a95369e6dd3d240c23c19313f8"
+ integrity sha512-5UXkr1LIRx3jmpXXNKDhv8OyAOeLTGuXNwdVfg8x27uASna/wQkr9p6fD3eupGOi4PLJfbezxTyi/7fSJypXHg==
+ dependencies:
+ prosemirror-model "^1.21.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.13.3, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.33.9:
+ version "1.33.9"
+ resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.33.9.tgz#0ed61ae42405cfc9799bde4db86badbb1ad99b08"
+ integrity sha512-xV1A0Vz9cIcEnwmMhKKFAOkfIp8XmJRnaZoPqNXrPS7EK5n11Ov8V76KhR0RsfQd/SIzmWY+bg+M44A2Lx/Nnw==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@@ -7447,6 +7913,11 @@ pump@^3.0.0:
end-of-stream "^1.1.0"
once "^1.3.1"
+punycode.js@^2.3.1:
+ version "2.3.1"
+ resolved "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz#6b53e56ad75588234e79f4affa90972c7dd8cdb7"
+ integrity sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==
+
punycode@^2.1.0, punycode@^2.1.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
@@ -7737,6 +8208,11 @@ rollup@^3.27.1:
optionalDependencies:
fsevents "~2.3.2"
+rope-sequence@^1.3.0:
+ version "1.3.4"
+ resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425"
+ integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
+
run-applescript@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-5.0.0.tgz#e11e1c932e055d5c6b40d98374e0268d9b11899c"
@@ -8423,6 +8899,23 @@ through@^2.3.6, through@^2.3.8:
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==
+tippy.js@^6.3.7:
+ version "6.3.7"
+ resolved "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz#8ccfb651d642010ed9a32ff29b0e9e19c5b8c61c"
+ integrity sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==
+ dependencies:
+ "@popperjs/core" "^2.9.0"
+
+tiptap-markdown@^0.8.10:
+ version "0.8.10"
+ resolved "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.8.10.tgz#864a54befc17b25e7f475ff6072de3d49814f09b"
+ integrity sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==
+ dependencies:
+ "@types/markdown-it" "^13.0.7"
+ markdown-it "^14.1.0"
+ markdown-it-task-lists "^2.1.1"
+ prosemirror-markdown "^1.11.1"
+
titleize@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/titleize/-/titleize-3.0.0.tgz#71c12eb7fdd2558aa8a44b0be83b8a76694acd53"
@@ -8651,6 +9144,11 @@ typescript@^5.2.2:
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
+uc.micro@^2.0.0, uc.micro@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee"
+ integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==
+
uglify-js@^3.1.4, uglify-js@^3.5.1:
version "3.17.4"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.17.4.tgz#61678cf5fa3f5b7eb789bb345df29afb8257c22c"
@@ -8889,6 +9387,11 @@ vue@^3.3.8:
"@vue/server-renderer" "3.3.11"
"@vue/shared" "3.3.11"
+w3c-keyname@^2.2.0:
+ version "2.2.8"
+ resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5"
+ integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
+
watchpack@^2.4.0:
version "2.4.0"
resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
From af2388071eb3c629db487ef48e7b9230f916b0f4 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 9 Aug 2024 11:15:15 +0200
Subject: [PATCH 02/55] Fix markdown content recovery
---
confiture-web-app/src/components/ui/Tiptap.vue | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index a9c08a6c5..ca5909c49 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -30,16 +30,14 @@ lowlight.register("ts", ts);
const props = defineProps(["content"]);
const emit = defineEmits(["update:content"]);
-//JSON.stringify(document.querySelector(".tiptap").editor.getJSON())
function getContent() {
- let jsonContent;
+ let jsonContent = props.content;
try {
jsonContent = JSON.parse(props.content);
- } catch (e) {
- return "";
+ } finally {
+ return jsonContent;
}
- return jsonContent;
}
const editor = useEditor({
From ffb60467f7f8f50d8bf2f46621ace7c7b1ce6d42 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 16 Aug 2024 19:48:01 +0200
Subject: [PATCH 03/55] Set default code block language to html
---
confiture-web-app/src/components/ui/Tiptap.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index ca5909c49..6a4bef9bc 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -43,7 +43,7 @@ function getContent() {
const editor = useEditor({
content: getContent(),
extensions: [
- CodeBlockLowlight.configure({ lowlight }),
+ CodeBlockLowlight.configure({ lowlight, defaultLanguage: "html" }),
Highlight,
Heading.configure({
levels: [2, 3, 4, 5, 6]
From 4becae3662a9f32d09e3988dcec1b66465bcdbd0 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 16 Aug 2024 20:25:13 +0200
Subject: [PATCH 04/55] Fix eslint imports
---
confiture-web-app/src/components/ui/Tiptap.vue | 7 ++-----
1 file changed, 2 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index 6a4bef9bc..ed15a1d52 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -6,18 +6,15 @@ 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 { useEditor, EditorContent } from "@tiptap/vue-3";
import StarterKit from "@tiptap/starter-kit";
-
+import { 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";
-
-import { Markdown } from "tiptap-markdown";
-
// load common languages
import { common, createLowlight } from "lowlight";
+import { Markdown } from "tiptap-markdown";
// create a lowlight instance
const lowlight = createLowlight(common);
From 8bfbe3568620f5126fc5b399420a8c313de3c39b Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 16 Aug 2024 20:26:27 +0200
Subject: [PATCH 05/55] Fix eslint finally
(and possibly importing JSON content?)
---
confiture-web-app/src/components/ui/Tiptap.vue | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index ed15a1d52..632934832 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -29,12 +29,14 @@ const props = defineProps(["content"]);
const emit = defineEmits(["update:content"]);
function getContent() {
- let jsonContent = props.content;
+ let jsonContent;
try {
jsonContent = JSON.parse(props.content);
- } finally {
- return jsonContent;
+ } catch {
+ jsonContent = props.content;
}
+
+ return jsonContent;
}
const editor = useEditor({
From ba218d0c4327a6e8f5612add5bcaed728a8b3308 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Sat, 17 Aug 2024 22:34:35 +0200
Subject: [PATCH 06/55] Fix tiptap code blocks style
So you can see it in dark mode
---
confiture-web-app/src/styles/main.css | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index 5b5f2dd83..5b75ff744 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -75,12 +75,14 @@ from DSFR links with `target="_blank"` */
padding: 0.75rem;
}
.tiptap code {
- padding: 0.2em 0.4em;
font-size: 85%;
}
+.tiptap :not(pre) code {
+ padding: 0.2em 0.4em;
+}
.tiptap pre,
.tiptap code {
- background-color: var(--background-alt-grey);
+ background-color: var(--background-contrast-overlap-grey);
border-radius: 0.25rem;
}
.tiptap blockquote:before {
From df48b9621ec48bf7b3114288f55a893b935b4e32 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 26 Aug 2024 18:05:01 +0200
Subject: [PATCH 07/55] Fix eslint warning (type)
Prop 'content' should define at least its type
---
confiture-web-app/src/components/ui/Tiptap.vue | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index 632934832..e0f364489 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -25,7 +25,9 @@ lowlight.register("css", css);
lowlight.register("js", js);
lowlight.register("ts", ts);
-const props = defineProps(["content"]);
+const props = defineProps<{
+ content: string;
+}>();
const emit = defineEmits(["update:content"]);
function getContent() {
From c8d12625206d1843100899214d2f99b62fa0139b Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Sun, 8 Sep 2024 11:11:13 +0200
Subject: [PATCH 08/55] Factorize code in AuditService
Use uploadFileToStorage within saveExampleImage and saveNotesFile
Fix dark background on notes thumbnails
---
.../src/audits/audit.service.ts | 89 +++++++++----------
1 file changed, 43 insertions(+), 46 deletions(-)
diff --git a/confiture-rest-api/src/audits/audit.service.ts b/confiture-rest-api/src/audits/audit.service.ts
index f613c9e82..7acc72086 100644
--- a/confiture-rest-api/src/audits/audit.service.ts
+++ b/confiture-rest-api/src/audits/audit.service.ts
@@ -420,28 +420,11 @@ export class AuditService {
criterium: number,
file: Express.Multer.File
) {
- const randomPrefix = nanoid();
-
- const key = `audits/${editUniqueId}/${randomPrefix}/${file.originalname}`;
-
- const thumbnailKey = `audits/${editUniqueId}/${randomPrefix}/thumbnail_${file.originalname}`;
-
- const thumbnailBuffer = await sharp(file.buffer)
- .jpeg({
- mozjpeg: true
- })
- .flatten({ background: { r: 255, g: 255, b: 255, alpha: 0 } })
- .resize(200, 200, { fit: "inside" })
- .toBuffer();
-
- await Promise.all([
- this.fileStorageService.uploadFile(file.buffer, file.mimetype, key),
- this.fileStorageService.uploadFile(
- thumbnailBuffer,
- "image/jpeg",
- thumbnailKey
- )
- ]);
+ const { key, thumbnailKey } = await this.uploadFileToStorage(
+ editUniqueId,
+ file,
+ { createThumbnail: display === FileDisplay.ATTACHMENT }
+ );
const storedFile = await this.prisma.storedFile.create({
data: {
@@ -501,21 +484,53 @@ export class AuditService {
}
async saveNotesFile(editUniqueId: string, file: Express.Multer.File) {
+ const { key, thumbnailKey } = await this.uploadFileToStorage(
+ editUniqueId,
+ file,
+ { createThumbnail: display === FileDisplay.ATTACHMENT }
+ );
+
+ const storedFile = await this.prisma.auditFile.create({
+ data: {
+ audit: {
+ connect: {
+ editUniqueId
+ }
+ },
+
+ key,
+ originalFilename: file.originalname,
+ mimetype: file.mimetype,
+ size: file.size,
+
+ thumbnailKey,
+ }
+ });
+
+ return storedFile;
+ }
+
+ async uploadFileToStorage(
+ uniqueId: string,
+ file: Express.Multer.File,
+ options?: { createThumbnail: boolean }
+ ): Promise<{ key: string; thumbnailKey?: string }> {
const randomPrefix = nanoid();
- const key = `audits/${editUniqueId}/${randomPrefix}/${file.originalname}`;
+ const key: string = `audits/${uniqueId}/${randomPrefix}/${file.originalname}`;
- let thumbnailKey;
+ let thumbnailKey: string;
- if (file.mimetype.startsWith("image")) {
+ if (file.mimetype.startsWith("image") && options.createThumbnail) {
// If it's an image, create a thumbnail and upload it
- thumbnailKey = `audits/${editUniqueId}/${randomPrefix}/thumbnail_${file.originalname}`;
+ thumbnailKey = `audits/${uniqueId}/${randomPrefix}/thumbnail_${file.originalname}`;
const thumbnailBuffer = await sharp(file.buffer)
- .resize(200, 200, { fit: "inside" })
.jpeg({
mozjpeg: true
})
+ .flatten({ background: { r: 255, g: 255, b: 255, alpha: 0 } })
+ .resize(200, 200, { fit: "inside" })
.toBuffer();
await Promise.all([
@@ -529,25 +544,7 @@ export class AuditService {
} else {
await this.fileStorageService.uploadFile(file.buffer, file.mimetype, key);
}
-
- const storedFile = await this.prisma.auditFile.create({
- data: {
- audit: {
- connect: {
- editUniqueId
- }
- },
-
- key,
- originalFilename: file.originalname,
- mimetype: file.mimetype,
- size: file.size,
-
- thumbnailKey
- }
- });
-
- return storedFile;
+ return { key, thumbnailKey };
}
/**
From beeef1ba39405310ce2ac4de2b9e0b287a030680 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 9 Sep 2024 11:06:34 +0200
Subject: [PATCH 09/55] Add display field for uploads
(ATTACHMENT or EDITOR)
---
.../migration.sql | 8 ++++++++
confiture-rest-api/prisma/schema.prisma | 13 +++++++++++-
.../src/audits/audit.service.ts | 20 ++++++++++++++-----
.../src/audits/audits.controller.ts | 9 ++++++---
.../src/audits/dto/audit-report.dto.ts | 18 +++++++++++++----
.../src/audits/dto/notes-file.dto.ts | 9 +++++++++
.../src/audits/dto/upload-image.dto.ts | 3 +++
.../src/components/audit/NotesModal.vue | 9 +++++++--
confiture-web-app/src/store/audit.ts | 10 +++++++++-
confiture-web-app/src/store/results.ts | 9 +++++++--
confiture-web-app/src/types/types.ts | 6 ++++++
11 files changed, 96 insertions(+), 18 deletions(-)
create mode 100644 confiture-rest-api/prisma/migrations/20240906161141_add_display_field_on_files_attachment_or_editor/migration.sql
create mode 100644 confiture-rest-api/src/audits/dto/notes-file.dto.ts
diff --git a/confiture-rest-api/prisma/migrations/20240906161141_add_display_field_on_files_attachment_or_editor/migration.sql b/confiture-rest-api/prisma/migrations/20240906161141_add_display_field_on_files_attachment_or_editor/migration.sql
new file mode 100644
index 000000000..503297a73
--- /dev/null
+++ b/confiture-rest-api/prisma/migrations/20240906161141_add_display_field_on_files_attachment_or_editor/migration.sql
@@ -0,0 +1,8 @@
+-- CreateEnum
+CREATE TYPE "FileDisplay" AS ENUM ('EDITOR', 'ATTACHMENT');
+
+-- AlterTable
+ALTER TABLE "AuditFile" ADD COLUMN "display" "FileDisplay" NOT NULL DEFAULT 'ATTACHMENT';
+
+-- AlterTable
+ALTER TABLE "StoredFile" ADD COLUMN "display" "FileDisplay" NOT NULL DEFAULT 'ATTACHMENT';
diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma
index 3c1a51b21..3da624b40 100644
--- a/confiture-rest-api/prisma/schema.prisma
+++ b/confiture-rest-api/prisma/schema.prisma
@@ -169,6 +169,11 @@ model AuditTrace {
Audit Audit?
}
+enum FileDisplay {
+ EDITOR
+ ATTACHMENT
+}
+
model StoredFile {
id Int @id @default(autoincrement())
originalFilename String
@@ -184,6 +189,9 @@ model StoredFile {
key String
thumbnailKey String
+ // Inside TipTap editor (EDITOR) or added as an attachment (ATTACHMENT)?
+ display FileDisplay @default(ATTACHMENT)
+
criterionResult CriterionResult? @relation(fields: [criterionResultId], references: [id], onDelete: Cascade, onUpdate: Cascade)
criterionResultId Int?
}
@@ -203,7 +211,10 @@ model AuditFile {
key String
thumbnailKey String?
- audit Audit? @relation(fields: [auditUniqueId], references: [editUniqueId], onDelete: Cascade)
+ audit Audit? @relation(fields: [auditUniqueId], references: [editUniqueId], onDelete: Cascade)
+ // Inside TipTap editor (EDITOR) or added as an attachment (ATTACHMENT)?
+ display FileDisplay @default(ATTACHMENT)
+
auditUniqueId String?
}
diff --git a/confiture-rest-api/src/audits/audit.service.ts b/confiture-rest-api/src/audits/audit.service.ts
index 7acc72086..61d5e6024 100644
--- a/confiture-rest-api/src/audits/audit.service.ts
+++ b/confiture-rest-api/src/audits/audit.service.ts
@@ -4,6 +4,7 @@ import {
CriterionResult,
CriterionResultStatus,
CriterionResultUserImpact,
+ FileDisplay,
Prisma,
StoredFile
} from "@prisma/client";
@@ -418,7 +419,8 @@ export class AuditService {
pageId: number,
topic: number,
criterium: number,
- file: Express.Multer.File
+ file: Express.Multer.File,
+ display?: FileDisplay
) {
const { key, thumbnailKey } = await this.uploadFileToStorage(
editUniqueId,
@@ -442,7 +444,8 @@ export class AuditService {
originalFilename: file.originalname,
mimetype: file.mimetype,
size: file.size,
- thumbnailKey
+ thumbnailKey,
+ display
}
});
@@ -483,7 +486,11 @@ export class AuditService {
return true;
}
- async saveNotesFile(editUniqueId: string, file: Express.Multer.File) {
+ async saveNotesFile(
+ editUniqueId: string,
+ file: Express.Multer.File,
+ display: FileDisplay = FileDisplay.ATTACHMENT
+ ) {
const { key, thumbnailKey } = await this.uploadFileToStorage(
editUniqueId,
file,
@@ -504,6 +511,7 @@ export class AuditService {
size: file.size,
thumbnailKey,
+ display
}
});
@@ -829,7 +837,8 @@ export class AuditService {
key: file.key,
thumbnailKey: file.thumbnailKey,
size: file.size,
- mimetype: file.mimetype
+ mimetype: file.mimetype,
+ display: file.display
})),
criteriaCount: {
@@ -996,7 +1005,8 @@ export class AuditService {
exampleImages: r.exampleImages.map((img) => ({
filename: img.originalFilename,
key: img.key,
- thumbnailKey: img.thumbnailKey
+ thumbnailKey: img.thumbnailKey,
+ display: img.display
}))
}))
};
diff --git a/confiture-rest-api/src/audits/audits.controller.ts b/confiture-rest-api/src/audits/audits.controller.ts
index d2a7c92af..391737ae4 100644
--- a/confiture-rest-api/src/audits/audits.controller.ts
+++ b/confiture-rest-api/src/audits/audits.controller.ts
@@ -28,6 +28,7 @@ import {
import { Audit } from "src/generated/nestjs-dto/audit.entity";
import { CriterionResult } from "src/generated/nestjs-dto/criterionResult.entity";
import { MailService } from "../mail/mail.service";
+import { NotesFileDto } from "./dto/notes-file.dto";
import { AuditExportService } from "./audit-export.service";
import { AuditService } from "./audit.service";
import { CreateAuditDto } from "./dto/create-audit.dto";
@@ -174,7 +175,8 @@ export class AuditsController {
body.pageId,
body.topic,
body.criterium,
- file
+ file,
+ body.display
);
}
@@ -191,7 +193,8 @@ export class AuditsController {
errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY
})
)
- file: Express.Multer.File
+ file: Express.Multer.File,
+ @Body() body: NotesFileDto
) {
const audit = await this.auditService.getAuditWithEditUniqueId(uniqueId);
@@ -199,7 +202,7 @@ export class AuditsController {
return this.sendAuditNotFoundStatus(uniqueId);
}
- return await this.auditService.saveNotesFile(uniqueId, file);
+ return await this.auditService.saveNotesFile(uniqueId, file, body.display);
}
@Delete("/:uniqueId/results/examples/:exampleId")
diff --git a/confiture-rest-api/src/audits/dto/audit-report.dto.ts b/confiture-rest-api/src/audits/dto/audit-report.dto.ts
index 0ac3e5be5..dca8d774a 100644
--- a/confiture-rest-api/src/audits/dto/audit-report.dto.ts
+++ b/confiture-rest-api/src/audits/dto/audit-report.dto.ts
@@ -2,7 +2,8 @@ import { ApiProperty } from "@nestjs/swagger";
import {
AuditType,
CriterionResultStatus,
- CriterionResultUserImpact
+ CriterionResultUserImpact,
+ FileDisplay
} from "@prisma/client";
export class AuditReportDto {
@@ -190,18 +191,27 @@ class ReportCriterionResult {
}
class ExampleImage {
- /** @example "mon-image.jpg" */
+ /** @example "my-image.jpg" */
filename: string;
- /** @example "audit/xxxx/my-image.jpg" */
+ /** @example "audit/EWIsM6sYI2cC0lI7Ok2PE/3gnCTQ5ztOdEnKRraIMYG/my-image.jpg" */
key: string;
- /** @example "audit/xxxx/my-image_thumbnail.jpg" */
+ /** @example "audit/EWIsM6sYI2cC0lI7Ok2PE/3gnCTQ5ztOdEnKRraIMYG/my-image_thumbnail.jpg" */
thumbnailKey: string;
+ /** @example "ATTACHMENT" */
+ display: FileDisplay;
}
class NotesFile {
+ /** @example "screenshot_001.png" */
originalFilename: string;
+ /** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/screenshot_001.png" */
key: string;
+ /** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/thumbnail_screenshot_001.png" */
thumbnailKey: string;
+ /** @example 4631 */
size: number;
+ /** @example "image/png" */
mimetype: string;
+ /** @example "ATTACHMENT" */
+ display: FileDisplay;
}
diff --git a/confiture-rest-api/src/audits/dto/notes-file.dto.ts b/confiture-rest-api/src/audits/dto/notes-file.dto.ts
new file mode 100644
index 000000000..8e3bc9cbf
--- /dev/null
+++ b/confiture-rest-api/src/audits/dto/notes-file.dto.ts
@@ -0,0 +1,9 @@
+import { FileDisplay } from "@prisma/client";
+import { IsIn, IsOptional, IsString } from "class-validator";
+
+export class NotesFileDto {
+ @IsOptional()
+ @IsString()
+ @IsIn(Object.values(FileDisplay))
+ display?: FileDisplay;
+}
diff --git a/confiture-rest-api/src/audits/dto/upload-image.dto.ts b/confiture-rest-api/src/audits/dto/upload-image.dto.ts
index 810f58478..5b9c9826f 100644
--- a/confiture-rest-api/src/audits/dto/upload-image.dto.ts
+++ b/confiture-rest-api/src/audits/dto/upload-image.dto.ts
@@ -1,6 +1,7 @@
import { Type } from "class-transformer";
import { IsInt, IsNumber, IsPositive, Max, Min } from "class-validator";
import { IsRgaaCriterium } from "./update-results.dto";
+import { FileDisplay } from "@prisma/client";
/*
The `@Type(() => Number)` decorator is required to correctly parse strings into numbers
@@ -34,4 +35,6 @@ export class UploadImageDto {
"topic and criterium numbers must be a valid RGAA criterium combination"
})
criterium: number;
+
+ display: FileDisplay;
}
diff --git a/confiture-web-app/src/components/audit/NotesModal.vue b/confiture-web-app/src/components/audit/NotesModal.vue
index 0e7d98c8e..f793c463a 100644
--- a/confiture-web-app/src/components/audit/NotesModal.vue
+++ b/confiture-web-app/src/components/audit/NotesModal.vue
@@ -6,7 +6,7 @@ import { useRoute } from "vue-router";
import { useIsOffline } from "../../composables/useIsOffline";
import { FileErrorMessage } from "../../enums";
import { useAuditStore } from "../../store/audit";
-import { AuditFile, StoreName } from "../../types";
+import { AuditFile, FileDisplay, StoreName } from "../../types";
import { handleFileDeleteError, handleFileUploadError } from "../../utils";
import DsfrModal from "../ui/DsfrModal.vue";
import FileUpload from "../ui/FileUpload.vue";
@@ -40,7 +40,12 @@ const isOffline = useIsOffline();
const notes = ref(auditStore.currentAudit?.notes || "");
const uniqueId = computed(() => route.params.uniqueId as string);
-const files = computed(() => auditStore.currentAudit?.notesFiles || []);
+const files = computed(
+ () =>
+ auditStore.currentAudit?.notesFiles?.filter(
+ (e) => e.display === FileDisplay.ATTACHMENT
+ ) || []
+);
const handleNotesChange = debounce(() => emit("confirm", notes.value), 500);
diff --git a/confiture-web-app/src/store/audit.ts b/confiture-web-app/src/store/audit.ts
index 4db3a3a31..92a484f28 100644
--- a/confiture-web-app/src/store/audit.ts
+++ b/confiture-web-app/src/store/audit.ts
@@ -5,6 +5,7 @@ import {
Audit,
AuditFile,
CreateAuditRequestData,
+ FileDisplay,
UpdateAuditRequestData
} from "../types";
import { AccountAudit } from "../types/account";
@@ -126,10 +127,17 @@ export const useAuditStore = defineStore("audit", {
}
},
- async uploadAuditFile(uniqueId: string, file: File) {
+ async uploadAuditFile(
+ uniqueId: string,
+ file: File,
+ display?: FileDisplay
+ ): Promise {
const formData = new FormData();
// To handle non-ascii characters, we encode the filename here and decode it on the back
formData.set("file", file, encodeURI(file.name));
+ if (display) {
+ formData.set("display", display.toString());
+ }
this.increaseCurrentRequestCount();
const notesFile = (await ky
diff --git a/confiture-web-app/src/store/results.ts b/confiture-web-app/src/store/results.ts
index 709064eb3..b52a4e7d9 100644
--- a/confiture-web-app/src/store/results.ts
+++ b/confiture-web-app/src/store/results.ts
@@ -6,7 +6,8 @@ import {
AuditFile,
CriterionResultUserImpact,
CriteriumResult,
- CriteriumResultStatus
+ CriteriumResultStatus,
+ FileDisplay
} from "../types";
import { useAuditStore } from "./audit";
import { useFiltersStore } from "./filters";
@@ -343,7 +344,8 @@ export const useResultsStore = defineStore("results", {
pageId: number,
topic: number,
criterium: number,
- file: File
+ file: File,
+ display?: FileDisplay
) {
const formData = new FormData();
formData.set("pageId", pageId.toString());
@@ -351,6 +353,9 @@ export const useResultsStore = defineStore("results", {
formData.set("criterium", criterium.toString());
// To handle non-ascii characters, we encode the filename here and decode it on the back
formData.set("image", file, encodeURI(file.name));
+ if (display) {
+ formData.set("display", display.toString());
+ }
this.increaseCurrentRequestCount();
diff --git a/confiture-web-app/src/types/types.ts b/confiture-web-app/src/types/types.ts
index fc48a08d3..7621b44e2 100644
--- a/confiture-web-app/src/types/types.ts
+++ b/confiture-web-app/src/types/types.ts
@@ -113,6 +113,12 @@ export interface AuditFile {
key: string;
mimetype: string;
thumbnailKey: string;
+ display: FileDisplay;
+}
+
+export enum FileDisplay {
+ ATTACHMENT = "ATTACHMENT",
+ EDITOR = "EDITOR"
}
export interface CriteriumResult {
From 7a165f8b38abbe368ab0d983e6d18e954e2acd83 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 13 Sep 2024 15:59:39 +0200
Subject: [PATCH 10/55] Tiptap: handle images drag and drop
Add a new extension: ImageUploadTiptapExtension
---
confiture-web-app/package.json | 1 +
.../src/components/audit/NotesModal.vue | 12 +-
.../src/components/ui/Tiptap.vue | 19 +-
confiture-web-app/src/store/audit.ts | 1 +
confiture-web-app/src/styles/main.css | 39 ++++
.../src/tiptap/ImageUploadTiptapExtension.ts | 166 ++++++++++++++++++
yarn.lock | 5 +
7 files changed, 236 insertions(+), 7 deletions(-)
create mode 100644 confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
diff --git a/confiture-web-app/package.json b/confiture-web-app/package.json
index 5ddddb7f2..1a8eea77e 100644
--- a/confiture-web-app/package.json
+++ b/confiture-web-app/package.json
@@ -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",
diff --git a/confiture-web-app/src/components/audit/NotesModal.vue b/confiture-web-app/src/components/audit/NotesModal.vue
index f793c463a..8869f352d 100644
--- a/confiture-web-app/src/components/audit/NotesModal.vue
+++ b/confiture-web-app/src/components/audit/NotesModal.vue
@@ -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
@@ -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"
/>
();
const emit = defineEmits(["update:content"]);
+const uniqueId = computed(() => route.params.uniqueId as string);
+
function getContent() {
let jsonContent;
try {
@@ -57,6 +68,10 @@ const editor = useEditor({
}),
TaskItem,
TaskList,
+ ImageExtension.configure({ inline: false }),
+ ImageUploadTiptapExtension.configure({
+ uniqueId: uniqueId.value
+ }),
Typography.configure({
openDoubleQuote: "« ",
closeDoubleQuote: " »"
@@ -66,7 +81,7 @@ const editor = useEditor({
// The content has changed.
emit("update:content", JSON.stringify(editor.getJSON()));
}
-});
+}) as ShallowRef;
diff --git a/confiture-web-app/src/store/audit.ts b/confiture-web-app/src/store/audit.ts
index 92a484f28..6cff3bb4c 100644
--- a/confiture-web-app/src/store/audit.ts
+++ b/confiture-web-app/src/store/audit.ts
@@ -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) {
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index 5b75ff744..4a63afc08 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -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;
}
@@ -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;
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
new file mode 100644
index 000000000..7bb893160
--- /dev/null
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -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,
+ 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({
+ name: "imageUpload",
+ addProseMirrorPlugins() {
+ return [
+ HandleDropPlugin({ uniqueId: this.options.uniqueId }),
+ placeholderPlugin
+ ];
+ }
+ });
diff --git a/yarn.lock b/yarn.lock
index 072e088e7..ba42e1867 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"
From bf48bc21b4dff8892ff4a8fd9105bedca80ed16a Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 9 Oct 2024 14:29:16 +0200
Subject: [PATCH 11/55] refactor(tiptap): simplify code
---
confiture-web-app/src/components/ui/Tiptap.vue | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index ccc6c953e..eedbe8a8b 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -1,6 +1,5 @@
-
+
+
+ Éditeur de texte riche, vous pouvez utiliser le format Markdown ou bien
+ utiliser les raccourcis clavier.
+
+
+
From 8a946b1b852b0e5903b7041fd672dcf00252729d Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 23 Oct 2024 19:04:30 +0200
Subject: [PATCH 19/55] Image upload: add error message
FETCH_ERROR is used, for the moment, when dragging and dropping an external image and a CORS issue happens
---
confiture-web-app/src/enums.ts | 2 +-
confiture-web-app/src/utils.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/confiture-web-app/src/enums.ts b/confiture-web-app/src/enums.ts
index 9a3d856c2..d464b2d90 100644
--- a/confiture-web-app/src/enums.ts
+++ b/confiture-web-app/src/enums.ts
@@ -24,11 +24,11 @@ export enum Browsers {
EDGE = "Microsoft Edge"
}
-/* UPLOAD_FORMAT should never happen… */
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é.",
UPLOAD_FORMAT_EXAMPLE = "Format de fichier non supporté. Veuillez choisir un fichier jpg, jpeg ou png.",
+ FETCH_ERROR = "Impossible de récupérer le fichier distant",
UPLOAD_UNKNOWN = "Une erreur inconnue empêche le téléchargement du fichier. Veuillez réessayer.",
DELETE_UNKNOWN = "Une erreur inconnue empêche la suppression du fichier. Veuillez réessayer."
}
diff --git a/confiture-web-app/src/utils.ts b/confiture-web-app/src/utils.ts
index 31c3bdcbc..640cbdee4 100644
--- a/confiture-web-app/src/utils.ts
+++ b/confiture-web-app/src/utils.ts
@@ -253,6 +253,7 @@ export async function handleFileUploadError(
}
// Unprocessable Entity
+ /* UPLOAD_FORMAT should never happen… */
if (error.response.status === 422) {
const body = await error.response.json();
From 0e6190cae633a043f07d4e90b3c2ac7a869c30dd Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Thu, 24 Oct 2024 16:10:23 +0200
Subject: [PATCH 20/55] Minor typo: Tiptap instead of TipTap
---
confiture-web-app/src/styles/main.css | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index 848405bb3..9d739d395 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -97,7 +97,7 @@ from DSFR links with `target="_blank"` */
vertical-align: middle;
}
-/* Testing some different UI for TipTap editor: */
+/* Testing some different UI for Tiptap editor: */
/* .tiptap[contenteditable]:not([contenteditable="false"]),
.tiptap[tabindex] {
color: rgba(10, 118, 246, 0);
From b571d241fa4e13a24b959dff979411d8f51d3c97 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Thu, 24 Oct 2024 16:17:26 +0200
Subject: [PATCH 21/55] Tiptap: improve drag and drop + upload
Handle multiple files, external URL, data URL
---
.../src/tiptap/ImageUploadTiptapExtension.ts | 240 +++++++++++++-----
1 file changed, 180 insertions(+), 60 deletions(-)
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index 4c1857249..4ecc18e47 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -13,9 +13,12 @@ export interface ImageUploadTiptapExtensionOptions {
}
/**
- * Placeholder: the image blob (local to browser), with 50% opacity
+ * Placeholder plugin
+ *
+ * The placeholder is an image blob (local to browser), with 50% opacity.
+ * Within ProseMirror it’s a Decoration.
*/
-const placeholderPlugin = new Plugin({
+const PlaceholderPlugin = new Plugin({
state: {
init() {
return DecorationSet.empty;
@@ -24,17 +27,17 @@ const placeholderPlugin = new Plugin({
// 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);
+ const action = tr.getMeta(PlaceholderPlugin);
if (action && action.add) {
const deco = Decoration.widget(
action.add.pos,
() => {
- return action.add.blobElement;
+ return action.add.element;
},
{
id: action.add.id,
- width: action.add.blobElement.width.toString(),
- height: action.add.blobElement.height.toString()
+ width: action.add.element.width.toString(),
+ height: action.add.element.height.toString()
}
);
set = set.add(tr.doc, [deco]);
@@ -53,10 +56,28 @@ const placeholderPlugin = new Plugin({
}
});
+/**
+ * HandleDrop plugin
+ *
+ * Handles drag and drop inside editor:
+ * - multiple image files
+ * - dataURL
+ * - external image from URL (⚠️ CORS)
+ */
const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
const { uniqueId } = options;
return new Plugin({
props: {
+ /**
+ * handleDrop: called when something is dropped on the editor.
+ *
+ * @param {Plugin} this
+ * @param {EditorView} view
+ * @param {DragEvent} dragEvent
+ * @param {Slice} slice
+ * @param {boolean} moved
+ * @returns true if event is handled, otherwise false
+ */
handleDrop(
this: Plugin,
view: EditorView,
@@ -68,62 +89,120 @@ const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
return false;
}
if (dragEvent.dataTransfer.files.length === 0) {
- // TODO external URL?
- return false;
+ // 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 = dragEvent.dataTransfer.getData("URL");
+ if (url) {
+ createFileFromImageUrl(url).then((file) => {
+ if (file) {
+ handleFileDrop(view, dragEvent, file);
+ }
+ });
+ }
+ return true;
}
+ // Handle multiple files
+ // FIXME: sometimes placeholders order differs from final images order
const files: FileList = dragEvent.dataTransfer.files;
for (let i = 0, il = files.length, file: File; i < il; i++) {
file = files.item(i)!;
-
- // If dropping external files
- 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 position = view.posAtCoords({
- left: dragEvent.clientX,
- top: dragEvent.clientY
- });
- if (!position) {
- console.warn("No position?!");
- return false;
- }
-
- // If image is being dropped *inside* a node,
- // move it to next "gap", between 2 nodes
- let pos = position.pos;
- if (isDropCursorVertical(view, pos)) {
- pos = view.state.doc.resolve(position.pos).end() + 1;
- }
-
- const _URL = window.URL || window.webkitURL;
- const blobUrl = _URL.createObjectURL(file);
- const blobElement: HTMLImageElement = document.createElement("img");
- blobElement.setAttribute("src", blobUrl);
- blobElement.onload = () => {
- tr.setMeta(placeholderPlugin, {
- add: { id, blobElement, pos }
- });
- view.dispatch(tr);
-
- uploadAndReplacePlaceHolder(view, file, id);
- };
- } else {
- //FIXME: use a notification
- window.alert(FileErrorMessage.UPLOAD_SIZE);
+ if (!handleFileDrop(view, dragEvent, file)) {
+ return false;
}
}
- // handled
return true;
}
}
});
- function uploadAndReplacePlaceHolder(view: EditorView, file: File, id: any) {
+ /**
+ * Handles file drop
+ *
+ * @param {EditorView} view
+ * @param {DragEvent} dragEvent
+ * @param {File} file
+ * @returns {boolean} true or false if file is not dropped inside of the editor (should not happen)
+ */
+ function handleFileDrop(
+ view: EditorView,
+ dragEvent: DragEvent,
+ file: File
+ ): boolean {
+ const position = view.posAtCoords({
+ left: dragEvent.clientX,
+ top: dragEvent.clientY
+ });
+ if (!position) {
+ console.warn(
+ `the given coordinates aren't inside of the editor: {${dragEvent.clientX}, ${dragEvent.clientY}}`
+ );
+ return false;
+ }
+
+ 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 = {};
+
+ // If image is being dropped *inside* a node,
+ // move it to next "gap", between 2 nodes
+ let pos = position.pos;
+ if (isPosInsideInlineContent(view, pos)) {
+ pos = view.state.doc.resolve(position.pos).end() + 1;
+ }
+
+ const _URL = window.URL || window.webkitURL;
+ const localURL = _URL.createObjectURL(file);
+ let element: HTMLImageElement | HTMLVideoElement;
+ if (file.type.startsWith("image")) {
+ element = document.createElement("img");
+ 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 { tr } = view.state;
+ tr.setMeta(PlaceholderPlugin, {
+ add: { id, element, pos }
+ });
+ 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;
+ }
+
+ /**
+ * Uploads and then replaces the placeholder
+ *
+ * @param {EditorView} view
+ * @param {DragEvent} dragEvent
+ * @param {File} file
+ */
+ function uploadAndReplacePlaceholder(view: EditorView, file: File, id: any) {
const auditStore = useAuditStore();
auditStore.uploadAuditFile(uniqueId, file, FileDisplay.EDITOR).then(
(response: AuditFile) => {
@@ -133,7 +212,7 @@ const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
// If the content around the placeholder has been deleted, drop
// the image
if (pos === undefined) {
- //TODO remove image from server
+ // TODO remove image from server
return;
}
@@ -154,13 +233,13 @@ const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
src: imgUrl
})
)
- .setMeta(placeholderPlugin, { remove: { id } })
+ .setMeta(PlaceholderPlugin, { remove: { id } })
);
},
async (reason: any) => {
// On failure, just clean up the placeholder
view.dispatch(
- view.state.tr.setMeta(placeholderPlugin, { remove: { id } })
+ view.state.tr.setMeta(PlaceholderPlugin, { remove: { id } })
);
//FIXME: use a notification
window.alert(await handleFileUploadError(reason));
@@ -168,23 +247,63 @@ const HandleDropPlugin = (options: ImageUploadTiptapExtensionOptions) => {
);
}
+ /**
+ * 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 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
+ *
+ * Tiptap extension handling images “drag and drop” and upload
+ * Adds 2 custom ProseMirror plugins (@see https://tiptap.dev/docs/editor/extensions/custom-extensions/extend-existing#prosemirror-plugins-advanced):
+ * - HandleDropPlugin
+ * - PlaceholderPlugin
+ * Modifies schema: adds a disableDropCursor property to Nodes spec to control
+ * the showing of a drop cursor inside them (only shows horizontal cursors)
+ * @see https://github.com/ProseMirror/prosemirror-dropcursor
+ */
export const ImageUploadTiptapExtension =
Extension.create({
name: "imageUpload",
addProseMirrorPlugins() {
return [
HandleDropPlugin({ uniqueId: this.options.uniqueId }),
- placeholderPlugin
+ PlaceholderPlugin
];
},
extendNodeSchema() {
@@ -193,21 +312,22 @@ export const ImageUploadTiptapExtension =
view: EditorView,
position: { pos: number; inside: number }
) => {
- return isDropCursorVertical(view, position.pos);
+ return isPosInsideInlineContent(view, position.pos);
}
};
}
});
/**
- * Tells if the drop cursor is vertical (inline content)
+ * Tells if the given position is inside inline content
+ * (meaning the drop cursor would be vertical)
* @see prosemirror-dropcursor extension
*
- * @param view:EditorView
- * @param pos:number
- * @returns boolean
+ * @param {EditorView} view
+ * @param {number} pos
+ * @returns {boolean} true if position is inside inline content, otherwise false
*/
-function isDropCursorVertical(view: EditorView, pos: number): boolean {
+function isPosInsideInlineContent(view: EditorView, pos: number): boolean {
if (!pos) {
return false;
}
From d98b78e492458291f64032c47d08b8595e974bc8 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 28 Oct 2024 20:31:59 +0100
Subject: [PATCH 22/55] Add upload/delete timeout error messages
---
confiture-web-app/src/enums.ts | 2 ++
confiture-web-app/src/utils.ts | 14 +++++++++-----
2 files changed, 11 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/enums.ts b/confiture-web-app/src/enums.ts
index d464b2d90..f9d9bc9b6 100644
--- a/confiture-web-app/src/enums.ts
+++ b/confiture-web-app/src/enums.ts
@@ -29,6 +29,8 @@ export enum FileErrorMessage {
UPLOAD_FORMAT = "Format de fichier non supporté.",
UPLOAD_FORMAT_EXAMPLE = "Format de fichier non supporté. Veuillez choisir un fichier jpg, jpeg ou png.",
FETCH_ERROR = "Impossible de récupérer le fichier distant",
+ UPLOAD_TIMEOUT = "Une erreur réseau empêche le téléchargement du fichier (expiration du délai d'attente). Veuillez réessayer.",
UPLOAD_UNKNOWN = "Une erreur inconnue empêche le téléchargement du fichier. Veuillez réessayer.",
+ DELETE_TIMEOUT = "Une erreur réseau empêche la suppression du fichier (expiration du délai d'attente). Veuillez réessayer.",
DELETE_UNKNOWN = "Une erreur inconnue empêche la suppression du fichier. Veuillez réessayer."
}
diff --git a/confiture-web-app/src/utils.ts b/confiture-web-app/src/utils.ts
index 640cbdee4..ba0f7e8fb 100644
--- a/confiture-web-app/src/utils.ts
+++ b/confiture-web-app/src/utils.ts
@@ -1,6 +1,6 @@
import { captureException, Scope } from "@sentry/vue";
import jwtDecode from "jwt-decode";
-import { HTTPError } from "ky";
+import { HTTPError, TimeoutError } from "ky";
import { noop } from "lodash-es";
import baseSlugify from "slugify";
@@ -246,7 +246,11 @@ export async function handleFileUploadError(
): Promise {
let errorType: FileErrorMessage | null = null;
if (!(error instanceof HTTPError)) {
- return null;
+ if (error instanceof TimeoutError) {
+ return FileErrorMessage.UPLOAD_TIMEOUT;
+ } else {
+ return null;
+ }
}
if (error.response.status === 413) {
errorType = FileErrorMessage.UPLOAD_SIZE;
@@ -276,9 +280,9 @@ export async function handleFileUploadError(
export async function handleFileDeleteError(
error: Error
): Promise {
- if (!(error instanceof HTTPError)) {
+ if (error instanceof TimeoutError) {
+ return FileErrorMessage.DELETE_TIMEOUT;
+ } else {
return null;
}
-
- return FileErrorMessage.DELETE_UNKNOWN;
}
From c92eddfcaf865bdd6f3888e1224ec8c69acf718c Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 28 Oct 2024 20:33:46 +0100
Subject: [PATCH 23/55] Improve error messages (avoid displaying null)
---
confiture-web-app/src/utils.ts | 60 +++++++++++++++++++---------------
1 file changed, 33 insertions(+), 27 deletions(-)
diff --git a/confiture-web-app/src/utils.ts b/confiture-web-app/src/utils.ts
index ba0f7e8fb..489943ecf 100644
--- a/confiture-web-app/src/utils.ts
+++ b/confiture-web-app/src/utils.ts
@@ -243,46 +243,52 @@ export function getUploadUrl(key: string): string {
export async function handleFileUploadError(
error: Error
-): Promise {
- let errorType: FileErrorMessage | null = null;
- if (!(error instanceof HTTPError)) {
- if (error instanceof TimeoutError) {
- return FileErrorMessage.UPLOAD_TIMEOUT;
- } else {
- return null;
+): Promise {
+ if (error instanceof HTTPError) {
+ let errorType: FileErrorMessage | null = null;
+ if (error.response.status === 413) {
+ errorType = FileErrorMessage.UPLOAD_SIZE;
}
- }
- if (error.response.status === 413) {
- errorType = FileErrorMessage.UPLOAD_SIZE;
- }
-
- // Unprocessable Entity
- /* UPLOAD_FORMAT should never happen… */
- if (error.response.status === 422) {
- const body = await error.response.json();
- if (body.message.includes("expected type")) {
- errorType = FileErrorMessage.UPLOAD_FORMAT;
- } else if (body.message.includes("expected size")) {
- errorType = FileErrorMessage.UPLOAD_SIZE;
+ // Unprocessable Entity
+ /* UPLOAD_FORMAT should never happen… */
+ if (error.response.status === 422) {
+ const body = await error.response.json();
+
+ if (body.message.includes("expected type")) {
+ errorType = FileErrorMessage.UPLOAD_FORMAT;
+ } else if (body.message.includes("expected size")) {
+ errorType = FileErrorMessage.UPLOAD_SIZE;
+ } else {
+ errorType = FileErrorMessage.UPLOAD_UNKNOWN;
+ captureWithPayloads(error);
+ }
} else {
errorType = FileErrorMessage.UPLOAD_UNKNOWN;
captureWithPayloads(error);
}
- } else {
- errorType = FileErrorMessage.UPLOAD_UNKNOWN;
- captureWithPayloads(error);
+
+ return errorType;
+ }
+
+ if (error instanceof TimeoutError) {
+ return FileErrorMessage.UPLOAD_TIMEOUT;
}
- return errorType;
+ console.warn(error);
+ return error.message;
}
export async function handleFileDeleteError(
error: Error
-): Promise {
+): Promise {
+ if (error instanceof HTTPError) {
+ return FileErrorMessage.DELETE_UNKNOWN;
+ }
if (error instanceof TimeoutError) {
return FileErrorMessage.DELETE_TIMEOUT;
- } else {
- return null;
}
+
+ console.warn(error);
+ return error.message;
}
From 5197c70539fda60cbe87bea32372dbe8327f91e5 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 28 Oct 2024 20:35:19 +0100
Subject: [PATCH 24/55] Tiptap: improve drop cursor style
blue and bigger
---
confiture-web-app/src/components/ui/Tiptap.vue | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index 822fee102..0178871cd 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -1,5 +1,6 @@
@@ -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 f9d9bc9b6..08265be94 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 baa800e0b..e6b6de3df 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
+ });
+}
From a28acde824956ad2486a7ba8dc39072a3b317606 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 15 Nov 2024 23:52:36 +0100
Subject: [PATCH 29/55] Fix CSS errors
---
confiture-web-app/src/styles/main.css | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index 68e0b733c..a464022bf 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -137,9 +137,10 @@ from DSFR links with `target="_blank"` */
display: inline-block;
flex: 0 0 auto;
height: var(--icon-size);
+ -webkit-mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTE0IDNhOCA4IDAgMSAxIDAgMTZ2My41Yy01LTItMTItNS0xMi0xMS41YTggOCAwIDAgMSA4LThoNFptMCAyaC00YTYgNiAwIDAgMC02IDZjMCAzLjYxIDIuNDYyIDUuOTY2IDggOC40OFYxN2gyYTYgNiAwIDEgMCAwLTEyWm0tMiAyLjUtMi41MDYgMy43NUwxMiAxNUg5LjI5NUw2Ljc1IDExLjI1IDkuMjk1IDcuNUgxMlptNC41IDAtMi41MDYgMy43NUwxNi41IDE1aC0yLjcwNWwtMi41NDUtMy43NSAyLjU0NS0zLjc1SDE2LjVaIi8+PC9zdmc+);
mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCI+PHBhdGggZD0iTTE0IDNhOCA4IDAgMSAxIDAgMTZ2My41Yy01LTItMTItNS0xMi0xMS41YTggOCAwIDAgMSA4LThoNFptMCAyaC00YTYgNiAwIDAgMC02IDZjMCAzLjYxIDIuNDYyIDUuOTY2IDggOC40OFYxN2gyYTYgNiAwIDEgMCAwLTEyWm0tMiAyLjUtMi41MDYgMy43NUwxMiAxNUg5LjI5NUw2Ljc1IDExLjI1IDkuMjk1IDcuNUgxMlptNC41IDAtMi41MDYgMy43NUwxNi41IDE1aC0yLjcwNWwtMi41NDUtMy43NSAyLjU0NS0zLjc1SDE2LjVaIi8+PC9zdmc+);
+ -webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
- vertical-align: calc((0.75em - var(--icon-size)) * 0.5);
width: var(--icon-size);
}
@@ -169,6 +170,7 @@ from DSFR links with `target="_blank"` */
.tiptap ul[data-type="taskList"] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
+ -webkit-user-select: none;
user-select: none;
}
From 46f21ebfcb9cd3d75b2e0fcb993866576058216c Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 15 Nov 2024 23:54:18 +0100
Subject: [PATCH 30/55] Fix type issues (error messages can be strings)
---
.../src/components/audit/AuditGenerationCriterium.vue | 2 +-
.../src/components/audit/CriteriumNotCompliantAccordion.vue | 2 +-
confiture-web-app/src/components/audit/NotesModal.vue | 2 +-
confiture-web-app/src/components/ui/FileUpload.vue | 2 +-
confiture-web-app/src/utils.ts | 2 +-
5 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue b/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
index f4d40356e..b6149172a 100644
--- a/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
+++ b/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
@@ -119,7 +119,7 @@ function toggleTransverseComment() {
const notify = useNotifications();
-const errorMessage: Ref = ref(null);
+const errorMessage: Ref = ref(null);
const criteriumNotCompliantAccordion =
ref>();
diff --git a/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue b/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
index c544da975..55cb7320e 100644
--- a/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
+++ b/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
@@ -14,7 +14,7 @@ import MarkdownHelpButton from "./MarkdownHelpButton.vue";
export interface Props {
id: string;
comment: string | null;
- errorMessage?: FileErrorMessage | null;
+ errorMessage?: FileErrorMessage | string | null;
exampleImages: AuditFile[];
quickWin?: boolean;
userImpact: CriterionResultUserImpact | null;
diff --git a/confiture-web-app/src/components/audit/NotesModal.vue b/confiture-web-app/src/components/audit/NotesModal.vue
index 4042504c4..fcd0c0e6a 100644
--- a/confiture-web-app/src/components/audit/NotesModal.vue
+++ b/confiture-web-app/src/components/audit/NotesModal.vue
@@ -28,7 +28,7 @@ defineExpose({
hide: () => modal.value?.hide()
});
-const errorMessage: Ref = ref(null);
+const errorMessage: Ref = ref(null);
const fileUpload = ref>();
const auditStore = useAuditStore();
diff --git a/confiture-web-app/src/components/ui/FileUpload.vue b/confiture-web-app/src/components/ui/FileUpload.vue
index 2d80e1793..058958127 100644
--- a/confiture-web-app/src/components/ui/FileUpload.vue
+++ b/confiture-web-app/src/components/ui/FileUpload.vue
@@ -10,7 +10,7 @@ import { formatBytes, getUploadUrl } from "../../utils";
export interface Props {
acceptedFormats?: Array;
auditFiles: AuditFile[];
- errorMessage?: FileErrorMessage | null;
+ errorMessage?: FileErrorMessage | string | null;
maxFileSize?: string;
multiple?: boolean;
readonly?: boolean;
diff --git a/confiture-web-app/src/utils.ts b/confiture-web-app/src/utils.ts
index 489943ecf..868a38042 100644
--- a/confiture-web-app/src/utils.ts
+++ b/confiture-web-app/src/utils.ts
@@ -245,7 +245,7 @@ export async function handleFileUploadError(
error: Error
): Promise {
if (error instanceof HTTPError) {
- let errorType: FileErrorMessage | null = null;
+ let errorType: FileErrorMessage;
if (error.response.status === 413) {
errorType = FileErrorMessage.UPLOAD_SIZE;
}
From b3b41d23ec02adcf07fb4ff19fdf7bbd2ec25ec0 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 4 Dec 2024 09:57:25 +0100
Subject: [PATCH 31/55] Tiptap: add buttons
+ fix text drag and drop
---
.../src/assets/images/code-block.svg | 1 +
.../src/assets/images/strikethrough-2.svg | 1 +
.../src/components/ui/Tiptap.vue | 252 ++++++++++++++++--
.../src/components/ui/TiptapButton.vue | 42 +++
confiture-web-app/src/styles/main.css | 116 +++++++-
.../src/tiptap/AraTiptapExtension.ts | 57 ++++
.../src/tiptap/ImageUploadTiptapExtension.ts | 2 +-
7 files changed, 439 insertions(+), 32 deletions(-)
create mode 100644 confiture-web-app/src/assets/images/code-block.svg
create mode 100644 confiture-web-app/src/assets/images/strikethrough-2.svg
create mode 100644 confiture-web-app/src/components/ui/TiptapButton.vue
create mode 100644 confiture-web-app/src/tiptap/AraTiptapExtension.ts
diff --git a/confiture-web-app/src/assets/images/code-block.svg b/confiture-web-app/src/assets/images/code-block.svg
new file mode 100644
index 000000000..9db393b1b
--- /dev/null
+++ b/confiture-web-app/src/assets/images/code-block.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/confiture-web-app/src/assets/images/strikethrough-2.svg b/confiture-web-app/src/assets/images/strikethrough-2.svg
new file mode 100644
index 000000000..b96583fc6
--- /dev/null
+++ b/confiture-web-app/src/assets/images/strikethrough-2.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index 8d01492cf..d748d8e3a 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -1,6 +1,8 @@
-
+
Éditeur de texte riche, vous pouvez utiliser le format Markdown ou bien
utiliser les raccourcis clavier.
-
-
+
diff --git a/confiture-web-app/src/components/ui/TiptapButton.vue b/confiture-web-app/src/components/ui/TiptapButton.vue
new file mode 100644
index 000000000..2bc8202d9
--- /dev/null
+++ b/confiture-web-app/src/components/ui/TiptapButton.vue
@@ -0,0 +1,42 @@
+
+
+
+
+
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index a464022bf..07ae654a9 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -65,11 +65,18 @@ from DSFR links with `target="_blank"` */
color: var(--background-action-high-error) !important;
}
+/* Tiptap */
+.tiptap-container {
+ position: relative;
+}
+
.tiptap {
- background-color: var(--background-default-grey);
- padding: 1rem;
- border: 1px solid var(--border-default-grey);
- min-height: 10rem;
+ background-color: var(--background-alt-grey);
+ border-radius: 0.5rem 0.5rem 0 0;
+ padding: 4rem 1.5rem 1rem;
+ border: 0 solid var(--border-plain-grey);
+ border-bottom-width: 1px;
+ min-height: 30rem;
overflow-y: auto;
}
@@ -85,7 +92,7 @@ from DSFR links with `target="_blank"` */
.tiptap:focus,
.tiptap:focus-visible {
- outline-width: 4px !important;
+ outline-width: 3px !important;
outline-offset: 0 !important;
}
@@ -190,6 +197,86 @@ from DSFR links with `target="_blank"` */
margin: 0;
}
+.tiptap-buttons,
+.tiptap-buttons ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.tiptap-buttons {
+ margin: 0.5rem 0.75rem;
+ width: calc(100% - 1.5rem);
+ position: absolute;
+ overflow-x: auto;
+ white-space: nowrap;
+ top: 0;
+ scrollbar-width: none; /* Firefox */
+ -ms-overflow-style: none; /* Internet Explorer 10+ */
+}
+.titptap-buttons::-webkit-scrollbar {
+ /* WebKit */
+ width: 0;
+ height: 0;
+}
+
+.tiptap-buttons li,
+.tiptap-buttons ul {
+ display: inline-block;
+}
+
+.tiptap-buttons li + li {
+ margin-left: 0.2rem;
+}
+
+.tiptap-buttons > li + li:before {
+ box-shadow: inset 0 0 0 1px #ddd;
+ box-shadow: inset 0 0 0 1px var(--border-default-grey);
+ content: "";
+ display: inline-block;
+ height: 1.5rem;
+ padding: 0;
+ margin-left: 0.5rem;
+ margin-right: 0.5rem;
+ position: relative;
+ vertical-align: baseline;
+ width: 1px;
+}
+
+.tiptap-buttons .fr-btn--tertiary {
+ color: var(--text-defult-grey);
+ box-shadow: none;
+ border-radius: 0.2rem;
+}
+.tiptap-buttons .fr-btn--tertiary:not([aria-pressed="true"]) {
+ --hover-tint: var(--background-alt-grey-hover);
+}
+
+.tiptap-buttons .fr-btn--tertiary[disabled] {
+ box-shadow: unset;
+ opacity: 0.5;
+}
+
+.tiptap-buttons .fr-btn--tertiary:not(:disabled):active {
+ background-color: var(--background-alt-grey-active);
+}
+
+.tiptap-buttons .fr-btn--tertiary[aria-pressed="true"] {
+ background-color: var(--background-alt-grey-active);
+}
+
+.tiptap-buttons .fr-btn--tertiary[aria-pressed="true"]:hover {
+ background-color: var(--background-alt-grey-hover);
+}
+
+/* @media (width < 36rem) {
+ .tiptap-buttons .fr-btn--icon-left[class*=" fr-icon-"] {
+ overflow: hidden;
+ max-width: 2.5rem;
+ max-height: 2.5rem;
+ }
+} */
+
.ProseMirror-selectednode {
outline-style: dotted;
outline-width: 2px;
@@ -200,6 +287,25 @@ from DSFR links with `target="_blank"` */
opacity: 0.5;
}
+/* Extra icons */
+.fr-icon-strikethrough::before,
+.fr-icon-strikethrough::after {
+ -webkit-mask-image: url("../assets/images/strikethrough-2.svg");
+ mask-image: url("../assets/images/strikethrough-2.svg");
+}
+.fr-icon-code-block::before,
+.fr-icon-code-block::after {
+ -webkit-mask-image: url("../assets/images/code-block.svg");
+ mask-image: url("../assets/images/code-block.svg");
+}
+
+.tiptap-buttons .fr-btn--icon-left[class*="fr-icon-image-add-line"] {
+ padding-left: 0.5rem;
+ padding-right: 0.5rem;
+}
+.tiptap-buttons .fr-btn--icon-left[class*="fr-icon-image-add-line"]:before {
+ --icon-size: 1.5rem;
+}
/* File upload styling */
/* input[type="file" i]::-webkit-file-upload-button {
background-color: transparent;
diff --git a/confiture-web-app/src/tiptap/AraTiptapExtension.ts b/confiture-web-app/src/tiptap/AraTiptapExtension.ts
new file mode 100644
index 000000000..5407ac242
--- /dev/null
+++ b/confiture-web-app/src/tiptap/AraTiptapExtension.ts
@@ -0,0 +1,57 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, Transaction } from "@tiptap/pm/state";
+
+export interface AraTiptapExtensionOptions {
+ uniqueId: string;
+}
+
+/**
+ * Link title plugin
+ *
+ * The placeholder is an image blob (local to browser), with 50% opacity.
+ * Within ProseMirror it’s a Decoration.
+ */
+const linkTitlePlugin = new Plugin({
+ appendTransaction: (
+ transactions: readonly Transaction[],
+ oldState,
+ newState
+ ) => {
+ const tr = newState.tr;
+
+ newState.doc.descendants((node, pos) => {
+ if (node.marks.length > 0) {
+ node.marks.forEach((mark) => {
+ const newAttrs = {
+ ...mark.attrs,
+ title: node.text + " - nouvelle fenêtre"
+ };
+ tr.removeMark(pos, pos + node.nodeSize, mark.type);
+ tr.addMark(pos, pos + node.nodeSize, mark.type.create(newAttrs));
+ });
+ }
+ });
+
+ return tr;
+ }
+});
+
+/**
+ * Extension AraTiptapExtension
+ *
+ * Tiptap extension for Ara specificities
+ * @see https://github.com/ProseMirror/prosemirror-dropcursor
+ */
+export const AraTiptapExtension = Extension.create
({
+ name: "ara",
+ addProseMirrorPlugins() {
+ return [linkTitlePlugin];
+ },
+ extendNodeSchema() {
+ return {
+ image: {
+ marks: "_"
+ }
+ };
+ }
+});
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index e6b6de3df..151be1f59 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -175,7 +175,7 @@ function handleDataTransfer(
}
});
}
- return true;
+ return false;
}
// Handle multiple files
From d689d71b81e6befedcd76aa88337408e8f308a07 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 15 Jan 2025 16:30:51 +0100
Subject: [PATCH 32/55] Fix duplicate image when dropping an image from an
external website
---
confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index 151be1f59..76b55ef13 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -174,8 +174,10 @@ function handleDataTransfer(
handleFileImport(uniqueId, view, pos, file, options);
}
});
+ return true;
+ } else {
+ return false;
}
- return false;
}
// Handle multiple files
From 6229c2ad36402c556b1c4a666c8698653de0adac Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Tue, 21 Jan 2025 15:34:00 +0100
Subject: [PATCH 33/55] Audit store: fix deleteAuditFile
Updates store only when delete request succeeds
---
confiture-web-app/src/store/audit.ts | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/store/audit.ts b/confiture-web-app/src/store/audit.ts
index 6cff3bb4c..2af0ba898 100644
--- a/confiture-web-app/src/store/audit.ts
+++ b/confiture-web-app/src/store/audit.ts
@@ -156,16 +156,17 @@ export const useAuditStore = defineStore("audit", {
async deleteAuditFile(uniqueId: string, fileId: number) {
this.increaseCurrentRequestCount();
+
await ky
.delete(`/api/audits/${uniqueId}/notes/files/${fileId}`)
+ .then(() => {
+ const notesFiles = this.entities[uniqueId].notesFiles || [];
+ const fileIndex = notesFiles.findIndex((f) => f.id === fileId);
+ notesFiles.splice(fileIndex, 1);
+ })
.finally(() => {
this.decreaseCurrentRequestCount();
});
-
- const notesFiles = this.entities[uniqueId].notesFiles || [];
- const fileIndex = notesFiles.findIndex((f) => f.id === fileId);
-
- notesFiles.splice(fileIndex, 1);
},
async publishAudit(uniqueId: string): Promise {
From a30323011918d5869063bffdd9c5a0c3cc89078e Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Tue, 21 Jan 2025 15:37:17 +0100
Subject: [PATCH 34/55] feat(Audit service) Add deleteAuditFiles
to delete multiple audit files (used in notes) at the same time
---
.../src/audits/audit.service.ts | 38 ++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/confiture-rest-api/src/audits/audit.service.ts b/confiture-rest-api/src/audits/audit.service.ts
index 542bb25ce..198ad3867 100644
--- a/confiture-rest-api/src/audits/audit.service.ts
+++ b/confiture-rest-api/src/audits/audit.service.ts
@@ -556,7 +556,7 @@ export class AuditService {
}
/**
- * Returns true if stored filed was found and deleted. False if not found.
+ * Returns true if stored file has been found and deleted. False if not found.
*/
async deleteAuditFile(
editUniqueId: string,
@@ -590,6 +590,42 @@ export class AuditService {
return true;
}
+ /**
+ * Returns true if all stored file have been found and deleted. False otherwise.
+ */
+ async deleteAuditFiles(fileIds: number[]): Promise {
+ const storedFiles = await this.prisma.auditFile.findMany({
+ select: {
+ id: true,
+ key: true,
+ thumbnailKey: true
+ },
+ where: {
+ id: {
+ in: fileIds
+ }
+ }
+ });
+
+ const filesToDelete = storedFiles.map((e) => e.key);
+ const thumbnailsToDelete = storedFiles
+ .map((e) => e.thumbnailKey)
+ .filter((e) => e != null);
+ await this.fileStorageService.deleteMultipleFiles(
+ ...filesToDelete.concat(thumbnailsToDelete)
+ );
+
+ await this.prisma.auditFile.deleteMany({
+ where: {
+ id: {
+ in: fileIds
+ }
+ }
+ });
+
+ return true;
+ }
+
/**
* Completely delete an audit and all the data associated with it.
* @returns True if an audit was deleted, false otherwise.
From 59ea3589c89fba7129db6e7ddef8d2d055cefa18 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Tue, 21 Jan 2025 15:44:56 +0100
Subject: [PATCH 35/55] chore(titap): Add comments for future use
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
it can help…
---
.../src/tiptap/ImageUploadTiptapExtension.ts | 37 ++++++++++++++++++-
1 file changed, 36 insertions(+), 1 deletion(-)
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index 76b55ef13..9c4b4d706 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -413,7 +413,42 @@ export const ImageUploadTiptapExtension =
PlaceholderPlugin
];
}
- // Deactivate vertical cursor?
+ // //Compare JSONContent before and after update
+ // onUpdate() {
+ // const editor = this.editor;
+ // const currentImages: JSONContent[] = [];
+ // editor.getJSON().content?.forEach((item: JSONContent) => {
+ // if (item.type === "image") {
+ // currentImages.push(item?.attrs?.src);
+ // }
+ // });
+ // const deletedImages = previousImages.filter(
+ // (url) => !currentImages.includes(url)
+ // );
+ // for (const url of deletedImages) {
+ // const uniqueId = this.options.uniqueId;
+ // const baseUri = "/uploads/";
+ // // Not an uploaded file?
+ // if (url.indexOf(baseUri) !== 0) {
+ // continue;
+ // }
+ // const key = url.slice(baseUri.length);
+ // if (!key) {
+ // // No key in URL?
+ // //TODO display error
+ // }
+ // const auditStore = useAuditStore();
+ // auditStore.deleteAuditFileByKey(uniqueId, key!).then(
+ // () => {},
+ // async (reason: any) => {
+ // window.alert(await handleFileDeleteError(reason));
+ // }
+ // );
+ // }
+ // previousImages = currentImages;
+ // }
+
+ // // Deactivate vertical cursor?
// extendNodeSchema() {
// return {
// disableDropCursor: (
From 0315f0cea293f5018d8caad97cc1cda6b78deb0d Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Tue, 21 Jan 2025 15:49:26 +0100
Subject: [PATCH 36/55] feat(system): add pruneUploads function (wip)
- backend: add a "system" module / controller / service
- frontend: add a pruneUploads function to the existing system store
TODO: add timestamps to AuditFile + check it when pruning
---
confiture-rest-api/src/app.module.ts | 4 +-
.../src/system/system.controller.ts | 26 +++++++++
.../src/system/system.module.ts | 13 +++++
.../src/system/system.service.ts | 55 +++++++++++++++++++
confiture-web-app/src/store/system.ts | 8 ++-
5 files changed, 104 insertions(+), 2 deletions(-)
create mode 100644 confiture-rest-api/src/system/system.controller.ts
create mode 100644 confiture-rest-api/src/system/system.module.ts
create mode 100644 confiture-rest-api/src/system/system.service.ts
diff --git a/confiture-rest-api/src/app.module.ts b/confiture-rest-api/src/app.module.ts
index 149f65d6a..8553a91e3 100644
--- a/confiture-rest-api/src/app.module.ts
+++ b/confiture-rest-api/src/app.module.ts
@@ -7,6 +7,7 @@ import { configValidationSchema } from "./config-validation-schema";
import { MailModule } from "./mail/mail.module";
import { AuthModule } from "./auth/auth.module";
import { ProfileModule } from "./profile/profile.module";
+import { SystemModule } from "./system/system.module";
import { UserMiddleware } from "./auth/user.middleware";
@Module({
@@ -19,7 +20,8 @@ import { UserMiddleware } from "./auth/user.middleware";
AuditsModule,
MailModule,
AuthModule,
- ProfileModule
+ ProfileModule,
+ SystemModule
],
controllers: [HealthCheckController]
})
diff --git a/confiture-rest-api/src/system/system.controller.ts b/confiture-rest-api/src/system/system.controller.ts
new file mode 100644
index 000000000..c9af0d6f9
--- /dev/null
+++ b/confiture-rest-api/src/system/system.controller.ts
@@ -0,0 +1,26 @@
+import { Controller, HttpCode, Post } from "@nestjs/common";
+import { ApiBadRequestResponse, ApiOkResponse } from "@nestjs/swagger";
+import { SystemService } from "./system.service";
+
+@Controller("system")
+export class SystemController {
+ constructor(private readonly systemService: SystemService) {}
+
+ /**
+ * Prune "expired" uploads
+ * ("expired" = removed from rich text editors + old enough to avoid undo/redo issues)
+ *
+ * Removes:
+ * - StoredFile (criteria) and AuditFile (notes) entries
+ * - corresponfing files from the S3 bucket
+ */
+ @Post("prune-uploads")
+ @HttpCode(200)
+ @ApiOkResponse({ description: "Expired uploads pruned successfully" })
+ @ApiBadRequestResponse({
+ description: "Pruning expired uploads failed"
+ })
+ async pruneUploads() {
+ return await this.systemService.pruneUploads();
+ }
+}
diff --git a/confiture-rest-api/src/system/system.module.ts b/confiture-rest-api/src/system/system.module.ts
new file mode 100644
index 000000000..33659af02
--- /dev/null
+++ b/confiture-rest-api/src/system/system.module.ts
@@ -0,0 +1,13 @@
+import { Module } from "@nestjs/common";
+import { SystemService } from "./system.service";
+import { SystemController } from "./system.controller";
+import { PrismaService } from "src/prisma.service";
+import { FileStorageService } from "src/audits/file-storage.service";
+
+@Module({
+ // FIXME: put PrismaService into a global module so the service is not instanciated multiple times
+ providers: [SystemService, PrismaService, FileStorageService],
+ controllers: [SystemController],
+ exports: [SystemService]
+})
+export class SystemModule {}
diff --git a/confiture-rest-api/src/system/system.service.ts b/confiture-rest-api/src/system/system.service.ts
new file mode 100644
index 000000000..7148828a9
--- /dev/null
+++ b/confiture-rest-api/src/system/system.service.ts
@@ -0,0 +1,55 @@
+import { Injectable } from "@nestjs/common";
+import { AuditFile } from "@prisma/client";
+import { PrismaService } from "src/prisma.service";
+import { FileStorageService } from "../audits/file-storage.service";
+
+@Injectable()
+export class SystemService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly fileStorageService: FileStorageService
+ ) {}
+
+ async pruneUploads() {
+ const prunableUploads: AuditFile[] = await this.prisma.$queryRaw`
+ SELECT "AuditFile"."id" FROM "AuditFile"
+ JOIN "Audit" ON "AuditFile"."auditUniqueId" = "Audit"."editUniqueId"
+ WHERE (
+ "AuditFile"."display" = 'EDITOR'
+ AND
+ "Audit"."notes" !~ ('/uploads/' || "AuditFile"."key")
+ )`;
+ const prunableUploadIds: number[] = prunableUploads.map((e) => e.id);
+
+ const storedFiles = await this.prisma.auditFile.findMany({
+ select: {
+ id: true,
+ key: true,
+ thumbnailKey: true
+ },
+ where: {
+ id: {
+ in: prunableUploadIds
+ }
+ }
+ });
+
+ const filesToDelete = storedFiles.map((e) => e.key);
+ const thumbnailsToDelete = storedFiles
+ .map((e) => e.thumbnailKey)
+ .filter((e) => e != null);
+ await this.fileStorageService.deleteMultipleFiles(
+ ...filesToDelete.concat(thumbnailsToDelete)
+ );
+
+ await this.prisma.auditFile.deleteMany({
+ where: {
+ id: {
+ in: prunableUploadIds
+ }
+ }
+ });
+
+ return true;
+ }
+}
diff --git a/confiture-web-app/src/store/system.ts b/confiture-web-app/src/store/system.ts
index 24bf665ac..2866d2c7a 100644
--- a/confiture-web-app/src/store/system.ts
+++ b/confiture-web-app/src/store/system.ts
@@ -1,3 +1,4 @@
+import ky from "ky";
import { defineStore } from "pinia";
import { ref } from "vue";
@@ -17,11 +18,16 @@ export const useSystemStore = defineStore("system", {
isOnline.value = false;
}
+ async function pruneUploads() {
+ await ky.post(`/api/system/prune-uploads`);
+ }
+
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
return {
- isOnline
+ isOnline,
+ pruneUploads
};
}
});
From e1967441bad9fa88d3fbc0ac0d269d64661a5ce7 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 24 Jan 2025 18:16:48 +0100
Subject: [PATCH 37/55] Sidebar: fix width on desktop
Could be bigger than 56rem.
For example, when an image has not been loaded (e.g. URL not found), the width of the image is given by the `width` attribute.
---
confiture-web-app/src/components/ui/DsfrModal.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/confiture-web-app/src/components/ui/DsfrModal.vue b/confiture-web-app/src/components/ui/DsfrModal.vue
index 87f238fdd..092389a9f 100644
--- a/confiture-web-app/src/components/ui/DsfrModal.vue
+++ b/confiture-web-app/src/components/ui/DsfrModal.vue
@@ -124,8 +124,8 @@ defineExpose({ show, hide });
@media (min-width: 56rem) {
.sidebar :deep(.sidebar-col) {
flex: 0 0 56rem;
- max-width: revert;
- width: revert;
+ max-width: 56rem;
+ width: 56rem;
}
}
From 51247c86e5f8a9f34ac8d06374b3d41245440438 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 24 Jan 2025 18:22:23 +0100
Subject: [PATCH 38/55] Prisma (AuditFile): add creationDate
---
.../20250121151450_add_audit_file_creation_date/migration.sql | 2 ++
confiture-rest-api/prisma/schema.prisma | 2 ++
2 files changed, 4 insertions(+)
create mode 100644 confiture-rest-api/prisma/migrations/20250121151450_add_audit_file_creation_date/migration.sql
diff --git a/confiture-rest-api/prisma/migrations/20250121151450_add_audit_file_creation_date/migration.sql b/confiture-rest-api/prisma/migrations/20250121151450_add_audit_file_creation_date/migration.sql
new file mode 100644
index 000000000..7a3efe9f8
--- /dev/null
+++ b/confiture-rest-api/prisma/migrations/20250121151450_add_audit_file_creation_date/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "AuditFile" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma
index 3da624b40..986b05bd8 100644
--- a/confiture-rest-api/prisma/schema.prisma
+++ b/confiture-rest-api/prisma/schema.prisma
@@ -216,6 +216,8 @@ model AuditFile {
display FileDisplay @default(ATTACHMENT)
auditUniqueId String?
+
+ creationDate DateTime @default(now())
}
enum EmailStatus {
From f576a7030a9f5ede95bd0ea9d75c9b154c433524 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 24 Jan 2025 23:26:15 +0100
Subject: [PATCH 39/55] Prisma (StoredFile): add creationDate
---
.../20250124221428_add_stored_file_creation_date/migration.sql | 2 ++
confiture-rest-api/prisma/schema.prisma | 2 ++
2 files changed, 4 insertions(+)
create mode 100644 confiture-rest-api/prisma/migrations/20250124221428_add_stored_file_creation_date/migration.sql
diff --git a/confiture-rest-api/prisma/migrations/20250124221428_add_stored_file_creation_date/migration.sql b/confiture-rest-api/prisma/migrations/20250124221428_add_stored_file_creation_date/migration.sql
new file mode 100644
index 000000000..a26845837
--- /dev/null
+++ b/confiture-rest-api/prisma/migrations/20250124221428_add_stored_file_creation_date/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "StoredFile" ADD COLUMN "creationDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma
index 986b05bd8..a2bff929c 100644
--- a/confiture-rest-api/prisma/schema.prisma
+++ b/confiture-rest-api/prisma/schema.prisma
@@ -192,6 +192,8 @@ model StoredFile {
// Inside TipTap editor (EDITOR) or added as an attachment (ATTACHMENT)?
display FileDisplay @default(ATTACHMENT)
+ creationDate DateTime @default(now())
+
criterionResult CriterionResult? @relation(fields: [criterionResultId], references: [id], onDelete: Cascade, onUpdate: Cascade)
criterionResultId Int?
}
From 52d0e0724447b71af72b24732d83c2b92d097e48 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Fri, 24 Jan 2025 23:26:41 +0100
Subject: [PATCH 40/55] Prisma(chore): reorder fields in StoredFile model
Just cleaning
---
confiture-rest-api/prisma/schema.prisma | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma
index a2bff929c..6e4e4887e 100644
--- a/confiture-rest-api/prisma/schema.prisma
+++ b/confiture-rest-api/prisma/schema.prisma
@@ -213,13 +213,13 @@ model AuditFile {
key String
thumbnailKey String?
- audit Audit? @relation(fields: [auditUniqueId], references: [editUniqueId], onDelete: Cascade)
+ creationDate DateTime @default(now())
+
// Inside TipTap editor (EDITOR) or added as an attachment (ATTACHMENT)?
display FileDisplay @default(ATTACHMENT)
+ audit Audit? @relation(fields: [auditUniqueId], references: [editUniqueId], onDelete: Cascade)
auditUniqueId String?
-
- creationDate DateTime @default(now())
}
enum EmailStatus {
From 58a5cf35e92f33fa08e3e4e988be4260fa6bce99 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 27 Jan 2025 14:08:13 +0100
Subject: [PATCH 41/55] StoredFile(db): thumbnailKey becomes optionnal
---
.../migration.sql | 2 ++
confiture-rest-api/prisma/schema.prisma | 2 +-
2 files changed, 3 insertions(+), 1 deletion(-)
create mode 100644 confiture-rest-api/prisma/migrations/20250126234052_thumbnail_optional_for_stored_file/migration.sql
diff --git a/confiture-rest-api/prisma/migrations/20250126234052_thumbnail_optional_for_stored_file/migration.sql b/confiture-rest-api/prisma/migrations/20250126234052_thumbnail_optional_for_stored_file/migration.sql
new file mode 100644
index 000000000..9a47236da
--- /dev/null
+++ b/confiture-rest-api/prisma/migrations/20250126234052_thumbnail_optional_for_stored_file/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "StoredFile" ALTER COLUMN "thumbnailKey" DROP NOT NULL;
diff --git a/confiture-rest-api/prisma/schema.prisma b/confiture-rest-api/prisma/schema.prisma
index 6e4e4887e..35b965e5e 100644
--- a/confiture-rest-api/prisma/schema.prisma
+++ b/confiture-rest-api/prisma/schema.prisma
@@ -187,7 +187,7 @@ model StoredFile {
// S3 storage keys
key String
- thumbnailKey String
+ thumbnailKey String?
// Inside TipTap editor (EDITOR) or added as an attachment (ATTACHMENT)?
display FileDisplay @default(ATTACHMENT)
From 0c967c83ec23f401db453922e0f0e76f49b6a7d0 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 27 Jan 2025 14:11:37 +0100
Subject: [PATCH 42/55] Fix DTO files (sync between StoredFile and AuditFile)
---
.../src/audits/dto/audit-report.dto.ts | 22 +++++++++++++------
.../src/audits/dto/upload-image.dto.ts | 14 +++++++++++-
2 files changed, 28 insertions(+), 8 deletions(-)
diff --git a/confiture-rest-api/src/audits/dto/audit-report.dto.ts b/confiture-rest-api/src/audits/dto/audit-report.dto.ts
index dca8d774a..ecc7a5d4f 100644
--- a/confiture-rest-api/src/audits/dto/audit-report.dto.ts
+++ b/confiture-rest-api/src/audits/dto/audit-report.dto.ts
@@ -191,27 +191,35 @@ class ReportCriterionResult {
}
class ExampleImage {
- /** @example "my-image.jpg" */
- filename: string;
- /** @example "audit/EWIsM6sYI2cC0lI7Ok2PE/3gnCTQ5ztOdEnKRraIMYG/my-image.jpg" */
+ /** @example "screenshot_001.png" */
+ originalFilename?: string;
+ /** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/screenshot_001.png" */
key: string;
- /** @example "audit/EWIsM6sYI2cC0lI7Ok2PE/3gnCTQ5ztOdEnKRraIMYG/my-image_thumbnail.jpg" */
+ /** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/thumbnail_screenshot_001.png" */
thumbnailKey: string;
+ /** @example 4631 */
+ size?: number;
+ /** @example "image/png" */
+ mimetype?: string;
/** @example "ATTACHMENT" */
display: FileDisplay;
+ /** @example 2025-01-24T16:39:12.811Z */
+ creationDate?: Date;
}
class NotesFile {
/** @example "screenshot_001.png" */
- originalFilename: string;
+ originalFilename?: string;
/** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/screenshot_001.png" */
key: string;
/** @example "audits/EWIsM6sYI2cC0lI7Ok2PE/uqoOes4QqhFyKV8v0s2AQ/thumbnail_screenshot_001.png" */
thumbnailKey: string;
/** @example 4631 */
- size: number;
+ size?: number;
/** @example "image/png" */
- mimetype: string;
+ mimetype?: string;
/** @example "ATTACHMENT" */
display: FileDisplay;
+ /** @example 2025-01-24T16:39:12.811Z */
+ creationDate?: Date;
}
diff --git a/confiture-rest-api/src/audits/dto/upload-image.dto.ts b/confiture-rest-api/src/audits/dto/upload-image.dto.ts
index 5b9c9826f..9c48b352a 100644
--- a/confiture-rest-api/src/audits/dto/upload-image.dto.ts
+++ b/confiture-rest-api/src/audits/dto/upload-image.dto.ts
@@ -1,5 +1,14 @@
import { Type } from "class-transformer";
-import { IsInt, IsNumber, IsPositive, Max, Min } from "class-validator";
+import {
+ IsIn,
+ IsInt,
+ IsNumber,
+ IsOptional,
+ IsPositive,
+ IsString,
+ Max,
+ Min
+} from "class-validator";
import { IsRgaaCriterium } from "./update-results.dto";
import { FileDisplay } from "@prisma/client";
@@ -36,5 +45,8 @@ export class UploadImageDto {
})
criterium: number;
+ @IsOptional()
+ @IsString()
+ @IsIn(Object.values(FileDisplay))
display: FileDisplay;
}
From 4acd69b35d4fb7fb5e4f298f12c2f7b5f02b9a0f Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 27 Jan 2025 14:16:26 +0100
Subject: [PATCH 43/55] feat(FileStorageService): Add getAllFileKeys
Retrieves all the keys of the files stored in the S3 bucket
---
.../src/audits/file-storage.service.ts | 38 ++++++++++++++++++-
1 file changed, 37 insertions(+), 1 deletion(-)
diff --git a/confiture-rest-api/src/audits/file-storage.service.ts b/confiture-rest-api/src/audits/file-storage.service.ts
index b5b44725a..f0b50fde1 100644
--- a/confiture-rest-api/src/audits/file-storage.service.ts
+++ b/confiture-rest-api/src/audits/file-storage.service.ts
@@ -3,7 +3,9 @@ import {
PutObjectCommand,
DeleteObjectCommand,
DeleteObjectsCommand,
- CopyObjectCommand
+ CopyObjectCommand,
+ ListObjectsV2Command,
+ ListObjectsV2Output
} from "@aws-sdk/client-s3";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@@ -34,6 +36,40 @@ export class FileStorageService {
await this.s3Client.send(command);
}
+ /**
+ * Retrieves all the keys of the files stored in the S3 bucket.
+ * Note: it’s retrieved by chunks of 1000 files at a time.
+ *
+ * @returns {Promise} An array of strings representing the keys of the files stored in the S3 bucket.
+ */
+ async getAllFileKeys() {
+ let allFiles = [];
+ let shouldContinue = true;
+ let nextContinuationToken = null;
+ let command = null;
+ while (shouldContinue) {
+ command = new ListObjectsV2Command({
+ Bucket: this.config.get("S3_BUCKET"),
+ ContinuationToken: nextContinuationToken || undefined
+ });
+
+ const res: ListObjectsV2Output = await ((
+ this.s3Client.send(command)
+ ));
+ if (!res.Contents?.length) {
+ break;
+ }
+ allFiles = [...allFiles, ...res.Contents];
+ if (res.IsTruncated) {
+ nextContinuationToken = res.NextContinuationToken;
+ } else {
+ shouldContinue = false;
+ nextContinuationToken = null;
+ }
+ }
+ return allFiles.map((e) => e.Key);
+ }
+
getPublicUrl(key: string): string {
return `${this.config.get("FRONT_BASE_URL")}/${key}}`;
}
From 7dab077d6cb226dce950a03a6c7977fe11693cad Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Mon, 27 Jan 2025 18:08:53 +0100
Subject: [PATCH 44/55] Prisma: better query logs (DEBUG="prisma:query")
Use env variable DEBUG="prisma:query"
Note: queries using an existing connection are not properly logged (without var replacement)
This could be improved when upgrading primsa
---
confiture-rest-api/.env.example | 6 +++-
confiture-rest-api/src/prisma.service.ts | 41 ++++++++++++++++++++++--
2 files changed, 44 insertions(+), 3 deletions(-)
diff --git a/confiture-rest-api/.env.example b/confiture-rest-api/.env.example
index 83803b2e2..54fbfea07 100644
--- a/confiture-rest-api/.env.example
+++ b/confiture-rest-api/.env.example
@@ -19,4 +19,8 @@ S3_VIRTUAL_HOST="xxx"
AWS_ACCESS_KEY_ID="xxx"
AWS_SECRET_ACCESS_KEY="xxx"
-JWT_SECRET="xxx"
\ No newline at end of file
+JWT_SECRET="xxx"
+
+# Debug Prisma queries
+# More info: [Logging | Prisma Documentation](https://www.prisma.io/docs/orm/prisma-client/observability-and-logging/logging)
+DEBUG="prisma:query"
diff --git a/confiture-rest-api/src/prisma.service.ts b/confiture-rest-api/src/prisma.service.ts
index aaa764a18..473e6ef67 100644
--- a/confiture-rest-api/src/prisma.service.ts
+++ b/confiture-rest-api/src/prisma.service.ts
@@ -1,10 +1,47 @@
import { INestApplication, Injectable, OnModuleInit } from "@nestjs/common";
-import { PrismaClient } from "@prisma/client";
+import { Prisma, PrismaClient } from "@prisma/client";
@Injectable()
-export class PrismaService extends PrismaClient implements OnModuleInit {
+export class PrismaService
+ extends PrismaClient<
+ Prisma.PrismaClientOptions,
+ "query" | "info" | "warn" | "error"
+ >
+ implements OnModuleInit
+{
+ constructor() {
+ const debugQuery = process.env.DEBUG === "prisma:query";
+ let logObject = {};
+ if (debugQuery) {
+ // Note: ideally we would use `emit: "event"` instead of `emit: "stdout"`
+ // to avoid double logging, but Prisma's query logging is connection-scoped
+ // rather than query-scoped, so queries using an existing connection
+ // would not appear in logs
+ // TODO: improve when upgrading Prisma
+ logObject = {
+ log: [
+ {
+ emit: "stdout",
+ level: "query"
+ }
+ ]
+ };
+ }
+ super(logObject);
+ }
+
async onModuleInit() {
await this.$connect();
+ this.$on("query", (query: Prisma.QueryEvent) => {
+ let q = query.query;
+ JSON.parse(query.params).forEach((e, i) => {
+ q = q.replace(`$${i + 1}`, `'${e}'`);
+ });
+ console.log("=======================================");
+ console.log("--- Prisma Query (with $n replaced) ---");
+ console.log(q);
+ console.log("Duration: " + query.duration + "ms\n");
+ });
}
async enableShutdownHooks(app: INestApplication) {
From 7d6a3442158143feebbfae733d98481688412198 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Tue, 28 Jan 2025 18:12:32 +0100
Subject: [PATCH 45/55] tiptap(feat): fix multiple image selection style
Add a new extension to handle visual decoration to selected block nodes without inline content (e.g. images)
Before that, only single image selection was working.
Now images look selected even when selecting a range of stuff (text+image+image for example)
---
.../src/components/ui/Tiptap.vue | 5 ++-
confiture-web-app/src/styles/main.css | 1 +
.../src/tiptap/CustomSelectionExtension.ts | 42 +++++++++++++++++++
3 files changed, 46 insertions(+), 2 deletions(-)
create mode 100644 confiture-web-app/src/tiptap/CustomSelectionExtension.ts
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index d748d8e3a..3e7a8d8d7 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -22,7 +22,7 @@ import { computed, onBeforeUnmount, onMounted, ref, ShallowRef } from "vue";
import { useRoute } from "vue-router";
import { useNotifications } from "../../composables/useNotifications";
-import { AraTiptapExtension } from "../../tiptap/AraTiptapExtension";
+import { AraTiptapExtension, CustomSelectionExtension } from "../../tiptap/AraTiptapExtension";
import {
ImageUploadTiptapExtension,
insertFilesAtSelection
@@ -139,7 +139,8 @@ const editor = useEditor({
Typography.configure({
openDoubleQuote: "« ",
closeDoubleQuote: " »"
- })
+ }),
+ CustomSelectionExtension
],
onUpdate({ editor }) {
// The content has changed.
diff --git a/confiture-web-app/src/styles/main.css b/confiture-web-app/src/styles/main.css
index 07ae654a9..a85a39ec4 100644
--- a/confiture-web-app/src/styles/main.css
+++ b/confiture-web-app/src/styles/main.css
@@ -277,6 +277,7 @@ from DSFR links with `target="_blank"` */
}
} */
+.tiptap-selection,
.ProseMirror-selectednode {
outline-style: dotted;
outline-width: 2px;
diff --git a/confiture-web-app/src/tiptap/CustomSelectionExtension.ts b/confiture-web-app/src/tiptap/CustomSelectionExtension.ts
new file mode 100644
index 000000000..934fc32af
--- /dev/null
+++ b/confiture-web-app/src/tiptap/CustomSelectionExtension.ts
@@ -0,0 +1,42 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+export const CustomSelectionExtension = Extension.create({
+ name: "CustomSelectionExtension",
+
+ // Add a plugin to handle selection decorations
+ addProseMirrorPlugins() {
+ const key = new PluginKey("CustomSelectionExtension");
+
+ return [
+ new Plugin({
+ key,
+ props: {
+ decorations: (state) => {
+ const { selection } = state;
+ const decorations: Decoration[] = [];
+
+ // Add visual decoration to selected block nodes
+ // without inline content (e.g. images)
+ state.doc.nodesBetween(
+ selection.from,
+ selection.to,
+ (node, pos) => {
+ if (!node.isTextblock) {
+ decorations.push(
+ Decoration.node(pos, pos + node.nodeSize, {
+ class: "tiptap-selection"
+ })
+ );
+ }
+ }
+ );
+
+ return DecorationSet.create(state.doc, decorations);
+ }
+ }
+ })
+ ];
+ }
+});
From a9abf6d97d16fc49bd29253935e51d20f2649521 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 29 Jan 2025 11:53:23 +0100
Subject: [PATCH 46/55] Tiptap: handle upload files from criteria
* Modify ImageUploadTiptapExtension with an uploadFn parameter
* Integrate Tiptap component in Criteria* components
---
.../audit/AuditGenerationCriterium.vue | 37 ++++++++++-
.../audit/CriteriumCompliantAccordion.vue | 36 ++++++-----
.../audit/CriteriumNotApplicableAccordion.vue | 37 +++++++----
.../audit/CriteriumNotCompliantAccordion.vue | 27 ++++----
.../src/components/audit/NotesModal.vue | 19 +++++-
.../src/components/ui/Tiptap.vue | 34 +++++-----
.../src/tiptap/ImageUploadTiptapExtension.ts | 64 +++++++++++--------
confiture-web-app/src/tiptap/index.ts | 3 +
8 files changed, 166 insertions(+), 91 deletions(-)
create mode 100644 confiture-web-app/src/tiptap/index.ts
diff --git a/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue b/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
index b6149172a..ac7c7db85 100644
--- a/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
+++ b/confiture-web-app/src/components/audit/AuditGenerationCriterium.vue
@@ -13,10 +13,12 @@ import {
AuditType,
CriterionResultUserImpact,
CriteriumResult,
- CriteriumResultStatus
+ CriteriumResultStatus,
+ FileDisplay
} from "../../types";
import {
formatStatus,
+ getUploadUrl,
handleFileDeleteError,
handleFileUploadError
} from "../../utils";
@@ -124,7 +126,7 @@ const criteriumNotCompliantAccordion =
ref>();
function handleUploadExample(file: File) {
- store
+ return store
.uploadExampleImage(
props.auditUniqueId,
props.page.id,
@@ -142,6 +144,28 @@ function handleUploadExample(file: File) {
criteriumNotCompliantAccordion.value?.onFileRequestFinished();
});
}
+function handleUploadExampleInEditor(file: File) {
+ return store
+ .uploadExampleImage(
+ props.auditUniqueId,
+ props.page.id,
+ props.topicNumber,
+ props.criterium.number,
+ file,
+ FileDisplay.EDITOR
+ )
+ .then((response: AuditFile) => {
+ errorMessage.value = null;
+ return getUploadUrl(response.key);
+ })
+ .catch(async (error) => {
+ errorMessage.value = await handleFileUploadError(error);
+ throw error;
+ })
+ .finally(() => {
+ criteriumNotCompliantAccordion.value?.onFileRequestFinished();
+ });
+}
const deleteFileModalRef = ref>();
const fileToDelete = ref();
@@ -332,6 +356,7 @@ const showTransverseStatus = computed(() => {
@@ -339,6 +364,7 @@ const showTransverseStatus = computed(() => {
@@ -349,9 +375,14 @@ const showTransverseStatus = computed(() => {
ref="criteriumNotCompliantAccordion"
:comment="result.notCompliantComment"
:user-impact="result.userImpact"
- :example-images="result.exampleImages"
+ :example-images="
+ result.exampleImages.filter(
+ (auditFile: AuditFile) => auditFile.display === FileDisplay.ATTACHMENT
+ )
+ "
:quick-win="result.quickWin"
:error-message="errorMessage"
+ :upload-fn="handleUploadExampleInEditor"
@update:comment="updateResultComment($event, 'notCompliantComment')"
@update:user-impact="updateResultImpact($event)"
@upload-file="handleUploadExample"
diff --git a/confiture-web-app/src/components/audit/CriteriumCompliantAccordion.vue b/confiture-web-app/src/components/audit/CriteriumCompliantAccordion.vue
index abdc71334..5b27a12a8 100644
--- a/confiture-web-app/src/components/audit/CriteriumCompliantAccordion.vue
+++ b/confiture-web-app/src/components/audit/CriteriumCompliantAccordion.vue
@@ -1,40 +1,46 @@
diff --git a/confiture-web-app/src/components/audit/CriteriumNotApplicableAccordion.vue b/confiture-web-app/src/components/audit/CriteriumNotApplicableAccordion.vue
index 0ad919dce..84242c734 100644
--- a/confiture-web-app/src/components/audit/CriteriumNotApplicableAccordion.vue
+++ b/confiture-web-app/src/components/audit/CriteriumNotApplicableAccordion.vue
@@ -1,37 +1,46 @@
diff --git a/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue b/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
index 55cb7320e..6f79a3fa2 100644
--- a/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
+++ b/confiture-web-app/src/components/audit/CriteriumNotCompliantAccordion.vue
@@ -3,11 +3,13 @@ import { ref } from "vue";
import { useIsOffline } from "../../composables/useIsOffline";
import { FileErrorMessage } from "../../enums";
+import { UploadFn } from "../../tiptap/ImageUploadTiptapExtension";
import { AuditFile, CriterionResultUserImpact } from "../../types";
import { formatUserImpact } from "../../utils";
import FileUpload from "../ui/FileUpload.vue";
import { RadioColor } from "../ui/Radio.vue";
import RadioGroup from "../ui/RadioGroup.vue";
+import Tiptap from "../ui/Tiptap.vue";
import LazyAccordion from "./LazyAccordion.vue";
import MarkdownHelpButton from "./MarkdownHelpButton.vue";
@@ -18,6 +20,7 @@ export interface Props {
exampleImages: AuditFile[];
quickWin?: boolean;
userImpact: CriterionResultUserImpact | null;
+ uploadFn: UploadFn;
}
withDefaults(defineProps(), {
@@ -60,6 +63,10 @@ const isOffline = useIsOffline();
const fileUpload = ref>();
+function handleCommentChange(commentContent: string) {
+ emit("update:comment", commentContent);
+}
+
function handleUploadFile(image: File) {
emit("upload-file", image);
}
@@ -105,21 +112,17 @@ const title = "Erreur et recommandation";
>
diff --git a/confiture-web-app/src/components/audit/NotesModal.vue b/confiture-web-app/src/components/audit/NotesModal.vue
index fcd0c0e6a..3c5204e61 100644
--- a/confiture-web-app/src/components/audit/NotesModal.vue
+++ b/confiture-web-app/src/components/audit/NotesModal.vue
@@ -7,7 +7,11 @@ import { useIsOffline } from "../../composables/useIsOffline";
import { FileErrorMessage } from "../../enums";
import { useAuditStore } from "../../store/audit";
import { AuditFile, FileDisplay, StoreName } from "../../types";
-import { handleFileDeleteError, handleFileUploadError } from "../../utils";
+import {
+ getUploadUrl,
+ handleFileDeleteError,
+ handleFileUploadError
+} from "../../utils";
import DsfrModal from "../ui/DsfrModal.vue";
import FileUpload from "../ui/FileUpload.vue";
import Tiptap from "../ui/Tiptap.vue";
@@ -79,6 +83,14 @@ function handleDeleteFile(file: AuditFile) {
fileUpload.value?.onFileRequestFinished();
});
}
+
+function handleUploadFileInEditor(file: File): Promise {
+ return auditStore
+ .uploadAuditFile(uniqueId.value, file, FileDisplay.EDITOR)
+ .then((response: AuditFile) => {
+ return getUploadUrl(response.key);
+ });
+}
@@ -110,13 +122,14 @@ function handleDeleteFile(file: AuditFile) {
diff --git a/confiture-web-app/src/components/ui/Tiptap.vue b/confiture-web-app/src/components/ui/Tiptap.vue
index 3e7a8d8d7..518923713 100644
--- a/confiture-web-app/src/components/ui/Tiptap.vue
+++ b/confiture-web-app/src/components/ui/Tiptap.vue
@@ -22,10 +22,11 @@ import { computed, onBeforeUnmount, onMounted, ref, ShallowRef } from "vue";
import { useRoute } from "vue-router";
import { useNotifications } from "../../composables/useNotifications";
-import { AraTiptapExtension, CustomSelectionExtension } from "../../tiptap/AraTiptapExtension";
+import { AraTiptapExtension, CustomSelectionExtension } from "../../tiptap";
import {
ImageUploadTiptapExtension,
- insertFilesAtSelection
+ insertFilesAtSelection,
+ UploadFn
} from "../../tiptap/ImageUploadTiptapExtension";
import TiptapButton from "./TiptapButton.vue";
@@ -41,23 +42,21 @@ lowlight.register("css", css);
lowlight.register("js", js);
lowlight.register("ts", ts);
-const route = useRoute();
-const notify = useNotifications();
-
const props = defineProps<{
- content: string;
+ content: string | null;
labelledBy: string;
+ uploadFn: UploadFn;
}>();
const emit = defineEmits(["update:content"]);
-const uniqueId = computed(() => route.params.uniqueId as string);
-
function getContent() {
- let jsonContent;
- try {
- jsonContent = JSON.parse(props.content);
- } catch {
- jsonContent = props.content;
+ let jsonContent = null;
+ if (props.content) {
+ try {
+ jsonContent = JSON.parse(props.content);
+ } catch {
+ jsonContent = props.content;
+ }
}
return jsonContent;
@@ -133,7 +132,7 @@ const editor = useEditor({
}
}).configure({ inline: false }),
ImageUploadTiptapExtension.configure({
- uniqueId: uniqueId.value
+ uploadFn: props.uploadFn
}),
AraTiptapExtension,
Typography.configure({
@@ -152,10 +151,10 @@ const browseInput = ref>();
onMounted(() => {
browseInput.value?.addEventListener(
"change",
- (e) => {
+ (e: Event) => {
const inputElement = e?.target as HTMLInputElement;
const files = inputElement.files!;
- insertFilesAtSelection(uniqueId.value, editor.value, files);
+ insertFilesAtSelection(props.uploadFn, editor.value, files);
},
false
);
@@ -191,6 +190,9 @@ function setLink() {
}
function onImageAdd() {
+ if (browseInput.value) {
+ browseInput.value.value = "";
+ }
browseInput.value?.click();
}
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index 9c4b4d706..748bd145a 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -5,12 +5,23 @@ import { canSplit } from "@tiptap/pm/transform";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import { FileErrorMessage, Limitations } from "../enums";
-import { useAuditStore } from "../store/audit";
-import { AuditFile, FileDisplay } from "../types";
-import { getUploadUrl, handleFileUploadError } from "../utils";
+import { handleFileUploadError } from "../utils";
export interface ImageUploadTiptapExtensionOptions {
- uniqueId: string;
+ /**
+ * Function to upload a file (image) inside the editor
+ */
+ uploadFn: UploadFn;
+}
+
+/**
+ * Function to upload a file inside the editor
+ *
+ * @param {File} file File to upload
+ * @returns {Promise} a promise to the URL of the uploaded image
+ */
+export interface UploadFn {
+ (file: File): Promise;
}
/**
@@ -67,7 +78,7 @@ const PlaceholderPlugin = new Plugin({
* - external image from URL (⚠️ CORS)
*/
const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => {
- const { uniqueId } = options;
+ const { uploadFn } = options;
return new Plugin({
props: {
/**
@@ -102,7 +113,7 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => {
}
return handleDataTransfer(
- uniqueId,
+ uploadFn,
view,
dragEvent.dataTransfer,
position.pos
@@ -132,7 +143,7 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => {
const pos = view.state.selection.from;
return handleDataTransfer(
- uniqueId,
+ uploadFn,
view,
clipboardEvent.clipboardData,
pos,
@@ -148,7 +159,7 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => {
/**
* handleDataTransfer: called for both drop and paste.
*
- * @param {string} uniqueId
+ * @param {UploadFn} uploadFn
* @param {EditorView} view
* @param {DataTransfer} dataTransfer
* @param {number} pos
@@ -156,7 +167,7 @@ const HandleFileImportPlugin = (options: ImageUploadTiptapExtensionOptions) => {
* @returns true if event is handled, otherwise false
*/
function handleDataTransfer(
- uniqueId: string,
+ uploadFn: UploadFn,
view: EditorView,
dataTransfer: DataTransfer,
pos: number,
@@ -171,7 +182,7 @@ function handleDataTransfer(
if (url) {
createFileFromImageUrl(url).then((file) => {
if (file) {
- handleFileImport(uniqueId, view, pos, file, options);
+ handleFileImport(uploadFn, view, pos, file, options);
}
});
return true;
@@ -181,13 +192,13 @@ function handleDataTransfer(
}
// Handle multiple files
- return handleFilesImport(uniqueId, view, pos, dataTransfer.files, options);
+ return handleFilesImport(uploadFn, view, pos, dataTransfer.files, options);
}
/**
* Handles multiple files import (drop or paste)
*
- * @param {string} uniqueId
+ * @param {UploadFn} uploadFn
* @param {EditorView} view
* @param {number} pos
* @param {FileList} files
@@ -197,7 +208,7 @@ function handleDataTransfer(
* - any file is not dropped inside of the editor (should not happen)
*/
function handleFilesImport(
- uniqueId: string,
+ uploadFn: UploadFn,
view: EditorView,
pos: number,
files: FileList,
@@ -206,7 +217,7 @@ function handleFilesImport(
// 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)) {
+ if (!handleFileImport(uploadFn, view, pos, file, options)) {
return false;
}
}
@@ -216,7 +227,7 @@ function handleFilesImport(
/**
* Handles file import (drop or paste)
*
- * @param {string} uniqueId
+ * @param {UploadFn} uploadFn
* @param {EditorView} view
* @param {number} pos
* @param {File} file
@@ -224,7 +235,7 @@ function handleFilesImport(
* @returns {boolean} true or false if file is not dropped inside of the editor (should not happen)
*/
function handleFileImport(
- uniqueId: string,
+ uploadFn: UploadFn,
view: EditorView,
pos: number,
file: File,
@@ -302,34 +313,31 @@ function handleFileImport(
/**
* Uploads and then replaces the placeholder
*
- * @param {string} uniqueId
+ * @param {UploadFn} uploadFn
* @param {EditorView} view
* @param {DragEvent} dragEvent
* @param {File} file
* @param {{replaceSelection: boolean}} options
*/
function uploadAndReplacePlaceholder(
- uniqueId: string,
+ uploadFn: UploadFn,
view: EditorView,
file: File,
id: any
) {
- const auditStore = useAuditStore();
- auditStore.uploadAuditFile(uniqueId, file, FileDisplay.EDITOR).then(
- (response: AuditFile) => {
+ uploadFn(file).then(
+ (imgUrl: string) => {
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 the content around the placeholder has been deleted,
+ // do not insert the image
if (pos === undefined) {
- // TODO remove image from server
return;
}
// 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({
@@ -409,7 +417,7 @@ export const ImageUploadTiptapExtension =
name: "imageUpload",
addProseMirrorPlugins() {
return [
- HandleFileImportPlugin({ uniqueId: this.options.uniqueId }),
+ HandleFileImportPlugin({ uploadFn: this.options.uploadFn }),
PlaceholderPlugin
];
}
@@ -463,7 +471,7 @@ export const ImageUploadTiptapExtension =
});
export function insertFilesAtSelection(
- uniqueId: string,
+ uploadFn: UploadFn,
editor: Editor,
files: FileList
) {
@@ -475,7 +483,7 @@ export function insertFilesAtSelection(
view.focus();
tr.deleteSelection();
- return handleFilesImport(uniqueId, view, pos, files, {
+ return handleFilesImport(uploadFn, view, pos, files, {
replaceSelection: true
});
}
diff --git a/confiture-web-app/src/tiptap/index.ts b/confiture-web-app/src/tiptap/index.ts
new file mode 100644
index 000000000..666d44e4f
--- /dev/null
+++ b/confiture-web-app/src/tiptap/index.ts
@@ -0,0 +1,3 @@
+export { AraTiptapExtension } from "./AraTiptapExtension";
+export { CustomSelectionExtension } from "./CustomSelectionExtension";
+export { ImageUploadTiptapExtension } from "./ImageUploadTiptapExtension";
From 26ffd7dc153a10f309f2d52df28f2c2d098fd35d Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 29 Jan 2025 11:56:52 +0100
Subject: [PATCH 47/55] feat(system): fix pruneUploads to handle StoredFiles
---
.../src/system/system.service.ts | 229 +++++++++++++++---
1 file changed, 198 insertions(+), 31 deletions(-)
diff --git a/confiture-rest-api/src/system/system.service.ts b/confiture-rest-api/src/system/system.service.ts
index 7148828a9..6695c9dbc 100644
--- a/confiture-rest-api/src/system/system.service.ts
+++ b/confiture-rest-api/src/system/system.service.ts
@@ -1,8 +1,22 @@
import { Injectable } from "@nestjs/common";
-import { AuditFile } from "@prisma/client";
+import { Prisma } from "@prisma/client";
import { PrismaService } from "src/prisma.service";
import { FileStorageService } from "../audits/file-storage.service";
+const FileType = {
+ NOTES: "NOTES",
+ CRITERIA: "CRITERIA"
+};
+
+type FileType = (typeof FileType)[keyof typeof FileType];
+
+type PrunableFile = {
+ fileType: FileType;
+ id: number;
+ key: string;
+ thumbnailKey: string;
+};
+
@Injectable()
export class SystemService {
constructor(
@@ -10,46 +24,199 @@ export class SystemService {
private readonly fileStorageService: FileStorageService
) {}
+ /**
+ * Deletes expired AuditFile and StoredFile entries from the database + the associated files from the S3 bucket.
+ * For each Audit, checks if the file URLs are still present in:
+ * - notes (AuditFile entries)
+ * - all criterionResult comments (StoredFile entries)
+ * If the URL is found, the AuditFile (or StoredFile) is considered expired only if it has been created more than 1 month ago.
+ * In that case, the entry is deleted from the database and the associated files (1 or 2 if a thumbnail exists) are deleted from the S3 bucket.
+ *
+ * Also, checks for obsolete files on the S3 bucket that are not associated anymore to any entry in the database,
+ * and deletes them if needed (checks all notes and all criteria in the database).
+ *
+ * Good to know: A key looks like "audits/lOuFFlopCxZ_mLKzAqpzu/BN2Jhq-iOiTIG96-f3lQU/image.png"
+ * It contains the audit id, a specific key for the file and the file name
+ * (or "external" if the image comes from an external URL – i.e. dragged and dropped
+ * from another website).
+ */
async pruneUploads() {
- const prunableUploads: AuditFile[] = await this.prisma.$queryRaw`
- SELECT "AuditFile"."id" FROM "AuditFile"
+ console.info(`1. Check for expired AuditFile/StoredFile entries on Ara DB`);
+ const query = Prisma.sql`
+ SELECT ${FileType.NOTES} as "fileType", "AuditFile"."id", "AuditFile"."key", "AuditFile"."thumbnailKey" FROM "AuditFile"
JOIN "Audit" ON "AuditFile"."auditUniqueId" = "Audit"."editUniqueId"
WHERE (
"AuditFile"."display" = 'EDITOR'
AND
- "Audit"."notes" !~ ('/uploads/' || "AuditFile"."key")
+ "Audit"."notes" !~ ('"/uploads/' || "AuditFile"."key" || '"')
+ AND
+ "AuditFile"."creationDate" < now() - interval '1 month'
+ )
+ UNION
+ SELECT ${FileType.CRITERIA} as "fileType", "StoredFile"."id", "StoredFile"."key", "StoredFile"."thumbnailKey" FROM "StoredFile"
+ JOIN "CriterionResult" ON "StoredFile"."criterionResultId" = "CriterionResult"."id"
+ WHERE (
+ "StoredFile"."display" = 'EDITOR'
+ AND
+ CONCAT_WS('',
+ "CriterionResult"."compliantComment",
+ "CriterionResult"."errorDescription",
+ "CriterionResult"."notApplicableComment") !~ ('"/uploads/' || "StoredFile"."key" || '"')
+ AND
+ "StoredFile"."creationDate" < now() - interval '1 month'
)`;
- const prunableUploadIds: number[] = prunableUploads.map((e) => e.id);
-
- const storedFiles = await this.prisma.auditFile.findMany({
- select: {
- id: true,
- key: true,
- thumbnailKey: true
- },
- where: {
- id: {
- in: prunableUploadIds
+ const prunableUploads: PrunableFile[] = await this.prisma.$queryRaw(query);
+
+ if (prunableUploads.length > 0) {
+ const entries = prunableUploads.length > 1 ? "entries" : "entry";
+ console.info(` 🗑 ${prunableUploads.length} expired ${entries} found!`);
+ console.info(` → ${prunableUploads.map((e) => e.id).join(", ")}`);
+
+ const oldImgs = prunableUploads.map((e) => e.key);
+ const oldThmbs =
+ prunableUploads.map((e) => e.thumbnailKey).filter((e) => e) || [];
+ await this.fileStorageService.deleteMultipleFiles(
+ ...oldImgs.concat(oldThmbs)
+ );
+ const fS = oldImgs.length > 1 ? "s" : "";
+ const fIcon = oldImgs.length > 0 ? "✅" : "🙅";
+ const tS = oldThmbs.length > 1 ? "s" : "";
+ const tIcon = oldThmbs.length > 0 ? "✅" : "🙅";
+ console.info(` a) S3 Bucket:`);
+ console.info(` ${fIcon} ${oldImgs.length} file${fS} deleted`);
+ if (oldImgs.length > 0) {
+ console.info(` → key${fS}: ${oldImgs.join(", ")}`);
+ }
+ console.info(` ${tIcon} ${oldThmbs.length} thumbnail${tS} deleted`);
+ if (oldThmbs.length > 0) {
+ console.info(` → key${tS}: ${oldThmbs.join(", ")}`);
+ }
+
+ const prunableAuditFileIds = prunableUploads
+ .filter((e) => e.fileType === FileType.NOTES)
+ .map((e) => e.id);
+ await this.prisma.auditFile.deleteMany({
+ where: {
+ id: {
+ in: prunableAuditFileIds
+ }
+ }
+ });
+ const prunableStoredFileIds = prunableUploads
+ .filter((e) => e.fileType === FileType.CRITERIA)
+ .map((e) => e.id);
+ await this.prisma.storedFile.deleteMany({
+ where: {
+ id: {
+ in: prunableStoredFileIds
+ }
}
+ });
+ console.info(` b) Ara DB:`);
+ const aIcon = prunableAuditFileIds.length > 0 ? "✅" : "🙅";
+ const sIcon = prunableStoredFileIds.length > 0 ? "✅" : "🙅";
+ console.info(
+ ` ${aIcon} ${prunableAuditFileIds.length} expired AuditFile ${entries} deleted`
+ );
+ if (prunableAuditFileIds.length > 0) {
+ console.info(` → ${prunableAuditFileIds.join(", ")}`);
}
- });
-
- const filesToDelete = storedFiles.map((e) => e.key);
- const thumbnailsToDelete = storedFiles
- .map((e) => e.thumbnailKey)
- .filter((e) => e != null);
- await this.fileStorageService.deleteMultipleFiles(
- ...filesToDelete.concat(thumbnailsToDelete)
- );
-
- await this.prisma.auditFile.deleteMany({
- where: {
- id: {
- in: prunableUploadIds
+ console.info(
+ ` ${sIcon} ${prunableStoredFileIds.length} expired StoredFile ${entries} deleted`
+ );
+ if (prunableStoredFileIds.length > 0) {
+ console.info(` → ${prunableStoredFileIds.join(", ")}`);
+ }
+ } else {
+ console.info(` 🙅 No expired entry found.`);
+ }
+
+ const keyOnS3 = await this.fileStorageService.getAllFileKeys();
+ if (keyOnS3) {
+ const s = keyOnS3.length > 1 ? "s" : "";
+ console.info(`2. Check for obsolete images on S3 bucket`);
+ console.info(` → total = ${keyOnS3.length} file${s}:`);
+ console.info(`${keyOnS3.join("\n")}`);
+ const obsoleteKeys: string[] = [];
+ const basePathLength = "audits/".length;
+ let auditUniqueId = null;
+ let res = null;
+ let url = null;
+ let query = null;
+ for (const key of keyOnS3) {
+ // Extract audit id from key
+ auditUniqueId = key.substring(
+ basePathLength,
+ key.indexOf("/", basePathLength)
+ );
+ url = `"/uploads/${key}"`;
+
+ // Query to check if the key is still present in notes
+ // or if the AuditFile/StoredFile is too recent (less than 1 month)
+ // That last condition is usefull for undo/redos.
+ query = Prisma.sql`
+ SELECT 1 FROM "AuditFile"
+ JOIN "Audit" ON "AuditFile"."auditUniqueId" = "Audit"."editUniqueId"
+ WHERE ( (
+ "Audit"."editUniqueId" = ${auditUniqueId}
+ AND
+ "Audit"."notes" ~ ${url}
+ )
+ OR (
+ ("AuditFile"."key" = ${key} OR "AuditFile"."thumbnailKey" = ${key})
+ AND
+ "AuditFile"."creationDate" >= now() - interval '1 month'
+ )
+ )
+ UNION
+ SELECT 1 FROM "StoredFile"
+ JOIN "CriterionResult" ON "StoredFile"."criterionResultId" = "CriterionResult"."id"
+ JOIN "AuditedPage" ON "CriterionResult"."pageId" = "AuditedPage"."id"
+ JOIN "Audit" ON (
+ "AuditedPage"."auditUniqueId" = "Audit"."editUniqueId"
+ OR
+ "AuditedPage"."id" = "Audit"."transverseElementsPageId"
+ )
+ WHERE ( (
+ "Audit"."editUniqueId" = ${auditUniqueId}
+ AND
+ CONCAT_WS('', "CriterionResult"."compliantComment", "CriterionResult"."errorDescription", "CriterionResult"."notApplicableComment") ~ ${url}
+ )
+ OR
+ (
+ "StoredFile"."key" = ${key} OR "StoredFile"."thumbnailKey" = ${key}
+ AND
+ "StoredFile"."creationDate" >= now() - interval '1 month'
+ ) )`;
+
+ res = await this.prisma.$queryRaw(query);
+ if (res.length === 0) {
+ console.warn(
+ ` Key "${key}" not found for audit "${auditUniqueId}" → mark it as obsolete"`
+ );
+ obsoleteKeys.push(key);
}
}
- });
- return true;
+ if (obsoleteKeys.length > 0) {
+ const s = obsoleteKeys.length > 1 ? "s" : "";
+ console.warn(` 🗑 ${obsoleteKeys.length} file${s} found.`);
+ // Split array in chunks of 1000 items
+ // (max number of entries to delete in one go is 1000 on S3)
+ const chunkSize = 1000;
+ for (let i = 0; i < obsoleteKeys.length; i += chunkSize) {
+ const chunk = obsoleteKeys.slice(i, i + chunkSize);
+ await this.fileStorageService.deleteMultipleFiles(...chunk);
+ }
+ console.warn(
+ ` ✅ ${obsoleteKeys.length} file${s} deleted from bucket`
+ );
+ console.info(` → ${obsoleteKeys.join(", ")}`);
+ } else {
+ console.warn(" 🙅 No obsolete file found");
+ }
+ } else {
+ console.info(" 🙅 No image found on S3 bucket");
+ }
}
}
From d44a6ae117d3bb2aa66bbcff0cf890992975dfc5 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 29 Jan 2025 11:59:15 +0100
Subject: [PATCH 48/55] results store: improve uploadExampleImage (no more
timeout)
---
confiture-web-app/src/store/results.ts | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/confiture-web-app/src/store/results.ts b/confiture-web-app/src/store/results.ts
index b52a4e7d9..fb21113fb 100644
--- a/confiture-web-app/src/store/results.ts
+++ b/confiture-web-app/src/store/results.ts
@@ -346,7 +346,7 @@ export const useResultsStore = defineStore("results", {
criterium: number,
file: File,
display?: FileDisplay
- ) {
+ ): Promise {
const formData = new FormData();
formData.set("pageId", pageId.toString());
formData.set("topic", topic.toString());
@@ -359,9 +359,12 @@ export const useResultsStore = defineStore("results", {
this.increaseCurrentRequestCount();
- const exampleImage = (await ky
+ // Adding images inside tiptap editor is asynchronous,
+ // so no need to set a timeout on the client side
+ const exampleImage: AuditFile | null = (await ky
.post(`/api/audits/${uniqueId}/results/examples`, {
- body: formData
+ body: formData,
+ timeout: false
})
.json()
.finally(() => {
@@ -370,9 +373,10 @@ export const useResultsStore = defineStore("results", {
const result = this.data![pageId][topic][criterium];
- if (result) {
+ if (result && exampleImage) {
result.exampleImages.push(exampleImage);
}
+ return exampleImage;
},
async deleteExampleImage(
From e3fa5f5f84803a204328c79698f3a3e8a1fe05a5 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 29 Jan 2025 12:01:56 +0100
Subject: [PATCH 49/55] tiptap: remove image properly when an error occured
---
.../src/tiptap/ImageUploadTiptapExtension.ts | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
index 748bd145a..2f82abae2 100644
--- a/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
+++ b/confiture-web-app/src/tiptap/ImageUploadTiptapExtension.ts
@@ -58,6 +58,8 @@ const PlaceholderPlugin = new Plugin({
set = set.remove(
set.find(undefined, undefined, (spec) => spec.id == action.remove.id)
);
+
+ action.element.remove();
}
return set;
}
@@ -293,7 +295,7 @@ function handleFileImport(
});
tr.setSelection(Selection.near(tr.doc.resolve(pos), 1));
view.dispatch(tr);
- uploadAndReplacePlaceholder(uniqueId, view, file, id);
+ uploadAndReplacePlaceholder(uploadFn, view, file, id, element);
};
element.src = localURL;
} else if (file.type.startsWith("video")) {
@@ -323,7 +325,8 @@ function uploadAndReplacePlaceholder(
uploadFn: UploadFn,
view: EditorView,
file: File,
- id: any
+ id: any,
+ element: HTMLImageElement | HTMLVideoElement
) {
uploadFn(file).then(
(imgUrl: string) => {
@@ -346,16 +349,16 @@ function uploadAndReplacePlaceholder(
src: imgUrl
});
tr.replaceWith(pos, pos, node);
- tr.setMeta(PlaceholderPlugin, { remove: { id } });
+ tr.setMeta(PlaceholderPlugin, { element, remove: { id } });
// Selects the image
// tr.setSelection(new NodeSelection(tr.doc.resolve(pos)));
view.dispatch(tr);
},
- async (reason: any) => {
+ async (reason: Error) => {
// On failure, just clean up the placeholder
view.dispatch(
- view.state.tr.setMeta(PlaceholderPlugin, { remove: { id } })
+ view.state.tr.setMeta(PlaceholderPlugin, { element, remove: { id } })
);
//FIXME: use a notification
window.alert(await handleFileUploadError(reason));
From 5ccf1a3b05514d7cbb89329cded5485c7e80d886 Mon Sep 17 00:00:00 2001
From: Yaacov
Date: Wed, 29 Jan 2025 16:33:05 +0100
Subject: [PATCH 50/55] tiptap: smaller buttons
---
confiture-web-app/src/components/ui/TiptapButton.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/confiture-web-app/src/components/ui/TiptapButton.vue b/confiture-web-app/src/components/ui/TiptapButton.vue
index 2bc8202d9..56edb1dd8 100644
--- a/confiture-web-app/src/components/ui/TiptapButton.vue
+++ b/confiture-web-app/src/components/ui/TiptapButton.vue
@@ -28,7 +28,7 @@ const title = computed(() =>