Commit ce0f7f66 authored by Himanshu Kapoor's avatar Himanshu Kapoor

Allow arbitrary html tags in content editor

Allow adding arbitrary html marks and nodes to be added to content
editor. The list of supported nodes is referenced from html-pipeline's
whitelist.

Changelog: added
parent df6b100c
......@@ -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