From dce292bcdc1ca8bb50cea3c0a977d382c2f41dda Mon Sep 17 00:00:00 2001 From: habibayman Date: Sat, 19 Jul 2025 07:20:21 +0300 Subject: [PATCH 01/14] config: add tiptap-markdown dependency --- package.json | 1 + pnpm-lock.yaml | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/package.json b/package.json index d6f1092fb8..f261d4e2ce 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "spark-md5": "^3.0.0", "store2": "^2.14.4", "string-strip-html": "8.3.0", + "tiptap-markdown": "^0.8.10", "uuid": "^11.1.0", "vue": "~2.7.16", "vue-croppa": "^1.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e290aa9f14..e148fb010d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: string-strip-html: specifier: 8.3.0 version: 8.3.0 + tiptap-markdown: + specifier: ^0.8.10 + version: 0.8.10(@tiptap/core@2.23.1(@tiptap/pm@2.23.1)) uuid: specifier: ^11.1.0 version: 11.1.0 @@ -1698,6 +1701,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/linkify-it@3.0.5': + resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -1708,9 +1714,15 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} + '@types/markdown-it@13.0.9': + resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} + '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdurl@1.0.5': + resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -5039,6 +5051,9 @@ packages: map-values@1.0.1: resolution: {integrity: sha512-BbShUnr5OartXJe1GeccAWtfro11hhgNJg6G9/UtWKjVGvV5U4C09cg5nk8JUevhXODaXY+hQ3xxMUKSs62ONQ==} + markdown-it-task-lists@2.1.1: + resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} + markdown-it@14.1.0: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true @@ -7029,6 +7044,11 @@ packages: tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} + tiptap-markdown@0.8.10: + resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==} + peerDependencies: + '@tiptap/core': ^2.0.3 + tmp@0.2.3: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} @@ -9451,6 +9471,8 @@ snapshots: '@types/json5@0.0.29': {} + '@types/linkify-it@3.0.5': {} + '@types/linkify-it@5.0.0': {} '@types/localforage@0.0.34': @@ -9459,11 +9481,18 @@ snapshots: '@types/lodash@4.17.20': {} + '@types/markdown-it@13.0.9': + dependencies: + '@types/linkify-it': 3.0.5 + '@types/mdurl': 1.0.5 + '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/mdurl@1.0.5': {} + '@types/mdurl@2.0.0': {} '@types/mime@1.3.5': {} @@ -13569,6 +13598,8 @@ snapshots: map-values@1.0.1: {} + markdown-it-task-lists@2.1.1: {} + markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -15742,6 +15773,14 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 + tiptap-markdown@0.8.10(@tiptap/core@2.23.1(@tiptap/pm@2.23.1)): + dependencies: + '@tiptap/core': 2.23.1(@tiptap/pm@2.23.1) + '@types/markdown-it': 13.0.9 + markdown-it: 14.1.0 + markdown-it-task-lists: 2.1.1 + prosemirror-markdown: 1.13.2 + tmp@0.2.3: {} tmpl@1.0.5: {} From 4086f591535e82fd7ba96fd8c59dac059991d5ab Mon Sep 17 00:00:00 2001 From: habibayman Date: Sat, 19 Jul 2025 07:22:25 +0300 Subject: [PATCH 02/14] feat(texteditor)[markdown]: configure bidirectional conversion --- .../TipTapEditor/composables/useEditor.js | 11 ++-- .../TipTapEditor/extensions/Markdown.js | 64 +++++++++++++++++++ .../TipTapEditor/utils/markdown.js | 46 +++++++++++++ 3 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js 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..216adb5d94 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 { Markdown } from '../extensions/Markdown'; export function useEditor() { const editor = ref(null); const isReady = ref(false); - const initializeEditor = () => { + const initializeEditor = content => { editor.value = new Editor({ extensions: [ StarterKitExtension.configure({ @@ -29,17 +30,19 @@ export function useEditor() { Image, CustomLink, // Use our custom Link extension Math, + Markdown, ], - 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; }; const destroyEditor = () => { diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js new file mode 100644 index 0000000000..dd6b2f668d --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js @@ -0,0 +1,64 @@ +import { Markdown as TiptapMarkdown } from 'tiptap-markdown'; +import { + IMAGE_REGEX, + imageMdToParams, + paramsToImageMd, + MATH_REGEX, + mathMdToParams, + paramsToMathMd, +} from '../utils/markdown'; + +export const Markdown = TiptapMarkdown.configure({ + html: true, + bulletList: { + tight: true, + }, + orderedList: { + tight: true, + }, + // --- LOADING CONFIGURATION --- + // This hook pre-processes the raw Markdown string before parsing. + transformMarkdown: markdown => { + let processedMarkdown = markdown; + + // Replace custom images with standard tags + processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { + const params = imageMdToParams(match); + if (!params) return match; + return `${params.alt}`; + }); + + // Replace $$...$$ with a custom tag for our Math extension + processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { + const params = mathMdToParams(match); + if (!params) return match; + return ``; + }); + + return processedMarkdown; + }, + + // --- SAVING CONFIGURATION --- + // These rules override the default serializers for specific nodes and marks. + toMarkdown: { + // --- Custom Node Rules --- + image(state, node) { + state.write(paramsToImageMd(node.attrs)); + }, + math(state, node) { + state.write(paramsToMathMd(node.attrs)); + }, + small(state, node) { + state.write(''); + state.renderContent(node); + state.write(''); + state.closeBlock(node); + }, + // --- Custom Mark Rules --- + underline: { + open: '', + close: '', + mixable: true, + }, + }, +}); 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..295fd6ba65 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -0,0 +1,46 @@ +// Contains utilities for handling Markdown content bidirectional conversion in TipTap editor. + +// --- Image Translation --- +export const IMAGE_PLACEHOLDER = 'placeholder'; +export const IMAGE_REGEX = /!\[([^\]]*)\]\(([^/]+\/[^\s=)]+)(?:\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; + const pathParts = fullPath.split('/'); + + // Ensure it matches the "placeholder/checksum.ext" structure + if (pathParts.length < 2 || pathParts[0] !== IMAGE_PLACEHOLDER) { + return null; + } + + const src = pathParts.slice(1).join('/'); // The "checksum.ext" part + + return { src, alt: alt || '', width: width || null, height: height || null }; +}; + +export const paramsToImageMd = ({ src, alt, width, height }) => { + const fileName = src.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 || ''}$$`; +}; From 52a1d8d88824bd836596411729c9be0e0e4f7b73 Mon Sep 17 00:00:00 2001 From: habibayman Date: Sat, 19 Jul 2025 07:34:51 +0300 Subject: [PATCH 03/14] feat(texteditor)[markdwon]: create temp fake parent --- .../frontend/channelEdit/router.js | 4 +- .../frontend/editorDev/index.js | 4 +- .../frontend/editorDev/router.js | 4 +- .../TipTapEditor/TipTapEditor/DevHarness.vue | 113 ++++++++++++++++++ .../TipTapEditor/TipTapEditor.vue | 79 +++++++++++- 5 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue diff --git a/contentcuration/contentcuration/frontend/channelEdit/router.js b/contentcuration/contentcuration/frontend/channelEdit/router.js index 3d362cbb59..b12d63857b 100644 --- a/contentcuration/contentcuration/frontend/channelEdit/router.js +++ b/contentcuration/contentcuration/frontend/channelEdit/router.js @@ -11,7 +11,7 @@ import ReviewSelectionsPage from './views/ImportFromChannels/ReviewSelectionsPag import EditModal from './components/edit/EditModal'; import ChannelDetailsModal from 'shared/views/channel/ChannelDetailsModal'; import ChannelModal from 'shared/views/channel/ChannelModal'; -import TipTapEditor from 'shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue'; +import DevHarness from 'shared/views/TipTapEditor/TipTapEditor/DevHarness.vue'; import { RouteNames as ChannelRouteNames } from 'frontend/channelList/constants'; const router = new VueRouter({ @@ -19,7 +19,7 @@ const router = new VueRouter({ { path: '/editor-dev', name: 'TipTapEditorDev', - component: TipTapEditor, + component: DevHarness, }, { name: RouteNames.TREE_ROOT_VIEW, 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..3dff5b605f --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue @@ -0,0 +1,113 @@ + + + + + + + diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 321b40743d..3257bff77a 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 @@ From d0460563b8cf7e0a9c738496b52d343990183d18 Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 20 Jul 2025 03:43:32 +0300 Subject: [PATCH 04/14] refactor(texteditor): create custom markdown serializer --- .../TipTapEditor/TipTapEditor/DevHarness.vue | 7 +- .../TipTapEditor/TipTapEditor.vue | 20 +- .../TipTapEditor/composables/useEditor.js | 6 + .../TipTapEditor/extensions/Markdown.js | 54 +---- .../TipTapEditor/utils/markdown.js | 27 +++ .../TipTapEditor/utils/markdownSerializer.js | 207 ++++++++++++++++++ 6 files changed, 248 insertions(+), 73 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue index 3dff5b605f..c461a05641 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue @@ -6,10 +6,7 @@

