diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index f277508f628a849c770fedb7d9b82617c21ab7fa..4af9dc8e40559628134f32f73b4491f078d2ef57 100644 --- a/app/assets/javascripts/content_editor/constants.js +++ b/app/assets/javascripts/content_editor/constants.js @@ -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; diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js new file mode 100644 index 0000000000000000000000000000000000000000..e312775e75c8137bf422b96d14c69513aac5d941 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -0,0 +1,66 @@ +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)]; + }, + }), +); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 81b974af91419246c1dbe295dd896e3aaf1e3ae9..afd23b55a63079150030ad35ef7dfb04b2b173e0 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,6 +1,7 @@ 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', }, { diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5f4484af9c8be3d0e228b438099748dd2eb35dce..fa5429ae0f48d104501eaab4741e6c5b337f46a7 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -1,4 +1,10 @@ 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', }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/subscript.js b/app/assets/javascripts/content_editor/extensions/subscript.js index 4bf89796efef42f7fded2f818bf1efbd33dc047d..d0766f42308f4d090a28731b56b959637fd0ee8d 100644 --- a/app/assets/javascripts/content_editor/extensions/subscript.js +++ b/app/assets/javascripts/content_editor/extensions/subscript.js @@ -1 +1,9 @@ -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)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/superscript.js b/app/assets/javascripts/content_editor/extensions/superscript.js index 3eb7d86d90d328ece785cb283a301d0b8d3efb30..6cd814977ea9f36baf62187668eb77b67f71f940 100644 --- a/app/assets/javascripts/content_editor/extensions/superscript.js +++ b/app/assets/javascripts/content_editor/extensions/superscript.js @@ -1 +1,9 @@ -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)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 6163c0e043ba46b32d5629a274c04b2d2b5ed6d4..d586478358d0d4ea1c9d6c16b55cfa36e57ee1b2 100644 --- a/app/assets/javascripts/content_editor/extensions/task_item.js +++ b/app/assets/javascripts/content_editor/extensions/task_item.js @@ -1,4 +1,5 @@ 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, }, ]; }, diff --git a/app/assets/javascripts/content_editor/extensions/task_list.js b/app/assets/javascripts/content_editor/extensions/task_list.js index b7f6c857bc7a9153e56a3fbcdd2d055096ffdfff..72806c944fbc4ff8edaa1cdea47789e16096a3e7 100644 --- a/app/assets/javascripts/content_editor/extensions/task_list.js +++ b/app/assets/javascripts/content_editor/extensions/task_list.js @@ -1,5 +1,6 @@ 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, }, ]; }, diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js index 8997960203acd0da27af2413b19e198af7738487..67d5a00b6c620e161d06f116449b8362ffa7472d 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -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, diff --git a/app/assets/javascripts/content_editor/services/mark_utils.js b/app/assets/javascripts/content_editor/services/mark_utils.js new file mode 100644 index 0000000000000000000000000000000000000000..6ccfed7810a1bc8eb42b969b017c5edcf9d08e47 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/mark_utils.js @@ -0,0 +1,17 @@ +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; +}; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 73d14f93e79b694f485ab616a9876480a8602328..d1f7e88b1db4bd99887349e14b8e9ccfb1ddee1c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -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: { diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 909ab3dbd6801ad07852815172c6a0877e2c45fb..e1d0388227b38d19b29f47d454efe3893ed6a555 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -80,21 +80,30 @@ function shouldRenderHTMLTable(table) { return true; } -function openTag(state, tagName, attrs) { +function htmlEncode(str = '') { + return str + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/'/g, ''') + .replace(/"/g, '"'); +} + +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(); } diff --git a/spec/frontend/content_editor/services/mark_utils_spec.js b/spec/frontend/content_editor/services/mark_utils_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..bbfb8f26f99c0b98c7b8df0c03fdabcc280a87af --- /dev/null +++ b/spec/frontend/content_editor/services/mark_utils_spec.js @@ -0,0 +1,38 @@ +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); + }); + }); +}); diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml index 6924423eecc6c7b016ab84fc63c6c743e3319c10..ff9da33c1168f49f1dee536f53f998cd27b26c1d 100644 --- a/spec/frontend/fixtures/api_markdown.yml +++ b/spec/frontend/fixtures/api_markdown.yml @@ -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