Commit 71055917 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Improve serialization of content editor extensions

This MR adds support for a couple of improvements in the existing
extensions and also backfills tests for serialization for all the
extensions.

Support added for:
* Multiline blockquotes and retaining it using source maps
* Maintaining start position of a numeric list
* Maintaining numeric list style: `1. ` vs `1) `
* ^ Same improvement in numeric task lists
* Serializing plain URLs as `<url>` instead of `[url](url)`

Changelog: added
parent a6009287
export { Blockquote as default } from '@tiptap/extension-blockquote';
import { Blockquote } from '@tiptap/extension-blockquote';
import { wrappingInputRule } from 'prosemirror-inputrules';
import { getParents } from '~/lib/utils/dom_utils';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export const multilineInputRegex = /^\s*>>>\s$/gm;
export default Blockquote.extend({
addAttributes() {
return {
...this.parent?.(),
multiline: {
default: false,
parseHTML: (element) => {
const source = getMarkdownSource(element);
const parentsIncludeBlockquote = getParents(element).some(
(p) => p.nodeName.toLowerCase() === 'blockquote',
);
return {
multiline: source && !source.startsWith('>') && !parentsIncludeBlockquote,
};
},
},
};
},
addInputRules() {
return [
...this.parent?.(),
wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })),
];
},
});
export { OrderedList as default } from '@tiptap/extension-ordered-list';
import { OrderedList } from '@tiptap/extension-ordered-list';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default OrderedList.extend({
addAttributes() {
return {
...this.parent?.(),
parens: {
default: false,
parseHTML: (element) => ({
parens: /^[0-9]+\)/.test(getMarkdownSource(element)),
}),
},
};
},
});
import { mergeAttributes } from '@tiptap/core';
import { TaskList } from '@tiptap/extension-task-list';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
import { getMarkdownSource } from '../services/markdown_sourcemap';
export default TaskList.extend({
addAttributes() {
return {
type: {
default: 'ul',
parseHTML: (element) => {
return {
type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul',
};
},
numeric: {
default: false,
parseHTML: (element) => ({
numeric: element.tagName.toLowerCase() === 'ol',
}),
},
start: {
default: 1,
parseHTML: (element) => ({
start: element.hasAttribute('start')
? parseInt(element.getAttribute('start') || '', 10)
: 1,
}),
},
parens: {
default: false,
parseHTML: (element) => ({
parens: /^[0-9]+\)/.test(getMarkdownSource(element)),
}),
},
};
},
......@@ -25,7 +40,7 @@ export default TaskList.extend({
];
},
renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) {
return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) {
return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0];
},
});
......@@ -32,12 +32,14 @@ import TaskItem from '../extensions/task_item';
import TaskList from '../extensions/task_list';
import Text from '../extensions/text';
import {
isPlainURL,
renderHardBreak,
renderTable,
renderTableCell,
renderTableRow,
openTag,
closeTag,
renderOrderedList,
} from './serialization_helpers';
const defaultSerializerConfig = {
......@@ -57,14 +59,15 @@ const defaultSerializerConfig = {
},
},
[Link.name]: {
open() {
return '[';
open(state, mark, parent, index) {
return isPlainURL(mark, parent, index, 1) ? '<' : '[';
},
close(state, mark) {
close(state, mark, parent, index) {
const href = mark.attrs.canonicalSrc || mark.attrs.href;
return `](${state.esc(href)}${
mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''
})`;
return isPlainURL(mark, parent, index, -1)
? '>'
: `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`;
},
},
[Strike.name]: {
......@@ -89,7 +92,18 @@ const defaultSerializerConfig = {
},
nodes: {
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[Blockquote.name]: (state, node) => {
if (node.attrs.multiline) {
state.write('>>>');
state.ensureNewLine();
state.renderContent(node);
state.ensureNewLine();
state.write('>>>');
state.closeBlock(node);
} else {
state.wrapBlock('> ', null, node, () => state.renderContent(node));
}
},
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[CodeBlockHighlight.name]: (state, node) => {
state.write(`\`\`\`${node.attrs.language || ''}\n`);
......@@ -113,7 +127,7 @@ const defaultSerializerConfig = {
state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`);
},
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
[OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
[OrderedList.name]: renderOrderedList,
[Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph,
[Reference.name]: (state, node) => {
state.write(node.attrs.originalText || node.attrs.text);
......@@ -127,8 +141,8 @@ const defaultSerializerConfig = {
state.renderContent(node);
},
[TaskList.name]: (state, node) => {
if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node);
else defaultMarkdownSerializer.nodes.ordered_list(state, node);
if (node.attrs.numeric) renderOrderedList(state, node);
else defaultMarkdownSerializer.nodes.bullet_list(state, node);
},
[Text.name]: defaultMarkdownSerializer.nodes.text,
},
......
......@@ -8,6 +8,22 @@ const defaultAttrs = {
const tableMap = new WeakMap();
// Source taken from
// prosemirror-markdown/src/to_markdown.js
export function isPlainURL(link, parent, index, side) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text !== link.attrs.href ||
content.marks[content.marks.length - 1] !== link
)
return false;
if (index === (side < 0 ? 1 : parent.childCount - 1)) return true;
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
function shouldRenderCellInline(cell) {
if (cell.childCount === 1) {
const parent = cell.child(0);
......@@ -206,6 +222,19 @@ function renderTableRowAsHTML(state, node) {
renderTagClose(state, 'tr');
}
export function renderOrderedList(state, node) {
const { parens } = node.attrs;
const start = node.attrs.start || 1;
const maxW = String(start + node.childCount - 1).length;
const space = state.repeat(' ', maxW + 2);
const delimiter = parens ? ')' : '.';
state.renderList(node, space, (i) => {
const nStr = String(start + i);
return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `;
});
}
export function renderTableCell(state, node) {
if (!isBlockTablesFeatureEnabled()) {
state.renderInline(node);
......
......@@ -77,3 +77,15 @@ export const isElementVisible = (element) =>
* @returns {Boolean} `true` if the element is currently hidden, otherwise false
*/
export const isElementHidden = (element) => !isElementVisible(element);
export const getParents = (element) => {
const parents = [];
let parent = element.parentNode;
do {
parents.push(parent);
parent = parent.parentNode;
} while (parent);
return parents;
};
import { multilineInputRegex } from '~/content_editor/extensions/blockquote';
describe('content_editor/extensions/blockquote', () => {
describe.each`
input | matches
${'>>> '} | ${true}
${' >>> '} | ${true}
${'\t>>> '} | ${true}
${'>> '} | ${false}
${'>>>x '} | ${false}
${'> '} | ${false}
`('multilineInputRegex', ({ input, matches }) => {
it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
const match = new RegExp(multilineInputRegex).test(input);
expect(match).toBe(matches);
});
});
});
......@@ -99,6 +99,11 @@
1. list item 1
2. list item 2
3. list item 3
- name: ordered_list_with_start_order
markdown: |-
134. list item 1
135. list item 2
136. list item 3
- name: task_list
markdown: |-
* [x] hello
......@@ -115,6 +120,11 @@
1. [ ] of nested
1. [x] task list
2. [ ] items
- name: ordered_task_list_with_order
markdown: |-
4893. [x] hello
4894. [x] world
4895. [ ] example
- name: image
markdown: '![alt text](https://gitlab.com/logo.png)'
- name: hard_break
......
......@@ -5,6 +5,7 @@ import {
parseBooleanDataAttributes,
isElementVisible,
isElementHidden,
getParents,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
......@@ -193,4 +194,18 @@ describe('DOM Utils', () => {
});
},
);
describe('getParents', () => {
it('gets all parents of an element', () => {
const el = document.createElement('div');
el.innerHTML = '<p><span><strong><mark>hello world';
expect(getParents(el.querySelector('mark'))).toEqual([
el.querySelector('strong'),
el.querySelector('span'),
el.querySelector('p'),
el,
]);
});
});
});
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