diff options
Diffstat (limited to 'app/assets/javascripts/content_editor')
38 files changed, 1164 insertions, 175 deletions
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a372233e543..02ab34447ca 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -100,11 +100,13 @@ export default { :class="{ 'is-focused': focused }" > <top-toolbar ref="toolbar" class="gl-mb-4" /> - <formatting-bubble-menu /> <div v-if="isLoadingContent" class="gl-w-full gl-display-flex gl-justify-content-center"> <gl-loading-icon size="sm" /> </div> - <tiptap-editor-content v-else class="md" :editor="contentEditor.tiptapEditor" /> + <template v-else> + <formatting-bubble-menu /> + <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> + </template> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue index 6c00480b87e..14a553ff30b 100644 --- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue @@ -20,7 +20,11 @@ export default { }; </script> <template> - <bubble-menu class="gl-shadow gl-rounded-base" :editor="tiptapEditor"> + <bubble-menu + data-testid="formatting-bubble-menu" + class="gl-shadow gl-rounded-base" + :editor="tiptapEditor" + > <gl-button-group> <toolbar-button data-testid="bold" diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue index 3762324a431..5b81e5fddcc 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/image.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -22,6 +22,7 @@ export default { <img data-testid="image" class="gl-max-w-full gl-h-auto" + :title="node.attrs.title" :class="{ 'gl-opacity-5': node.attrs.uploading }" :src="node.attrs.src" /> 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 new file mode 100644 index 00000000000..c44e8145982 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_base.vue @@ -0,0 +1,142 @@ +<script> +import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { selectedRect as getSelectedRect } from 'prosemirror-tables'; +import { __ } from '~/locale'; + +const TABLE_CELL_HEADER = 'th'; +const TABLE_CELL_BODY = 'td'; + +export default { + name: 'TableCellBaseWrapper', + components: { + NodeViewWrapper, + NodeViewContent, + GlDropdown, + GlDropdownItem, + GlDropdownDivider, + }, + props: { + cellType: { + type: String, + validator: (type) => [TABLE_CELL_HEADER, TABLE_CELL_BODY].includes(type), + required: true, + }, + editor: { + type: Object, + required: true, + }, + getPos: { + type: Function, + required: true, + }, + }, + data() { + return { + displayActionsDropdown: false, + preventHide: true, + selectedRect: null, + }; + }, + computed: { + totalRows() { + return this.selectedRect?.map.height; + }, + totalCols() { + return this.selectedRect?.map.width; + }, + isTableBodyCell() { + return this.cellType === TABLE_CELL_BODY; + }, + }, + mounted() { + this.editor.on('selectionUpdate', this.handleSelectionUpdate); + this.handleSelectionUpdate(); + }, + beforeDestroy() { + this.editor.off('selectionUpdate', this.handleSelectionUpdate); + }, + methods: { + handleSelectionUpdate() { + const { state } = this.editor; + const { $cursor } = state.selection; + + this.displayActionsDropdown = $cursor?.pos - $cursor?.parentOffset - 1 === this.getPos(); + if (this.displayActionsDropdown) { + this.selectedRect = getSelectedRect(state); + } + }, + runCommand(command) { + this.editor.chain()[command]().run(); + this.hideDropdown(); + }, + handleHide($event) { + if (this.preventHide) { + $event.preventDefault(); + } + this.preventHide = true; + }, + hideDropdown() { + this.preventHide = false; + this.$refs.dropdown?.hide(); + }, + }, + i18n: { + insertColumnBefore: __('Insert column before'), + insertColumnAfter: __('Insert column after'), + insertRowBefore: __('Insert row before'), + insertRowAfter: __('Insert row after'), + deleteRow: __('Delete row'), + deleteColumn: __('Delete column'), + deleteTable: __('Delete table'), + editTableActions: __('Edit table'), + }, +}; +</script> +<template> + <node-view-wrapper + class="gl-relative gl-padding-5 gl-min-w-10" + :as="cellType" + @click="hideDropdown" + > + <span v-if="displayActionsDropdown" class="gl-absolute gl-right-0 gl-top-0"> + <gl-dropdown + ref="dropdown" + dropup + icon="chevron-down" + size="small" + category="tertiary" + boundary="viewport" + no-caret + text-sr-only + :text="$options.i18n.editTableActions" + :popper-opts="{ positionFixed: true }" + @hide="handleHide($event)" + > + <gl-dropdown-item @click="runCommand('addColumnBefore')"> + {{ $options.i18n.insertColumnBefore }} + </gl-dropdown-item> + <gl-dropdown-item @click="runCommand('addColumnAfter')"> + {{ $options.i18n.insertColumnAfter }} + </gl-dropdown-item> + <gl-dropdown-item v-if="isTableBodyCell" @click="runCommand('addRowBefore')"> + {{ $options.i18n.insertRowBefore }} + </gl-dropdown-item> + <gl-dropdown-item @click="runCommand('addRowAfter')"> + {{ $options.i18n.insertRowAfter }} + </gl-dropdown-item> + <gl-dropdown-divider /> + <gl-dropdown-item v-if="totalRows > 2 && isTableBodyCell" @click="runCommand('deleteRow')"> + {{ $options.i18n.deleteRow }} + </gl-dropdown-item> + <gl-dropdown-item v-if="totalCols > 1" @click="runCommand('deleteColumn')"> + {{ $options.i18n.deleteColumn }} + </gl-dropdown-item> + <gl-dropdown-item @click="runCommand('deleteTable')"> + {{ $options.i18n.deleteTable }} + </gl-dropdown-item> + </gl-dropdown> + </span> + <node-view-content /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue new file mode 100644 index 00000000000..6b4343dd5b8 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_body.vue @@ -0,0 +1,23 @@ +<script> +import TableCellBase from './table_cell_base.vue'; + +export default { + name: 'TableCellBody', + components: { + TableCellBase, + }, + props: { + editor: { + type: Object, + required: true, + }, + getPos: { + type: Function, + required: true, + }, + }, +}; +</script> +<template> + <table-cell-base cell-type="td" v-bind="$props" /> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue new file mode 100644 index 00000000000..5f9889374f6 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/table_cell_header.vue @@ -0,0 +1,23 @@ +<script> +import TableCellBase from './table_cell_base.vue'; + +export default { + name: 'TableCellHeader', + components: { + TableCellBase, + }, + props: { + editor: { + type: Object, + required: true, + }, + getPos: { + type: Function, + required: true, + }, + }, +}; +</script> +<template> + <table-cell-base cell-type="th" v-bind="$props" /> +</template> diff --git a/app/assets/javascripts/content_editor/constants.js b/app/assets/javascripts/content_editor/constants.js index f277508f628..4af9dc8e405 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/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js new file mode 100644 index 00000000000..8f2ce8feb5d --- /dev/null +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -0,0 +1,27 @@ +import { ContentEditor } from './index'; + +export default { + component: ContentEditor, + title: 'Components/Content Editor', +}; + +const Template = (_, { argTypes }) => ({ + components: { ContentEditor }, + props: Object.keys(argTypes), + template: '<content-editor v-bind="$props" @initialized="loadContent" />', + methods: { + loadContent(contentEditor) { + // eslint-disable-next-line @gitlab/require-i18n-strings + contentEditor.setSerializedContent('Hello content editor'); + }, + }, +}); + +export const Default = Template.bind({}); + +Default.args = { + renderMarkdown: () => '<p>Hello content editor</p>', + uploadsPath: '/uploads/', + serializerConfig: {}, + extensions: [], +}; diff --git a/app/assets/javascripts/content_editor/extensions/audio.js b/app/assets/javascripts/content_editor/extensions/audio.js new file mode 100644 index 00000000000..25d4068c93f --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/audio.js @@ -0,0 +1,9 @@ +import Playable from './playable'; + +export default Playable.extend({ + name: 'audio', + defaultOptions: { + ...Playable.options, + mediaType: 'audio', + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/blockquote.js b/app/assets/javascripts/content_editor/extensions/blockquote.js index 45f53fe230b..4512ead44bc 100644 --- a/app/assets/javascripts/content_editor/extensions/blockquote.js +++ b/app/assets/javascripts/content_editor/extensions/blockquote.js @@ -1 +1,33 @@ -export { Blockquote as default } from '@tiptap/extension-blockquote'; +import { Blockquote } from '@tiptap/extension-blockquote'; +import { wrappingInputRule } from 'prosemirror-inputrules'; +import { getParents } from '~/lib/utils/dom_utils'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export const multilineInputRegex = /^\s*>>>\s$/gm; + +export default Blockquote.extend({ + addAttributes() { + return { + ...this.parent?.(), + + multiline: { + default: false, + parseHTML: (element) => { + const source = getMarkdownSource(element); + const parentsIncludeBlockquote = getParents(element).some( + (p) => p.nodeName.toLowerCase() === 'blockquote', + ); + + return source && !source.startsWith('>') && !parentsIncludeBlockquote; + }, + }, + }; + }, + + addInputRules() { + return [ + ...this.parent?.(), + wrappingInputRule(multilineInputRegex, this.type, () => ({ multiline: true })), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/bullet_list.js b/app/assets/javascripts/content_editor/extensions/bullet_list.js index 01ead571fe1..8d0faf7a9fe 100644 --- a/app/assets/javascripts/content_editor/extensions/bullet_list.js +++ b/app/assets/javascripts/content_editor/extensions/bullet_list.js @@ -1 +1,19 @@ -export { BulletList as default } from '@tiptap/extension-bullet-list'; +import { BulletList } from '@tiptap/extension-bullet-list'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export default BulletList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + bullet: { + default: '*', + parseHTML(element) { + const bullet = getMarkdownSource(element)?.charAt(0); + + return '*+-'.includes(bullet) ? bullet : '*'; + }, + }, + }; + }, +}); 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 c6d32fb8547..25f5837d2a6 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -8,11 +8,7 @@ export default CodeBlockLowlight.extend({ return { language: { default: null, - parseHTML: (element) => { - return { - language: extractLanguage(element), - }; - }, + parseHTML: (element) => extractLanguage(element), }, class: { default: 'code highlight js-syntax-highlight', diff --git a/app/assets/javascripts/content_editor/extensions/description_item.js b/app/assets/javascripts/content_editor/extensions/description_item.js new file mode 100644 index 00000000000..957fdede27b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/description_item.js @@ -0,0 +1,49 @@ +import { Node, mergeAttributes } from '@tiptap/core'; + +export default Node.create({ + name: 'descriptionItem', + content: 'block+', + defining: true, + + addAttributes() { + return { + isTerm: { + default: true, + parseHTML: (element) => element.tagName.toLowerCase() === 'dt', + }, + }; + }, + + parseHTML() { + return [{ tag: 'dt' }, { tag: 'dd' }]; + }, + + renderHTML({ HTMLAttributes: { isTerm, ...HTMLAttributes } }) { + return [ + 'li', + mergeAttributes(HTMLAttributes, { class: isTerm ? 'dl-term' : 'dl-description' }), + 0, + ]; + }, + + addKeyboardShortcuts() { + return { + Enter: () => { + return this.editor.commands.splitListItem('descriptionItem'); + }, + Tab: () => { + const { isTerm } = this.editor.getAttributes('descriptionItem'); + if (isTerm) + return this.editor.commands.updateAttributes('descriptionItem', { isTerm: !isTerm }); + + return false; + }, + 'Shift-Tab': () => { + const { isTerm } = this.editor.getAttributes('descriptionItem'); + if (isTerm) return this.editor.commands.liftListItem('descriptionItem'); + + return this.editor.commands.updateAttributes('descriptionItem', { isTerm: true }); + }, + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/description_list.js b/app/assets/javascripts/content_editor/extensions/description_list.js new file mode 100644 index 00000000000..a516dfad2b8 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/description_list.js @@ -0,0 +1,23 @@ +import { Node, mergeAttributes } from '@tiptap/core'; +import { wrappingInputRule } from 'prosemirror-inputrules'; + +export const inputRegex = /^\s*(<dl>)$/; + +export default Node.create({ + name: 'descriptionList', + // eslint-disable-next-line @gitlab/require-i18n-strings + group: 'block list', + content: 'descriptionItem+', + + parseHTML() { + return [{ tag: 'dl' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ul', mergeAttributes(HTMLAttributes, { class: 'dl-content' }), 0]; + }, + + addInputRules() { + return [wrappingInputRule(inputRegex, this.type)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/division.js b/app/assets/javascripts/content_editor/extensions/division.js new file mode 100644 index 00000000000..c70d1700941 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/division.js @@ -0,0 +1,17 @@ +import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_LOWEST } from '../constants'; + +export default Node.create({ + name: 'division', + content: 'block*', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'div', priority: PARSE_HTML_PRIORITY_LOWEST }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['div', HTMLAttributes, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index d88b9f92215..de608c3aaa2 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -17,30 +17,18 @@ export default Node.create({ return { moji: { default: null, - parseHTML: (element) => { - return { - moji: element.textContent, - }; - }, + parseHTML: (element) => element.textContent, }, name: { default: null, - parseHTML: (element) => { - return { - name: element.dataset.name, - }; - }, + parseHTML: (element) => element.dataset.name, }, title: { default: null, }, unicodeVersion: { default: '6.0', - parseHTML: (element) => { - return { - unicodeVersion: element.dataset.unicodeVersion, - }; - }, + parseHTML: (element) => element.dataset.unicodeVersion, }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/figure.js b/app/assets/javascripts/content_editor/extensions/figure.js new file mode 100644 index 00000000000..b2076894412 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/figure.js @@ -0,0 +1,16 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'figure', + content: 'block+', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'figure' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['figure', HTMLAttributes, 0]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/figure_caption.js b/app/assets/javascripts/content_editor/extensions/figure_caption.js new file mode 100644 index 00000000000..ffd1b474f03 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/figure_caption.js @@ -0,0 +1,16 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'figureCaption', + content: 'inline*', + group: 'block', + defining: true, + + parseHTML() { + return [{ tag: 'figcaption' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['figcaption', HTMLAttributes, 0]; + }, +}); 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 00000000000..54adb9efa0c --- /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) => 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 c9e8dfa4ad9..837fab0585f 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'); @@ -27,27 +28,27 @@ export default Image.extend({ parseHTML: (element) => { const img = resolveImageEl(element); - return { - src: img.dataset.src || img.getAttribute('src'), - }; + return img.dataset.src || img.getAttribute('src'); }, }, canonicalSrc: { default: null, + parseHTML: (element) => element.dataset.canonicalSrc, + }, + alt: { + default: null, parseHTML: (element) => { - return { - canonicalSrc: element.dataset.canonicalSrc, - }; + const img = resolveImageEl(element); + + return img.getAttribute('alt'); }, }, - alt: { + title: { default: null, parseHTML: (element) => { const img = resolveImageEl(element); - return { - alt: img.getAttribute('alt'), - }; + return img.getAttribute('title'); }, }, }; @@ -55,7 +56,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/inline_diff.js b/app/assets/javascripts/content_editor/extensions/inline_diff.js index 9471d324764..3bd328958df 100644 --- a/app/assets/javascripts/content_editor/extensions/inline_diff.js +++ b/app/assets/javascripts/content_editor/extensions/inline_diff.js @@ -14,11 +14,7 @@ export default Mark.create({ return { type: { default: 'addition', - parseHTML: (element) => { - return { - type: element.classList.contains('deletion') ? 'deletion' : 'addition', - }; - }, + parseHTML: (element) => (element.classList.contains('deletion') ? 'deletion' : 'addition'), }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/link.js b/app/assets/javascripts/content_editor/extensions/link.js index 53104fe07a3..fc0f38e6935 100644 --- a/app/assets/javascripts/content_editor/extensions/link.js +++ b/app/assets/javascripts/content_editor/extensions/link.js @@ -36,19 +36,15 @@ export default Link.extend({ ...this.parent?.(), href: { default: null, - parseHTML: (element) => { - return { - href: element.getAttribute('href'), - }; - }, + parseHTML: (element) => element.getAttribute('href'), + }, + title: { + title: null, + parseHTML: (element) => element.getAttribute('title'), }, canonicalSrc: { default: null, - parseHTML: (element) => { - return { - canonicalSrc: element.dataset.canonicalSrc, - }; - }, + parseHTML: (element) => element.dataset.canonicalSrc, }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/ordered_list.js b/app/assets/javascripts/content_editor/extensions/ordered_list.js index 9a79187d9c1..57d5bd6ebf8 100644 --- a/app/assets/javascripts/content_editor/extensions/ordered_list.js +++ b/app/assets/javascripts/content_editor/extensions/ordered_list.js @@ -1 +1,15 @@ -export { OrderedList as default } from '@tiptap/extension-ordered-list'; +import { OrderedList } from '@tiptap/extension-ordered-list'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; + +export default OrderedList.extend({ + addAttributes() { + return { + ...this.parent?.(), + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), + }, + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/playable.js b/app/assets/javascripts/content_editor/extensions/playable.js new file mode 100644 index 00000000000..0062bc563db --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/playable.js @@ -0,0 +1,66 @@ +/* eslint-disable @gitlab/require-i18n-strings */ + +import { Node } from '@tiptap/core'; + +const queryPlayableElement = (element, mediaType) => element.querySelector(mediaType); + +export default Node.create({ + group: 'inline', + inline: true, + draggable: true, + + addAttributes() { + return { + src: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.src; + }, + }, + canonicalSrc: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.dataset.canonicalSrc; + }, + }, + alt: { + default: null, + parseHTML: (element) => { + const playable = queryPlayableElement(element, this.options.mediaType); + + return playable.dataset.title; + }, + }, + }; + }, + + parseHTML() { + return [ + { + tag: `.${this.options.mediaType}-container`, + }, + ]; + }, + + renderHTML({ node }) { + return [ + 'span', + { class: `media-container ${this.options.mediaType}-container` }, + [ + this.options.mediaType, + { + src: node.attrs.src, + controls: true, + 'data-setup': '{}', + 'data-title': node.attrs.alt, + ...this.extraElementAttrs, + }, + ], + ['a', { href: node.attrs.src }, node.attrs.alt], + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index 5f4484af9c8..5e459e65de2 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', @@ -13,43 +19,23 @@ export default Node.create({ return { className: { default: null, - parseHTML: (element) => { - return { - className: element.className, - }; - }, + parseHTML: (element) => getAnchor(element).className, }, referenceType: { default: null, - parseHTML: (element) => { - return { - referenceType: element.dataset.referenceType, - }; - }, + parseHTML: (element) => getAnchor(element).dataset.referenceType, }, originalText: { default: null, - parseHTML: (element) => { - return { - originalText: element.dataset.original, - }; - }, + parseHTML: (element) => getAnchor(element).dataset.original, }, href: { default: null, - parseHTML: (element) => { - return { - href: element.getAttribute('href'), - }; - }, + parseHTML: (element) => getAnchor(element).getAttribute('href'), }, text: { default: null, - parseHTML: (element) => { - return { - text: element.textContent, - }; - }, + parseHTML: (element) => getAnchor(element).textContent, }, }; }, @@ -58,7 +44,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 4bf89796efe..d0766f42308 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 3eb7d86d90d..6cd814977ea 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/table_cell.js b/app/assets/javascripts/content_editor/extensions/table_cell.js index 5bdc39231a1..befc33e669f 100644 --- a/app/assets/javascripts/content_editor/extensions/table_cell.js +++ b/app/assets/javascripts/content_editor/extensions/table_cell.js @@ -1,5 +1,12 @@ import { TableCell } from '@tiptap/extension-table-cell'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import TableCellBodyWrapper from '../components/wrappers/table_cell_body.vue'; +import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableCell.extend({ - content: 'inline*', + content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + + addNodeView() { + return VueNodeViewRenderer(TableCellBodyWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/table_header.js b/app/assets/javascripts/content_editor/extensions/table_header.js index 23509706e4b..829b06fc14b 100644 --- a/app/assets/javascripts/content_editor/extensions/table_header.js +++ b/app/assets/javascripts/content_editor/extensions/table_header.js @@ -1,5 +1,11 @@ import { TableHeader } from '@tiptap/extension-table-header'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import TableCellHeaderWrapper from '../components/wrappers/table_cell_header.vue'; +import { isBlockTablesFeatureEnabled } from '../services/feature_flags'; export default TableHeader.extend({ - content: 'inline*', + content: isBlockTablesFeatureEnabled() ? 'block+' : 'inline*', + addNodeView() { + return VueNodeViewRenderer(TableCellHeaderWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/task_item.js b/app/assets/javascripts/content_editor/extensions/task_item.js index 6163c0e043b..9b050edcb28 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: { @@ -12,7 +13,8 @@ export default TaskItem.extend({ default: false, parseHTML: (element) => { const checkbox = element.querySelector('input[type=checkbox].task-list-item-checkbox'); - return { checked: checkbox?.checked }; + + return checkbox?.checked; }, renderHTML: (attributes) => ({ 'data-checked': attributes.checked, @@ -26,7 +28,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 b7f6c857bc7..72c6e020102 100644 --- a/app/assets/javascripts/content_editor/extensions/task_list.js +++ b/app/assets/javascripts/content_editor/extensions/task_list.js @@ -1,16 +1,24 @@ import { mergeAttributes } from '@tiptap/core'; import { TaskList } from '@tiptap/extension-task-list'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { getMarkdownSource } from '../services/markdown_sourcemap'; export default TaskList.extend({ addAttributes() { return { - type: { - default: 'ul', - parseHTML: (element) => { - return { - type: element.tagName.toLowerCase() === 'ol' ? 'ol' : 'ul', - }; - }, + numeric: { + default: false, + parseHTML: (element) => element.tagName.toLowerCase() === 'ol', + }, + start: { + default: 1, + parseHTML: (element) => + element.hasAttribute('start') ? parseInt(element.getAttribute('start') || '', 10) : 1, + }, + + parens: { + default: false, + parseHTML: (element) => /^[0-9]+\)/.test(getMarkdownSource(element)), }, }; }, @@ -19,12 +27,12 @@ export default TaskList.extend({ return [ { tag: '.task-list', - priority: 100, + priority: PARSE_HTML_PRIORITY_HIGHEST, }, ]; }, - renderHTML({ HTMLAttributes: { type, ...HTMLAttributes } }) { - return [type, mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; + renderHTML({ HTMLAttributes: { numeric, ...HTMLAttributes } }) { + return [numeric ? 'ol' : 'ul', mergeAttributes(HTMLAttributes, { 'data-type': 'taskList' }), 0]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/video.js b/app/assets/javascripts/content_editor/extensions/video.js new file mode 100644 index 00000000000..9923b7c04cd --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/video.js @@ -0,0 +1,10 @@ +import Playable from './playable'; + +export default Playable.extend({ + name: 'video', + defaultOptions: { + ...Playable.options, + mediaType: 'video', + extraElementAttrs: { width: '400' }, + }, +}); 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 8997960203a..9b2d4c9a062 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -2,19 +2,26 @@ import { Editor } from '@tiptap/vue-2'; import { isFunction } from 'lodash'; import { PROVIDE_SERIALIZER_OR_RENDERER_ERROR } from '../constants'; import Attachment from '../extensions/attachment'; +import Audio from '../extensions/audio'; import Blockquote from '../extensions/blockquote'; import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import DescriptionItem from '../extensions/description_item'; +import DescriptionList from '../extensions/description_list'; +import Division from '../extensions/division'; import Document from '../extensions/document'; import Dropcursor from '../extensions/dropcursor'; import Emoji from '../extensions/emoji'; +import Figure from '../extensions/figure'; +import FigureCaption from '../extensions/figure_caption'; import Gapcursor from '../extensions/gapcursor'; 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'; @@ -34,6 +41,7 @@ import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; +import Video from '../extensions/video'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; @@ -62,19 +70,26 @@ export const createContentEditor = ({ const builtInContentEditorExtensions = [ Attachment.configure({ uploadsPath, renderMarkdown }), + Audio, Blockquote, Bold, BulletList, Code, CodeBlockHighlight, + DescriptionItem, + DescriptionList, Document, + Division, Dropcursor, Emoji, + Figure, + FigureCaption, Gapcursor, HardBreak, Heading, History, HorizontalRule, + ...HTMLMarks, Image, InlineDiff, Italic, @@ -94,6 +109,7 @@ export const createContentEditor = ({ TaskItem, TaskList, Text, + Video, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/feature_flags.js b/app/assets/javascripts/content_editor/services/feature_flags.js new file mode 100644 index 00000000000..5f7a4595938 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/feature_flags.js @@ -0,0 +1,3 @@ +export function isBlockTablesFeatureEnabled() { + return gon.features?.contentEditorBlockTables; +} 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 00000000000..6ccfed7810a --- /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 df4d31c3d7f..bc6d98511f9 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -3,15 +3,22 @@ import { defaultMarkdownSerializer, } from 'prosemirror-markdown/src/to_markdown'; import { DOMParser as ProseMirrorDOMParser } from 'prosemirror-model'; +import Audio from '../extensions/audio'; import Blockquote from '../extensions/blockquote'; import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import DescriptionItem from '../extensions/description_item'; +import DescriptionList from '../extensions/description_list'; +import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; +import Figure from '../extensions/figure'; +import FigureCaption from '../extensions/figure_caption'; 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'; @@ -30,6 +37,20 @@ import TableRow from '../extensions/table_row'; import TaskItem from '../extensions/task_item'; import TaskList from '../extensions/task_list'; import Text from '../extensions/text'; +import Video from '../extensions/video'; +import { + isPlainURL, + renderHardBreak, + renderTable, + renderTableCell, + renderTableRow, + openTag, + closeTag, + renderOrderedList, + renderImage, + renderPlayable, + renderHTMLNode, +} from './serialization_helpers'; const defaultSerializerConfig = { marks: { @@ -48,14 +69,15 @@ const defaultSerializerConfig = { }, }, [Link.name]: { - open() { - return '['; + open(state, mark, parent, index) { + return isPlainURL(mark, parent, index, 1) ? '<' : '['; }, - close(state, mark) { + close(state, mark, parent, index) { const href = mark.attrs.canonicalSrc || mark.attrs.href; - return `](${state.esc(href)}${ - mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : '' - })`; + + return isPlainURL(mark, parent, index, -1) + ? '>' + : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; }, }, [Strike.name]: { @@ -64,9 +86,35 @@ 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: { - [Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote, + [Audio.name]: renderPlayable, + [Blockquote.name]: (state, node) => { + if (node.attrs.multiline) { + state.write('>>>'); + state.ensureNewLine(); + state.renderContent(node); + state.ensureNewLine(); + state.write('>>>'); + state.closeBlock(node); + } else { + state.wrapBlock('> ', null, node, () => state.renderContent(node)); + } + }, [BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list, [CodeBlockHighlight.name]: (state, node) => { state.write(`\`\`\`${node.attrs.language || ''}\n`); @@ -75,94 +123,47 @@ const defaultSerializerConfig = { state.write('```'); state.closeBlock(node); }, + [Division.name]: renderHTMLNode('div'), + [DescriptionList.name]: renderHTMLNode('dl', true), + [DescriptionItem.name]: (state, node, parent, index) => { + if (index === 1) state.ensureNewLine(); + renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); + if (index === parent.childCount - 1) state.ensureNewLine(); + }, [Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, - [HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break, + [Figure.name]: renderHTMLNode('figure'), + [FigureCaption.name]: renderHTMLNode('figcaption'), + [HardBreak.name]: renderHardBreak, [Heading.name]: defaultMarkdownSerializer.nodes.heading, [HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule, - [Image.name]: (state, node) => { - const { alt, canonicalSrc, src, title } = node.attrs; - const quotedTitle = title ? ` ${state.quote(title)}` : ''; - - state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); - }, + [Image.name]: renderImage, [ListItem.name]: defaultMarkdownSerializer.nodes.list_item, - [OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list, + [OrderedList.name]: renderOrderedList, [Paragraph.name]: defaultMarkdownSerializer.nodes.paragraph, [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, - [Table.name]: (state, node) => { - state.renderContent(node); - }, - [TableCell.name]: (state, node) => { - state.renderInline(node); - }, - [TableHeader.name]: (state, node) => { - state.renderInline(node); - }, - [TableRow.name]: (state, node) => { - const isHeaderRow = node.child(0).type.name === 'tableHeader'; - - const renderRow = () => { - const cellWidths = []; - - state.flushClose(1); - - state.write('| '); - node.forEach((cell, _, i) => { - if (i) state.write(' | '); - - const { length } = state.out; - state.render(cell, node, i); - cellWidths.push(state.out.length - length); - }); - state.write(' |'); - - state.closeBlock(node); - - return cellWidths; - }; - - const renderHeaderRow = (cellWidths) => { - state.flushClose(1); - - state.write('|'); - node.forEach((cell, _, i) => { - if (i) state.write('|'); - - state.write(cell.attrs.align === 'center' ? ':' : '-'); - state.write(state.repeat('-', cellWidths[i])); - state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); - }); - state.write('|'); - - state.closeBlock(node); - }; - - if (isHeaderRow) { - renderHeaderRow(renderRow()); - } else { - renderRow(); - } - }, + [Table.name]: renderTable, + [TableCell.name]: renderTableCell, + [TableHeader.name]: renderTableCell, + [TableRow.name]: renderTableRow, [TaskItem.name]: (state, node) => { state.write(`[${node.attrs.checked ? 'x' : ' '}] `); state.renderContent(node); }, [TaskList.name]: (state, node) => { - if (node.attrs.type === 'ul') defaultMarkdownSerializer.nodes.bullet_list(state, node); - else defaultMarkdownSerializer.nodes.ordered_list(state, node); + if (node.attrs.numeric) renderOrderedList(state, node); + else defaultMarkdownSerializer.nodes.bullet_list(state, node); }, [Text.name]: defaultMarkdownSerializer.nodes.text, + [Video.name]: renderPlayable, }, }; -const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; - /** * A markdown serializer converts arbitrary Markdown content * into a ProseMirror document and viceversa. To convert Markdown @@ -175,7 +176,7 @@ const wrapHtmlPayload = (payload) => `<div>${payload}</div>`; * that parses the Markdown and converts it into HTML. * @returns a markdown serializer */ -export default ({ render = () => null, serializerConfig }) => ({ +export default ({ render = () => null, serializerConfig = {} } = {}) => ({ /** * Converts a Markdown string into a ProseMirror JSONDocument based * on a ProseMirror schema. @@ -187,15 +188,15 @@ export default ({ render = () => null, serializerConfig }) => ({ deserialize: async ({ schema, content }) => { const html = await render(content); - if (!html) { - return null; - } + if (!html) return null; const parser = new DOMParser(); - const { - body: { firstElementChild }, - } = parser.parseFromString(wrapHtmlPayload(html), 'text/html'); - const state = ProseMirrorDOMParser.fromSchema(schema).parse(firstElementChild); + const { body } = parser.parseFromString(html, 'text/html'); + + // append original source as a comment that nodes can access + body.append(document.createComment(content)); + + const state = ProseMirrorDOMParser.fromSchema(schema).parse(body); return state.toJSON(); }, diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js new file mode 100644 index 00000000000..a1199589c9b --- /dev/null +++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js @@ -0,0 +1,40 @@ +const getFullSource = (element) => { + const commentNode = element.ownerDocument.body.lastChild; + + if (commentNode.nodeName === '#comment') { + return commentNode.textContent.split('\n'); + } + + return []; +}; + +const getRangeFromSourcePos = (sourcePos) => { + const [start, end] = sourcePos.split('-'); + const [startRow, startCol] = start.split(':'); + const [endRow, endCol] = end.split(':'); + + return { + start: { row: Number(startRow) - 1, col: Number(startCol) - 1 }, + end: { row: Number(endRow) - 1, col: Number(endCol) - 1 }, + }; +}; + +export const getMarkdownSource = (element) => { + if (!element.dataset.sourcepos) return undefined; + + const source = getFullSource(element); + const range = getRangeFromSourcePos(element.dataset.sourcepos); + let elSource = ''; + + for (let i = range.start.row; i <= range.end.row; i += 1) { + if (i === range.start.row) { + elSource += source[i]?.substring(range.start.col); + } else if (i === range.end.row) { + elSource += `\n${source[i]?.substring(0, range.start.col)}`; + } else { + elSource += `\n${source[i]}` || ''; + } + } + + return elSource.trim(); +}; diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js new file mode 100644 index 00000000000..b2327555b45 --- /dev/null +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -0,0 +1,345 @@ +import { uniq } from 'lodash'; +import { isBlockTablesFeatureEnabled } from './feature_flags'; + +const defaultAttrs = { + td: { colspan: 1, rowspan: 1, colwidth: null }, + th: { colspan: 1, rowspan: 1, colwidth: null }, +}; + +const ignoreAttrs = { + dd: ['isTerm'], + dt: ['isTerm'], +}; + +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); + return child.isText && child.marks.length === 0; + } + + return false; +} + +function containsParagraphWithOnlyText(cell) { + if (cell.childCount === 1) { + const child = cell.child(0); + if (child.type.name === 'paragraph') { + return containsOnlyText(child); + } + } + + return false; +} + +function getRowsAndCells(table) { + const cells = []; + const rows = []; + table.descendants((n) => { + if (n.type.name === 'tableCell' || n.type.name === 'tableHeader') { + cells.push(n); + return false; + } + + if (n.type.name === 'tableRow') { + rows.push(n); + } + + return true; + }); + return { rows, cells }; +} + +function getChildren(node) { + const children = []; + for (let i = 0; i < node.childCount; i += 1) { + children.push(node.child(i)); + } + return children; +} + +function shouldRenderHTMLTable(table) { + const { rows, cells } = getRowsAndCells(table); + + const cellChildCount = Math.max(...cells.map((cell) => cell.childCount)); + const maxColspan = Math.max(...cells.map((cell) => cell.attrs.colspan)); + const maxRowspan = Math.max(...cells.map((cell) => cell.attrs.rowspan)); + + const rowChildren = rows.map((row) => uniq(getChildren(row).map((cell) => cell.type.name))); + const cellTypeInFirstRow = rowChildren[0]; + const cellTypesInOtherRows = uniq(rowChildren.slice(1).map(([type]) => type)); + + // if the first row has headers, and there are no headers anywhere else, render markdown table + if ( + !( + cellTypeInFirstRow.length === 1 && + cellTypeInFirstRow[0] === 'tableHeader' && + cellTypesInOtherRows.length === 1 && + cellTypesInOtherRows[0] === 'tableCell' + ) + ) { + return true; + } + + if (cellChildCount === 1 && maxColspan === 1 && maxRowspan === 1) { + // if all rows contain only one paragraph each and no rowspan/colspan, render markdown table + const children = uniq(cells.map((cell) => cell.child(0).type.name)); + if (children.length === 1 && children[0] === 'paragraph') { + return false; + } + } + + return true; +} + +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 ((ignoreAttrs[tagName] || []).includes(key) || defaultAttrs[tagName]?.[key] === value) + return ''; + + return ` ${key}="${htmlEncode(value?.toString())}"`; + }) + .join(''); + + return `${str}>`; +} + +export function closeTag(tagName) { + return `</${tagName}>`; +} + +function isInBlockTable(node) { + return tableMap.get(node); +} + +function isInTable(node) { + return tableMap.has(node); +} + +function setIsInBlockTable(table, value) { + tableMap.set(table, value); + + const { rows, cells } = getRowsAndCells(table); + rows.forEach((row) => tableMap.set(row, value)); + cells.forEach((cell) => { + tableMap.set(cell, value); + if (cell.childCount && cell.child(0).type.name === 'paragraph') + tableMap.set(cell.child(0), value); + }); +} + +function unsetIsInBlockTable(table) { + tableMap.delete(table); + + const { rows, cells } = getRowsAndCells(table); + rows.forEach((row) => tableMap.delete(row)); + cells.forEach((cell) => { + tableMap.delete(cell); + if (cell.childCount) tableMap.delete(cell.child(0)); + }); +} + +function renderTagOpen(state, tagName, attrs) { + state.ensureNewLine(); + state.write(openTag(tagName, attrs)); +} + +function renderTagClose(state, tagName, insertNewline = true) { + state.write(closeTag(tagName)); + if (insertNewline) state.ensureNewLine(); +} + +function renderTableHeaderRowAsMarkdown(state, node, cellWidths) { + state.flushClose(1); + + state.write('|'); + node.forEach((cell, _, i) => { + if (i) state.write('|'); + + state.write(cell.attrs.align === 'center' ? ':' : '-'); + state.write(state.repeat('-', cellWidths[i])); + state.write(cell.attrs.align === 'center' || cell.attrs.align === 'right' ? ':' : '-'); + }); + state.write('|'); + + state.closeBlock(node); +} + +function renderTableRowAsMarkdown(state, node, isHeaderRow = false) { + const cellWidths = []; + + state.flushClose(1); + + state.write('| '); + node.forEach((cell, _, i) => { + if (i) state.write(' | '); + + const { length } = state.out; + state.render(cell, node, i); + cellWidths.push(state.out.length - length); + }); + state.write(' |'); + + state.closeBlock(node); + + if (isHeaderRow) renderTableHeaderRowAsMarkdown(state, node, cellWidths); +} + +function renderTableRowAsHTML(state, node) { + renderTagOpen(state, 'tr'); + + node.forEach((cell, _, i) => { + const tag = cell.type.name === 'tableHeader' ? 'th' : 'td'; + + renderTagOpen(state, tag, cell.attrs); + + if (!containsParagraphWithOnlyText(cell)) { + state.closeBlock(node); + state.flushClose(); + } + + state.render(cell, node, i); + state.flushClose(1); + + renderTagClose(state, tag); + }); + + renderTagClose(state, 'tr'); +} + +export function renderContent(state, node, forceRenderInline) { + if (node.type.inlineContent) { + if (containsOnlyText(node)) { + state.renderInline(node); + } else { + state.closeBlock(node); + state.flushClose(); + state.renderInline(node); + state.closeBlock(node); + state.flushClose(); + } + } else { + const renderInline = forceRenderInline || containsParagraphWithOnlyText(node); + if (!renderInline) { + state.closeBlock(node); + state.flushClose(); + state.renderContent(node); + state.ensureNewLine(); + } else { + state.renderInline(forceRenderInline ? node : node.child(0)); + } + } +} + +export function renderHTMLNode(tagName, forceRenderInline = false) { + return (state, node) => { + renderTagOpen(state, tagName, node.attrs); + renderContent(state, node, forceRenderInline); + renderTagClose(state, tagName, 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 (!isBlockTablesFeatureEnabled()) { + state.renderInline(node); + return; + } + + if (!isInBlockTable(node) || containsParagraphWithOnlyText(node)) { + state.renderInline(node.child(0)); + } else { + state.renderContent(node); + } +} + +export function renderTableRow(state, node) { + if (isInBlockTable(node)) { + renderTableRowAsHTML(state, node); + } else { + renderTableRowAsMarkdown(state, node, node.child(0).type.name === 'tableHeader'); + } +} + +export function renderTable(state, node) { + if (isBlockTablesFeatureEnabled()) { + setIsInBlockTable(node, shouldRenderHTMLTable(node)); + } + + if (isInBlockTable(node)) renderTagOpen(state, 'table'); + + state.renderContent(node); + + if (isInBlockTable(node)) renderTagClose(state, 'table'); + + // ensure at least one blank line after any table + state.closeBlock(node); + state.flushClose(); + + if (isBlockTablesFeatureEnabled()) { + unsetIsInBlockTable(node); + } +} + +export function renderHardBreak(state, node, parent, index) { + const br = isInTable(parent) ? '<br>' : '\\\n'; + + for (let i = index + 1; i < parent.childCount; i += 1) { + if (parent.child(i).type !== node.type) { + state.write(br); + return; + } + } +} + +export function renderImage(state, node) { + const { alt, canonicalSrc, src, title } = node.attrs; + const quotedTitle = title ? ` ${state.quote(title)}` : ''; + + state.write(`![${state.esc(alt || '')}](${state.esc(canonicalSrc || src)}${quotedTitle})`); +} + +export function renderPlayable(state, node) { + renderImage(state, node); +} |