From b76c17efb61d35a820b42e1dddd7a526cd165938 Mon Sep 17 00:00:00 2001 From: habibayman Date: Wed, 6 Aug 2025 15:07:15 +0300 Subject: [PATCH 01/13] refactor(texteditor): remove MarkdownEditor dependencies --- pnpm-lock.yaml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d4c2ad6af3..fa341a3362 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2619,10 +2619,6 @@ packages: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} - commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} - engines: {node: ^12.20.0 || >=14} - common-path-prefix@3.0.0: resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} @@ -9306,7 +9302,7 @@ snapshots: '@types/bonjour@3.5.13': dependencies: - '@types/node': 24.1.0 + '@types/node': 24.0.10 '@types/connect-history-api-fallback@1.5.4': dependencies: @@ -10554,8 +10550,6 @@ snapshots: commander@7.2.0: {} - commander@9.5.0: {} - common-path-prefix@3.0.0: {} common-tags@1.8.2: {} From 8a9038fa87cdb6947d58783b10f0862915b78b45 Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 10 Aug 2025 02:24:29 +0300 Subject: [PATCH 02/13] refactor(texteditor): refactor image services to use server side upload --- .../TipTapEditor/services/imageService.js | 131 +++++++++++++----- .../frontend/shared/vuex/file/actions.js | 2 +- 2 files changed, 100 insertions(+), 33 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js index 2577312a80..04cc6bff19 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/services/imageService.js @@ -1,11 +1,19 @@ import { getTipTapEditorStrings } from '../TipTapEditorStrings'; +import { cleanFile } from 'shared/vuex/file/clean'; +import { getHash, extractMetadata, storageUrl } from 'shared/vuex/file/utils'; +import { hexToBase64 } from 'shared/vuex/file/actions'; +import { File } from 'shared/data/resources'; +import client from 'shared/client'; +import { fileErrors } from 'shared/constants'; +import { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; -const MAX_FILE_SIZE_MB = 10; // I think I need review on this value, I just picked what seemed reasonable +const MAX_FILE_SIZE_MB = 10; +// see: shared/leUtils/FormatPresets.js export const ACCEPTED_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/jpg', - 'image/webp', + 'image/gif', 'image/svg+xml', ]; @@ -19,55 +27,57 @@ const { noFileProvided$, invalidFileType$, fileTooLarge$, fileSizeUnit$, failedT */ export function validateFile(file) { if (!file) { - return { isValid: false, error: noFileProvided$ }; + return { isValid: false, error: noFileProvided$() }; } if (!ACCEPTED_MIME_TYPES.includes(file.type)) { return { isValid: false, - error: invalidFileType$ + ACCEPTED_MIME_TYPES.join(', '), + error: invalidFileType$() + ACCEPTED_MIME_TYPES.join(', '), }; } if (file.size > MAX_FILE_SIZE_MB * 1024 * 1024) { return { isValid: false, - error: fileTooLarge$ + MAX_FILE_SIZE_MB + fileSizeUnit$, + error: fileTooLarge$() + MAX_FILE_SIZE_MB + fileSizeUnit$(), }; } return { isValid: true }; } /** - * Reads a file and returns it as a Data URL. - * @param {File} file - The image file. - * @returns {Promise} A promise that resolves with the Data URL. + * Uploads file to storage (adapted from actions.js). + * @param {Object} params - Upload parameters. + * @returns {Promise} */ -function readFileAsDataURL(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result); - reader.onerror = error => reject(error); - reader.readAsDataURL(file); - }); +function uploadFileToStorage({ file_format, mightSkip, checksum, file, url, contentType }) { + return (mightSkip ? client.head(storageUrl(checksum, file_format)) : Promise.reject()) + .then(() => { + // File already exists, complete immediately + }) + .catch(() => { + return client.put(url, file, { + headers: { + 'Content-Type': contentType, + 'Content-MD5': hexToBase64(checksum), + }, + }); + }); } /** - * Gets the natural dimensions of an image from its source. - * @param {string} src - The image source (e.g., a Data URL). - * @returns {Promise<{width: number, height: number}>} + * Gets accepted file types string for file input. + * @returns {string} */ -export function getImageDimensions(src) { - return new Promise((resolve, reject) => { - const img = new window.Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = error => reject(error); - img.src = src; - }); +export function getAcceptedFileTypes() { + const extensions = ['.png', '.jpg', '.jpeg', '.webp', '.svg']; + return [...ACCEPTED_MIME_TYPES, ...extensions].join(','); } /** - * Fully processes a file: validates, reads, and gets dimensions. - * @param {File} file - The file to process. - * @returns {Promise<{src: string, width: number, height: number, file: File}>} + * Main file processing function that uploads to server. + * Adapted from uploadFile action but simplified for image-specific use. + * @param {File} file - The image file to process. + * @returns {Promise<{src: string, width: number, height: number, file: File, id: string}>} */ export async function processFile(file) { const validation = validateFile(file); @@ -76,10 +86,67 @@ export async function processFile(file) { } try { - const src = await readFileAsDataURL(file); - const { width, height } = await getImageDimensions(src); - return { src, width, height, file }; + const file_format = file.name.split('.').pop().toLowerCase(); + const cleanedFile = await cleanFile(file, FormatPresetsNames.EXERCISE_IMAGE); + + // Get file hash and metadata + const [checksum, metadata] = await Promise.all([ + getHash(cleanedFile).catch(() => Promise.reject(fileErrors.CHECKSUM_HASH_FAILED)), + extractMetadata(cleanedFile, FormatPresetsNames.EXERCISE_IMAGE), + ]); + + // Get upload URL from server + const uploadData = await File.uploadUrl({ + checksum, + size: cleanedFile.size, + type: cleanedFile.type, + name: cleanedFile.name, + file_format, + ...metadata, + }).catch(error => { + let errorType = fileErrors.UPLOAD_FAILED; + if (error.response && error.response.status === 412) { + errorType = fileErrors.NO_STORAGE; + } + throw errorType; + }); + + const fileObject = { + ...uploadData.file, + metadata, + }; + + // Upload file to storage + await uploadFileToStorage({ + id: fileObject.id, + checksum, + file: cleanedFile, + file_format, + url: uploadData.uploadURL, + contentType: uploadData.mimetype, + mightSkip: uploadData.might_skip, + }); + + // Return data in expected format for ImageUploadModal + return { + src: storageUrl(checksum, file_format), + width: metadata.width || null, + height: metadata.height || null, + file: file, + id: fileObject.id, + checksum, + fileObject, // Include full file object for additional data + }; } catch (error) { - throw new Error(failedToProcessImage$); + // Handle specific error types + if (error === fileErrors.NO_STORAGE) { + throw new Error('Not enough storage space available'); + } else if (error === fileErrors.CHECKSUM_HASH_FAILED) { + throw new Error('Failed to process file checksum'); + } else if (Object.values(fileErrors).includes(error)) { + throw new Error(failedToProcessImage$() + ': ' + error); + } else { + throw new Error(failedToProcessImage$() + ': ' + error.message); + } } } diff --git a/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js b/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js index ea1c38008c..5fe322c8aa 100644 --- a/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js +++ b/contentcuration/contentcuration/frontend/shared/vuex/file/actions.js @@ -110,7 +110,7 @@ export function deleteFile(context, file) { }); } -function hexToBase64(str) { +export function hexToBase64(str) { return btoa( String.fromCharCode.apply( null, From 4a39f5c3aba304b8f95ee7d242320fdb49350efd Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 10 Aug 2025 23:46:26 +0300 Subject: [PATCH 03/13] refactor(texteditor): remove data url markdown parsing regex --- .../TipTapEditor/utils/markdown.js | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js index 9fa6ff9a58..26a1bd5c71 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -6,8 +6,6 @@ import { storageUrl } from '../../../../vuex/file/utils'; // --- Image Translation --- export const IMAGE_PLACEHOLDER = '${☣ CONTENTSTORAGE}'; export const IMAGE_REGEX = /!\[([^\]]*)\]\(([^/]+\/[^\s=)]+)(?:\s*=\s*([0-9.]+)x([0-9.]+))?\)/g; -export const DATA_URL_IMAGE_REGEX = - /!\[([^\]]*)\]\(((?:data:|blob:).+?)(?:\s*=\s*([0-9.]+)x([0-9.]+))?\)/g; export const imageMdToParams = markdown => { // Reset regex state before executing to ensure it works on all matches @@ -32,16 +30,6 @@ export const imageMdToParams = markdown => { export const paramsToImageMd = ({ src, alt, width, height, permanentSrc }) => { const sourceToSave = permanentSrc || src; - // As a safety net, if the source is still a data/blob URL, we should not - // try to create a placeholder format. This should not happen with our new logic, - // but it makes the function more robust. - if (sourceToSave.startsWith('data:') || sourceToSave.startsWith('blob:')) { - if (width && height) { - return `![${alt || ''}](${sourceToSave} =${width}x${height})`; - } - return `![${alt || ''}](${sourceToSave})`; - } - const fileName = sourceToSave.split('/').pop(); if (Number.isFinite(+width) && Number.isFinite(+height)) { return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName} =${width}x${height})`; @@ -74,16 +62,6 @@ export function preprocessMarkdown(markdown) { let processedMarkdown = markdown; - // First, handle data URLs and blob URLs for images. - processedMarkdown = processedMarkdown.replace( - DATA_URL_IMAGE_REGEX, - (match, alt, src, width, height) => { - const widthAttr = width ? ` width="${width}"` : ''; - const heightAttr = height ? ` height="${height}"` : ''; - return `${alt || ''}`; - }, - ); - // Then, handle our standard content-storage images. processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { const params = imageMdToParams(match); From 2346c06cf575c73a6175bd0727159fbccfe5141a Mon Sep 17 00:00:00 2001 From: habibayman Date: Mon, 11 Aug 2025 03:02:12 +0300 Subject: [PATCH 04/13] refactor(texteditor)[image]: abstract upload services from the editor --- .../AnswersEditor/AnswersEditor.vue | 3 ++ .../AssessmentItemEditor.vue | 3 ++ .../components/HintsEditor/HintsEditor.vue | 3 ++ .../TipTapEditor/TipTapEditor.vue | 5 ++++ .../components/image/ImageUploadModal.vue | 7 +++-- .../TipTapEditor/services/imageService.js | 28 +++++++------------ 6 files changed, 29 insertions(+), 20 deletions(-) diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue index e558478cb0..d141d98945 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AnswersEditor/AnswersEditor.vue @@ -78,6 +78,7 @@ v-model="answer.answer" class="editor" :mode="isAnswerOpen(answerIdx) ? 'edit' : 'view'" + :imageProcessor="EditorImageProcessor" @update="updateAnswerText($event, answerIdx)" @minimize="emitClose" @open-editor="emitOpen(answerIdx)" @@ -127,6 +128,7 @@ import { AssessmentItemTypes } from 'shared/constants'; import { swapElements } from 'shared/utils/helpers'; import Checkbox from 'shared/views/form/Checkbox'; + import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; @@ -169,6 +171,7 @@ }, data() { return { + EditorImageProcessor, // Make it available in the template correctAnswersIndices: getCorrectAnswersIndices(this.questionKind, this.answers), numericRule: val => floatOrIntRegex.test(val) || this.$tr('numberFieldErrorLabel'), }; diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue index bfb2c9ed19..3ffa20ca65 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/AssessmentItemEditor/AssessmentItemEditor.vue @@ -45,6 +45,7 @@ v-if="isQuestionOpen" v-model="question" mode="edit" + :imageProcessor="EditorImageProcessor" @update="onQuestionUpdate" @minimize="closeQuestion" /> @@ -143,6 +144,7 @@ import { FormatPresetsNames } from 'shared/leUtils/FormatPresets'; import DropdownWrapper from 'shared/views/form/DropdownWrapper'; import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; + import EditorImageProcessor from 'shared/views/TipTapEditor/TipTapEditor/services/imageService'; export default { name: 'AssessmentItemEditor', @@ -212,6 +214,7 @@ openAnswerIdx: null, kindSelectKey: 0, AssessmentItemTypes, + EditorImageProcessor, }; }, computed: { diff --git a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue index 523451a055..6be4253ed5 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue +++ b/contentcuration/contentcuration/frontend/channelEdit/components/HintsEditor/HintsEditor.vue @@ -38,6 +38,7 @@ ({ 'insert-image': target => imageHandler.openCreateModal({ targetElement: target }), @@ -294,6 +295,10 @@ type: [String, Number], default: 0, }, + imageProcessor: { + type: Object, + default: () => ({}), + }, }, emits: ['update', 'minimize', 'open-editor'], }); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue index 211627d4eb..a7246d48a8 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageUploadModal.vue @@ -144,8 +144,7 @@