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 @@
+
+
+
+
+
+
+
+
+
Live Markdown Output (v-model state)
+
{{ markdownContent }}
+
+
+
+
+
+
+
+
+
+
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 ``;
+ }
+ return ``;
+ }
+
+ const fileName = sourceToSave.split('/').pop();
+ if (width && height) {
+ return ``;
+ }
+ return ``;
+};
+
+// --- 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 `

`;
+ },
+ );
+
+ // 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 `

`;
+ });
+
+ 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 = '';
+ const expectedHtml =
+ '

';
+ expect(preprocessMarkdown(legacyMd)).toBe(expectedHtml);
+ });
+
+ it('should detect & convert a legacy image WITHOUT dimensions', () => {
+ const legacyMd = '';
+ const expectedHtml =
+ '

';
+ expect(preprocessMarkdown(legacyMd)).toBe(expectedHtml);
+ });
+
+ it('should process multiple legacy images correctly', () => {
+ const mixedMd = [
+ '',
+ 'Some text',
+ '',
+ ].join('\n');
+
+ const expectedHtml = [
+ '

',
+ 'Some text',
+ '

',
+ ].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 = '';
+ const expectedHtml =
+ '

';
+ 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: \nData: \nFormula: $$E=mc^2$$';
+ const expectedHtml =
+ 'Legacy:
\nData:
\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 ``;
+ }),
+ };
+});
+
+// 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 = '';
+ 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 = ``;
+ 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: {}