diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-10-20 08:43:02 +0000 |
commit | d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb (patch) | |
tree | 2341ef426af70ad1e289c38036737e04b0aa5007 /app/assets/javascripts/content_editor | |
parent | d6e514dd13db8947884cd58fe2a9c2a063400a9b (diff) | |
download | gitlab-ce-d9ab72d6080f594d0b3cae15f14b3ef2c6c638cb.tar.gz |
Add latest changes from gitlab-org/gitlab@14-4-stable-eev14.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
14 files changed, 400 insertions, 2 deletions
diff --git a/app/assets/javascripts/content_editor/components/top_toolbar.vue b/app/assets/javascripts/content_editor/components/top_toolbar.vue index 82a449ae6af..89182b3a09f 100644 --- a/app/assets/javascripts/content_editor/components/top_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/top_toolbar.vue @@ -112,6 +112,15 @@ export default { @execute="trackToolbarControlExecution" /> <toolbar-button + data-testid="details" + content-type="details" + icon-name="details-block" + class="gl-mx-2" + editor-command="toggleDetails" + :label="__('Add a collapsible section')" + @execute="trackToolbarControlExecution" + /> + <toolbar-button data-testid="horizontal-rule" content-type="horizontalRule" icon-name="dash" diff --git a/app/assets/javascripts/content_editor/components/wrappers/details.vue b/app/assets/javascripts/content_editor/components/wrappers/details.vue new file mode 100644 index 00000000000..aff15ac3e53 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/details.vue @@ -0,0 +1,33 @@ +<script> +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; + +export default { + name: 'DetailsWrapper', + components: { + NodeViewWrapper, + NodeViewContent, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + data() { + return { + open: true, + }; + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-flex"> + <div + class="details-toggle-icon" + data-testid="details-toggle-icon" + :class="{ 'is-open': open }" + @click="open = !open" + ></div> + <node-view-content as="ul" class="details-content" :class="{ 'is-open': open }" /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue new file mode 100644 index 00000000000..97b69afd12e --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/frontmatter.vue @@ -0,0 +1,32 @@ +<script> +import { NodeViewWrapper, NodeViewContent } from '@tiptap/vue-2'; +import { __ } from '~/locale'; + +export default { + name: 'FrontMatter', + components: { + NodeViewWrapper, + NodeViewContent, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + i18n: { + frontmatter: __('frontmatter'), + }, +}; +</script> +<template> + <node-view-wrapper class="gl-relative code highlight" as="pre"> + <span + 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 as="code" /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js index 8f2ce8feb5d..9329bbcb2c7 100644 --- a/app/assets/javascripts/content_editor/content_editor.stories.js +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -2,7 +2,7 @@ import { ContentEditor } from './index'; export default { component: ContentEditor, - title: 'Components/Content Editor', + title: 'content_editor/components/content_editor', }; const Template = (_, { argTypes }) => ({ 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 25f5837d2a6..1ed1ab0315f 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -11,7 +11,8 @@ export default CodeBlockLowlight.extend({ parseHTML: (element) => extractLanguage(element), }, class: { - default: 'code highlight js-syntax-highlight', + // eslint-disable-next-line @gitlab/require-i18n-strings + default: 'code highlight', }, }; }, diff --git a/app/assets/javascripts/content_editor/extensions/color_chip.js b/app/assets/javascripts/content_editor/extensions/color_chip.js new file mode 100644 index 00000000000..deb5029a1f0 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/color_chip.js @@ -0,0 +1,73 @@ +import { Node } from '@tiptap/core'; +import { Plugin, PluginKey } from 'prosemirror-state'; +import { Decoration, DecorationSet } from 'prosemirror-view'; +import { isValidColorExpression } from '~/lib/utils/color_utils'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +const colorExpressionTypes = ['#', 'hsl', 'rgb']; + +const isValidColor = (color) => { + if (!colorExpressionTypes.some((type) => color.toLowerCase().startsWith(type))) { + return false; + } + + return isValidColorExpression(color); +}; + +const highlightColors = (doc) => { + const decorations = []; + + doc.descendants((node, position) => { + const { text, marks } = node; + + if (!text || marks.length === 0 || marks[0].type.name !== 'code' || !isValidColor(text)) { + return; + } + + const from = position; + const to = from + text.length; + const decoration = Decoration.inline(from, to, { + class: 'gl-display-inline-flex gl-align-items-center content-editor-color-chip', + style: `--gl-color-chip-color: ${text}`, + }); + + decorations.push(decoration); + }); + + return DecorationSet.create(doc, decorations); +}; + +export const colorDecoratorPlugin = new Plugin({ + key: new PluginKey('colorDecorator'), + state: { + init(_, { doc }) { + return highlightColors(doc); + }, + apply(transaction, oldState) { + return transaction.docChanged ? highlightColors(transaction.doc) : oldState; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export default Node.create({ + name: 'colorChip', + + parseHTML() { + return [ + { + tag: '.gfm-color_chip', + ignore: true, + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + addProseMirrorPlugins() { + return [colorDecoratorPlugin]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/details.js b/app/assets/javascripts/content_editor/extensions/details.js new file mode 100644 index 00000000000..e3d54ed01fd --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/details.js @@ -0,0 +1,36 @@ +import { Node } from '@tiptap/core'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { wrappingInputRule } from 'prosemirror-inputrules'; +import DetailsWrapper from '../components/wrappers/details.vue'; + +export const inputRegex = /^\s*(<details>)$/; + +export default Node.create({ + name: 'details', + content: 'detailsContent+', + // eslint-disable-next-line @gitlab/require-i18n-strings + group: 'block list', + + parseHTML() { + return [{ tag: 'details' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['ul', HTMLAttributes, 0]; + }, + + addNodeView() { + return VueNodeViewRenderer(DetailsWrapper); + }, + + addInputRules() { + return [wrappingInputRule(inputRegex, this.type)]; + }, + + addCommands() { + return { + setDetails: () => ({ commands }) => commands.wrapInList('details'), + toggleDetails: () => ({ commands }) => commands.toggleList('details', 'detailsContent'), + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/details_content.js b/app/assets/javascripts/content_editor/extensions/details_content.js new file mode 100644 index 00000000000..fb6c49d91aa --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/details_content.js @@ -0,0 +1,25 @@ +import { Node } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export default Node.create({ + name: 'detailsContent', + content: 'block+', + defining: true, + + parseHTML() { + return [ + { tag: '*', consuming: false, context: 'details/', priority: PARSE_HTML_PRIORITY_HIGHEST }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ['li', HTMLAttributes, 0]; + }, + + addKeyboardShortcuts() { + return { + Enter: () => this.editor.commands.splitListItem('detailsContent'), + 'Shift-Tab': () => this.editor.commands.liftListItem('detailsContent'), + }; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/frontmatter.js b/app/assets/javascripts/content_editor/extensions/frontmatter.js new file mode 100644 index 00000000000..64c84fe046b --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/frontmatter.js @@ -0,0 +1,20 @@ +import { VueNodeViewRenderer } from '@tiptap/vue-2'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import FrontmatterWrapper from '../components/wrappers/frontmatter.vue'; +import CodeBlockHighlight from './code_block_highlight'; + +export default CodeBlockHighlight.extend({ + name: 'frontmatter', + parseHTML() { + return [ + { + tag: 'pre[data-lang-params="frontmatter"]', + preserveWhitespace: 'full', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + addNodeView() { + return new VueNodeViewRenderer(FrontmatterWrapper); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/math_inline.js b/app/assets/javascripts/content_editor/extensions/math_inline.js new file mode 100644 index 00000000000..60f5288dcf6 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/math_inline.js @@ -0,0 +1,35 @@ +import { Mark, markInputRule } from '@tiptap/core'; +import { __ } from '~/locale'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const inputRegex = /(?:^|\s)\$`([^`]+)`\$$/gm; + +export default Mark.create({ + name: 'mathInline', + + parseHTML() { + return [ + { + tag: 'code.math[data-math-style=inline]', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'code', + { + title: __('Inline math'), + 'data-toggle': 'tooltip', + class: 'gl-inset-border-1-gray-400', + ...HTMLAttributes, + }, + 0, + ]; + }, + + addInputRules() { + return [markInputRule(inputRegex, this.type)]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/table_of_contents.js b/app/assets/javascripts/content_editor/extensions/table_of_contents.js new file mode 100644 index 00000000000..9e31158837e --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/table_of_contents.js @@ -0,0 +1,51 @@ +import { Node } from '@tiptap/core'; +import { InputRule } from 'prosemirror-inputrules'; +import { s__ } from '~/locale'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; + +export const inputRuleRegExps = [/^\[\[_TOC_\]\]$/, /^\[TOC\]$/]; + +export default Node.create({ + name: 'tableOfContents', + + inline: false, + + group: 'block', + + parseHTML() { + return [ + { + tag: 'ul.section-nav', + priority: PARSE_HTML_PRIORITY_HIGHEST, + }, + ]; + }, + + renderHTML() { + return [ + 'div', + { + class: + 'table-of-contents gl-border-1 gl-border-solid gl-text-center gl-border-gray-100 gl-mb-5', + }, + s__('ContentEditor|Table of Contents'), + ]; + }, + + addInputRules() { + const { type } = this; + + return inputRuleRegExps.map( + (regex) => + new InputRule(regex, (state, match, start, end) => { + const { tr } = state; + + if (match) { + tr.replaceWith(start - 1, end, type.create()); + } + + return tr; + }), + ); + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js new file mode 100644 index 00000000000..93b42466850 --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -0,0 +1,29 @@ +import { Node, mergeAttributes, nodeInputRule } from '@tiptap/core'; + +export const inputRegex = /^<wbr>$/; + +export default Node.create({ + name: 'wordBreak', + inline: true, + group: 'inline', + selectable: false, + atom: true, + + defaultOptions: { + HTMLAttributes: { + class: 'gl-display-inline-flex gl-px-1 gl-bg-blue-100 gl-rounded-base gl-font-sm', + }, + }, + + parseHTML() { + return [{ tag: 'wbr' }]; + }, + + renderHTML({ HTMLAttributes }) { + return ['span', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), '-']; + }, + + addInputRules() { + return [nodeInputRule(inputRegex, this.type)]; + }, +}); 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 9b2d4c9a062..385f1c63801 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -8,14 +8,18 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import ColorChip from '../extensions/color_chip'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; +import Details from '../extensions/details'; +import DetailsContent from '../extensions/details_content'; 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 Frontmatter from '../extensions/frontmatter'; import Gapcursor from '../extensions/gapcursor'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; @@ -28,6 +32,7 @@ import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; import Loading from '../extensions/loading'; +import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; @@ -37,11 +42,13 @@ import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; import TableHeader from '../extensions/table_header'; +import TableOfContents from '../extensions/table_of_contents'; 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 WordBreak from '../extensions/word_break'; import { ContentEditor } from './content_editor'; import createMarkdownSerializer from './markdown_serializer'; import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; @@ -75,15 +82,19 @@ export const createContentEditor = ({ Bold, BulletList, Code, + ColorChip, CodeBlockHighlight, DescriptionItem, DescriptionList, + Details, + DetailsContent, Document, Division, Dropcursor, Emoji, Figure, FigureCaption, + Frontmatter, Gapcursor, HardBreak, Heading, @@ -96,6 +107,7 @@ export const createContentEditor = ({ Link, ListItem, Loading, + MathInline, OrderedList, Paragraph, Reference, @@ -104,12 +116,14 @@ export const createContentEditor = ({ Superscript, TableCell, TableHeader, + TableOfContents, TableRow, Table, TaskItem, TaskList, Text, Video, + WordBreak, ]; const allExtensions = [...builtInContentEditorExtensions, ...extensions]; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index bc6d98511f9..0dd3cb5b73f 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -11,10 +11,13 @@ 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 Details from '../extensions/details'; +import DetailsContent from '../extensions/details_content'; import Division from '../extensions/division'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; import FigureCaption from '../extensions/figure_caption'; +import Frontmatter from '../extensions/frontmatter'; import HardBreak from '../extensions/hard_break'; import Heading from '../extensions/heading'; import HorizontalRule from '../extensions/horizontal_rule'; @@ -24,6 +27,7 @@ import InlineDiff from '../extensions/inline_diff'; import Italic from '../extensions/italic'; import Link from '../extensions/link'; import ListItem from '../extensions/list_item'; +import MathInline from '../extensions/math_inline'; import OrderedList from '../extensions/ordered_list'; import Paragraph from '../extensions/paragraph'; import Reference from '../extensions/reference'; @@ -33,11 +37,13 @@ import Superscript from '../extensions/superscript'; import Table from '../extensions/table'; import TableCell from '../extensions/table_cell'; import TableHeader from '../extensions/table_header'; +import TableOfContents from '../extensions/table_of_contents'; 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 WordBreak from '../extensions/word_break'; import { isPlainURL, renderHardBreak, @@ -50,6 +56,7 @@ import { renderImage, renderPlayable, renderHTMLNode, + renderContent, } from './serialization_helpers'; const defaultSerializerConfig = { @@ -80,6 +87,11 @@ const defaultSerializerConfig = { : `](${state.esc(href)}${mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ''})`; }, }, + [MathInline.name]: { + open: (...args) => `$${defaultMarkdownSerializer.marks.code.open(...args)}`, + close: (...args) => `${defaultMarkdownSerializer.marks.code.close(...args)}$`, + escape: false, + }, [Strike.name]: { open: '~~', close: '~~', @@ -130,11 +142,34 @@ const defaultSerializerConfig = { renderHTMLNode(node.attrs.isTerm ? 'dt' : 'dd')(state, node); if (index === parent.childCount - 1) state.ensureNewLine(); }, + [Details.name]: renderHTMLNode('details', true), + [DetailsContent.name]: (state, node, parent, index) => { + if (!index) renderHTMLNode('summary')(state, node); + else { + if (index === 1) state.ensureNewLine(); + renderContent(state, node); + if (index === parent.childCount - 1) state.ensureNewLine(); + } + }, [Emoji.name]: (state, node) => { const { name } = node.attrs; state.write(`:${name}:`); }, + [Frontmatter.name]: (state, node) => { + const { language } = node.attrs; + const syntax = { + toml: '+++', + json: ';;;', + yaml: '---', + }[language]; + + state.write(`${syntax}\n`); + state.text(node.textContent, false); + state.ensureNewLine(); + state.write(syntax); + state.closeBlock(node); + }, [Figure.name]: renderHTMLNode('figure'), [FigureCaption.name]: renderHTMLNode('figcaption'), [HardBreak.name]: renderHardBreak, @@ -147,6 +182,10 @@ const defaultSerializerConfig = { [Reference.name]: (state, node) => { state.write(node.attrs.originalText || node.attrs.text); }, + [TableOfContents.name]: (state, node) => { + state.write('[[_TOC_]]'); + state.closeBlock(node); + }, [Table.name]: renderTable, [TableCell.name]: renderTableCell, [TableHeader.name]: renderTableCell, @@ -161,6 +200,7 @@ const defaultSerializerConfig = { }, [Text.name]: defaultMarkdownSerializer.nodes.text, [Video.name]: renderPlayable, + [WordBreak.name]: (state) => state.write('<wbr>'), }, }; |