Commit 9ba51c65 authored by Paul Slaughter's avatar Paul Slaughter

Merge branch '338380-content-editor-html' into 'master'

Allow GFM supported html marks in content editor

See merge request gitlab-org/gitlab!68224
parents bc0a1bb5 ce0f7f66
......@@ -45,3 +45,7 @@ export const TEXT_STYLE_DROPDOWN_ITEMS = [
export const LOADING_CONTENT_EVENT = 'loadingContent';
export const LOADING_SUCCESS_EVENT = 'loadingSuccess';
export const LOADING_ERROR_EVENT = 'loadingError';
export const PARSE_HTML_PRIORITY_LOWEST = 1;
export const PARSE_HTML_PRIORITY_DEFAULT = 50;
export const PARSE_HTML_PRIORITY_HIGHEST = 100;
import { Mark, mergeAttributes, markInputRule } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_LOWEST } from '../constants';
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
const marks = [
'ins',
'abbr',
'bdo',
'cite',
'dfn',
'mark',
'small',
'span',
'time',
'kbd',
'q',
'samp',
'var',
'ruby',
'rp',
'rt',
];
const attrs = {
time: ['datetime'],
abbr: ['title'],
span: ['dir'],
bdo: ['dir'],
};
export default marks.map((name) =>
Mark.create({
name,
inclusive: false,
defaultOptions: {
HTMLAttributes: {},
},
addAttributes() {
return (attrs[name] || []).reduce(
(acc, attr) => ({
...acc,
[attr]: {
default: null,
parseHTML: (element) => ({ [attr]: element.getAttribute(attr) }),
},
}),
{},
);
},
parseHTML() {
return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }];
},
renderHTML({ HTMLAttributes }) {
return [name, mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addInputRules() {
return [markInputRule(markInputRegex(name), this.type, extractMarkAttributesFromMatch)];
},
}),
);
import { Image } from '@tiptap/extension-image';
import { VueNodeViewRenderer } from '@tiptap/vue-2';
import ImageWrapper from '../components/wrappers/image.vue';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const resolveImageEl = (element) =>
element.nodeName === 'IMG' ? element : element.querySelector('img');
......@@ -65,7 +66,7 @@ export default Image.extend({
parseHTML() {
return [
{
priority: 100,
priority: PARSE_HTML_PRIORITY_HIGHEST,
tag: 'a.no-attachment-icon',
},
{
......
import { Node } from '@tiptap/core';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
const getAnchor = (element) => {
if (element.nodeName === 'A') return element;
return element.querySelector('a');
};
export default Node.create({
name: 'reference',
......@@ -15,7 +21,7 @@ export default Node.create({
default: null,
parseHTML: (element) => {
return {
className: element.className,
className: getAnchor(element).className,
};
},
},
......@@ -23,7 +29,7 @@ export default Node.create({
default: null,
parseHTML: (element) => {
return {
referenceType: element.dataset.referenceType,
referenceType: getAnchor(element).dataset.referenceType,
};
},
},
......@@ -31,7 +37,7 @@ export default Node.create({
default: null,
parseHTML: (element) => {
return {
originalText: element.dataset.original,
originalText: getAnchor(element).dataset.original,
};
},
},
......@@ -39,7 +45,7 @@ export default Node.create({
default: null,
parseHTML: (element) => {
return {
href: element.getAttribute('href'),
href: getAnchor(element).getAttribute('href'),
};
},
},
......@@ -47,7 +53,7 @@ export default Node.create({
default: null,
parseHTML: (element) => {
return {
text: element.textContent,
text: getAnchor(element).textContent,
};
},
},
......@@ -58,7 +64,10 @@ export default Node.create({
return [
{
tag: 'a.gfm:not([data-link=true])',
priority: 51,
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
{
tag: 'span.gl-label',
},
];
},
......
export { Subscript as default } from '@tiptap/extension-subscript';
import { markInputRule } from '@tiptap/core';
import { Subscript } from '@tiptap/extension-subscript';
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
export default Subscript.extend({
addInputRules() {
return [markInputRule(markInputRegex('sub'), this.type, extractMarkAttributesFromMatch)];
},
});
export { Superscript as default } from '@tiptap/extension-superscript';
import { markInputRule } from '@tiptap/core';
import { Superscript } from '@tiptap/extension-superscript';
import { markInputRegex, extractMarkAttributesFromMatch } from '../services/mark_utils';
export default Superscript.extend({
addInputRules() {
return [markInputRule(markInputRegex('sup'), this.type, extractMarkAttributesFromMatch)];
},
});
import { TaskItem } from '@tiptap/extension-task-item';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskItem.extend({
defaultOptions: {
......@@ -26,7 +27,7 @@ export default TaskItem.extend({
return [
{
tag: 'li.task-list-item',
priority: 100,
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
......
import { mergeAttributes } from '@tiptap/core';
import { TaskList } from '@tiptap/extension-task-list';
import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants';
export default TaskList.extend({
addAttributes() {
......@@ -19,7 +20,7 @@ export default TaskList.extend({
return [
{
tag: '.task-list',
priority: 100,
priority: PARSE_HTML_PRIORITY_HIGHEST,
},
];
},
......
......@@ -15,6 +15,7 @@ import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import History from '../extensions/history';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
......@@ -75,6 +76,7 @@ export const createContentEditor = ({
Heading,
History,
HorizontalRule,
...HTMLMarks,
Image,
InlineDiff,
Italic,
......
export const markInputRegex = (tag) =>
new RegExp(`(<(${tag})((?: \\w+=".+?")+)?>([^<]+)</${tag}>)$`, 'gm');
export const extractMarkAttributesFromMatch = ([, , , attrsString]) => {
const attrRegex = /(\w+)="(.+?)"/g;
const attrs = {};
let key;
let value;
do {
[, key, value] = attrRegex.exec(attrsString) || [];
if (key) attrs[key] = value;
} while (key);
return attrs;
};
......@@ -12,6 +12,7 @@ import Emoji from '../extensions/emoji';
import HardBreak from '../extensions/hard_break';
import Heading from '../extensions/heading';
import HorizontalRule from '../extensions/horizontal_rule';
import HTMLMarks from '../extensions/html_marks';
import Image from '../extensions/image';
import InlineDiff from '../extensions/inline_diff';
import Italic from '../extensions/italic';
......@@ -35,6 +36,8 @@ import {
renderTable,
renderTableCell,
renderTableRow,
openTag,
closeTag,
} from './serialization_helpers';
const defaultSerializerConfig = {
......@@ -70,6 +73,19 @@ const defaultSerializerConfig = {
mixable: true,
expelEnclosingWhitespace: true,
},
...HTMLMarks.reduce(
(acc, { name }) => ({
...acc,
[name]: {
mixable: true,
open(state, node) {
return openTag(name, node.attrs);
},
close: closeTag(name),
},
}),
{},
),
},
nodes: {
......
......@@ -80,21 +80,30 @@ function shouldRenderHTMLTable(table) {
return true;
}
function openTag(state, tagName, attrs) {
function htmlEncode(str = '') {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/'/g, '&#39;')
.replace(/"/g, '&#34;');
}
export function openTag(tagName, attrs) {
let str = `<${tagName}`;
str += Object.entries(attrs || {})
.map(([key, value]) => {
if (defaultAttrs[tagName]?.[key] === value) return '';
return ` ${key}=${state.quote(value?.toString() || '')}`;
return ` ${key}="${htmlEncode(value?.toString())}"`;
})
.join('');
return `${str}>`;
}
function closeTag(state, tagName) {
export function closeTag(tagName) {
return `</${tagName}>`;
}
......@@ -131,11 +140,11 @@ function unsetIsInBlockTable(table) {
function renderTagOpen(state, tagName, attrs) {
state.ensureNewLine();
state.write(openTag(state, tagName, attrs));
state.write(openTag(tagName, attrs));
}
function renderTagClose(state, tagName, insertNewline = true) {
state.write(closeTag(state, tagName));
state.write(closeTag(tagName));
if (insertNewline) state.ensureNewLine();
}
......
import {
markInputRegex,
extractMarkAttributesFromMatch,
} from '~/content_editor/services/mark_utils';
describe('content_editor/services/mark_utils', () => {
describe.each`
tag | input | matches
${'tag'} | ${'<tag>hello</tag>'} | ${true}
${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${true}
${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${true}
${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${true}
${'tag'} | ${'<tag width=30 height=30>attrs not quoted</tag>'} | ${false}
${'tag'} | ${"<tag title='abc'>single quote attrs not supported</tag>"} | ${false}
${'tag'} | ${'<tag title>attr has no value</tag>'} | ${false}
${'tag'} | ${'<tag>tag opened but not closed'} | ${false}
${'tag'} | ${'</tag>tag closed before opened<tag>'} | ${false}
`('inputRegex("$tag")', ({ tag, input, matches }) => {
it(`${matches ? 'matches' : 'does not match'}: "${input}"`, () => {
const match = markInputRegex(tag).test(input);
expect(match).toBe(matches);
});
});
describe.each`
tag | input | attrs
${'kbd'} | ${'Hold <kbd>Ctrl</kbd>'} | ${{}}
${'tag'} | ${'<tag title="tooltip">hello</tag>'} | ${{ title: 'tooltip' }}
${'time'} | ${'Lets meet at <time title="today" datetime="20:00">20:00</time>'} | ${{ title: 'today', datetime: '20:00' }}
${'abbr'} | ${'Sure, you can try it out but <abbr title="Your mileage may vary">YMMV</abbr>'} | ${{ title: 'Your mileage may vary' }}
`('extractAttributesFromMatch(inputRegex("$tag").exec(\'$input\'))', ({ tag, input, attrs }) => {
it(`returns: "${JSON.stringify(attrs)}"`, () => {
const matches = markInputRegex(tag).exec(input);
expect(extractMarkAttributesFromMatch(matches)).toEqual(attrs);
});
});
});
......@@ -12,14 +12,27 @@
markdown: |-
* {-deleted-}
* {+added+}
- name: subscript
markdown: H<sub>2</sub>O
- name: superscript
markdown: 2<sup>8</sup> = 256
- name: strike
markdown: '~~del~~'
- name: horizontal_rule
markdown: '---'
- name: html_marks
markdown: |-
* Content editor is ~~great~~<ins>amazing</ins>.
* If the changes <abbr title="Looks good to merge">LGTM</abbr>, please <abbr title="Merge when pipeline succeeds">MWPS</abbr>.
* The English song <q>Oh I do like to be beside the seaside</q> looks like this in Hebrew: <span dir="rtl">אה, אני אוהב להיות ליד חוף הים</span>. In the computer's memory, this is stored as <bdo dir="ltr">אה, אני אוהב להיות ליד חוף הים</bdo>.
* <cite>The Scream</cite> by Edvard Munch. Painted in 1893.
* <dfn>HTML</dfn> is the standard markup language for creating web pages.
* Do not forget to buy <mark>milk</mark> today.
* This is a paragraph and <small>smaller text goes here</small>.
* The concert starts at <time datetime="20:00">20:00</time> and you'll be able to enjoy the band for at least <time datetime="PT2H30M">2h 30m</time>.
* Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to copy text (Windows).
* WWF's goal is to: <q>Build a future where people live in harmony with nature.</q> We hope they succeed.
* The error occured was: <samp>Keyboard not found. Press F1 to continue.</samp>
* The area of a triangle is: 1/2 x <var>b</var> x <var>h</var>, where <var>b</var> is the base, and <var>h</var> is the vertical height.
* <ruby>漢<rt>ㄏㄢˋ</rt></ruby>
* C<sub>7</sub>H<sub>16</sub> + O<sub>2</sub> → CO<sub>2</sub> + H<sub>2</sub>O
* The **Pythagorean theorem** is often expressed as <var>a<sup>2</sup></var> + <var>b<sup>2</sup></var> = <var>c<sup>2</sup></var>
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: attachment_link
......
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