diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/components')
6 files changed, 214 insertions, 32 deletions
diff --git a/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue new file mode 100644 index 00000000000..87f22a27856 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/code_block_bubble_menu.vue @@ -0,0 +1,146 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + GlTooltipDirective as GlTooltip, +} from '@gitlab/ui'; +import { BubbleMenu } from '@tiptap/vue-2'; +import codeBlockLanguageLoader from '../services/code_block_language_loader'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Diagram from '../extensions/diagram'; +import Frontmatter from '../extensions/frontmatter'; +import EditorStateObserver from './editor_state_observer.vue'; + +const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + +export default { + components: { + BubbleMenu, + GlButton, + GlButtonGroup, + GlDropdown, + GlDropdownItem, + GlSearchBoxByType, + EditorStateObserver, + }, + directives: { + GlTooltip, + }, + inject: ['tiptapEditor'], + data() { + return { + selectedLanguage: {}, + filterTerm: '', + filteredLanguages: [], + }; + }, + watch: { + filterTerm: { + handler(val) { + this.filteredLanguages = codeBlockLanguageLoader.filterLanguages(val); + }, + immediate: true, + }, + }, + methods: { + shouldShow: ({ editor }) => { + return CODE_BLOCK_NODE_TYPES.some((type) => editor.isActive(type)); + }, + + getSelectedLanguage() { + const { language } = this.tiptapEditor.getAttributes(this.getCodeBlockType()); + + this.selectedLanguage = codeBlockLanguageLoader.findLanguageBySyntax(language); + }, + + async setSelectedLanguage(language) { + this.selectedLanguage = language; + + await codeBlockLanguageLoader.loadLanguages([language.syntax]); + + this.tiptapEditor.commands.setCodeBlock({ language: this.selectedLanguage.syntax }); + }, + + tippyOnBeforeUpdate(tippy, props) { + if (props.getReferenceClientRect) { + // eslint-disable-next-line no-param-reassign + props.getReferenceClientRect = () => { + const { view } = this.tiptapEditor; + const { from } = this.tiptapEditor.state.selection; + + for (let { node } = view.domAtPos(from); node; node = node.parentElement) { + if (node.nodeName?.toLowerCase() === 'pre') { + return node.getBoundingClientRect(); + } + } + + return new DOMRect(-1000, -1000, 0, 0); + }; + } + }, + + deleteCodeBlock() { + this.tiptapEditor.chain().focus().deleteNode(this.getCodeBlockType()).run(); + }, + + getCodeBlockType() { + return ( + CODE_BLOCK_NODE_TYPES.find((type) => this.tiptapEditor.isActive(type)) || + CodeBlockHighlight.name + ); + }, + }, +}; +</script> +<template> + <bubble-menu + data-testid="code-block-bubble-menu" + class="gl-shadow gl-rounded-base" + :editor="tiptapEditor" + plugin-key="bubbleMenuCodeBlock" + :should-show="shouldShow" + :tippy-options="{ onBeforeUpdate: tippyOnBeforeUpdate }" + > + <editor-state-observer @transaction="getSelectedLanguage"> + <gl-button-group> + <gl-dropdown contenteditable="false" boundary="viewport" :text="selectedLanguage.label"> + <template #header> + <gl-search-box-by-type + v-model="filterTerm" + :clear-button-title="__('Clear')" + :placeholder="__('Search')" + /> + </template> + + <template #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="setSelectedLanguage(language)" + > + {{ language.label }} + </gl-dropdown-item> + </gl-dropdown> + <gl-button + v-gl-tooltip + variant="default" + category="primary" + size="medium" + :aria-label="__('Delete code block')" + :title="__('Delete code block')" + icon="remove" + @click="deleteCodeBlock" + /> + </gl-button-group> + </editor-state-observer> + </bubble-menu> +</template> diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index a942c9f1149..5b3f4f4ddf2 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -5,6 +5,7 @@ import ContentEditorAlert from './content_editor_alert.vue'; import ContentEditorProvider from './content_editor_provider.vue'; import EditorStateObserver from './editor_state_observer.vue'; import FormattingBubbleMenu from './formatting_bubble_menu.vue'; +import CodeBlockBubbleMenu from './code_block_bubble_menu.vue'; import TopToolbar from './top_toolbar.vue'; import LoadingIndicator from './loading_indicator.vue'; @@ -16,6 +17,7 @@ export default { TiptapEditorContent, TopToolbar, FormattingBubbleMenu, + CodeBlockBubbleMenu, EditorStateObserver, }, props: { @@ -89,6 +91,7 @@ export default { <top-toolbar ref="toolbar" class="gl-mb-4" /> <div class="gl-relative"> <formatting-bubble-menu /> + <code-block-bubble-menu /> <tiptap-editor-content class="md" :editor="contentEditor.tiptapEditor" /> <loading-indicator /> </div> 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 14a553ff30b..103079534bc 100644 --- a/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/formatting_bubble_menu.vue @@ -3,6 +3,10 @@ import { GlButtonGroup } from '@gitlab/ui'; import { BubbleMenu } from '@tiptap/vue-2'; import { BUBBLE_MENU_TRACKING_ACTION } from '../constants'; import trackUIControl from '../services/track_ui_control'; +import Code from '../extensions/code'; +import CodeBlockHighlight from '../extensions/code_block_highlight'; +import Diagram from '../extensions/diagram'; +import Frontmatter from '../extensions/frontmatter'; import ToolbarButton from './toolbar_button.vue'; export default { @@ -16,6 +20,14 @@ export default { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ action: BUBBLE_MENU_TRACKING_ACTION, property: contentType, value }); }, + + shouldShow: ({ editor, from, to }) => { + if (from === to) return false; + + const exclude = [Code.name, CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; + + return !exclude.some((type) => editor.isActive(type)); + }, }, }; </script> @@ -24,6 +36,7 @@ export default { data-testid="formatting-bubble-menu" class="gl-shadow gl-rounded-base" :editor="tiptapEditor" + :should-show="shouldShow" > <gl-button-group> <toolbar-button diff --git a/app/assets/javascripts/content_editor/components/loading_indicator.vue b/app/assets/javascripts/content_editor/components/loading_indicator.vue index 5b9383d6e11..620324adb06 100644 --- a/app/assets/javascripts/content_editor/components/loading_indicator.vue +++ b/app/assets/javascripts/content_editor/components/loading_indicator.vue @@ -30,6 +30,7 @@ export default { > <div v-if="isLoading" + data-testid="content-editor-loading-indicator" class="gl-w-full gl-display-flex gl-justify-content-center gl-align-items-center gl-absolute gl-top-0 gl-bottom-0" > <div class="gl-bg-white gl-absolute gl-w-full gl-h-full gl-opacity-3"></div> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue deleted file mode 100644 index 5b81e5fddcc..00000000000 --- a/app/assets/javascripts/content_editor/components/wrappers/image.vue +++ /dev/null @@ -1,32 +0,0 @@ -<script> -import { GlLoadingIcon } from '@gitlab/ui'; -import { NodeViewWrapper } from '@tiptap/vue-2'; - -export default { - name: 'ImageWrapper', - components: { - NodeViewWrapper, - GlLoadingIcon, - }, - props: { - node: { - type: Object, - required: true, - }, - }, -}; -</script> -<template> - <node-view-wrapper class="gl-display-inline-block"> - <span class="gl-relative"> - <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" - /> - <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> - </span> - </node-view-wrapper> -</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/media.vue b/app/assets/javascripts/content_editor/components/wrappers/media.vue new file mode 100644 index 00000000000..37119bdd066 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/media.vue @@ -0,0 +1,51 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { NodeViewWrapper } from '@tiptap/vue-2'; + +const tagNameMap = { + image: 'img', + video: 'video', + audio: 'audio', +}; + +export default { + name: 'MediaWrapper', + components: { + NodeViewWrapper, + GlLoadingIcon, + }, + props: { + node: { + type: Object, + required: true, + }, + }, + computed: { + tagName() { + return tagNameMap[this.node.type.name] || 'img'; + }, + }, +}; +</script> +<template> + <node-view-wrapper class="gl-display-inline-block"> + <span class="gl-relative" :class="{ [`media-container ${tagName}-container`]: true }"> + <gl-loading-icon v-if="node.attrs.uploading" class="gl-absolute gl-left-50p gl-top-half" /> + <component + :is="tagName" + data-testid="media" + :class="{ + 'gl-max-w-full gl-h-auto': tagName !== 'audio', + 'gl-opacity-5': node.attrs.uploading, + }" + :title="node.attrs.title || node.attrs.alt" + :alt="node.attrs.alt" + :src="node.attrs.src" + controls="true" + /> + <a v-if="tagName !== 'img'" :href="node.attrs.canonicalSrc || node.attrs.src" @click.prevent> + {{ node.attrs.title || node.attrs.alt }} + </a> + </span> + </node-view-wrapper> +</template> |