Skip to content

Commit d046056

Browse files
committed
refactor(texteditor): create custom markdown serializer
1 parent 52a1d8d commit d046056

6 files changed

Lines changed: 248 additions & 73 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/DevHarness.vue

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,7 @@
66
<p>This page simulates a parent component to test the editor in isolation.</p>
77
</header>
88
<hr >
9-
<TipTapEditor
10-
v-model="markdownContent"
11-
:mode="mode"
12-
/>
9+
<TipTapEditor v-model="markdownContent" />
1310
<hr >
1411
<div class="raw-output-wrapper">
1512
<h2>Live Markdown Output (v-model state)</h2>
@@ -29,7 +26,7 @@
2926
3027
**bold** *italic* <u>underline</u> ~~strikethrough~~
3128
32-
try inline formulas<span data-latex="x^2"></span> test
29+
try inline formulas $$x^2$$ test
3330
3431
- list a
3532
- list b

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/TipTapEditor.vue

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
import LinkEditor from './components/link/LinkEditor.vue';
8686
import { useMathHandling } from './composables/useMathHandling';
8787
import FormulasMenu from './components/math/FormulasMenu.vue';
88+
import { preprocessMarkdown } from './utils/markdown';
8889
8990
export default defineComponent({
9091
name: 'RichTextEditor',
@@ -133,33 +134,23 @@
133134
return editor.value.storage.markdown.getMarkdown();
134135
};
135136
136-
const setMarkdownContent = (content, emitUpdate = false) => {
137-
if (!editor.value || !isReady.value || !editor.value.storage?.markdown) {
138-
return;
139-
}
140-
editor.value.storage.markdown.setMarkdown(content, emitUpdate);
141-
};
142-
143137
let isUpdatingFromOutside = false; // A flag to prevent infinite update loops
144138
145139
// sync changes from the parent component to the editor
146140
watch(
147141
() => props.value,
148142
newValue => {
149-
if (!editor.value) {
150-
initializeEditor(newValue);
151-
return;
152-
}
143+
const processedContent = preprocessMarkdown(newValue);
153144
154-
if (!isReady.value || !editor.value.storage?.markdown) {
145+
if (!editor.value) {
146+
initializeEditor(processedContent);
155147
return;
156148
}
157149
158150
const editorContent = getMarkdownContent();
159-
160151
if (editorContent !== newValue) {
161152
isUpdatingFromOutside = true;
162-
setMarkdownContent(newValue, false);
153+
editor.value.commands.setContent(processedContent, false);
163154
nextTick(() => {
164155
isUpdatingFromOutside = false;
165156
});
@@ -182,7 +173,6 @@
182173
}
183174
184175
const markdown = getMarkdownContent();
185-
186176
if (markdown !== props.value) {
187177
emit('input', markdown);
188178
}

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/composables/useEditor.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { CodeBlockSyntaxHighlight } from '../extensions/CodeBlockSyntaxHighlight
1010
import { CustomLink } from '../extensions/Link';
1111
import { Math } from '../extensions/Math';
1212
import { Markdown } from '../extensions/Markdown';
13+
import { createCustomMarkdownSerializer } from '../utils/markdownSerializer';
1314

1415
export function useEditor() {
1516
const editor = ref(null);
@@ -41,6 +42,11 @@ export function useEditor() {
4142
},
4243
onCreate: () => {
4344
isReady.value = true;
45+
46+
const markdownStorage = editor.value.storage.markdown;
47+
if (markdownStorage) {
48+
markdownStorage.getMarkdown = createCustomMarkdownSerializer(editor.value);
49+
}
4450
},
4551
});
4652
};
Lines changed: 1 addition & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import { Markdown as TiptapMarkdown } from 'tiptap-markdown';
2-
import {
3-
IMAGE_REGEX,
4-
imageMdToParams,
5-
paramsToImageMd,
6-
MATH_REGEX,
7-
mathMdToParams,
8-
paramsToMathMd,
9-
} from '../utils/markdown';
102

3+
// Minimal configuration - we handle preprocessing manually via preprocessMarkdown()
114
export const Markdown = TiptapMarkdown.configure({
125
html: true,
136
bulletList: {
@@ -16,49 +9,4 @@ export const Markdown = TiptapMarkdown.configure({
169
orderedList: {
1710
tight: true,
1811
},
19-
// --- LOADING CONFIGURATION ---
20-
// This hook pre-processes the raw Markdown string before parsing.
21-
transformMarkdown: markdown => {
22-
let processedMarkdown = markdown;
23-
24-
// Replace custom images with standard <img> tags
25-
processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => {
26-
const params = imageMdToParams(match);
27-
if (!params) return match;
28-
return `<img src="${params.src}" alt="${params.alt}" width="${params.width}" height="${params.height}" />`;
29-
});
30-
31-
// Replace $$...$$ with a custom <span> tag for our Math extension
32-
processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => {
33-
const params = mathMdToParams(match);
34-
if (!params) return match;
35-
return `<span data-latex="${params.latex}"></span>`;
36-
});
37-
38-
return processedMarkdown;
39-
},
40-
41-
// --- SAVING CONFIGURATION ---
42-
// These rules override the default serializers for specific nodes and marks.
43-
toMarkdown: {
44-
// --- Custom Node Rules ---
45-
image(state, node) {
46-
state.write(paramsToImageMd(node.attrs));
47-
},
48-
math(state, node) {
49-
state.write(paramsToMathMd(node.attrs));
50-
},
51-
small(state, node) {
52-
state.write('<small>');
53-
state.renderContent(node);
54-
state.write('</small>');
55-
state.closeBlock(node);
56-
},
57-
// --- Custom Mark Rules ---
58-
underline: {
59-
open: '<u>',
60-
close: '</u>',
61-
mixable: true,
62-
},
63-
},
6412
});

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/utils/markdown.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,30 @@ export const mathMdToParams = markdown => {
4444
export const paramsToMathMd = ({ latex }) => {
4545
return `$$${latex || ''}$$`;
4646
};
47+
48+
/**
49+
* Pre-processes a raw Markdown string to convert custom syntax into HTML tags
50+
* that Tiptap's extensions can understand. This is our custom "loader".
51+
* @param {string} markdown - The raw markdown string.
52+
* @returns {string} - The processed string with HTML tags.
53+
*/
54+
export function preprocessMarkdown(markdown) {
55+
if (!markdown) return '';
56+
let processedMarkdown = markdown;
57+
58+
// Replace custom images with standard <img> tags
59+
processedMarkdown = processedMarkdown.replace(IMAGE_REGEX, match => {
60+
const params = imageMdToParams(match);
61+
if (!params) return match;
62+
return `<img src="${params.src}" alt="${params.alt}" width="${params.width}" height="${params.height}" />`;
63+
});
64+
65+
// Replace $$...$$ with a custom <span> tag for our Math extension
66+
processedMarkdown = processedMarkdown.replace(MATH_REGEX, match => {
67+
const params = mathMdToParams(match);
68+
if (!params) return match;
69+
return `<span data-latex="${params.latex}"></span>`;
70+
});
71+
72+
return processedMarkdown;
73+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
// Custom markdown serialization that handles Math nodes properly
2+
import { paramsToMathMd, paramsToImageMd } from './markdown';
3+
4+
export const createCustomMarkdownSerializer = editor => {
5+
return function getMarkdown() {
6+
const doc = editor.state.doc;
7+
let result = '';
8+
9+
// Handle marks (bold, italic, etc.)
10+
const serializeMarks = node => {
11+
if (!node.marks || node.marks.length === 0) return node.text || '';
12+
13+
let text = node.text || '';
14+
node.marks.forEach(mark => {
15+
switch (mark.type.name) {
16+
case 'bold':
17+
text = `**${text}**`;
18+
break;
19+
case 'italic':
20+
text = `*${text}*`;
21+
break;
22+
case 'underline':
23+
text = `<u>${text}</u>`;
24+
break;
25+
case 'strike':
26+
text = `~~${text}~~`;
27+
break;
28+
case 'code':
29+
text = `\`${text}\``;
30+
break;
31+
case 'link': {
32+
const href = mark.attrs.href || '';
33+
text = `[${text}](${href})`;
34+
break;
35+
}
36+
case 'superscript':
37+
text = `<sup>${text}</sup>`;
38+
break;
39+
case 'subscript':
40+
text = `<sub>${text}</sub>`;
41+
break;
42+
}
43+
});
44+
return text;
45+
};
46+
47+
const serializeNode = (node, listNumber = null) => {
48+
if (!node || !node.type) {
49+
return;
50+
}
51+
52+
switch (node.type.name) {
53+
case 'doc':
54+
// Process all children
55+
if (node.content && node.content.size > 0) {
56+
for (let i = 0; i < node.content.size; i++) {
57+
const child = node.content.content[i];
58+
if (child) {
59+
if (i > 0) result += '\n\n';
60+
serializeNode(child);
61+
}
62+
}
63+
}
64+
break;
65+
66+
case 'paragraph':
67+
if (node.content && node.content.size > 0) {
68+
for (let i = 0; i < node.content.size; i++) {
69+
const child = node.content.content[i];
70+
if (child) {
71+
serializeNode(child);
72+
}
73+
}
74+
}
75+
break;
76+
77+
case 'heading': {
78+
const level = node.attrs.level || 1;
79+
result += '#'.repeat(level) + ' ';
80+
if (node.content && node.content.size > 0) {
81+
for (let i = 0; i < node.content.size; i++) {
82+
const child = node.content.content[i];
83+
if (child) {
84+
serializeNode(child);
85+
}
86+
}
87+
}
88+
break;
89+
}
90+
91+
case 'text':
92+
result += serializeMarks(node);
93+
break;
94+
95+
case 'math':
96+
result += paramsToMathMd(node.attrs);
97+
break;
98+
99+
case 'image':
100+
result += paramsToImageMd(node.attrs);
101+
break;
102+
103+
case 'small':
104+
result += '<small>';
105+
if (node.content && node.content.size > 0) {
106+
for (let i = 0; i < node.content.size; i++) {
107+
const child = node.content.content[i];
108+
if (child) {
109+
serializeNode(child);
110+
}
111+
}
112+
}
113+
result += '</small>';
114+
break;
115+
116+
case 'bulletList':
117+
for (let i = 0; i < node.content.size; i++) {
118+
const child = node.content.content[i];
119+
if (child) {
120+
serializeNode(child, 'bullet');
121+
if (i < node.content.size - 1) result += '\n';
122+
}
123+
}
124+
break;
125+
126+
case 'orderedList':
127+
for (let i = 0; i < node.content.size; i++) {
128+
const child = node.content.content[i];
129+
if (child) {
130+
serializeNode(child, i + 1);
131+
if (i < node.content.size - 1) result += '\n';
132+
}
133+
}
134+
break;
135+
136+
case 'listItem':
137+
// Use the passed listNumber parameter
138+
if (listNumber === 'bullet') {
139+
result += '- ';
140+
} else if (typeof listNumber === 'number') {
141+
result += `${listNumber}. `;
142+
}
143+
144+
// Process list item content properly
145+
if (node.content && node.content.size > 0) {
146+
for (let i = 0; i < node.content.size; i++) {
147+
const child = node.content.content[i];
148+
if (child && child.type) {
149+
if (child.type.name === 'paragraph') {
150+
// For paragraphs in list items, process their content directly
151+
if (child.content && child.content.size > 0) {
152+
for (let j = 0; j < child.content.size; j++) {
153+
const grandchild = child.content.content[j];
154+
if (grandchild) {
155+
serializeNode(grandchild);
156+
}
157+
}
158+
}
159+
} else {
160+
serializeNode(child);
161+
}
162+
}
163+
}
164+
}
165+
break;
166+
167+
case 'blockquote':
168+
result += '> ';
169+
if (node.content && node.content.size > 0) {
170+
for (let i = 0; i < node.content.size; i++) {
171+
const child = node.content.content[i];
172+
if (child) {
173+
serializeNode(child);
174+
}
175+
}
176+
}
177+
break;
178+
179+
case 'codeBlock': {
180+
const language = node.attrs.language || '';
181+
result += '```' + language + '\n';
182+
result += node.textContent;
183+
result += '\n```';
184+
break;
185+
}
186+
187+
case 'hardBreak':
188+
result += ' \n';
189+
break;
190+
191+
case 'horizontalRule':
192+
result += '---';
193+
break;
194+
195+
default:
196+
// Fallback: try to process children
197+
if (node.content) {
198+
node.content.forEach(child => serializeNode(child));
199+
}
200+
break;
201+
}
202+
};
203+
204+
serializeNode(doc, true);
205+
return result.trim();
206+
};
207+
};

0 commit comments

Comments
 (0)