Commit aa8b8b0d authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'himkp-content-editor-sourcemap' into 'master'

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

See merge request gitlab-org/gitlab!68481
parents 84c13147 579b91fe
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 : '*' };
},
},
};
},
});
......@@ -118,8 +118,6 @@ const defaultSerializerConfig = {
},
};
const wrapHtmlPayload = (payload) => `<div>${payload}</div>`;
/**
* A markdown serializer converts arbitrary Markdown content
* into a ProseMirror document and viceversa. To convert Markdown
......@@ -144,15 +142,15 @@ export default ({ render = () => null, serializerConfig = {} } = {}) => ({
deserialize: async ({ schema, content }) => {
const html = await render(content);
if (!html) {
return null;
}
if (!html) return null;
const parser = new DOMParser();
const {
body: { firstElementChild },
} = parser.parseFromString(wrapHtmlPayload(html), 'text/html');
const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild);
const { body } = parser.parseFromString(html, 'text/html');
// append original source as a comment that nodes can access
body.append(document.createComment(content));
const state = ProseMirrorDOMParser.fromSchema(schema).parse(body);
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', () => {
const base64EncodedFile = 'data:image/png;base64,Zm9v';
beforeEach(() => {
renderMarkdown.mockResolvedValue(
loadMarkdownApiResult('project_wiki_attachment_image').body,
);
renderMarkdown.mockResolvedValue(loadMarkdownApiResult('project_wiki_attachment_image'));
});
describe('when uploading succeeds', () => {
......@@ -153,7 +151,7 @@ describe('content_editor/extensions/attachment', () => {
});
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(() => {
renderMarkdown.mockResolvedValue(markdownApiResult);
......
......@@ -11,10 +11,8 @@ describe('content_editor/extensions/code_block_highlight', () => {
const preElement = () => parsedCodeBlockHtmlFixture.querySelector('pre');
beforeEach(() => {
const { html } = loadMarkdownApiResult('code_block');
tiptapEditor = createTestEditor({ extensions: [CodeBlockHighlight] });
codeBlockHtmlFixture = html;
codeBlockHtmlFixture = loadMarkdownApiResult('code_block');
parsedCodeBlockHtmlFixture = parseHTML(codeBlockHtmlFixture);
tiptapEditor.commands.setContent(codeBlockHtmlFixture);
......
......@@ -6,7 +6,8 @@ import { getJSONFixture } from 'helpers/fixtures';
export const loadMarkdownApiResult = (testName) => {
const fixturePathPrefix = `api/markdown/${testName}.json`;
return getJSONFixture(fixturePathPrefix);
const fixture = getJSONFixture(fixturePathPrefix);
return fixture.body || fixture.html;
};
export const loadMarkdownApiExamples = () => {
......@@ -16,3 +17,9 @@ export const loadMarkdownApiExamples = () => {
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', () => {
'correctly handles %s (context: %s)',
async (name, context, markdown) => {
const testName = context ? `${context}_${name}` : name;
const { html, body } = loadMarkdownApiResult(testName);
const contentEditor = createContentEditor({ renderMarkdown: () => html || body });
const contentEditor = createContentEditor({
renderMarkdown: () => loadMarkdownApiResult(testName),
});
await contentEditor.setSerializedContent(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 @@
- name: thematic_break
markdown: |-
---
- name: bullet_list
- name: bullet_list_style_1
markdown: |-
* list item 1
* list item 2
* 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
markdown: |-
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