This page simulates a parent component to test the editor in isolation.


- +

Live Markdown Output (v-model state)

@@ -29,7 +26,7 @@ **bold** *italic* underline ~~strikethrough~~ -try inline formulas test +try inline formulas $$x^2$$ test - list a - list b diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue index 3257bff77a..5fa36f768a 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue @@ -85,6 +85,7 @@ import LinkEditor from './components/link/LinkEditor.vue'; import { useMathHandling } from './composables/useMathHandling'; import FormulasMenu from './components/math/FormulasMenu.vue'; + import { preprocessMarkdown } from './utils/markdown'; export default defineComponent({ name: 'RichTextEditor', @@ -133,33 +134,23 @@ return editor.value.storage.markdown.getMarkdown(); }; - const setMarkdownContent = (content, emitUpdate = false) => { - if (!editor.value || !isReady.value || !editor.value.storage?.markdown) { - return; - } - editor.value.storage.markdown.setMarkdown(content, emitUpdate); - }; - let isUpdatingFromOutside = false; // A flag to prevent infinite update loops // sync changes from the parent component to the editor watch( () => props.value, newValue => { - if (!editor.value) { - initializeEditor(newValue); - return; - } + const processedContent = preprocessMarkdown(newValue); - if (!isReady.value || !editor.value.storage?.markdown) { + if (!editor.value) { + initializeEditor(processedContent); return; } const editorContent = getMarkdownContent(); - if (editorContent !== newValue) { isUpdatingFromOutside = true; - setMarkdownContent(newValue, false); + editor.value.commands.setContent(processedContent, false); nextTick(() => { isUpdatingFromOutside = false; }); @@ -182,7 +173,6 @@ } const markdown = getMarkdownContent(); - if (markdown !== props.value) { emit('input', markdown); } 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 216adb5d94..d30dcc5207 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js @@ -10,6 +10,7 @@ import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight import { CustomLink } from '../extensions/Link'; import { Math } from '../extensions/Math'; import { Markdown } from '../extensions/Markdown'; +import { createCustomMarkdownSerializer } from '../utils/markdownSerializer'; export function useEditor() { const editor = ref(null); @@ -41,6 +42,11 @@ export function useEditor() { }, onCreate: () => { isReady.value = true; + + const markdownStorage = editor.value.storage.markdown; + if (markdownStorage) { + markdownStorage.getMarkdown = createCustomMarkdownSerializer(editor.value); + } }, }); }; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js index dd6b2f668d..25d16bcb21 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js @@ -1,13 +1,6 @@ import { Markdown as TiptapMarkdown } from 'tiptap-markdown'; -import { - IMAGE_REGEX, - imageMdToParams, - paramsToImageMd, - MATH_REGEX, - mathMdToParams, - paramsToMathMd, -} from '../utils/markdown'; +// Minimal configuration - we handle preprocessing manually via preprocessMarkdown() export const Markdown = TiptapMarkdown.configure({ html: true, bulletList: { @@ -16,49 +9,4 @@ export const Markdown = TiptapMarkdown.configure({ orderedList: { tight: true, }, - // --- LOADING CONFIGURATION --- - // This hook pre-processes the raw Markdown string before parsing. - transformMarkdown: markdown => { - let processedMarkdown = markdown; - - // Replace custom images with standard tags - processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { - const params = imageMdToParams(match); - if (!params) return match; - return `${params.alt}`; - }); - - // Replace $$...$$ with a custom tag for our Math extension - processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { - const params = mathMdToParams(match); - if (!params) return match; - return ``; - }); - - return processedMarkdown; - }, - - // --- SAVING CONFIGURATION --- - // These rules override the default serializers for specific nodes and marks. - toMarkdown: { - // --- Custom Node Rules --- - image(state, node) { - state.write(paramsToImageMd(node.attrs)); - }, - math(state, node) { - state.write(paramsToMathMd(node.attrs)); - }, - small(state, node) { - state.write(''); - state.renderContent(node); - state.write(''); - state.closeBlock(node); - }, - // --- Custom Mark Rules --- - underline: { - open: '', - close: '', - mixable: true, - }, - }, }); 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 295fd6ba65..62fb18d3a0 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -44,3 +44,30 @@ export const mathMdToParams = markdown => { 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; + + // Replace custom images with standard tags + processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { + const params = imageMdToParams(match); + if (!params) return match; + return `${params.alt}`; + }); + + // Replace $$...$$ with a custom tag for our Math extension + processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { + const params = mathMdToParams(match); + if (!params) return match; + return ``; + }); + + return 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..261a59372e --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js @@ -0,0 +1,207 @@ +// 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 || ''; + + let text = node.text || ''; + node.marks.forEach(mark => { + switch (mark.type.name) { + case 'bold': + text = `**${text}**`; + break; + case 'italic': + text = `*${text}*`; + break; + case 'underline': + text = `${text}`; + break; + case 'strike': + text = `~~${text}~~`; + break; + case 'code': + text = `\`${text}\``; + break; + case 'link': { + const href = mark.attrs.href || ''; + text = `[${text}](${href})`; + break; + } + case 'superscript': + text = `${text}`; + break; + case 'subscript': + text = `${text}`; + break; + } + }); + return text; + }; + + const serializeNode = (node, listNumber = null) => { + 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); + } + } + } + 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); + } + } + } + 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); + } + } + } + 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); + } + } + } + result += ''; + break; + + case 'bulletList': + for (let i = 0; i < node.content.size; i++) { + const child = node.content.content[i]; + if (child) { + serializeNode(child, 'bullet'); + 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); + if (i < node.content.size - 1) result += '\n'; + } + } + break; + + case 'listItem': + // Use the passed listNumber parameter + if (listNumber === 'bullet') { + result += '- '; + } else if (typeof listNumber === 'number') { + result += `${listNumber}. `; + } + + // Process list item content properly + if (node.content && node.content.size > 0) { + 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); + } + } + } + } else { + serializeNode(child); + } + } + } + } + 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); + } + } + } + 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)); + } + break; + } + }; + + serializeNode(doc, true); + return result.trim(); + }; +}; From 4788ad2dc00d9de6a7641ed1669b522e75691b1b Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 20 Jul 2025 05:02:20 +0300 Subject: [PATCH 05/14] refactor(texteditor): remove tiptap-markdown with related logic --- .../TipTapEditor/TipTapEditor/DevHarness.vue | 2 +- .../TipTapEditor/composables/useEditor.js | 9 ++-- .../TipTapEditor/extensions/Markdown.js | 12 ----- .../TipTapEditor/utils/markdown.js | 9 ++-- package.json | 2 +- pnpm-lock.yaml | 49 ++++--------------- 6 files changed, 22 insertions(+), 61 deletions(-) delete mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue index c461a05641..9ba867b339 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue @@ -35,7 +35,7 @@ try inline formulas $$x^2$$ test small text -1. list one +1. list one[1] 2. list two There is a [link here](https://github.com/learningequality/studio/pull/5155/checks)! 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 d30dcc5207..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,7 +9,6 @@ import { Image } from '../extensions/Image'; import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight'; import { CustomLink } from '../extensions/Link'; import { Math } from '../extensions/Math'; -import { Markdown } from '../extensions/Markdown'; import { createCustomMarkdownSerializer } from '../utils/markdownSerializer'; export function useEditor() { @@ -31,7 +30,6 @@ export function useEditor() { Image, CustomLink, // Use our custom Link extension Math, - Markdown, ], content: content || '

', editorProps: { @@ -43,10 +41,11 @@ export function useEditor() { onCreate: () => { isReady.value = true; - const markdownStorage = editor.value.storage.markdown; - if (markdownStorage) { - markdownStorage.getMarkdown = createCustomMarkdownSerializer(editor.value); + // 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); }, }); }; diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js deleted file mode 100644 index 25d16bcb21..0000000000 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/extensions/Markdown.js +++ /dev/null @@ -1,12 +0,0 @@ -import { Markdown as TiptapMarkdown } from 'tiptap-markdown'; - -// Minimal configuration - we handle preprocessing manually via preprocessMarkdown() -export const Markdown = TiptapMarkdown.configure({ - html: true, - bulletList: { - tight: true, - }, - orderedList: { - tight: true, - }, -}); 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 62fb18d3a0..38227c95d1 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -1,4 +1,6 @@ // Contains utilities for handling Markdown content bidirectional conversion in TipTap editor. +// eslint-disable-next-line import/namespace +import { marked } from 'marked'; // --- Image Translation --- export const IMAGE_PLACEHOLDER = 'placeholder'; @@ -53,21 +55,22 @@ export const paramsToMathMd = ({ latex }) => { */ export function preprocessMarkdown(markdown) { if (!markdown) return ''; + let processedMarkdown = markdown; - // Replace custom images with standard tags + // First handle your custom syntax (images and math) as before processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { const params = imageMdToParams(match); if (!params) return match; return `${params.alt}`; }); - // Replace $$...$$ with a custom tag for our Math extension processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { const params = mathMdToParams(match); if (!params) return match; return ``; }); - return processedMarkdown; + // Use marked.js to parse the rest of the markdown + return marked(processedMarkdown); } diff --git a/package.json b/package.json index f261d4e2ce..c1c81ffdbf 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", @@ -97,7 +98,6 @@ "spark-md5": "^3.0.0", "store2": "^2.14.4", "string-strip-html": "8.3.0", - "tiptap-markdown": "^0.8.10", "uuid": "^11.1.0", "vue": "~2.7.16", "vue-croppa": "^1.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e148fb010d..4eb3e61e65 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 @@ -140,9 +143,6 @@ importers: string-strip-html: specifier: 8.3.0 version: 8.3.0 - tiptap-markdown: - specifier: ^0.8.10 - version: 0.8.10(@tiptap/core@2.23.1(@tiptap/pm@2.23.1)) uuid: specifier: ^11.1.0 version: 11.1.0 @@ -1701,9 +1701,6 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} - '@types/linkify-it@3.0.5': - resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==} - '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -1714,15 +1711,9 @@ packages: '@types/lodash@4.17.20': resolution: {integrity: sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==} - '@types/markdown-it@13.0.9': - resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} - '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} - '@types/mdurl@1.0.5': - resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==} - '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} @@ -5051,13 +5042,15 @@ packages: map-values@1.0.1: resolution: {integrity: sha512-BbShUnr5OartXJe1GeccAWtfro11hhgNJg6G9/UtWKjVGvV5U4C09cg5nk8JUevhXODaXY+hQ3xxMUKSs62ONQ==} - markdown-it-task-lists@2.1.1: - resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==} - markdown-it@14.1.0: 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==} @@ -7044,11 +7037,6 @@ packages: tippy.js@6.3.7: resolution: {integrity: sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==} - tiptap-markdown@0.8.10: - resolution: {integrity: sha512-iDVkR2BjAqkTDtFX0h94yVvE2AihCXlF0Q7RIXSJPRSR5I0PA1TMuAg6FHFpmqTn4tPxJ0by0CK7PUMlnFLGEQ==} - peerDependencies: - '@tiptap/core': ^2.0.3 - tmp@0.2.3: resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==} engines: {node: '>=14.14'} @@ -9471,8 +9459,6 @@ snapshots: '@types/json5@0.0.29': {} - '@types/linkify-it@3.0.5': {} - '@types/linkify-it@5.0.0': {} '@types/localforage@0.0.34': @@ -9481,18 +9467,11 @@ snapshots: '@types/lodash@4.17.20': {} - '@types/markdown-it@13.0.9': - dependencies: - '@types/linkify-it': 3.0.5 - '@types/mdurl': 1.0.5 - '@types/markdown-it@14.1.2': dependencies: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 - '@types/mdurl@1.0.5': {} - '@types/mdurl@2.0.0': {} '@types/mime@1.3.5': {} @@ -13598,8 +13577,6 @@ snapshots: map-values@1.0.1: {} - markdown-it-task-lists@2.1.1: {} - markdown-it@14.1.0: dependencies: argparse: 2.0.1 @@ -13609,6 +13586,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: {} @@ -15773,14 +15752,6 @@ snapshots: dependencies: '@popperjs/core': 2.11.8 - tiptap-markdown@0.8.10(@tiptap/core@2.23.1(@tiptap/pm@2.23.1)): - dependencies: - '@tiptap/core': 2.23.1(@tiptap/pm@2.23.1) - '@types/markdown-it': 13.0.9 - markdown-it: 14.1.0 - markdown-it-task-lists: 2.1.1 - prosemirror-markdown: 1.13.2 - tmp@0.2.3: {} tmpl@1.0.5: {} From 2bae21dcdf796b1628d00d70f972b85423a07cc7 Mon Sep 17 00:00:00 2001 From: habibayman Date: Sun, 20 Jul 2025 05:15:29 +0300 Subject: [PATCH 06/14] config: add marked to jest transformIgnorePatterns --- jest_config/jest.conf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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'], From 807c8401be53792cdf7452eac0303685beb54851 Mon Sep 17 00:00:00 2001 From: habibayman Date: Mon, 21 Jul 2025 09:29:18 +0300 Subject: [PATCH 07/14] fix(texteditor)[markdown]: handle legacy images --- .../TipTapEditor/extensions/Image.js | 1 + .../TipTapEditor/utils/markdown.js | 43 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) 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 index 38227c95d1..279dd451db 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -1,9 +1,10 @@ // 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 = 'placeholder'; +export const IMAGE_PLACEHOLDER = '${☣ CONTENTSTORAGE}'; export const IMAGE_REGEX = /!\[([^\]]*)\]\(([^/]+\/[^\s=)]+)(?:\s*=\s*([0-9.]+)x([0-9.]+))?\)/g; export const imageMdToParams = markdown => { @@ -13,20 +14,31 @@ export const imageMdToParams = markdown => { if (!match) return null; const [, alt, fullPath, width, height] = match; - const pathParts = fullPath.split('/'); - // Ensure it matches the "placeholder/checksum.ext" structure - if (pathParts.length < 2 || pathParts[0] !== IMAGE_PLACEHOLDER) { - return null; - } + // Extract just the filename from the full path + const checksumWithExt = fullPath.split('/').pop(); - const src = pathParts.slice(1).join('/'); // The "checksum.ext" part + // Now, split the filename into its parts + const parts = checksumWithExt.split('.'); + const extension = parts.pop(); + const checksum = parts.join('.'); - return { src, alt: alt || '', width: width || null, height: height || null }; + // 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 }) => { - const fileName = src.split('/').pop(); +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:')) { + // TODO: This is not a good permanent format, temp fix + return `![${alt || ''}](${sourceToSave})`; + } + + const fileName = sourceToSave.split('/').pop(); if (width && height) { return `![${alt || ''}](${IMAGE_PLACEHOLDER}/${fileName} =${width}x${height})`; } @@ -58,11 +70,18 @@ export function preprocessMarkdown(markdown) { let processedMarkdown = markdown; - // First handle your custom syntax (images and math) as before processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => { const params = imageMdToParams(match); if (!params) return match; - return `${params.alt}`; + + // 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 an tag with the REAL display URL in `src`. + return `${params.alt}`; }); processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => { From 01f25a3d7d16529fb9a385e465d68848b47e00f9 Mon Sep 17 00:00:00 2001 From: habibayman Date: Mon, 21 Jul 2025 19:03:29 +0300 Subject: [PATCH 08/14] fix(texteditor)[markdown]: save img dimensions properly --- .../components/image/ImageNodeView.vue | 88 +++++++++++++++++-- .../TipTapEditor/utils/markdown.js | 17 +++- 2 files changed, 95 insertions(+), 10 deletions(-) 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..b2e78c3387 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'; export default defineComponent({ @@ -72,12 +74,15 @@ 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 + // (to work with undo/redo) Watch for external changes to the node's width and height watch( () => props.node.attrs.width, newWidth => { @@ -85,6 +90,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 +138,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 +168,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 +205,12 @@ 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); + debounceTimer = setTimeout(saveSize, 500); }; const removeImage = () => { @@ -166,18 +228,26 @@ }); }; + onMounted(() => { + if (imageRef.value && imageRef.value.complete) { + onImageLoad(); + } + }); + onUnmounted(() => { clearTimeout(debounceTimer); }); return { width, + imageRef, styleWidth, onResizeStart, removeImage, editImage, isCompact, onResizeKeyDown, + onImageLoad, }; }, props: { 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 279dd451db..0ac3204ade 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -6,6 +6,8 @@ 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 @@ -34,7 +36,9 @@ export const paramsToImageMd = ({ src, alt, width, height, permanentSrc }) => { // 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:')) { - // TODO: This is not a good permanent format, temp fix + if (width && height) { + return `![${alt || ''}](${sourceToSave} =${width}x${height})`; + } return `![${alt || ''}](${sourceToSave})`; } @@ -70,6 +74,17 @@ 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); if (!params) return match; From 4043ca0164a4c536073ea28b1acd0f688bd12989 Mon Sep 17 00:00:00 2001 From: habibayman Date: Fri, 1 Aug 2025 06:43:31 +0300 Subject: [PATCH 09/14] tests(texteditor): write tests for loading custom markdown --- .../TipTapEditor/utils/markdown.js | 8 +- .../TipTapEditor/__tests__/markdown.spec.js | 123 ++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdown.spec.js 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 0ac3204ade..0b823625f2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js @@ -95,8 +95,12 @@ export function preprocessMarkdown(markdown) { // 2. The permanentSrc is just the checksum + extension. const permanentSrc = `${params.checksum}.${params.extension}`; - // 3. Create an tag with the REAL display URL in `src`. - return `${params.alt}`; + // 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 => { 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(''); + }); + }); +}); From c54c68825eb5cbed4716de5cefcd16c48581b737 Mon Sep 17 00:00:00 2001 From: habibayman Date: Fri, 1 Aug 2025 09:34:32 +0300 Subject: [PATCH 10/14] fix(markdown srializer): add depth to handle nested lists --- .../TipTapEditor/utils/markdownSerializer.js | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js index 261a59372e..d859771eb9 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js @@ -44,7 +44,7 @@ export const createCustomMarkdownSerializer = editor => { return text; }; - const serializeNode = (node, listNumber = null) => { + const serializeNode = (node, listNumber = null, depth = 0) => { if (!node || !node.type) { return; } @@ -57,7 +57,7 @@ export const createCustomMarkdownSerializer = editor => { const child = node.content.content[i]; if (child) { if (i > 0) result += '\n\n'; - serializeNode(child); + serializeNode(child, null, depth); } } } @@ -68,7 +68,7 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child); + serializeNode(child, null, depth); } } } @@ -81,7 +81,7 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child); + serializeNode(child, null, depth); } } } @@ -106,7 +106,7 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child); + serializeNode(child, null, depth); } } } @@ -117,7 +117,7 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child, 'bullet'); + serializeNode(child, 'bullet', depth); if (i < node.content.size - 1) result += '\n'; } } @@ -127,22 +127,27 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child, i + 1); + serializeNode(child, i + 1, depth); if (i < node.content.size - 1) result += '\n'; } } break; - case 'listItem': + case 'listItem': { + // Add indentation for nested lists + const indent = ' '.repeat(depth); + // Use the passed listNumber parameter if (listNumber === 'bullet') { - result += '- '; + result += indent + '- '; } else if (typeof listNumber === 'number') { - result += `${listNumber}. `; + 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) { @@ -152,17 +157,25 @@ export const createCustomMarkdownSerializer = editor => { for (let j = 0; j < child.content.size; j++) { const grandchild = child.content.content[j]; if (grandchild) { - serializeNode(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); + serializeNode(child, null, depth); } } } } break; + } case 'blockquote': result += '> '; @@ -170,7 +183,7 @@ export const createCustomMarkdownSerializer = editor => { for (let i = 0; i < node.content.size; i++) { const child = node.content.content[i]; if (child) { - serializeNode(child); + serializeNode(child, null, depth); } } } @@ -195,13 +208,13 @@ export const createCustomMarkdownSerializer = editor => { default: // Fallback: try to process children if (node.content) { - node.content.forEach(child => serializeNode(child)); + node.content.forEach(child => serializeNode(child, null, depth)); } break; } }; - serializeNode(doc, true); + serializeNode(doc, null, 0); return result.trim(); }; }; From 3ee55305c8ae537528af1de4b147e23ba5188ddd Mon Sep 17 00:00:00 2001 From: habibayman Date: Fri, 1 Aug 2025 18:10:51 +0300 Subject: [PATCH 11/14] tests(texteditor): write tests for custom markdown serializer --- .../__tests__/markdownSerializer.spec.js | 322 ++++++++++++++++++ 1 file changed, 322 insertions(+) create mode 100644 contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js 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..72f56ebeb0 --- /dev/null +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js @@ -0,0 +1,322 @@ +// 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)'); + }); + }); + + // 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); + }); + }); +}); From 48a0bc4e8467be664a6c74ef3ec30747e5faeaa3 Mon Sep 17 00:00:00 2001 From: habibayman Date: Fri, 1 Aug 2025 18:42:48 +0300 Subject: [PATCH 12/14] refactor(texteditor)[image]: use lodash debounce --- .../components/image/ImageNodeView.vue | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) 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 b2e78c3387..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 @@ -67,6 +67,7 @@ import { defineComponent, ref, computed, onUnmounted, onMounted, watch } from 'vue'; import { NodeViewWrapper } from '@tiptap/vue-2'; + import _ from 'lodash'; export default defineComponent({ name: 'ImageNodeView', @@ -80,7 +81,14 @@ const naturalAspectRatio = ref(null); const minWidth = 50; const compactThreshold = 200; - let debounceTimer = null; + + // 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( @@ -209,8 +217,7 @@ width.value = clampedWidth; height.value = calculateProportionalHeight(clampedWidth); - clearTimeout(debounceTimer); - debounceTimer = setTimeout(saveSize, 500); + debouncedSaveSize(); }; const removeImage = () => { @@ -235,7 +242,8 @@ }); onUnmounted(() => { - clearTimeout(debounceTimer); + // Cancel any pending debounced calls + debouncedSaveSize.cancel(); }); return { From c50cd920443627709b6171070889896da9063ec0 Mon Sep 17 00:00:00 2001 From: habibayman Date: Wed, 20 Aug 2025 16:11:57 +0300 Subject: [PATCH 13/14] fix(texteditor)[serializer]: make markdown sticky to text --- .../TipTapEditor/utils/markdownSerializer.js | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js index d859771eb9..3b9239caa2 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdownSerializer.js @@ -10,38 +10,43 @@ export const createCustomMarkdownSerializer = editor => { const serializeMarks = node => { if (!node.marks || node.marks.length === 0) return node.text || ''; - let text = 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': - text = `**${text}**`; + trimmedText = `**${trimmedText}**`; break; case 'italic': - text = `*${text}*`; + trimmedText = `*${trimmedText}*`; break; case 'underline': - text = `${text}`; + trimmedText = `${trimmedText}`; break; case 'strike': - text = `~~${text}~~`; + trimmedText = `~~${trimmedText}~~`; break; case 'code': - text = `\`${text}\``; + trimmedText = `\`${trimmedText}\``; break; case 'link': { const href = mark.attrs.href || ''; - text = `[${text}](${href})`; + trimmedText = `[${trimmedText}](${href})`; break; } case 'superscript': - text = `${text}`; + trimmedText = `${trimmedText}`; break; case 'subscript': - text = `${text}`; + trimmedText = `${trimmedText}`; break; } }); - return text; + return leadingWhitespace + trimmedText + trailingWhitespace; }; const serializeNode = (node, listNumber = null, depth = 0) => { From 59250bf9142130e1a34b257e77deddc5e1d5b04e Mon Sep 17 00:00:00 2001 From: habibayman Date: Wed, 20 Aug 2025 16:12:41 +0300 Subject: [PATCH 14/14] test(texteditor)[serializer]: test markdown stickiness to text --- .../__tests__/markdownSerializer.spec.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js index 72f56ebeb0..6c5354639e 100644 --- a/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js +++ b/contentcuration/contentcuration/frontend/shared/views/TipTapEditor/__tests__/markdownSerializer.spec.js @@ -265,6 +265,27 @@ describe('createCustomMarkdownSerializer', () => { 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