diff options
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/rich_content_editor/services')
11 files changed, 369 insertions, 0 deletions
diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js new file mode 100644 index 00000000000..70d29b5b3df --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -0,0 +1,68 @@ +import renderBlockHtml from './renderers/render_html_block'; +import renderKramdownList from './renderers/render_kramdown_list'; +import renderKramdownText from './renderers/render_kramdown_text'; +import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; +import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; +import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text'; +import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; + +const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; +const htmlBlockRenderers = [renderBlockHtml]; +const listRenderers = [renderKramdownList]; +const paragraphRenderers = [renderIdentifierParagraph]; +const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText]; + +const executeRenderer = (renderers, node, context) => { + const availableRenderer = renderers.find(renderer => renderer.canRender(node, context)); + + return availableRenderer ? availableRenderer.render(node, context) : context.origin(); +}; + +const buildCustomRendererFunctions = (customRenderers, defaults) => { + const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]); + const customEntries = customTypes.map(type => { + const fn = (node, context) => executeRenderer(customRenderers[type], node, context); + return [type, fn]; + }); + + return Object.fromEntries(customEntries); +}; + +const buildCustomHTMLRenderer = ( + customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] }, +) => { + const defaults = { + htmlBlock(node, context) { + const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers]; + + return executeRenderer(allHtmlBlockRenderers, node, context); + }, + htmlInline(node, context) { + const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers]; + + return executeRenderer(allHtmlInlineRenderers, node, context); + }, + list(node, context) { + const allListRenderers = [...customRenderers.list, ...listRenderers]; + + return executeRenderer(allListRenderers, node, context); + }, + paragraph(node, context) { + const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers]; + + return executeRenderer(allParagraphRenderers, node, context); + }, + text(node, context) { + const allTextRenderers = [...customRenderers.text, ...textRenderers]; + + return executeRenderer(allTextRenderers, node, context); + }, + }; + + return { + ...buildCustomRendererFunctions(customRenderers, defaults), + ...defaults, + }; +}; + +export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js new file mode 100644 index 00000000000..ed04765c871 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -0,0 +1,53 @@ +import { defaults, repeat } from 'lodash'; + +const DEFAULTS = { + subListIndentSpaces: 4, +}; + +const countIndentSpaces = text => { + const matches = text.match(/^\s+/m); + + return matches ? matches[0].length : 0; +}; + +const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => { + const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS); + // eslint-disable-next-line @gitlab/require-i18n-strings + const sublistNode = 'LI OL, LI UL'; + + return { + TEXT_NODE(node) { + return baseRenderer.getSpaceControlled( + baseRenderer.trim(baseRenderer.getSpaceCollapsedText(node.nodeValue)), + node, + ); + }, + /* + * This converter overwrites the default indented list converter + * to allow us to parameterize the number of indent spaces for + * sublists. + * + * See the original implementation in + * https://github.com/nhn/tui.editor/blob/master/libs/to-mark/src/renderer.basic.js#L161 + */ + [sublistNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + // Default to 1 to prevent possible divide by 0 + const firstLevelIndentSpacesCount = countIndentSpaces(baseResult) || 1; + const reindentedList = baseResult + .split('\n') + .map(line => { + const itemIndentSpacesCount = countIndentSpaces(line); + const nestingLevel = Math.ceil(itemIndentSpacesCount / firstLevelIndentSpacesCount); + const indentSpaces = repeat(' ', subListIndentSpaces * nestingLevel); + + return line.replace(/^ +/, indentSpaces); + }) + .join('\n'); + + return reindentedList; + }, + }; +}; + +export default buildHTMLToMarkdownRender; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js new file mode 100644 index 00000000000..6436dcaae64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -0,0 +1,56 @@ +import Vue from 'vue'; +import ToolbarItem from '../toolbar_item.vue'; +import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; + +const buildWrapper = propsData => { + const instance = new Vue({ + render(createElement) { + return createElement(ToolbarItem, propsData); + }, + }); + + instance.$mount(); + return instance.$el; +}; + +export const generateToolbarItem = config => { + const { icon, classes, event, command, tooltip, isDivider } = config; + + if (isDivider) { + return 'divider'; + } + + return { + type: 'button', + options: { + el: buildWrapper({ props: { icon, tooltip }, class: classes }), + event, + command, + }, + }; +}; + +export const addCustomEventListener = (editorApi, event, handler) => { + editorApi.eventManager.addEventType(event); + editorApi.eventManager.listen(event, handler); +}; + +export const removeCustomEventListener = (editorApi, event, handler) => + editorApi.eventManager.removeEventHandler(event, handler); + +export const addImage = ({ editor }, image) => editor.exec('AddImage', image); + +export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown'); + +/** + * This function allow us to extend Toast UI HTML to Markdown renderer. It is + * a temporary measure because Toast UI does not provide an API + * to achieve this goal. + */ +export const registerHTMLToMarkdownRenderer = editorApi => { + const { renderer } = editorApi.toMarkOptions; + + Object.assign(editorApi.toMarkOptions, { + renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js new file mode 100644 index 00000000000..d96cadafdbb --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js @@ -0,0 +1,63 @@ +const buildToken = (type, tagName, props) => { + return { type, tagName, ...props }; +}; + +const TAG_TYPES = { + block: 'div', + inline: 'a', +}; + +// Open helpers (singular and multiple) + +const buildUneditableOpenToken = (tagType = TAG_TYPES.block) => + buildToken('openTag', tagType, { + attributes: { contenteditable: false }, + classNames: [ + 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', + ], + }); + +export const buildUneditableOpenTokens = (token, tagType = TAG_TYPES.block) => { + return [buildUneditableOpenToken(tagType), token]; +}; + +// Close helpers (singular and multiple) + +export const buildUneditableCloseToken = (tagType = TAG_TYPES.block) => + buildToken('closeTag', tagType); + +export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => { + return [token, buildUneditableCloseToken(tagType)]; +}; + +// Complete helpers (open plus close) + +export const buildTextToken = content => buildToken('text', null, { content }); + +export const buildUneditableTokens = token => { + return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; +}; + +export const buildUneditableInlineTokens = token => { + return [ + ...buildUneditableOpenTokens(token, TAG_TYPES.inline), + buildUneditableCloseToken(TAG_TYPES.inline), + ]; +}; + +export const buildUneditableHtmlAsTextTokens = node => { + /* + Toast UI internally appends ' data-tomark-pass ' attribute flags so it can target certain + nested nodes for internal use during Markdown <=> WYSIWYG conversions. In our case, we want + to prevent HTML being rendered completely in WYSIWYG mode and thus we use a `text` vs. `html` + type when building the token. However, in doing so, we need to strip out the ` data-tomark-pass ` + to prevent their persistence within the `text` content as the user did not intend these as edits. + + https://github.com/nhn/tui.editor/blob/cc54ec224fc3a4b6e5a2b19a71650959f41adc0e/apps/editor/src/js/convertor.js#L72 + */ + const regex = / data-tomark-pass /gm; + const content = node.literal.replace(regex, ''); + const htmlAsTextToken = buildToken('text', null, { content }); + + return [buildUneditableOpenToken(), htmlAsTextToken, buildUneditableCloseToken()]; +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js new file mode 100644 index 00000000000..494057fc75b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const embeddedRubyRegex = /(^<%.+%>$)/; + +const canRender = ({ literal }) => { + return embeddedRubyRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js new file mode 100644 index 00000000000..572f6e3cf9d --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline.js @@ -0,0 +1,11 @@ +import { buildUneditableInlineTokens } from './build_uneditable_token'; + +const fontAwesomeRegexOpen = /<i class="fa.+>/; + +const canRender = ({ literal }) => { + return fontAwesomeRegexOpen.test(literal); +}; + +const render = (_, { origin }) => buildUneditableInlineTokens(origin()); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js new file mode 100644 index 00000000000..b179ca61dba --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_html_block.js @@ -0,0 +1,9 @@ +import { buildUneditableHtmlAsTextTokens } from './build_uneditable_token'; + +const canRender = ({ type }) => { + return type === 'htmlBlock'; +}; + +const render = node => buildUneditableHtmlAsTextTokens(node); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js new file mode 100644 index 00000000000..a9c3dfcd728 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text.js @@ -0,0 +1,40 @@ +import { buildTextToken, buildUneditableInlineTokens } from './build_uneditable_token'; + +/* +Use case examples: +- Majority: two bracket pairs, back-to-back, each with content (including spaces) + - `[environment terraform plans][terraform]` + - `[an issue labelled `~"master:broken"`][broken-master-issues]` +- Minority: two bracket pairs the latter being empty or only one pair with content (including spaces) + - `[this link][]` + - `[this link]` + +Regexp notes: + - `(?:\[.+?\]){1}`: Always one bracket pair with content (including spaces) + - `(?:\[\]|\[.+?\])?`: Optional second pair that may or may not contain content (including spaces) + - `(?!:)`: Never followed by a `:` which is reserved for identifier definition syntax (`[identifier]: /the-link`) + - Each of the three parts is non-captured, but the match as a whole is captured +*/ +const identifierInstanceRegex = /((?:\[.+?\]){1}(?:\[\]|\[.+?\])?(?!:))/g; + +const isIdentifierInstance = literal => { + // Reset lastIndex as global flag in regexp are stateful (https://stackoverflow.com/a/11477448) + identifierInstanceRegex.lastIndex = 0; + return identifierInstanceRegex.test(literal); +}; + +const canRender = ({ literal }) => isIdentifierInstance(literal); + +const tokenize = text => { + const matches = text.split(identifierInstanceRegex); + const tokens = matches.map(match => { + const token = buildTextToken(match); + return isIdentifierInstance(match) ? buildUneditableInlineTokens(token) : token; + }); + + return tokens.flat(); +}; + +const render = (_, { origin }) => tokenize(origin().content); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js new file mode 100644 index 00000000000..f5b4502ea3c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -0,0 +1,16 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const identifierRegex = /(^\[.+\]: .+)/; + +const isIdentifier = text => { + return identifierRegex.test(text); +}; + +const canRender = (node, context) => { + return isIdentifier(context.getChildrenText(node)); +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js new file mode 100644 index 00000000000..491a26c81d0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js @@ -0,0 +1,27 @@ +import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; + +const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC'; + +const canRender = node => { + let targetNode = node; + while (targetNode !== null) { + const { firstChild } = targetNode; + const isLeaf = firstChild === null; + if (isLeaf) { + if (isKramdownTOC(targetNode)) { + return true; + } + + break; + } + + targetNode = targetNode.firstChild; + } + + return false; +}; + +const render = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js new file mode 100644 index 00000000000..01384699e4f --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js @@ -0,0 +1,13 @@ +import { buildUneditableTokens } from './build_uneditable_token'; + +const kramdownRegex = /(^{:.+}$)/; + +const canRender = ({ literal }) => { + return kramdownRegex.test(literal); +}; + +const render = (_, { origin }) => { + return buildUneditableTokens(origin()); +}; + +export default { canRender, render }; |