diff --git a/contentcuration/contentcuration/frontend/editorDev/index.js b/contentcuration/contentcuration/frontend/editorDev/index.js index ef9a10c8db..4b7e6e0c63 100644 --- a/contentcuration/contentcuration/frontend/editorDev/index.js +++ b/contentcuration/contentcuration/frontend/editorDev/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import Vuex from 'vuex'; import VueRouter from 'vue-router'; -import TipTapEditor from '../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; +import DevHarness from '../shared/views/TipTapEditor/TipTapEditor/DevHarness.vue'; import startApp from 'shared/app'; import storeFactory from 'shared/vuex/baseStore'; @@ -17,7 +17,7 @@ startApp({ routes: [ { path: '/', - component: TipTapEditor, + component: DevHarness, }, ], }), diff --git a/contentcuration/contentcuration/frontend/editorDev/router.js b/contentcuration/contentcuration/frontend/editorDev/router.js index 1bb8ee6838..bdbebda15f 100644 --- a/contentcuration/contentcuration/frontend/editorDev/router.js +++ b/contentcuration/contentcuration/frontend/editorDev/router.js @@ -1,10 +1,10 @@ import Vue from 'vue'; import Router from 'vue-router'; -import TipTapEditor from '../shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; +import DevHarness from '../shared/views/TipTapEditor/TipTapEditor/DevHarness.vue'; Vue.use(Router); export default new Router({ mode: 'history', base: '/editor-dev/', - routes: [{ path: '/', component: TipTapEditor }], + routes: [{ path: '/', component: DevHarness }], }); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue new file mode 100644 index 0000000000..9ba867b339 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue @@ -0,0 +1,110 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 321b40743d..5fa36f768a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -73,7 +73,7 @@ diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue index 0e16eb11d4..9e5e3c4f8a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/image/ImageNodeView.vue @@ -7,9 +7,11 @@ :style="{ width: styleWidth }" >
- import { defineComponent, ref, computed, onUnmounted, watch } from 'vue'; + import { defineComponent, ref, computed, onUnmounted, onMounted, watch } from 'vue'; import { NodeViewWrapper } from '@tiptap/vue-2'; + import _ from 'lodash'; export default defineComponent({ name: 'ImageNodeView', @@ -72,12 +75,22 @@ NodeViewWrapper, }, setup(props) { - const width = ref(props.node.attrs.width); + const width = ref(props.node.attrs.width || null); + const height = ref(props.node.attrs.height || null); + const imageRef = ref(null); + const naturalAspectRatio = ref(null); const minWidth = 50; const compactThreshold = 200; - let debounceTimer = null; - // (to work with undo/redo) Watch for external changes to the node's width + // Create debounced version of saveSize function + const debouncedSaveSize = _.debounce(() => { + props.updateAttributes({ + width: width.value, + height: height.value, + }); + }, 500); + + // (to work with undo/redo) Watch for external changes to the node's width and height watch( () => props.node.attrs.width, newWidth => { @@ -85,6 +98,47 @@ }, ); + watch( + () => props.node.attrs.height, + newHeight => { + height.value = newHeight; + }, + ); + + // Watch for src changes to recalculate aspect ratio + watch( + () => props.node.attrs.src, + () => { + // Reset aspect ratio when src changes + naturalAspectRatio.value = null; + // Force image to reload and recalculate dimensions + if (imageRef.value) { + imageRef.value.onload = onImageLoad; + } + }, + ); + + const onImageLoad = () => { + if (imageRef.value && imageRef.value.naturalWidth && imageRef.value.naturalHeight) { + naturalAspectRatio.value = imageRef.value.naturalWidth / imageRef.value.naturalHeight; + + // If no dimensions are set, use natural dimensions + if (!width.value && !height.value) { + width.value = imageRef.value.naturalWidth; + height.value = imageRef.value.naturalHeight; + saveSize(); + } else if (width.value && !height.value) { + // If we have width but no height, calculate height + height.value = calculateProportionalHeight(width.value); + saveSize(); + } else if (!width.value && height.value) { + // If we have height but no width, calculate width + width.value = Math.round(height.value * naturalAspectRatio.value); + saveSize(); + } + } + }; + const isRtl = computed(() => { return props.editor.view.dom.closest('[dir="rtl"]') !== null; }); @@ -92,13 +146,25 @@ const styleWidth = computed(() => (width.value ? `${width.value}px` : 'auto')); const isCompact = computed(() => width.value < compactThreshold); - const saveWidth = () => { + const saveSize = () => { props.updateAttributes({ width: width.value, - height: null, + height: height.value, }); }; + const calculateProportionalHeight = newWidth => { + if (naturalAspectRatio.value) { + return Math.round(newWidth / naturalAspectRatio.value); + } + // Fallback: try to get aspect ratio directly from the image element + if (imageRef.value && imageRef.value.naturalWidth && imageRef.value.naturalHeight) { + const ratio = imageRef.value.naturalWidth / imageRef.value.naturalHeight; + return Math.round(newWidth / ratio); + } + return null; + }; + const onResizeStart = startEvent => { const startX = startEvent.clientX; const startWidth = width.value || startEvent.target.parentElement.offsetWidth; @@ -110,13 +176,15 @@ ? startWidth - deltaX // In RTL, moving right should decrease width : startWidth + deltaX; // In LTR, moving right should increase width - width.value = Math.max(minWidth, newWidth); + const clampedWidth = Math.max(minWidth, newWidth); + width.value = clampedWidth; + height.value = calculateProportionalHeight(clampedWidth); }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); - saveWidth(); + saveSize(); }; document.addEventListener('mousemove', onMouseMove); @@ -145,10 +213,11 @@ return; } - width.value = Math.max(minWidth, newWidth); + const clampedWidth = Math.max(minWidth, newWidth); + width.value = clampedWidth; + height.value = calculateProportionalHeight(clampedWidth); - clearTimeout(debounceTimer); - debounceTimer = setTimeout(saveWidth, 500); + debouncedSaveSize(); }; const removeImage = () => { @@ -166,18 +235,27 @@ }); }; + onMounted(() => { + if (imageRef.value && imageRef.value.complete) { + onImageLoad(); + } + }); + onUnmounted(() => { - clearTimeout(debounceTimer); + // Cancel any pending debounced calls + debouncedSaveSize.cancel(); }); return { width, + imageRef, styleWidth, onResizeStart, removeImage, editImage, isCompact, onResizeKeyDown, + onImageLoad, }; }, props: { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js index 47b1b48501..fd400779b0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -9,12 +9,13 @@ import { Image } from '../extensions/Image'; import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight'; import { CustomLink } from '../extensions/Link'; import { Math } from '../extensions/Math'; +import { createCustomMarkdownSerializer } from '../utils/markdownSerializer'; export function useEditor() { const editor = ref(null); const isReady = ref(false); - const initializeEditor = () => { + const initializeEditor = content => { editor.value = new Editor({ extensions: [ StarterKitExtension.configure({ @@ -30,16 +31,23 @@ export function useEditor() { CustomLink, // Use our custom Link extension Math, ], - content: '

', + content: content || '

', editorProps: { attributes: { class: 'prose prose-sm sm:prose lg:prose-lg xl:prose-2xl focus:outline-none', dir: 'auto', }, }, - }); + onCreate: () => { + isReady.value = true; - isReady.value = true; + // Create a simple storage object to hold our custom markdown serializer + if (!editor.value.storage.markdown) { + editor.value.storage.markdown = {}; + } + editor.value.storage.markdown.getMarkdown = createCustomMarkdownSerializer(editor.value); + }, + }); }; const destroyEditor = () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js index 6b75c2eae1..140e1cd3d0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Image.js @@ -14,6 +14,7 @@ export const Image = Node.create({ addAttributes() { return { src: { default: null }, + permanentSrc: { default: null }, alt: { default: null }, width: { default: null }, height: { default: null }, diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js new file mode 100644 index 0000000000..0b823625f2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -0,0 +1,114 @@ +// Contains utilities for handling Markdown content bidirectional conversion in TipTap editor. +// eslint-disable-next-line import/namespace +import { marked } from 'marked'; +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 + IMAGE_REGEX.lastIndex = 0; + const match = IMAGE_REGEX.exec(markdown); + if (!match) return null; + + const [, alt, fullPath, width, height] = match; + + // Extract just the filename from the full path + const checksumWithExt = fullPath.split('/').pop(); + + // Now, split the filename into its parts + const parts = checksumWithExt.split('.'); + const extension = parts.pop(); + const checksum = parts.join('.'); + + // Return the data with the correct property names that the rest of the system expects. + return { checksum, extension, alt: alt || '', width, height }; +}; + +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 (width && height) { + return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName} =${width}x${height})`; + } + return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName})`; +}; + +// --- Math/Formula Translation --- +export const MATH_REGEX = /\$\$([^$]+)\$\$/g; + +export const mathMdToParams = markdown => { + MATH_REGEX.lastIndex = 0; + const match = MATH_REGEX.exec(markdown); + if (!match) return null; + return { latex: match[1].trim() }; +}; + +export const paramsToMathMd = ({ latex }) => { + return `$$${latex || ''}$$`; +}; + +/** + * Pre-processes a raw Markdown string to convert custom syntax into HTML tags + * that Tiptap's extensions can understand. This is our custom "loader". + * @param {string} markdown - The raw markdown string. + * @returns {string} - The processed string with HTML tags. + */ +export function preprocessMarkdown(markdown) { + if (!markdown) return ''; + + 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); + if (!params) return match; + + // 1. Convert the checksum into a real, displayable URL. + const displayUrl = storageUrl(params.checksum, params.extension); + + // 2. The permanentSrc is just the checksum + extension. + const permanentSrc = `${params.checksum}.${params.extension}`; + + // 3. Create attributes string for width and height only if they exist + const widthAttr = params.width ? ` width="${params.width}"` : ''; + const heightAttr = params.height ? ` height="${params.height}"` : ''; + + // 4. Create an tag with the REAL display URL in `src`. + return `${params.alt}`; + }); + + processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { + const params = mathMdToParams(match); + if (!params) return match; + return ``; + }); + + // Use marked.js to parse the rest of the markdown + return marked(processedMarkdown); +} diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js new file mode 100644 index 0000000000..3b9239caa2 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js @@ -0,0 +1,225 @@ +// Custom markdown serialization that handles Math nodes properly +import { paramsToMathMd, paramsToImageMd } from './markdown'; + +export const createCustomMarkdownSerializer = editor => { + return function getMarkdown() { + const doc = editor.state.doc; + let result = ''; + + // Handle marks (bold, italic, etc.) + const serializeMarks = node => { + if (!node.marks || node.marks.length === 0) return node.text || ''; + + const text = node.text || ''; + // Markdown only works if it's sticky to the text + const leadingWhitespace = text.match(/^\s+/)?.[0] || ''; + const trailingWhitespace = text.match(/\s+$/)?.[0] || ''; + let trimmedText = text.trim(); + + node.marks.forEach(mark => { + switch (mark.type.name) { + case 'bold': + trimmedText = `**${trimmedText}**`; + break; + case 'italic': + trimmedText = `*${trimmedText}*`; + break; + case 'underline': + trimmedText = `${trimmedText}`; + break; + case 'strike': + trimmedText = `~~${trimmedText}~~`; + break; + case 'code': + trimmedText = `\`${trimmedText}\``; + break; + case 'link': { + const href = mark.attrs.href || ''; + trimmedText = `[${trimmedText}](${href})`; + break; + } + case 'superscript': + trimmedText = `${trimmedText}`; + break; + case 'subscript': + trimmedText = `${trimmedText}`; + break; + } + }); + return leadingWhitespace + trimmedText + trailingWhitespace; + }; + + const serializeNode = (node, listNumber = null, depth = 0) => { + if (!node || !node.type) { + return; + } + + switch (node.type.name) { + case 'doc': + // Process all children + if (node.content && node.content.size > 0) { + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + if (i > 0) result += '\n\n'; + serializeNode(child, null, depth); + } + } + } + break; + + case 'paragraph': + if (node.content && node.content.size > 0) { + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, null, depth); + } + } + } + break; + + case 'heading': { + const level = node.attrs.level || 1; + result += '#'.repeat(level) + ' '; + if (node.content && node.content.size > 0) { + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, null, depth); + } + } + } + break; + } + + case 'text': + result += serializeMarks(node); + break; + + case 'math': + result += paramsToMathMd(node.attrs); + break; + + case 'image': + result += paramsToImageMd(node.attrs); + break; + + case 'small': + result += ''; + if (node.content && node.content.size > 0) { + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, null, depth); + } + } + } + result += ''; + break; + + case 'bulletList': + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, 'bullet', depth); + if (i < node.content.size - 1) result += '\n'; + } + } + break; + + case 'orderedList': + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, i + 1, depth); + if (i < node.content.size - 1) result += '\n'; + } + } + break; + + case 'listItem': { + // Add indentation for nested lists + const indent = ' '.repeat(depth); + + // Use the passed listNumber parameter + if (listNumber === 'bullet') { + result += indent + '- '; + } else if (typeof listNumber === 'number') { + result += indent + `${listNumber}. `; + } + + // Process list item content properly + if (node.content && node.content.size > 0) { + let hasProcessedFirstParagraph = false; + + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child && child.type) { + if (child.type.name === 'paragraph') { + // For paragraphs in list items, process their content directly + if (child.content && child.content.size > 0) { + for (let j = 0; j < child.content.size; j++) { + const grandchild = child.content.content[j]; + if (grandchild) { + serializeNode(grandchild, null, depth); + } + } + } + hasProcessedFirstParagraph = true; + } else if (child.type.name === 'bulletList' || child.type.name === 'orderedList') { + // Handle nested lists + if (hasProcessedFirstParagraph) { + result += '\n'; + } + serializeNode(child, null, depth + 1); + } else { + serializeNode(child, null, depth); + } + } + } + } + break; + } + + case 'blockquote': + result += '> '; + if (node.content && node.content.size > 0) { + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, null, depth); + } + } + } + break; + + case 'codeBlock': { + const language = node.attrs.language || ''; + result += '```' + language + '\n'; + result += node.textContent; + result += '\n```'; + break; + } + + case 'hardBreak': + result += ' \n'; + break; + + case 'horizontalRule': + result += '---'; + break; + + default: + // Fallback: try to process children + if (node.content) { + node.content.forEach(child => serializeNode(child, null, depth)); + } + break; + } + }; + + serializeNode(doc, null, 0); + return result.trim(); + }; +}; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdown.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdown.spec.js new file mode 100644 index 0000000000..520e056666 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdown.spec.js @@ -0,0 +1,123 @@ +// eslint-disable-next-line import/namespace +import { marked } from 'marked'; +import { preprocessMarkdown } from '../TipTapEditor/utils/markdown'; +import { storageUrl } from '../../../vuex/file/utils'; + +// Mock the dependencies at the top level +jest.mock('marked', () => ({ + marked: jest.fn(str => str), +})); +jest.mock('../../../vuex/file/utils', () => ({ + storageUrl: jest.fn((checksum, ext) => `/content/storage/${checksum}.${ext}`), +})); + +describe('preprocessMarkdown', () => { + beforeEach(() => { + marked.mockClear(); + storageUrl.mockClear(); + }); + + // 1: Legacy Checksum Images + // Old images did not add dimensions unless a resize was used. + describe('Legacy Image Handling', () => { + it('should convert a legacy image with dimensions into a full tag', () => { + const legacyMd = '![some alt text](${CONTENTSTORAGE}/checksum.png =100x200)'; + const expectedHtml = + 'some alt text'; + expect(preprocessMarkdown(legacyMd)).toBe(expectedHtml); + }); + + it('should detect & convert a legacy image WITHOUT dimensions', () => { + const legacyMd = '![No Dims](${☣ CONTENTSTORAGE}/file.jpg)'; + const expectedHtml = + 'No Dims'; + expect(preprocessMarkdown(legacyMd)).toBe(expectedHtml); + }); + + it('should process multiple legacy images correctly', () => { + const mixedMd = [ + '![first](${CONTENTSTORAGE}/image1.jpg =100x100)', + 'Some text', + '![second](${CONTENTSTORAGE}/image2.png)', + ].join('\n'); + + const expectedHtml = [ + 'first', + 'Some text', + 'second', + ].join('\n'); + + const result = preprocessMarkdown(mixedMd); + + expect(result).toBe(expectedHtml); + expect(storageUrl).toHaveBeenCalledWith('image1', 'jpg'); + expect(storageUrl).toHaveBeenCalledWith('image2', 'png'); + }); + }); + + // 2: Data URL / Blob URL Images + describe('Data URL Image Handling', () => { + it('should convert a data URL image with dimensions into an tag', () => { + const dataUrlMd = '![Data](data:image/png;base64,iVBORw0KGgo= =300x400)'; + const expectedHtml = + 'Data'; + expect(preprocessMarkdown(dataUrlMd)).toBe(expectedHtml); + }); + }); + + // 3: Math Formulas + describe('Math Formula Handling', () => { + it('should convert a simple math formula into a tag', () => { + const mathMd = 'Some text $$x^2$$ and more text.'; + const expectedHtml = 'Some text and more text.'; + expect(preprocessMarkdown(mathMd)).toBe(expectedHtml); + }); + + it('should handle math formulas with spaces', () => { + const mathMd = 'Formula: $$ x + y = z $$'; + const expectedHtml = 'Formula: '; + expect(preprocessMarkdown(mathMd)).toBe(expectedHtml); + }); + }); + + // 4: Standard Markdown Passthrough + describe('Standard Markdown Passthrough', () => { + it('should pass non-custom syntax through to the marked library', () => { + const standardMd = 'Here is **bold** and a [link](url).'; + preprocessMarkdown(standardMd); + // We test the *interaction*, not the result (the library). + expect(marked).toHaveBeenCalled(); + expect(marked).toHaveBeenCalledWith(standardMd); + }); + }); + + // 5: Mixed Content + describe('Mixed Content Handling', () => { + it('should correctly process a string with legacy images, data URLs, math, and standard markdown', () => { + const mixedMd = + 'Legacy: ![](${CONTENTSTORAGE}/file.png =10x10)\nData: ![alt](data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=)\nFormula: $$E=mc^2$$'; + const expectedHtml = + 'Legacy: \nData: alt\nFormula: '; + + const result = preprocessMarkdown(mixedMd); + expect(result).toBe(expectedHtml); + }); + }); + + // 6: Edge Cases + describe('Edge Case Handling', () => { + it('should return an empty string for null, undefined, or empty string input', () => { + expect(preprocessMarkdown(null)).toBe(''); + expect(preprocessMarkdown(undefined)).toBe(''); + expect(preprocessMarkdown('')).toBe(''); + }); + + it('should handle very long input without crashing', () => { + const longMd = 'Text '.repeat(10000) + '$$x^2$$'; + expect(() => preprocessMarkdown(longMd)).not.toThrow(); + + const result = preprocessMarkdown(longMd); + expect(result).toContain(''); + }); + }); +}); diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js new file mode 100644 index 0000000000..6c5354639e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js @@ -0,0 +1,343 @@ +// Import the function we are testing +import { createCustomMarkdownSerializer } from '../TipTapEditor/utils/markdownSerializer'; + +// Mock the IMAGE_PLACEHOLDER to match what our tests expect +jest.mock('../TipTapEditor/utils/markdown', () => { + const originalModule = jest.requireActual('../TipTapEditor/utils/markdown'); + return { + ...originalModule, + IMAGE_PLACEHOLDER: '${CONTENTSTORAGE}', + paramsToImageMd: jest.fn(params => { + const alt = params.alt || ''; + const src = params.permanentSrc ? `\${CONTENTSTORAGE}/${params.permanentSrc}` : params.src; + const dimensions = params.width && params.height ? ` =${params.width}x${params.height}` : ''; + return `![${alt}](${src}${dimensions})`; + }), + }; +}); + +// mock editor object with a proper ProseMirror-like structure +const createMockEditor = docContent => { + // Create a mock node structure that mimics ProseMirror nodes + const createNode = node => { + const result = { ...node }; + + // Add required properties expected by the serializer + if (result.content) { + const contentArray = result.content.map(createNode); + result.content = { + size: contentArray.length, + content: contentArray, + // Add forEach to support iteration in the serializer + forEach: function (callback) { + contentArray.forEach(callback); + }, + }; + } + + if (result.type && typeof result.type === 'string') { + result.type = { name: result.type }; + } + + if (result.type && result.type.name === 'text' && result.text) { + result.textContent = result.text; + } + + // Process marks to make them compatible with the serializer + if (result.marks && Array.isArray(result.marks)) { + result.marks = result.marks.map(mark => { + if (typeof mark.type === 'string') { + return { ...mark, type: { name: mark.type } }; + } + return mark; + }); + } + + return result; + }; + + // Create the document structure + const doc = createNode({ + type: 'doc', + content: docContent, + }); + + return { + state: { + doc: doc, + }, + }; +}; + +describe('createCustomMarkdownSerializer', () => { + // 1: Custom Block Nodes + describe('Custom Block Node Serialization', () => { + it('should serialize a legacy image node using its permanentSrc', () => { + const docContent = [ + { + type: 'image', + attrs: { + src: '/content/storage/c/h/checksum.png', + permanentSrc: 'checksum.png', + alt: 'Legacy Cat', + width: '150', + height: '100', + }, + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + + const expectedMd = '![Legacy Cat](${CONTENTSTORAGE}/checksum.png =150x100)'; + expect(getMarkdown()).toBe(expectedMd); + }); + + it('should serialize a new, unsaved data: URL image', () => { + const dataUrl = 'data:image/png;base64,iVBORw0KGgo='; + const docContent = [ + { + type: 'image', + attrs: { + src: dataUrl, + permanentSrc: null, // No permanent source yet + alt: 'New Cat', + width: '80', + height: '60', + }, + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + + const expectedMd = `![New Cat](${dataUrl} =80x60)`; + expect(getMarkdown()).toBe(expectedMd); + }); + + it('should serialize a node correctly', () => { + const docContent = [ + { + type: 'small', + content: [{ type: 'text', text: 'This is small text.' }], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + + const expectedMd = 'This is small text.'; + expect(getMarkdown()).toBe(expectedMd); + }); + }); + + // 2: Custom Inline Nodes + describe('Custom Inline Node Serialization', () => { + it('should serialize a math node correctly', () => { + const docContent = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'The formula is ' }, + { type: 'math', attrs: { latex: 'E=mc^2' } }, + { type: 'text', text: '.' }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + + const expectedMd = 'The formula is $$E=mc^2$$.'; + expect(getMarkdown()).toBe(expectedMd); + }); + }); + + // 3: Standard Block Nodes + describe('Standard Block Node Serialization', () => { + it('should serialize headings with the correct level', () => { + const docContent = [ + { + type: 'heading', + attrs: { level: 2 }, + content: [{ type: 'text', text: 'My Subtitle' }], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('## My Subtitle'); + }); + + it('should serialize nested lists correctly', () => { + const docContent = [ + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Item 1' }] }, + { + type: 'bulletList', + content: [ + { + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Nested 1.1' }] }, + ], + }, + ], + }, + ], + }, + { + type: 'listItem', + content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Item 2' }] }], + }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('- Item 1\n - Nested 1.1\n- Item 2'); + }); + + it('should place two newlines between block elements', () => { + const docContent = [ + { type: 'paragraph', content: [{ type: 'text', text: 'First paragraph.' }] }, + { type: 'paragraph', content: [{ type: 'text', text: 'Second paragraph.' }] }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('First paragraph.\n\nSecond paragraph.'); + }); + }); + + // 4: Inline Formatting (Marks) + describe('Mark Serialization', () => { + it('should serialize a text node with multiple marks correctly', () => { + const docContent = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'bold and italic', + marks: [{ type: 'bold' }, { type: 'italic' }], + }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + // The order of marks can vary, but typically it's inside-out + expect(getMarkdown()).toBe('*' + '**bold and italic**' + '*'); + }); + + it('should serialize an underlined node with a tag', () => { + const docContent = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'underlined', + marks: [{ type: 'underline' }], + }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('underlined'); + }); + + it('should serialize a link node correctly', () => { + const docContent = [ + { + type: 'paragraph', + content: [ + { + type: 'text', + text: 'Google', + marks: [{ type: 'link', attrs: { href: 'https://google.com' } }], + }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe('[Google](https://google.com)'); + }); + + it('should preserve whitespace around marks correctly', () => { + const docContent = [ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Text with' }, + { + type: 'text', + text: ' bold text ', + marks: [{ type: 'bold' }], + }, + { type: 'text', text: 'at the end.' }, + ], + }, + ]; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + // Whitespace should be preserved outside the markdown formatting + expect(getMarkdown()).toBe('Text with **bold text** at the end.'); + }); + }); + + // 5: Edge cases + describe('Structural and Edge Case Serialization', () => { + it('should return an empty string for an empty document', () => { + const docContent = []; + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + expect(getMarkdown()).toBe(''); + }); + + it('should serialize very deep nested lists correctly', () => { + // Helper function to create nested list structure + const createNestedList = (levels, currentLevel = 1) => { + const listItem = { + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: `Level ${currentLevel}` }] }, + ], + }; + + if (currentLevel < levels) { + listItem.content.push({ + type: 'bulletList', + content: [createNestedList(levels, currentLevel + 1)], + }); + } + + return listItem; + }; + + const docContent = [ + { + type: 'bulletList', + content: [ + createNestedList(5), + { + type: 'listItem', + content: [ + { type: 'paragraph', content: [{ type: 'text', text: 'Back to Level 1' }] }, + ], + }, + ], + }, + ]; + + const mockEditor = createMockEditor(docContent); + const getMarkdown = createCustomMarkdownSerializer(mockEditor); + + const expectedMd = + '- Level 1\n - Level 2\n - Level 3\n - Level 4\n - Level 5\n- Back to Level 1'; + expect(getMarkdown()).toBe(expectedMd); + }); + }); +}); diff --git a/jest_config/jest.conf.js b/jest_config/jest.conf.js index 20987fa663..b515327281 100644 --- a/jest_config/jest.conf.js +++ b/jest_config/jest.conf.js @@ -30,7 +30,7 @@ module.exports = { '.*\\.(vue)$': '/node_modules/vue-jest', }, transformIgnorePatterns: [ - '/node_modules/.pnpm/(?!(vuetify|epubjs|kolibri-design-system|kolibri-constants|axios|lowlight|@tiptap|tiptap|prosemirror-.*|unified|unist-.*|hast-.*|bail|trough|vfile.*|remark-.*|rehype-.*|mdast-.*|devlop))', + '/node_modules/.pnpm/(?!(vuetify|epubjs|kolibri-design-system|kolibri-constants|axios|lowlight|@tiptap|tiptap|prosemirror-.*|unified|unist-.*|hast-.*|bail|trough|vfile.*|remark-.*|rehype-.*|mdast-.*|devlop|marked))', ], snapshotSerializers: ['/node_modules/jest-serializer-vue'], setupFilesAfterEnv: ['/jest_config/setup.js'], diff --git a/package.json b/package.json index 194b138b77..39137522c1 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "kolibri-design-system": "5.2.0", "lodash": "^4.17.21", "lowlight": "^3.3.0", + "marked": "^16.1.1", "material-icons": "0.3.1", "mathlive": "^0.105.3", "mutex-js": "^1.1.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5279ab3b09..448b45d295 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,6 +101,9 @@ importers: lowlight: specifier: ^3.3.0 version: 3.3.0 + marked: + specifier: ^16.1.1 + version: 16.1.1 material-icons: specifier: 0.3.1 version: 0.3.1 @@ -5026,6 +5029,11 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + marked@16.1.1: + resolution: {integrity: sha512-ij/2lXfCRT71L6u0M29tJPhP0bM5shLL3u5BePhFwPELj2blMJ6GDtD7PfJhRLhJ/c2UwrK17ySVcDzy2YHjHQ==} + engines: {node: '>= 20'} + hasBin: true + marks-pane@1.0.9: resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==} @@ -13544,6 +13552,8 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + marked@16.1.1: {} + marks-pane@1.0.9: {} material-icons@0.3.1: {}