diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-16 15:09:26 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-06-16 15:09:26 +0300 |
commit | b019dc959ec16b15fe42a680dbd729542ff61537 (patch) | |
tree | cde5c3d0582bd7542e1e195d83ad7a50079f4e49 /app/assets/javascripts/content_editor | |
parent | b7b44de429911864686599ef1643baf525bf75ec (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/content_editor')
8 files changed, 188 insertions, 37 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 4ed89140e5b..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 @@ -36,16 +36,19 @@ export default { directives: { GlTooltip, }, - inject: ['tiptapEditor'], + inject: ['tiptapEditor', 'contentEditor'], data() { return { codeBlockType: undefined, - selectedLanguage: {}, filterTerm: '', filteredLanguages: [], showCustomLanguageInput: false, customLanguageType: '', + + selectedLanguage: {}, + isDiagram: false, + showPreview: false, }; }, watch: { @@ -61,21 +64,36 @@ 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.findOrCreateLanguageBySyntax(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()); + }, + + togglePreview() { + this.showPreview = !this.showPreview; + this.tiptapEditor.commands.updateAttributes(Diagram.name, { showPreview: this.showPreview }); }, async applyLanguage(language) { @@ -125,7 +143,7 @@ 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" @@ -228,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 647f0798364..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,13 +29,48 @@ export default { type: Function, required: true, }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + diagramUrl: '', + diagramSource: '', + }; }, async mounted() { + 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'), }, @@ -32,17 +78,26 @@ export default { }; </script> <template> - <node-view-wrapper - :class="`content-editor-code-block gl-relative code highlight ${$options.userColorScheme}`" - 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/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/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 fa0549d4183..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, - findOrCreateLanguageBySyntax(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, + }), } ); }, 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; |