diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-20 14:10:13 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-20 14:10:13 +0300 |
commit | 0ea3fcec397b69815975647f5e2aa5fe944a8486 (patch) | |
tree | 7979381b89d26011bcf9bdc989a40fcc2f1ed4ff /app/assets/javascripts/content_editor | |
parent | 72123183a20411a36d607d70b12d57c484394c8e (diff) |
Add latest changes from gitlab-org/gitlab@15-1-stable-eev15.1.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
19 files changed, 936 insertions, 270 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue index 518ddd7a09c..6c0ac8e54d2 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/code_block.vue @@ -1,5 +1,8 @@ <script> import { + GlDropdownForm, + GlFormInput, + GlDropdownDivider, GlButton, GlButtonGroup, GlDropdown, @@ -20,23 +23,32 @@ const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatte export default { components: { BubbleMenu, + GlDropdownForm, + GlFormInput, GlButton, GlButtonGroup, GlDropdown, GlDropdownItem, + GlDropdownDivider, GlSearchBoxByType, EditorStateObserver, }, directives: { GlTooltip, }, - inject: ['tiptapEditor'], + inject: ['tiptapEditor', 'contentEditor'], data() { return { codeBlockType: undefined, - selectedLanguage: {}, filterTerm: '', filteredLanguages: [], + + showCustomLanguageInput: false, + customLanguageType: '', + + selectedLanguage: {}, + isDiagram: false, + showPreview: false, }; }, watch: { @@ -52,24 +64,39 @@ export default { return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type)); }, - updateSelectedLanguage() { + async updateCodeBlockInfoToState() { this.codeBlockType = CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)); - if (this.codeBlockType) { - const { language } = this.tiptapEditor.getAttributes(this.codeBlockType); - this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); - } + if (!this.codeBlockType) return; + + const { language, isDiagram, showPreview } = this.tiptapEditor.getAttributes( + this.codeBlockType, + ); + this.selectedLanguage = codeBlockLanguageLoader.findOrCreateLanguageBySyntax( + language, + isDiagram, + ); + this.isDiagram = isDiagram; + this.showPreview = showPreview; }, - copyCodeBlockText() { + getCodeBlockText() { const { view } = this.tiptapEditor; const { from } = this.tiptapEditor.state.selection; const node = getParentByTagName(view.domAtPos(from).node, 'pre'); + return node?.textContent || ''; + }, - navigator.clipboard.writeText(node?.textContent || ''); + copyCodeBlockText() { + navigator.clipboard.writeText(this.getCodeBlockText()); }, - async applySelectedLanguage(language) { + togglePreview() { + this.showPreview = !this.showPreview; + this.tiptapEditor.commands.updateAttributes(Diagram.name, { showPreview: this.showPreview }); + }, + + async applyLanguage(language) { this.selectedLanguage = language; await codeBlockLanguageLoader.loadLanguage(language.syntax); @@ -77,6 +104,21 @@ export default { this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); }, + clearCustomLanguageForm() { + this.showCustomLanguageInput = false; + this.customLanguageType = ''; + }, + + applyCustomLanguage() { + this.showCustomLanguageInput = false; + + const language = codeBlockLanguageLoader.findOrCreateLanguageBySyntax( + this.customLanguageType, + ); + + this.applyLanguage(language); + }, + getReferenceClientRect() { const { view } = this.tiptapEditor; const { from } = this.tiptapEditor.state.selection; @@ -101,15 +143,36 @@ export default { getReferenceClientRect, } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" > - <editor-state-observer @transaction="updateSelectedLanguage"> + <editor-state-observer @transaction="updateCodeBlockInfoToState"> <gl-button-group> <gl-dropdown category="tertiary" contenteditable="false" boundary="viewport" :text="selectedLanguage.label" + @hide="clearCustomLanguageForm" > - <template #header> + <template v-if="showCustomLanguageInput" #header> + <div class="gl-relative"> + <gl-button + v-gl-tooltip + class="gl-absolute gl-mt-n3 gl-ml-2" + variant="default" + category="tertiary" + size="medium" + :aria-label="__('Go back')" + :title="__('Go back')" + icon="arrow-left" + @click.prevent.stop="showCustomLanguageInput = false" + /> + <p + class="gl-text-center gl-new-dropdown-header-top gl-mb-0! gl-border-none! gl-pb-1!" + > + {{ __('Create custom type') }} + </p> + </div> + </template> + <template v-else #header> <gl-search-box-by-type v-model="filterTerm" :clear-button-title="__('Clear')" @@ -117,20 +180,59 @@ export default { /> </template> - <template #highlighted-items> + <template v-if="!showCustomLanguageInput" #highlighted-items> <gl-dropdown-item :key="selectedLanguage.syntax" is-check-item :is-checked="true"> {{ selectedLanguage.label }} </gl-dropdown-item> </template> - <gl-dropdown-item - v-for="language in filteredLanguages" - v-show="selectedLanguage.syntax !== language.syntax" - :key="language.syntax" - @click="applySelectedLanguage(language)" - > - {{ language.label }} - </gl-dropdown-item> + <template v-if="!showCustomLanguageInput" #default> + <gl-dropdown-item + v-for="language in filteredLanguages" + v-show="selectedLanguage.syntax !== language.syntax" + :key="language.syntax" + @click="applyLanguage(language)" + > + {{ language.label }} + </gl-dropdown-item> + </template> + <template v-else #default> + <gl-dropdown-form @submit.prevent="applyCustomLanguage"> + <div class="gl-mx-4 gl-mt-2 gl-mb-3"> + <gl-form-input v-model="customLanguageType" :placeholder="__('Language type')" /> + </div> + <gl-dropdown-divider /> + <div class="gl-mx-4 gl-mt-3 gl-display-flex gl-justify-content-end"> + <gl-button + variant="default" + size="medium" + category="primary" + class="gl-mr-2 gl-w-auto!" + @click.prevent.stop="showCustomLanguageInput = false" + > + {{ __('Cancel') }} + </gl-button> + <gl-button + variant="confirm" + size="medium" + category="primary" + type="submit" + class="gl-w-auto!" + > + {{ __('Apply') }} + </gl-button> + </div> + </gl-dropdown-form> + </template> + + <template v-if="!showCustomLanguageInput" #footer> + <gl-dropdown-item + data-testid="create-custom-type" + @click.capture.native.stop="showCustomLanguageInput = true" + > + {{ __('Create custom type') }} + </gl-dropdown-item> + </template> </gl-dropdown> <gl-button v-gl-tooltip @@ -144,6 +246,19 @@ export default { @click="copyCodeBlockText" /> <gl-button + v-if="isDiagram" + v-gl-tooltip + variant="default" + category="tertiary" + size="medium" + :class="{ active: showPreview }" + data-testid="preview-diagram" + :aria-label="__('Preview diagram')" + :title="__('Preview diagram')" + icon="eye" + @click="togglePreview" + /> + <gl-button v-gl-tooltip variant="default" category="tertiary" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue new file mode 100644 index 00000000000..ecde593147c --- /dev/null +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -0,0 +1,34 @@ +<script> +import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlDropdown, + GlDropdownItem, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor'], + methods: { + execute(contentType, attrs) { + this.tiptapEditor.chain().focus().setNode(contentType, attrs).run(); + + this.$emit('execute', { contentType }); + }, + }, +}; +</script> +<template> + <gl-dropdown size="small" category="tertiary" icon="plus"> + <gl-dropdown-item @click="execute('diagram', { language: 'mermaid' })"> + {{ __('Mermaid diagram') }} + </gl-dropdown-item> + <gl-dropdown-item @click="execute('diagram', { language: 'plantuml' })"> + {{ __('PlantUML diagram') }} + </gl-dropdown-item> + <gl-dropdown-item @click="execute('horizontalRule')"> + {{ __('Horizontal rule') }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 19e150a4da9..b652e634b0c 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -5,6 +5,7 @@ import ToolbarImageButton from './toolbar_image_button.vue'; import ToolbarLinkButton from './toolbar_link_button.vue'; import ToolbarTableButton from './toolbar_table_button.vue'; import ToolbarTextStyleDropdown from './toolbar_text_style_dropdown.vue'; +import ToolbarMoreDropdown from './toolbar_more_dropdown.vue'; export default { components: { @@ -13,6 +14,7 @@ export default { ToolbarLinkButton, ToolbarTableButton, ToolbarImageButton, + ToolbarMoreDropdown, }, methods: { trackToolbarControlExecution({ contentType, value }) { @@ -117,16 +119,8 @@ export default { :label="__('Add a collapsible section')" @execute="trackToolbarControlExecution" /> - <toolbar-button - data-testid="horizontal-rule" - content-type="horizontalRule" - icon-name="dash" - class="gl-mx-2" - editor-command="setHorizontalRule" - :label="__('Add a horizontal rule')" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button @execute="trackToolbarControlExecution" /> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> </div> </template> <style> diff --git a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue index 1390b9b2daf..81f9b1f0af5 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -1,15 +1,26 @@ <script> +import { debounce } from 'lodash'; import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import SandboxedMermaid from '~/behaviors/components/sandboxed_mermaid.vue'; import codeBlockLanguageLoader from '../../services/code_block_language_loader'; +import EditorStateObserver from '../editor_state_observer.vue'; export default { name: 'CodeBlock', components: { NodeViewWrapper, NodeViewContent, + EditorStateObserver, + SandboxedMermaid, }, + inject: ['contentEditor'], props: { + editor: { + type: Object, + required: true, + }, node: { type: Object, required: true, @@ -18,27 +29,75 @@ export default { type: Function, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + diagramUrl: '', + diagramSource: '', + }; }, async mounted() { - const lang = codeBlockLanguageLoader.findLanguageBySyntax(this.node.attrs.language); + this.updateDiagramPreview = debounce( + this.updateDiagramPreview, + DEFAULT_DEBOUNCE_AND_THROTTLE_MS, + ); + + const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language); await codeBlockLanguageLoader.loadLanguage(lang.syntax); this.updateAttributes({ language: this.node.attrs.language }); }, + methods: { + async updateDiagramPreview() { + if (!this.node.attrs.showPreview) { + this.diagramSource = ''; + return; + } + + if (!this.editor.isActive('diagram')) return; + + this.diagramSource = this.$refs.nodeViewContent.$el.textContent; + + if (this.node.attrs.language !== 'mermaid') { + this.diagramUrl = await this.contentEditor.renderDiagram( + this.diagramSource, + this.node.attrs.language, + ); + } + }, + }, i18n: { frontmatter: __('frontmatter'), }, + userColorScheme: gon.user_color_scheme, }; </script> <template> - <node-view-wrapper class="content-editor-code-block gl-relative code highlight" as="pre"> - <span - v-if="node.attrs.isFrontmatter" - data-testid="frontmatter-label" - class="gl-absolute gl-top-0 gl-right-3" - contenteditable="false" - >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span + <editor-state-observer @transaction="updateDiagramPreview"> + <node-view-wrapper + :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`" + as="pre" > - <node-view-content as="code" /> - </node-view-wrapper> + <div + v-if="node.attrs.showPreview" + class="gl-mt-n3! gl-ml-n4! gl-mr-n4! gl-mb-3 gl-bg-white! gl-p-4 gl-border-b-1 gl-border-b-solid gl-border-b-gray-100" + > + <sandboxed-mermaid v-if="node.attrs.language === 'mermaid'" :source="diagramSource" /> + <img v-else ref="diagramContainer" :src="diagramUrl" /> + </div> + <span + v-if="node.attrs.isFrontmatter" + data-testid="frontmatter-label" + class="gl-absolute gl-top-0 gl-right-3" + contenteditable="false" + >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span + > + <node-view-content ref="nodeViewContent" as="code" /> + </node-view-wrapper> + </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue new file mode 100644 index 00000000000..8b7b02605f7 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/footnote_definition.vue @@ -0,0 +1,28 @@ +<script> +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; + +export default { + name: 'FootnoteDefinitionWrapper', + components: { + NodeViewWrapper, + NodeViewContent, + }, + props: { + node: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-flex gl-font-sm" as="div"> + <span + data-testid="footnote-label" + contenteditable="false" + class="gl-display-inline-flex gl-mr-2" + >{{ node.attrs.label }}:</span + > + <node-view-content /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue index 209e4629830..c0d6e32a739 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -101,6 +101,9 @@ export default { deleteTable: __('Delete table'), editTableActions: __('Edit table'), }, + dropdownPopperOpts: { + positionFixed: true, + }, }; </script> <template> @@ -124,9 +127,7 @@ export default { no-caret text-sr-only :text="$options.i18n.editTableActions" - :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { - positionFixed: true, - } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :popper-opts="$options.dropdownPopperOpts" @hide="handleHide($event)" > <gl-dropdown-item @click="runCommand('addColumnBefore')"> diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index cc4ba84a29d..61f6a233694 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -4,7 +4,8 @@ import { VueNodeViewRenderer } from '@tiptap/vue-2'; import languageLoader from '../services/code_block_language_loader'; import CodeBlockWrapper from '../components/wrappers/code_block.vue'; -const extractLanguage = (element) => element.getAttribute('lang'); +const extractLanguage = (element) => element.dataset.canonicalLang ?? element.getAttribute('lang'); + export const backtickInputRegex = /^```([a-z]+)?[\s\n]$/; export const tildeInputRegex = /^~~~([a-z]+)?[\s\n]$/; diff --git a/app/assets/javascripts/content_editor/extensions/diagram.js b/app/assets/javascripts/content_editor/extensions/diagram.js index f9dfeb92e9a..c59ca8a28b8 100644 --- a/app/assets/javascripts/content_editor/extensions/diagram.js +++ b/app/assets/javascripts/content_editor/extensions/diagram.js @@ -1,6 +1,10 @@ +import { textblockTypeInputRule } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import languageLoader from '../services/code_block_language_loader'; import CodeBlockHighlight from './code_block_highlight'; +const backtickInputRegex = /^```(mermaid|plantuml)[\s\n]$/; + export default CodeBlockHighlight.extend({ name: 'diagram', @@ -17,6 +21,9 @@ export default CodeBlockHighlight.extend({ isDiagram: { default: true, }, + showPreview: { + default: true, + }, }; }, @@ -24,6 +31,11 @@ export default CodeBlockHighlight.extend({ return [ { priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: 'pre[lang="mermaid"]', + getAttrs: () => ({ language: 'mermaid' }), + }, + { + priority: PARSE_HTML_PRIORITY_HIGHEST, tag: '[data-diagram]', getContent(element, schema) { const source = atob(element.dataset.diagramSrc.replace('data:text/plain;base64,', '')); @@ -54,6 +66,14 @@ export default CodeBlockHighlight.extend({ }, addInputRules() { - return []; + const getAttributes = (match) => languageLoader?.loadLanguageFromInputRule(match) || {}; + + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes, + }), + ]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/footnote_definition.js b/app/assets/javascripts/content_editor/extensions/footnote_definition.js index dbab0de3421..bf752918934 100644 --- a/app/assets/javascripts/content_editor/extensions/footnote_definition.js +++ b/app/assets/javascripts/content_editor/extensions/footnote_definition.js @@ -1,12 +1,27 @@ import { mergeAttributes, Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import FootnoteDefinitionWrapper from '../components/wrappers/footnote_definition.vue'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +const extractFootnoteIdentifier = (idAttribute) => /^fn-(\w+)-\d+$/.exec(idAttribute)?.[1]; + export default Node.create({ name: 'footnoteDefinition', - content: 'paragraph', - group: 'block', + isolating: true, + addAttributes() { + return { + identifier: { + default: null, + parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')), + }, + label: { + default: null, + parseHTML: (element) => extractFootnoteIdentifier(element.getAttribute('id')), + }, + }; + }, parseHTML() { return [ @@ -15,7 +30,11 @@ export default Node.create({ ]; }, - renderHTML({ HTMLAttributes }) { - return ['li', mergeAttributes(HTMLAttributes), 0]; + renderHTML({ label, ...HTMLAttributes }) { + return ['div', mergeAttributes(HTMLAttributes), 0]; + }, + + addNodeView() { + return new VueNodeViewRenderer(FootnoteDefinitionWrapper); }, }); diff --git a/app/assets/javascripts/content_editor/extensions/footnote_reference.js b/app/assets/javascripts/content_editor/extensions/footnote_reference.js index 1ac8016f774..ae5b8edc7af 100644 --- a/app/assets/javascripts/content_editor/extensions/footnote_reference.js +++ b/app/assets/javascripts/content_editor/extensions/footnote_reference.js @@ -1,6 +1,9 @@ import { Node, mergeAttributes } from '@tiptap/core'; import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +const extractFootnoteIdentifier = (element) => + /^fnref-(\w+)-\d+$/.exec(element.querySelector('a')?.getAttribute('id'))?.[1]; + export default Node.create({ name: 'footnoteReference', @@ -16,13 +19,13 @@ export default Node.create({ addAttributes() { return { - footnoteId: { + identifier: { default: null, - parseHTML: (element) => element.querySelector('a').getAttribute('id'), + parseHTML: extractFootnoteIdentifier, }, - footnoteNumber: { + label: { default: null, - parseHTML: (element) => element.textContent, + parseHTML: extractFootnoteIdentifier, }, }; }, @@ -31,7 +34,7 @@ export default Node.create({ return [{ tag: 'sup.footnote-ref', priority: PARSE_HTML_PRIORITY_HIGHEST }]; }, - renderHTML({ HTMLAttributes: { footnoteNumber, footnoteId, ...HTMLAttributes } }) { - return ['sup', mergeAttributes(HTMLAttributes), footnoteNumber]; + renderHTML({ HTMLAttributes: { label, ...HTMLAttributes } }) { + return ['sup', mergeAttributes(HTMLAttributes), label]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/footnotes_section.js b/app/assets/javascripts/content_editor/extensions/footnotes_section.js index 914a8934734..2b2c4177e1d 100644 --- a/app/assets/javascripts/content_editor/extensions/footnotes_section.js +++ b/app/assets/javascripts/content_editor/extensions/footnotes_section.js @@ -10,7 +10,10 @@ export default Node.create({ isolating: true, parseHTML() { - return [{ tag: 'section.footnotes > ol' }]; + return [ + { tag: 'section.footnotes', skip: true }, + { tag: 'section.footnotes > ol', skip: true }, + ]; }, renderHTML({ HTMLAttributes }) { diff --git a/app/assets/javascripts/content_editor/extensions/sourcemap.js b/app/assets/javascripts/content_editor/extensions/sourcemap.js index 94236e2e70e..87118074462 100644 --- a/app/assets/javascripts/content_editor/extensions/sourcemap.js +++ b/app/assets/javascripts/content_editor/extensions/sourcemap.js @@ -4,6 +4,8 @@ import Bold from './bold'; import BulletList from './bullet_list'; import Code from './code'; import CodeBlockHighlight from './code_block_highlight'; +import FootnoteReference from './footnote_reference'; +import FootnoteDefinition from './footnote_definition'; import Heading from './heading'; import HardBreak from './hard_break'; import HorizontalRule from './horizontal_rule'; @@ -13,6 +15,13 @@ import Link from './link'; import ListItem from './list_item'; import OrderedList from './ordered_list'; import Paragraph from './paragraph'; +import Strike from './strike'; +import TaskList from './task_list'; +import TaskItem from './task_item'; +import Table from './table'; +import TableCell from './table_cell'; +import TableHeader from './table_header'; +import TableRow from './table_row'; export default Extension.create({ addGlobalAttributes() { @@ -24,6 +33,8 @@ export default Extension.create({ BulletList.name, Code.name, CodeBlockHighlight.name, + FootnoteReference.name, + FootnoteDefinition.name, HardBreak.name, Heading.name, HorizontalRule.name, @@ -33,6 +44,13 @@ export default Extension.create({ ListItem.name, OrderedList.name, Paragraph.name, + Strike.name, + TaskList.name, + TaskItem.name, + Table.name, + TableCell.name, + TableHeader.name, + TableRow.name, ], attributes: { sourceMarkdown: { diff --git a/app/assets/javascripts/content_editor/services/asset_resolver.js b/app/assets/javascripts/content_editor/services/asset_resolver.js index 942457b9664..c0bcddbe58d 100644 --- a/app/assets/javascripts/content_editor/services/asset_resolver.js +++ b/app/assets/javascripts/content_editor/services/asset_resolver.js @@ -1,13 +1,24 @@ import { memoize } from 'lodash'; +const parser = new DOMParser(); + export default ({ renderMarkdown }) => ({ resolveUrl: memoize(async (canonicalSrc) => { const html = await renderMarkdown(`[link](${canonicalSrc})`); if (!html) return canonicalSrc; - const parser = new DOMParser(); const { body } = parser.parseFromString(html, 'text/html'); - return body.querySelector('a').getAttribute('href'); }), + + renderDiagram: memoize(async (code, language) => { + const backticks = '`'.repeat(4); + const html = await renderMarkdown(`${backticks}${language}\n${code}\n${backticks}`); + + const { body } = parser.parseFromString(html, 'text/html'); + const img = body.querySelector('img'); + if (!img) return ''; + + return img.dataset.src || img.getAttribute('src'); + }), }); diff --git a/app/assets/javascripts/content_editor/services/code_block_language_loader.js b/app/assets/javascripts/content_editor/services/code_block_language_loader.js index 1afaf4bfef6..b7cf1bb087c 100644 --- a/app/assets/javascripts/content_editor/services/code_block_language_loader.js +++ b/app/assets/javascripts/content_editor/services/code_block_language_loader.js @@ -8,7 +8,7 @@ const codeBlockLanguageLoader = { allLanguages: CODE_BLOCK_LANGUAGES, - findLanguageBySyntax(value) { + findOrCreateLanguageBySyntax(value, isDiagram) { const lowercaseValue = value?.toLowerCase() || 'plaintext'; return ( this.allLanguages.find( @@ -16,7 +16,9 @@ const codeBlockLanguageLoader = { syntax === lowercaseValue || variants?.toLowerCase().split(', ').includes(lowercaseValue), ) || { syntax: lowercaseValue, - label: sprintf(__(`Custom (%{language})`), { language: lowercaseValue }), + label: sprintf(isDiagram ? __(`Diagram (%{language})`) : __(`Custom (%{language})`), { + language: lowercaseValue, + }), } ); }, @@ -38,7 +40,7 @@ const codeBlockLanguageLoader = { }, loadLanguageFromInputRule(match) { - const { syntax } = this.findLanguageBySyntax(match[1]); + const { syntax } = this.findOrCreateLanguageBySyntax(match[1]); this.loadLanguage(syntax); diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index 52dacb84153..06757e7a280 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -52,6 +52,10 @@ export class ContentEditor { return this._assetResolver.resolveUrl(canonicalSrc); } + renderDiagram(code, language) { + return this._assetResolver.renderDiagram(code, language); + } + async setSerializedContent(serializedContent) { const { _tiptapEditor: editor, _eventHub: eventHub } = this; const { doc, tr } = editor.state; diff --git a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js index b6a3e0bc26a..2c462cdde91 100644 --- a/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js +++ b/app/assets/javascripts/content_editor/services/hast_to_prosemirror_converter.js @@ -20,9 +20,9 @@ */ import { Mark } from 'prosemirror-model'; -import { visitParents } from 'unist-util-visit-parents'; +import { visitParents, SKIP } from 'unist-util-visit-parents'; import { toString } from 'hast-util-to-string'; -import { isFunction } from 'lodash'; +import { isFunction, isString, noop } from 'lodash'; /** * Merges two ProseMirror text nodes if both text nodes @@ -63,10 +63,12 @@ function maybeMerge(a, b) { function createSourceMapAttributes(hastNode, source) { const { position } = hastNode; - return { - sourceMapKey: `${position.start.offset}:${position.end.offset}`, - sourceMarkdown: source.substring(position.start.offset, position.end.offset), - }; + return position && position.end + ? { + sourceMapKey: `${position.start.offset}:${position.end.offset}`, + sourceMarkdown: source.substring(position.start.offset, position.end.offset), + } + : {}; } /** @@ -141,6 +143,20 @@ class HastToProseMirrorConverterState { return this.stack.length === 0; } + findInStack(fn) { + const last = this.stack.length - 1; + + for (let i = last; i >= 0; i -= 1) { + const item = this.stack[i]; + + if (fn(item) === true) { + return item; + } + } + + return null; + } + /** * Creates a text node and adds it to * the top node in the stack. @@ -249,33 +265,38 @@ class HastToProseMirrorConverterState { * @returns An object that contains ProseMirror node factories */ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) => { - const handlers = { - root: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}), - text: (state, hastNode) => { - const { factorySpec } = state.top; - - if (/^\s+$/.test(hastNode.value)) { - return; - } + const factories = { + root: { + selector: 'root', + wrapInParagraph: true, + handle: (state, hastNode) => state.openNode(schema.topNodeType, hastNode, {}, {}), + }, + text: { + selector: 'text', + handle: (state, hastNode) => { + const found = state.findInStack((node) => isFunction(node.factorySpec.processText)); + const { value: text } = hastNode; + + if (/^\s+$/.test(text)) { + return; + } - if (factorySpec.wrapTextInParagraph === true) { - state.openNode(schema.nodeType('paragraph')); - state.addText(schema, hastNode.value); - state.closeNode(); - } else { - state.addText(schema, hastNode.value); - } + state.addText(schema, found ? found.factorySpec.processText(text) : text); + }, }, }; - - for (const [hastNodeTagName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) { - if (factorySpec.block) { - handlers[hastNodeTagName] = (state, hastNode, parent, ancestors) => { - const nodeType = schema.nodeType( - isFunction(factorySpec.block) - ? factorySpec.block(hastNode, parent, ancestors) - : factorySpec.block, - ); + for (const [proseMirrorName, factorySpec] of Object.entries(proseMirrorFactorySpecs)) { + const factory = { + selector: factorySpec.selector, + skipChildren: factorySpec.skipChildren, + processText: factorySpec.processText, + parent: factorySpec.parent, + wrapInParagraph: factorySpec.wrapInParagraph, + }; + + if (factorySpec.type === 'block') { + factory.handle = (state, hastNode, parent) => { + const nodeType = schema.nodeType(proseMirrorName); state.closeUntil(parent); state.openNode( @@ -297,9 +318,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) state.closeNode(); } }; - } else if (factorySpec.inline) { - const nodeType = schema.nodeType(factorySpec.inline); - handlers[hastNodeTagName] = (state, hastNode, parent) => { + } else if (factorySpec.type === 'inline') { + const nodeType = schema.nodeType(proseMirrorName); + factory.handle = (state, hastNode, parent) => { state.closeUntil(parent); state.openNode( nodeType, @@ -310,23 +331,115 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) // Inline nodes do not have children therefore they are immediately closed state.closeNode(); }; - } else if (factorySpec.mark) { - const markType = schema.marks[factorySpec.mark]; - handlers[hastNodeTagName] = (state, hastNode, parent) => { + } else if (factorySpec.type === 'mark') { + const markType = schema.marks[proseMirrorName]; + factory.handle = (state, hastNode, parent) => { state.openMark(markType, getAttrs(factorySpec, hastNode, parent, source)); if (factorySpec.inlineContent) { state.addText(schema, hastNode.value); } }; + } else if (factorySpec.type === 'ignore') { + factory.handle = noop; } else { - throw new RangeError(`Unrecognized node factory spec ${JSON.stringify(factorySpec)}`); + throw new RangeError( + `Unrecognized ProseMirror object type ${JSON.stringify(factorySpec.type)}`, + ); } + + factories[proseMirrorName] = factory; } - return handlers; + return factories; }; +const findFactory = (hastNode, ancestors, factories) => + Object.entries(factories).find(([, factorySpec]) => { + const { selector } = factorySpec; + + return isFunction(selector) + ? selector(hastNode, ancestors) + : [hastNode.tagName, hastNode.type].includes(selector); + })?.[1]; + +const findParent = (ancestors, parent) => { + if (isString(parent)) { + return ancestors.reverse().find((ancestor) => ancestor.tagName === parent); + } + + return ancestors[ancestors.length - 1]; +}; + +const calcTextNodePosition = (textNode) => { + const { position, value, type } = textNode; + + if (type !== 'text' || (!position.start && !position.end) || (position.start && position.end)) { + return textNode.position; + } + + const span = value.length - 1; + + if (position.start && !position.end) { + const { start } = position; + + return { + start, + end: { + row: start.row, + column: start.column + span, + offset: start.offset + span, + }, + }; + } + + const { end } = position; + + return { + start: { + row: end.row, + column: end.column - span, + offset: end.offset - span, + }, + end, + }; +}; + +const removeEmptyTextNodes = (nodes) => + nodes.filter( + (node) => node.type !== 'text' || (node.type === 'text' && !/^\s+$/.test(node.value)), + ); + +const wrapInlineElements = (nodes, wrappableTags) => + nodes.reduce((children, child) => { + const previous = children[children.length - 1]; + + if (child.type !== 'text' && !wrappableTags.includes(child.tagName)) { + return [...children, child]; + } + + const wrapperExists = previous?.properties.wrapper; + + if (wrapperExists) { + const wrapper = previous; + + wrapper.position.end = child.position.end; + wrapper.children.push(child); + + return children; + } + + const wrapper = { + type: 'element', + tagName: 'p', + position: calcTextNodePosition(child), + children: [child], + properties: { wrapper: true }, + }; + + return [...children, wrapper]; + }, []); + /** * Converts a Hast AST to a ProseMirror document based on a series * of specifications that describe how to map all the nodes of the former @@ -339,8 +452,9 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * The object should have the following shape: * * { - * [hastNode.tagName]: { - * [block|node|mark]: [ProseMirror.Node.name], + * [ProseMirrorNodeOrMarkName]: { + * type: 'block' | 'inline' | 'mark', + * selector: String | hastNode -> Boolean, * ...configurationOptions * } * } @@ -348,57 +462,21 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * Where each property in the object represents a HAST node with a given tag name, for example: * * { - * h1: {}, - * h2: {}, - * table: {}, - * strong: {}, - * // etc - * } - * - * You can specify the type of ProseMirror object adding one the following - * properties: - * - * 1. "block": A ProseMirror node that contains one or more children. - * 2. "inline": A ProseMirror node that doesn’t contain any children although - * it can have inline content like a code block or a reference. - * 3. "mark": A ProseMirror mark. - * - * The value of that property should be the name of the ProseMirror node or mark, i.e: - * - * { - * h1: { - * block: 'heading', + * horizontalRule: { + * type: 'block', + * selector: 'hr', * }, - * h2: { - * block: 'heading', + * heading: { + * type: 'block', + * selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode), * }, - * img: { - * node: 'image', + * bold: { + * type: 'mark' + * selector: (hastNode) => ['b', 'strong'].includes(hastNode), * }, - * strong: { - * mark: 'bold', - * } - * } + * // etc + * } * - * You can compute a ProseMirror’s node or mark name based on the HAST node - * by passing a function instead of a String. The converter invokes the function - * and provides a HAST node object: - * - * { - * list: { - * block: (hastNode) => { - * let type = 'bulletList'; - - * if (hastNode.children.some(isTaskItem)) { - * type = 'taskList'; - * } else if (hastNode.ordered) { - * type = 'orderedList'; - * } - - * return type; - * } - * } - * } * * Configuration options * ---------------------- @@ -406,6 +484,28 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * You can customize the conversion process for every node or mark * setting the following properties in the specification object: * + * **type** + * + * The `type` property should have one of following three values: + * + * 1. "block": A ProseMirror node that contains one or more children. + * 2. "inline": A ProseMirror node that doesn’t contain any children although + * it can have inline content like an image or a mention object. + * 3. "mark": A ProseMirror mark. + * 4. "ignore": A hast node that should be ignored and won’t be mapped to a + * ProseMirror node. + * + * **selector** + * + * The `selector` property matches a HastNode to a ProseMirror node or + * Mark. If you assign a string value to this property, the converter + * will match the first hast node with a `tagName` or `type` property + * that equals the string value. + * + * If you assign a function, the converter will invoke the function with + * the hast node and its ancestors. The function should return `true` + * if the hastNode matches the custom criteria implemented in the function + * * **getAttrs** * * Computes a ProseMirror node or mark attributes. The converter will invoke @@ -415,12 +515,19 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * 2. hasParents: All the hast node’s ancestors up to the root node * 3. source: Markdown source file’s content * - * **wrapTextInParagraph** + * **wrapInParagraph** * - * This property only applies to block nodes. If a block node contains text, - * it will wrap that text in a paragraph. This is useful for ProseMirror block + * This property only applies to block nodes. If a block node contains inline + * elements like text, images, links, etc, the converter will wrap those inline + * elements in a paragraph. This is useful for ProseMirror block * nodes that don’t allow text directly such as list items and tables. * + * **processText** + * + * This property only applies to block nodes. If a block node contains text, + * it allows applying a processing function to that text. This is useful when + * you can transform the text node, i.e trim(), substring(), etc. + * * **skipChildren** * * Skips a hast node’s children while traversing the tree. @@ -434,6 +541,13 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * Use this property along skipChildren to provide custom processing of child nodes * for a block node. * + * **parent** + * + * Specifies what is the node’s parent. This is useful when the node’s parent is not + * its direct ancestor in Abstract Syntax Tree. For example, imagine that you want + * to make <tr> elements a direct children of tables and skip `<thead>` and `<tbody>` + * altogether. + * * @param {model.Document_Schema} params.schema A ProseMirror schema that specifies the shape * of the ProseMirror document. * @param {Object} params.factorySpec A factory specification as described above @@ -442,17 +556,20 @@ const createProseMirrorNodeFactories = (schema, proseMirrorFactorySpecs, source) * * @returns A ProseMirror document */ -export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, source }) => { +export const createProseMirrorDocFromMdastTree = ({ + schema, + factorySpecs, + wrappableTags, + tree, + source, +}) => { const proseMirrorNodeFactories = createProseMirrorNodeFactories(schema, factorySpecs, source); const state = new HastToProseMirrorConverterState(); visitParents(tree, (hastNode, ancestors) => { - const parent = ancestors[ancestors.length - 1]; - const skipChildren = factorySpecs[hastNode.tagName]?.skipChildren; - - const handler = proseMirrorNodeFactories[hastNode.tagName || hastNode.type]; + const factory = findFactory(hastNode, ancestors, proseMirrorNodeFactories); - if (!handler) { + if (!factory) { throw new Error( `Hast node of type "${ hastNode.tagName || hastNode.type @@ -460,9 +577,25 @@ export const createProseMirrorDocFromMdastTree = ({ schema, factorySpecs, tree, ); } - handler(state, hastNode, parent, ancestors); + const parent = findParent(ancestors, factory.parent); + + if (factory.wrapInParagraph) { + /** + * Modifying parameters is a bad practice. For performance reasons, + * the author of the unist-util-visit-parents function recommends + * modifying nodes in place to avoid traversing the Abstract Syntax + * Tree more than once + */ + // eslint-disable-next-line no-param-reassign + hastNode.children = wrapInlineElements( + removeEmptyTextNodes(hastNode.children), + wrappableTags, + ); + } + + factory.handle(state, hastNode, parent); - return skipChildren === true ? 'skip' : true; + return factory.skipChildren === true ? SKIP : true; }); let doc; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index d665f24bba1..2d33a16f1a5 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -17,7 +17,6 @@ import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; -import FootnotesSection from '../extensions/footnotes_section'; import FootnoteDefinition from '../extensions/footnote_definition'; import FootnoteReference from '../extensions/footnote_reference'; import Frontmatter from '../extensions/frontmatter'; @@ -60,11 +59,13 @@ import { renderPlayable, renderHTMLNode, renderContent, + renderBulletList, preserveUnchanged, bold, italic, link, code, + strike, } from './serialization_helpers'; const defaultSerializerConfig = { @@ -89,12 +90,7 @@ const defaultSerializerConfig = { close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, escape: false, }, - [Strike.name]: { - open: '~~', - close: '~~', - mixable: true, - expelEnclosingWhitespace: true, - }, + [Strike.name]: strike, ...HTMLMarks.reduce( (acc, { name }) => ({ ...acc, @@ -124,7 +120,7 @@ const defaultSerializerConfig = { state.wrapBlock('> ', null, node, () => state.renderContent(node)); } }), - [BulletList.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.bullet_list), + [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), [Diagram.name]: renderCodeBlock, [Division.name]: (state, node) => { @@ -157,15 +153,14 @@ const defaultSerializerConfig = { state.write(`:${name}:`); }, - [FootnoteDefinition.name]: (state, node) => { + [FootnoteDefinition.name]: preserveUnchanged((state, node) => { + state.write(`[^${node.attrs.identifier}]: `); state.renderInline(node); - }, - [FootnoteReference.name]: (state, node) => { - state.write(`[^${node.attrs.footnoteNumber}]`); - }, - [FootnotesSection.name]: (state, node) => { - state.renderList(node, '', (index) => `[^${index + 1}]: `); - }, + state.ensureNewLine(); + }), + [FootnoteReference.name]: preserveUnchanged((state, node) => { + state.write(`[^${node.attrs.identifier}]`); + }), [Frontmatter.name]: (state, node) => { const { language } = node.attrs; const syntax = { @@ -196,18 +191,18 @@ const defaultSerializerConfig = { state.write('[[_TOC_]]'); state.closeBlock(node); }, - [Table.name]: renderTable, + [Table.name]: preserveUnchanged(renderTable), [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, [TableRow.name]: renderTableRow, - [TaskItem.name]: (state, node) => { + [TaskItem.name]: preserveUnchanged((state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); state.renderContent(node); - }, - [TaskList.name]: (state, node) => { + }), + [TaskList.name]: preserveUnchanged((state, node) => { if (node.attrs.numeric) renderOrderedList(state, node); - else defaultMarkdownSerializer.nodes.bullet_list(state, node); - }, + else renderBulletList(state, node); + }), [Text.name]: defaultMarkdownSerializer.nodes.text, [Video.name]: renderPlayable, [WordBreak.name]: (state) => state.write('<wbr>'), diff --git a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js index 770de1df0d0..da10c684b0b 100644 --- a/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js +++ b/app/assets/javascripts/content_editor/services/remark_markdown_deserializer.js @@ -2,39 +2,51 @@ import { isString } from 'lodash'; import { render } from '~/lib/gfm'; import { createProseMirrorDocFromMdastTree } from './hast_to_prosemirror_converter'; +const wrappableTags = ['img', 'br', 'code', 'i', 'em', 'b', 'strong', 'a', 'strike', 's', 'del']; + +const isTaskItem = (hastNode) => { + const { className } = hastNode.properties; + + return ( + hastNode.tagName === 'li' && Array.isArray(className) && className.includes('task-list-item') + ); +}; + +const getTableCellAttrs = (hastNode) => ({ + colspan: parseInt(hastNode.properties.colSpan, 10) || 1, + rowspan: parseInt(hastNode.properties.rowSpan, 10) || 1, +}); + const factorySpecs = { - blockquote: { block: 'blockquote' }, - p: { block: 'paragraph' }, - li: { block: 'listItem', wrapTextInParagraph: true }, - ul: { block: 'bulletList' }, - ol: { block: 'orderedList' }, - h1: { - block: 'heading', - getAttrs: () => ({ level: 1 }), - }, - h2: { - block: 'heading', - getAttrs: () => ({ level: 2 }), - }, - h3: { - block: 'heading', - getAttrs: () => ({ level: 3 }), - }, - h4: { - block: 'heading', - getAttrs: () => ({ level: 4 }), - }, - h5: { - block: 'heading', - getAttrs: () => ({ level: 5 }), - }, - h6: { - block: 'heading', - getAttrs: () => ({ level: 6 }), - }, - pre: { - block: 'codeBlock', + blockquote: { type: 'block', selector: 'blockquote' }, + paragraph: { type: 'block', selector: 'p' }, + listItem: { + type: 'block', + wrapInParagraph: true, + selector: (hastNode) => hastNode.tagName === 'li' && !hastNode.properties.className, + processText: (text) => text.trimRight(), + }, + orderedList: { + type: 'block', + selector: (hastNode) => hastNode.tagName === 'ol' && !hastNode.properties.className, + }, + bulletList: { + type: 'block', + selector: (hastNode) => hastNode.tagName === 'ul' && !hastNode.properties.className, + }, + heading: { + type: 'block', + selector: (hastNode) => ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(hastNode.tagName), + getAttrs: (hastNode) => { + const level = parseInt(/(\d)$/.exec(hastNode.tagName)?.[1], 10) || 1; + + return { level }; + }, + }, + codeBlock: { + type: 'block', skipChildren: true, + selector: 'pre', getContent: ({ hastNodeText }) => hastNodeText.replace(/\n$/, ''), getAttrs: (hastNode) => { const languageClass = hastNode.children[0]?.properties.className?.[0]; @@ -43,28 +55,111 @@ const factorySpecs = { return { language }; }, }, - hr: { inline: 'horizontalRule' }, - img: { - inline: 'image', + horizontalRule: { + type: 'block', + selector: 'hr', + }, + taskList: { + type: 'block', + selector: (hastNode) => { + const { className } = hastNode.properties; + + return ( + ['ul', 'ol'].includes(hastNode.tagName) && + Array.isArray(className) && + className.includes('contains-task-list') + ); + }, + getAttrs: (hastNode) => ({ + numeric: hastNode.tagName === 'ol', + }), + }, + taskItem: { + type: 'block', + wrapInParagraph: true, + selector: isTaskItem, + getAttrs: (hastNode) => ({ + checked: hastNode.children[0].properties.checked, + }), + processText: (text) => text.trimLeft(), + }, + taskItemCheckbox: { + type: 'ignore', + selector: (hastNode, ancestors) => + hastNode.tagName === 'input' && isTaskItem(ancestors[ancestors.length - 1]), + }, + table: { + type: 'block', + selector: 'table', + }, + tableRow: { + type: 'block', + selector: 'tr', + parent: 'table', + }, + tableHeader: { + type: 'block', + selector: 'th', + getAttrs: getTableCellAttrs, + wrapInParagraph: true, + }, + tableCell: { + type: 'block', + selector: 'td', + getAttrs: getTableCellAttrs, + wrapInParagraph: true, + }, + ignoredTableNodes: { + type: 'ignore', + selector: (hastNode) => ['thead', 'tbody', 'tfoot'].includes(hastNode.tagName), + }, + footnoteDefinition: { + type: 'block', + selector: 'footnotedefinition', + getAttrs: (hastNode) => hastNode.properties, + }, + image: { + type: 'inline', + selector: 'img', getAttrs: (hastNode) => ({ src: hastNode.properties.src, title: hastNode.properties.title, alt: hastNode.properties.alt, }), }, - br: { inline: 'hardBreak' }, - code: { mark: 'code' }, - em: { mark: 'italic' }, - i: { mark: 'italic' }, - strong: { mark: 'bold' }, - b: { mark: 'bold' }, - a: { - mark: 'link', + hardBreak: { + type: 'inline', + selector: 'br', + }, + footnoteReference: { + type: 'inline', + selector: 'footnotereference', + getAttrs: (hastNode) => hastNode.properties, + }, + code: { + type: 'mark', + selector: 'code', + }, + italic: { + type: 'mark', + selector: (hastNode) => ['em', 'i'].includes(hastNode.tagName), + }, + bold: { + type: 'mark', + selector: (hastNode) => ['strong', 'b'].includes(hastNode.tagName), + }, + link: { + type: 'mark', + selector: 'a', getAttrs: (hastNode) => ({ href: hastNode.properties.href, title: hastNode.properties.title, }), }, + strike: { + type: 'mark', + selector: (hastNode) => ['strike', 's', 'del'].includes(hastNode.tagName), + }, }; export default () => { @@ -77,6 +172,7 @@ export default () => { schema, factorySpecs, tree, + wrappableTags, source: markdown, }), }); diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index 089d30edec7..88f5192af77 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -1,4 +1,4 @@ -import { uniq, isString } from 'lodash'; +import { uniq, isString, omit } from 'lodash'; const defaultAttrs = { td: { colspan: 1, rowspan: 1, colwidth: null }, @@ -12,22 +12,6 @@ const ignoreAttrs = { 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 containsOnlyText(node) { if (node.childCount === 1) { const child = node.child(0); @@ -219,7 +203,7 @@ function renderTableRowAsHTML(state, node) { node.forEach((cell, _, i) => { const tag = cell.type.name === 'tableHeader' ? 'th' : 'td'; - renderTagOpen(state, tag, cell.attrs); + renderTagOpen(state, tag, omit(cell.attrs, 'sourceMapKey', 'sourceMarkdown')); if (!containsParagraphWithOnlyText(cell)) { state.closeBlock(node); @@ -272,19 +256,6 @@ export function renderHTMLNode(tagName, forceRenderContentInline = false) { }; } -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 (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) { state.renderInline(node.child(0)); @@ -364,7 +335,72 @@ export function preserveUnchanged(render) { }; } -const generateBoldTags = (open = true) => { +/** + * We extracted this function from + * https://github.com/ProseMirror/prosemirror-markdown/blob/master/src/to_markdown.ts#L350. + * + * We need to overwrite this function because we don’t want to wrap the list item nodes + * with the bullet delimiter when the list item node hasn’t changed + */ +const renderList = (state, node, delim, firstDelim) => { + if (state.closed && state.closed.type === node.type) state.flushClose(3); + else if (state.inTightList) state.flushClose(1); + + const isTight = + typeof node.attrs.tight !== 'undefined' ? node.attrs.tight : state.options.tightLists; + const prevTight = state.inTightList; + + state.inTightList = isTight; + + node.forEach((child, _, i) => { + const same = state.options.changeTracker.get(child); + + if (i && isTight) { + state.flushClose(1); + } + + if (same) { + // Avoid wrapping list item when node hasn’t changed + state.render(child, node, i); + } else { + state.wrapBlock(delim, firstDelim(i), node, () => state.render(child, node, i)); + } + }); + + state.inTightList = prevTight; +}; + +export const renderBulletList = (state, node) => { + const { sourceMarkdown, bullet: bulletAttr } = node.attrs; + const bullet = /^(\*|\+|-)\s/.exec(sourceMarkdown)?.[1] || bulletAttr || '*'; + + renderList(state, node, ' ', () => `${bullet} `); +}; + +export function renderOrderedList(state, node) { + const { sourceMarkdown } = node.attrs; + let start; + let delimiter; + + if (sourceMarkdown) { + const match = /^(\d+)(\)|\.)/.exec(sourceMarkdown); + start = parseInt(match[1], 10) || 1; + [, , delimiter] = match; + } else { + start = node.attrs.start || 1; + delimiter = node.attrs.parens ? ')' : '.'; + } + + const maxW = String(start + node.childCount - 1).length; + const space = state.repeat(' ', maxW + 2); + + renderList(state, node, space, (i) => { + const nStr = String(start + i); + return `${state.repeat(' ', maxW - nStr.length) + nStr}${delimiter} `; + }); +} + +const generateBoldTags = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*\*|__|<strong|<b).*/.exec(mark.attrs.sourceMarkdown)?.[1]; @@ -375,7 +411,7 @@ const generateBoldTags = (open = true) => { // eslint-disable-next-line @gitlab/require-i18n-strings case '<strong': case '<b': - return (open ? openTag : closeTag)(type.substring(1)); + return wrapTagName(type.substring(1)); default: return '**'; } @@ -384,12 +420,12 @@ const generateBoldTags = (open = true) => { export const bold = { open: generateBoldTags(), - close: generateBoldTags(false), + close: generateBoldTags(closeTag), mixable: true, expelEnclosingWhitespace: true, }; -const generateItalicTag = (open = true) => { +const generateItalicTag = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(\*|_|<em|<i).*/.exec(mark.attrs.sourceMarkdown)?.[1]; @@ -400,7 +436,7 @@ const generateItalicTag = (open = true) => { // eslint-disable-next-line @gitlab/require-i18n-strings case '<em': case '<i': - return (open ? openTag : closeTag)(type.substring(1)); + return wrapTagName(type.substring(1)); default: return '_'; } @@ -409,17 +445,17 @@ const generateItalicTag = (open = true) => { export const italic = { open: generateItalicTag(), - close: generateItalicTag(false), + close: generateItalicTag(closeTag), mixable: true, expelEnclosingWhitespace: true, }; -const generateCodeTag = (open = true) => { +const generateCodeTag = (wrapTagName = openTag) => { return (_, mark) => { const type = /^(`|<code).*/.exec(mark.attrs.sourceMarkdown)?.[1]; if (type === '<code') { - return (open ? openTag : closeTag)(type.substring(1)); + return wrapTagName(type.substring(1)); } return '`'; @@ -428,7 +464,7 @@ const generateCodeTag = (open = true) => { export const code = { open: generateCodeTag(), - close: generateCodeTag(false), + close: generateCodeTag(closeTag), mixable: true, expelEnclosingWhitespace: true, }; @@ -446,10 +482,79 @@ const linkType = (sourceMarkdown) => { return LINK_HTML; }; +const removeUrlProtocol = (url) => url.replace(/^\w+:\/?\/?/, ''); + +const normalizeUrl = (url) => decodeURIComponent(removeUrlProtocol(url)); + +/** + * Validates that the provided URL is well-formed + * + * @param {String} url + * @returns Returns true when the browser’s URL constructor + * can successfully parse the URL string + */ +const isValidUrl = (url) => { + try { + return new URL(url) && true; + } catch { + return false; + } +}; + +const findChildWithMark = (mark, parent) => { + let child; + let offset; + let index; + + parent.forEach((_child, _offset, _index) => { + if (mark.isInSet(_child.marks)) { + child = _child; + offset = _offset; + index = _index; + } + }); + + return child ? { child, offset, index } : null; +}; + +/** + * This function detects whether a link should be serialized + * as an autolink. + * + * See https://github.github.com/gfm/#autolinks-extension- + * to understand the parsing rules of autolinks. + * */ +const isAutoLink = (linkMark, parent) => { + const { title, href } = linkMark.attrs; + + if (title || !/^\w+:/.test(href)) { + return false; + } + + const { child } = findChildWithMark(linkMark, parent); + + if ( + !child || + !child.isText || + !isValidUrl(href) || + normalizeUrl(child.text) !== normalizeUrl(href) + ) { + return false; + } + + return true; +}; + +/** + * Returns true if the user used brackets to the define + * the autolink in the original markdown source + */ +const isBracketAutoLink = (sourceMarkdown) => /^<.+?>$/.test(sourceMarkdown); + export const link = { - open(state, mark, parent, index) { - if (isPlainURL(mark, parent, index, 1)) { - return '<'; + open(state, mark, parent) { + if (isAutoLink(mark, parent)) { + return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '<' : ''; } const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; @@ -466,9 +571,9 @@ export const link = { return openTag('a', attrs); }, - close(state, mark, parent, index) { - if (isPlainURL(mark, parent, index, -1)) { - return '>'; + close(state, mark, parent) { + if (isAutoLink(mark, parent)) { + return isBracketAutoLink(mark.attrs.sourceMarkdown) ? '>' : ''; } const { canonicalSrc, href, title, sourceMarkdown } = mark.attrs; @@ -480,3 +585,28 @@ export const link = { return `](${state.esc(canonicalSrc || href)}${title ? ` ${state.quote(title)}` : ''})`; }, }; + +const generateStrikeTag = (wrapTagName = openTag) => { + return (_, mark) => { + const type = /^(~~|<del|<strike|<s).*/.exec(mark.attrs.sourceMarkdown)?.[1]; + + switch (type) { + case '~~': + return type; + /* eslint-disable @gitlab/require-i18n-strings */ + case '<del': + case '<strike': + case '<s': + return wrapTagName(type.substring(1)); + default: + return '~~'; + } + }; +}; + +export const strike = { + open: generateStrikeTag(), + close: generateStrikeTag(closeTag), + mixable: true, + expelEnclosingWhitespace: true, +}; |