Commit 579b91fe authored by Himanshu Kapoor's avatar Himanshu Kapoor Committed by Kushal Pandya

Retain bullet style after editing a page with lists in content editor

parent 6f9481a6
export { BulletList as default } from '@tiptap/extension-bullet-list'; import { BulletList } from '@tiptap/extension-bullet-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default BulletList.extend({
addAttributes() {
return {
...this.parent?.(),
bullet: {
default: '*',
parseHTML(element) {
const bullet = getMarkdownSource(element)?.charAt(0);
return { bullet: '*+-'.includes(bullet) ? bullet : '*' };
},
},
};
},
});
...@@ -161,8 +161,6 @@ const defaultSerializerConfig = { ...@@ -161,8 +161,6 @@ const defaultSerializerConfig = {
}, },
}; };
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
/** /**
* A markdown serializer converts arbitrary Markdown content * A markdown serializer converts arbitrary Markdown content
* into a ProseMirror document and viceversa. To convert Markdown * into a ProseMirror document and viceversa. To convert Markdown
...@@ -187,15 +185,15 @@ export default ({ render = () => null, serializerConfig }) => ({ ...@@ -187,15 +185,15 @@ export default ({ render = () => null, serializerConfig }) => ({
deserialize: async ({ schema, content }) => { deserialize: async ({ schema, content }) => {
const html = await render(content); const html = await render(content);
if (!html) { if (!html) return null;
return null;
}
const parser = new DOMParser(); const parser = new DOMParser();
const { const { body } = parser.parseFromString(html, 'text/html');
body: { firstElementChild },
} = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); // append original source as a comment that nodes can access
const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); body.append(document.createComment(content));
const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
return state.toJSON(); return state.toJSON();
}, },
......
const getFullSource = (element) => {
const commentNode = element.ownerDocument.body.lastChild;
if (commentNode.nodeName === '#comment') {
return commentNode.textContent.split('\n');
}
return [];
};
const getRangeFromSourcePos = (sourcePos) => {
const [start, end] = sourcePos.split('-');
const [startRow, startCol] = start.split(':');
const [endRow, endCol] = end.split(':');
return {
start: { row: Number(startRow) - 1, col: Number(startCol) - 1 },
end: { row: Number(endRow) - 1, col: Number(endCol) - 1 },
};
};
export const getMarkdownSource = (element) => {
if (!element.dataset.sourcepos) return undefined;
const source = getFullSource(element);
const range = getRangeFromSourcePos(element.dataset.sourcepos);
let elSource = '';
for (let i = range.start.row; i <= range.end.row; i += 1) {
if (i === range.start.row) {
elSource += source[i]?.substring(range.start.col);
} else if (i === range.end.row) {
elSource += `\n${source[i]?.substring(0, range.start.col)}`;
} else {
elSource += `\n${source[i]}` || '';
}
}
return elSource.trim();
};
...@@ -76,9 +76,7 @@ describe('content_editor/extensions/attachment', () => { ...@@ -76,9 +76,7 @@ describe('content_editor/extensions/attachment', () => {
const base64EncodedFile = 'data:image/png;base64,Zm9v'; const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => { beforeEach(() => {
renderMarkdown.mockResolvedValue( renderMarkdown.mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image'));
loadMarkdownApiResult('project_wiki_attachment_image').body,
);
}); });
describe('when uploading succeeds', () => { describe('when uploading succeeds', () => {
...@@ -153,7 +151,7 @@ describe('content_editor/extensions/attachment', () => { ...@@ -153,7 +151,7 @@ describe('content_editor/extensions/attachment', () => {
}); });
describe('when the file has a zip (or any other attachment) mime type', () => { describe('when the file has a zip (or any other attachment) mime type', () => {
const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link').body; const markdownApiResult = loadMarkdownApiResult('project_wiki_attachment_link');
beforeEach(() => { beforeEach(() => {
renderMarkdown.mockResolvedValue(markdownApiResult); renderMarkdown.mockResolvedValue(markdownApiResult);
......
...@@ -11,10 +11,8 @@ describe('content_editor/extensions/code_block_highlight', () => { ...@@ -11,10 +11,8 @@ describe('content_editor/extensions/code_block_highlight', () => {
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre'); const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => { beforeEach(() => {
const { html } = loadMarkdownApiResult('code_block');
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] }); tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
codeBlockHtmlFixture = html; codeBlockHtmlFixture = loadMarkdownApiResult('code_block');
parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture); parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture);
tiptapEditor.commands.setContent(codeBlockHtmlFixture); tiptapEditor.commands.setContent(codeBlockHtmlFixture);
......
...@@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures'; ...@@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures';
export const loadMarkdownApiResult = (testName) => { export const loadMarkdownApiResult = (testName) => {
const fixturePathPrefix = `api/markdown/${testName}.json`; const fixturePathPrefix = `api/markdown/${testName}.json`;
return getJSONFixture(fixturePathPrefix); const fixture = getJSONFixture(fixturePathPrefix);
return fixture.body || fixture.html;
}; };
export const loadMarkdownApiExamples = () => { export const loadMarkdownApiExamples = () => {
...@@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => { ...@@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => {
return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]); return apiMarkdownExampleObjects.map(({ name, context, markdown }) => [name, context, markdown]);
}; };
export const loadMarkdownApiExample = (testName) => {
return loadMarkdownApiExamples().find(([name, context]) => {
return (context ? `${context}_${name}` : name) === testName;
})[2];
};
...@@ -9,8 +9,9 @@ describe('markdown processing', () => { ...@@ -9,8 +9,9 @@ describe('markdown processing', () => {
'correctly handles %s (context: %s)', 'correctly handles %s (context: %s)',
async (name, context, markdown) => { async (name, context, markdown) => {
const testName = context ? `${context}_${name}` : name; const testName = context ? `${context}_${name}` : name;
const { html, body } = loadMarkdownApiResult(testName); const contentEditor = createContentEditor({
const contentEditor = createContentEditor({ renderMarkdown: () => html || body }); renderMarkdown: () => loadMarkdownApiResult(testName),
});
await contentEditor.setSerializedContent(markdown); await contentEditor.setSerializedContent(markdown);
expect(contentEditor.getSerializedContent()).toBe(markdown); expect(contentEditor.getSerializedContent()).toBe(markdown);
......
import { Extension } from '@tiptap/core';
import BulletList from '~/content_editor/extensions/bullet_list';
import ListItem from '~/content_editor/extensions/list_item';
import Paragraph from '~/content_editor/extensions/paragraph';
import markdownSerializer from '~/content_editor/services/markdown_serializer';
import { getMarkdownSource } from '~/content_editor/services/markdown_sourcemap';
import { loadMarkdownApiResult, loadMarkdownApiExample } from '../markdown_processing_examples';
import { createTestEditor, createDocBuilder } from '../test_utils';
const SourcemapExtension = Extension.create({
// lets add `source` attribute to every element using `getMarkdownSource`
addGlobalAttributes() {
return [
{
types: [Paragraph.name, BulletList.name, ListItem.name],
attributes: {
source: {
parseHTML: (element) => {
const source = getMarkdownSource(element);
if (source) return { source };
return {};
},
},
},
},
];
},
});
const tiptapEditor = createTestEditor({
extensions: [BulletList, ListItem, SourcemapExtension],
});
const {
builders: { doc, bulletList, listItem, paragraph },
} = createDocBuilder({
tiptapEditor,
names: {
bulletList: { nodeType: BulletList.name },
listItem: { nodeType: ListItem.name },
},
});
describe('content_editor/services/markdown_sourcemap', () => {
it('gets markdown source for a rendered HTML element', async () => {
const deserialized = await markdownSerializer({
render: () => loadMarkdownApiResult('bullet_list_style_3'),
serializerConfig: {},
}).deserialize({
schema: tiptapEditor.schema,
content: loadMarkdownApiExample('bullet_list_style_3'),
});
const expected = doc(
bulletList(
{ bullet: '+', source: '+ list item 1\n+ list item 2' },
listItem({ source: '+ list item 1' }, paragraph('list item 1')),
listItem(
{ source: '+ list item 2' },
paragraph('list item 2'),
bulletList(
{ bullet: '-', source: '- embedded list item 3' },
listItem({ source: '- embedded list item 3' }, paragraph('embedded list item 3')),
),
),
),
);
expect(deserialized).toEqual(expected.toJSON());
});
});
...@@ -66,11 +66,21 @@ ...@@ -66,11 +66,21 @@
- name: thematic_break - name: thematic_break
markdown: |- markdown: |-
--- ---
- name: bullet_list - name: bullet_list_style_1
markdown: |- markdown: |-
* list item 1 * list item 1
* list item 2 * list item 2
* embedded list item 3 * embedded list item 3
- name: bullet_list_style_2
markdown: |-
- list item 1
- list item 2
* embedded list item 3
- name: bullet_list_style_3
markdown: |-
+ list item 1
+ list item 2
- embedded list item 3
- name: ordered_list - name: ordered_list
markdown: |- markdown: |-
1. list item 1 1. list item 1
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment