diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/components')
12 files changed, 650 insertions, 302 deletions
diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue index ce5b566ba20..948c58287fb 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue @@ -44,7 +44,7 @@ export default { this.menuVisible = false; }, strategy: 'fixed', - maxWidth: 'auto', + maxWidth: '400px', }, }), ); diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue index 6bb6bdc4e65..6ce6e731551 100644 --- a/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue +++ b/app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue @@ -69,7 +69,6 @@ export default { mediaSrc: undefined, mediaCanonicalSrc: undefined, mediaAlt: undefined, - mediaTitle: undefined, isEditing: false, isUpdating: false, @@ -130,16 +129,13 @@ export default { const position = this.tiptapEditor.state.selection.from; - this.tiptapEditor - .chain() - .focus() - .updateAttributes(this.mediaType, { - src: this.mediaSrc, - alt: this.mediaAlt, - canonicalSrc: this.mediaCanonicalSrc, - title: this.mediaTitle, - }) - .run(); + const attrs = { + src: this.mediaSrc, + alt: this.mediaAlt, + canonicalSrc: this.mediaCanonicalSrc, + }; + + this.tiptapEditor.chain().focus().updateAttributes(this.mediaType, attrs).run(); this.tiptapEditor.commands.setNodeSelection(position); @@ -155,13 +151,11 @@ export default { this.isUpdating = true; - const { src, title, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes( - this.mediaType, - ); + const { src, alt, canonicalSrc, uploading } = this.tiptapEditor.getAttributes(this.mediaType); - this.mediaTitle = title; this.mediaAlt = alt; this.mediaCanonicalSrc = canonicalSrc || src; + this.uploading = uploading; this.mediaSrc = await this.contentEditor.resolveUrl(this.mediaCanonicalSrc); @@ -177,7 +171,6 @@ export default { }, resetMediaInfo() { - this.mediaTitle = null; this.mediaAlt = null; this.mediaCanonicalSrc = null; this.uploading = false; @@ -248,7 +241,6 @@ export default { data-qa-selector="file_upload_field" @change="onFileSelect" /> - <gl-link v-if="!showProgressIndicator" v-gl-tooltip @@ -261,17 +253,6 @@ export default { {{ mediaCanonicalSrc }} </gl-link> <gl-button - v-gl-tooltip - variant="default" - category="tertiary" - size="medium" - data-testid="copy-media-src" - :aria-label="copySourceLabel" - :title="copySourceLabel" - icon="copy-to-clipboard" - @click="copyMediaSrc" - /> - <gl-button v-if="!showProgressIndicator" v-gl-tooltip variant="default" @@ -290,8 +271,8 @@ export default { category="tertiary" size="medium" data-testid="edit-diagram" - :aria-label="replaceLabel" - title="Edit diagram" + :aria-label="editLabel" + :title="editLabel" icon="diagram" @click="editDiagram" /> @@ -307,28 +288,14 @@ export default { icon="retry" @click="replaceMedia" /> - <gl-button - v-gl-tooltip - variant="default" - category="tertiary" - size="medium" - data-testid="delete-media" - :aria-label="deleteLabel" - :title="deleteLabel" - icon="remove" - @click="deleteMedia" - /> </gl-button-group> <gl-form v-else class="bubble-menu-form gl-p-4 gl-w-100" @submit.prevent="saveEditedMedia"> <gl-form-group :label="__('URL')" label-for="media-src"> <gl-form-input id="media-src" v-model="mediaCanonicalSrc" data-testid="media-src" /> </gl-form-group> - <gl-form-group :label="__('Description (alt text)')" label-for="media-alt"> + <gl-form-group :label="__('Alt text')" label-for="media-alt"> <gl-form-input id="media-alt" v-model="mediaAlt" data-testid="media-alt" /> </gl-form-group> - <gl-form-group :label="__('Title')" label-for="media-title"> - <gl-form-input id="media-title" v-model="mediaTitle" data-testid="media-title" /> - </gl-form-group> <div class="gl-display-flex gl-justify-content-end"> <gl-button class="gl-mr-3" diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 92f3c3fb8fa..1036b6552d1 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -1,8 +1,9 @@ <script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { EditorContent as TiptapEditorContent } from '@tiptap/vue-2'; -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; +import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; import { createContentEditor } from '../services/create_content_editor'; import { ALERT_EVENT, TIPTAP_AUTOFOCUS_OPTIONS } from '../constants'; import ContentEditorAlert from './content_editor_alert.vue'; @@ -17,8 +18,7 @@ import LoadingIndicator from './loading_indicator.vue'; export default { components: { - GlSprintf, - GlLink, + GlButton, LoadingIndicator, ContentEditorAlert, ContentEditorProvider, @@ -29,12 +29,20 @@ export default { MediaBubbleMenu, EditorStateObserver, ReferenceBubbleMenu, + EditorModeSwitcher, + }, + directives: { + GlTooltip: GlTooltipDirective, }, props: { renderMarkdown: { type: Function, required: true, }, + markdownDocsPath: { + type: String, + required: true, + }, uploadsPath: { type: String, required: true, @@ -65,16 +73,21 @@ export default { default: false, validator: (autofocus) => TIPTAP_AUTOFOCUS_OPTIONS.includes(autofocus), }, - quickActionsDocsPath: { - type: String, + supportsQuickActions: { + type: Boolean, required: false, - default: '', + default: false, }, drawioEnabled: { type: Boolean, required: false, default: false, }, + codeSuggestionsConfig: { + type: Object, + required: false, + default: () => ({}), + }, editable: { type: Boolean, required: false, @@ -129,6 +142,7 @@ export default { editable, enableAutocomplete, autocompleteDataSources, + codeSuggestionsConfig, } = this; // This is a non-reactive attribute intentionally since this is a complex object. @@ -140,6 +154,7 @@ export default { drawioEnabled, enableAutocomplete, autocompleteDataSources, + codeSuggestionsConfig, tiptapOptions: { autofocus, editable, @@ -204,17 +219,15 @@ export default { markdown: this.latestMarkdown, }); }, - }, - i18n: { - quickActionsText: s__( - 'ContentEditor|For %{quickActionsDocsLinkStart}quick actions%{quickActionsDocsLinkEnd}, type %{keyboardStart}/%{keyboardEnd}.', - ), + handleEditorModeChanged() { + this.$emit('enableMarkdownEditor'); + }, }, }; </script> <template> <content-editor-provider :content-editor="contentEditor"> - <div> + <div class="md-area gl-overflow-hidden"> <editor-state-observer @docUpdate="notifyChange" @focus="focus" @@ -225,11 +238,11 @@ export default { <div data-testid="content-editor" data-qa-selector="content_editor_container" - class="md-area gl-border-none! gl-shadow-none!" :class="{ 'is-focused': focused }" > <formatting-toolbar ref="toolbar" + :supports-quick-actions="supportsQuickActions" :hide-attachment-button="disableAttachments" @enableMarkdownEditor="$emit('enableMarkdownEditor')" /> @@ -237,7 +250,7 @@ export default { {{ placeholder }} </div> <tiptap-editor-content - class="md gl-px-5" + class="md" data-testid="content_editor_editablebox" :editor="contentEditor.tiptapEditor" /> @@ -249,21 +262,19 @@ export default { <reference-bubble-menu /> </div> <div - v-if="quickActionsDocsPath" - class="gl-display-flex gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-4 gl-mx-2 gl-mb-2 gl-bg-gray-10 gl-text-secondary" + class="gl-display-flex gl-display-flex gl-flex-direction-row gl-justify-content-space-between gl-align-items-center gl-rounded-bottom-left-base gl-rounded-bottom-right-base gl-px-2 gl-border-t gl-border-gray-100 gl-text-secondary" > - <div class="gl-w-full gl-line-height-32 gl-font-sm"> - <gl-sprintf :message="$options.i18n.quickActionsText"> - <template #keyboard="{ content }"> - <kbd>{{ content }}</kbd> - </template> - <template #quickActionsDocsLink="{ content }"> - <gl-link :href="quickActionsDocsPath" target="_blank" class="gl-font-sm">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </div> + <editor-mode-switcher size="small" value="richText" @switch="handleEditorModeChanged" /> + <gl-button + v-gl-tooltip + icon="markdown-mark" + :href="markdownDocsPath" + target="_blank" + category="tertiary" + size="small" + title="Markdown is supported" + class="gl-px-3!" + /> </div> </div> </content-editor-provider> diff --git a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue index c53007b68cf..dc27278d255 100644 --- a/app/assets/javascripts/content_editor/components/formatting_toolbar.vue +++ b/app/assets/javascripts/content_editor/components/formatting_toolbar.vue @@ -1,5 +1,7 @@ <script> -import EditorModeSwitcher from '~/vue_shared/components/markdown/editor_mode_switcher.vue'; +import CommentTemplatesDropdown from '~/vue_shared/components/markdown/comment_templates_dropdown.vue'; +import { __, sprintf } from '~/locale'; +import { getModifierKey } from '~/constants'; import trackUIControl from '../services/track_ui_control'; import ToolbarButton from './toolbar_button.vue'; import ToolbarAttachmentButton from './toolbar_attachment_button.vue'; @@ -14,122 +16,179 @@ export default { ToolbarTableButton, ToolbarAttachmentButton, ToolbarMoreDropdown, - EditorModeSwitcher, + CommentTemplatesDropdown, + }, + inject: { + newCommentTemplatePath: { default: null }, + tiptapEditor: { default: null }, + contentEditor: { default: null }, }, props: { + supportsQuickActions: { + type: Boolean, + required: false, + default: false, + }, hideAttachmentButton: { type: Boolean, default: false, required: false, }, }, + data() { + const modifierKey = getModifierKey(); + const shiftKey = modifierKey === '⌘' ? '⇧' : 'Shift+'; + + return { + i18n: { + bold: sprintf(__('Bold (%{modifierKey}B)'), { modifierKey }), + italic: sprintf(__('Italic (%{modifierKey}I)'), { modifierKey }), + strike: sprintf(__('Strikethrough (%{modifierKey}%{shiftKey}X)'), { + modifierKey, + shiftKey, + }), + quote: __('Insert a quote'), + code: __('Code'), + link: sprintf(__('Insert link (%{modifierKey}K)'), { modifierKey }), + bulletList: __('Add a bullet list'), + numberedList: __('Add a numbered list'), + taskList: __('Add a checklist'), + }, + }; + }, + computed: { + codeSuggestionsEnabled() { + return this.contentEditor.codeSuggestionsConfig?.canSuggest; + }, + }, methods: { trackToolbarControlExecution({ contentType, value }) { trackUIControl({ property: contentType, value }); }, - handleEditorModeChanged() { - this.$emit('enableMarkdownEditor'); + insertSavedReply(savedReply) { + this.tiptapEditor.chain().focus().pasteContent(savedReply).run(); }, }, }; </script> <template> - <div class="gl-mx-2 gl-mt-2"> - <div - class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-bg-gray-50 gl-px-2 gl-rounded-base gl-justify-content-space-between" - data-testid="formatting-toolbar" - > - <div class="gl-py-2 gl-display-flex gl-flex-wrap"> - <toolbar-text-style-dropdown - data-testid="text-styles" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bold" - content-type="bold" - icon-name="bold" - editor-command="toggleBold" - :label="__('Bold text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="italic" - content-type="italic" - icon-name="italic" - editor-command="toggleItalic" - :label="__('Italic text')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="strike" - content-type="strike" - icon-name="strikethrough" - editor-command="toggleStrike" - :label="__('Strikethrough')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="blockquote" - content-type="blockquote" - icon-name="quote" - editor-command="toggleBlockquote" - :label="__('Insert a quote')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="code" - content-type="code" - icon-name="code" - editor-command="toggleCode" - :label="__('Code')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="link" - content-type="link" - icon-name="link" - editor-command="editLink" - :label="__('Insert link')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="bullet-list" - content-type="bulletList" - icon-name="list-bulleted" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleBulletList" - :label="__('Add a bullet list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="ordered-list" - content-type="orderedList" - icon-name="list-numbered" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleOrderedList" - :label="__('Add a numbered list')" - @execute="trackToolbarControlExecution" - /> - <toolbar-button - data-testid="task-list" - content-type="taskList" - icon-name="list-task" - class="gl-display-none gl-sm-display-inline" - editor-command="toggleTaskList" - :label="__('Add a checklist')" - @execute="trackToolbarControlExecution" - /> - <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> - <toolbar-attachment-button - v-if="!hideAttachmentButton" - data-testid="attachment" - @execute="trackToolbarControlExecution" - /> - <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> - </div> - <div class="content-editor-switcher gl-display-flex gl-align-items-center gl-ml-auto"> - <editor-mode-switcher size="small" value="richText" @input="handleEditorModeChanged" /> - </div> + <div + class="gl-w-full gl-display-flex gl-align-items-center gl-flex-wrap gl-border-b gl-border-gray-100 gl-px-3 gl-rounded-top-base gl-justify-content-space-between" + data-testid="formatting-toolbar" + > + <div class="gl-py-3 gl-display-flex gl-flex-wrap"> + <toolbar-text-style-dropdown + data-testid="text-styles" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + v-if="codeSuggestionsEnabled" + data-testid="code-suggestion" + content-type="codeSuggestion" + icon-name="doc-code" + editor-command="insertCodeSuggestion" + :label="__('Insert suggestion')" + :show-active-state="false" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bold" + content-type="bold" + icon-name="bold" + editor-command="toggleBold" + :label="i18n.bold" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="italic" + content-type="italic" + icon-name="italic" + editor-command="toggleItalic" + :label="i18n.italic" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="strike" + content-type="strike" + icon-name="strikethrough" + editor-command="toggleStrike" + :label="i18n.strike" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="blockquote" + content-type="blockquote" + icon-name="quote" + editor-command="toggleBlockquote" + :label="i18n.quote" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="code" + content-type="code" + icon-name="code" + editor-command="toggleCode" + :label="i18n.code" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="link" + content-type="link" + icon-name="link" + editor-command="editLink" + :label="i18n.link" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="bullet-list" + content-type="bulletList" + icon-name="list-bulleted" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleBulletList" + :label="i18n.bulletList" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="ordered-list" + content-type="orderedList" + icon-name="list-numbered" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleOrderedList" + :label="i18n.numberedList" + @execute="trackToolbarControlExecution" + /> + <toolbar-button + data-testid="task-list" + content-type="taskList" + icon-name="list-task" + class="gl-display-none gl-sm-display-inline" + editor-command="toggleTaskList" + :label="i18n.taskList" + @execute="trackToolbarControlExecution" + /> + <toolbar-table-button data-testid="table" @execute="trackToolbarControlExecution" /> + <toolbar-attachment-button + v-if="!hideAttachmentButton" + data-testid="attachment" + @execute="trackToolbarControlExecution" + /> + <!-- TODO Add icon and trigger functionality from here --> + <toolbar-button + v-if="supportsQuickActions" + data-testid="quick-actions" + content-type="quickAction" + icon-name="quick-actions" + class="gl-display-none gl-sm-display-inline" + editor-command="insertQuickAction" + :label="__('Add a quick action')" + @execute="trackToolbarControlExecution" + /> + <comment-templates-dropdown + v-if="newCommentTemplatePath" + :new-comment-template-path="newCommentTemplatePath" + @select="insertSavedReply" + /> + <toolbar-more-dropdown data-testid="more" @execute="trackToolbarControlExecution" /> </div> </div> </template> diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 4074e50a706..6535d9eaa5d 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -1,9 +1,8 @@ <script> -import { GlDropdownItem, GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; +import { GlAvatarLabeled, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlDropdownItem, GlAvatarLabeled, GlLoadingIcon, }, @@ -43,7 +42,7 @@ export default { data() { return { - selectedIndex: 0, + selectedIndex: -1, }; }, @@ -95,7 +94,7 @@ export default { watch: { items() { - this.selectedIndex = 0; + this.selectedIndex = -1; }, selectedIndex() { this.scrollIntoView(); @@ -193,7 +192,7 @@ export default { }, scrollIntoView() { - this.$refs.dropdownItems[this.selectedIndex].$el.scrollIntoView({ block: 'nearest' }); + this.$refs.dropdownItems[this.selectedIndex]?.scrollIntoView({ block: 'nearest' }); }, selectItem(index) { @@ -215,72 +214,83 @@ export default { </script> <template> - <div> - <ul - v-if="!loading" - :class="{ show: items.length > 0 }" - class="gl-dropdown dropdown-menu gl-relative gl-m-0!" - data-testid="content-editor-suggestions-dropdown" + <div class="gl-new-dropdown content-editor-suggestions-dropdown"> + <div + v-if="!loading && items.length > 0" + class="gl-new-dropdown-panel gl-display-block! gl-absolute" > - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <gl-dropdown-item - v-for="(item, index) in items" - ref="dropdownItems" - :key="index" - :class="{ 'gl-bg-gray-50': index === selectedIndex }" - @click="selectItem(index)" - > - <gl-avatar-labeled - v-if="isUser" - :label="item.username" - :sub-label="avatarSubLabel(item)" - :src="item.avatar_url" - :entity-name="item.username" - :shape="item.type === 'Group' ? 'rect' : 'circle'" - :size="32" - /> - <span v-if="isIssue || isMergeRequest"> - <small>{{ item.iid }}</small> - {{ item.title }} - </span> - <span v-if="isVulnerability || isSnippet"> - <small>{{ item.id }}</small> - {{ item.title }} - </span> - <span v-if="isEpic"> - <small>{{ item.reference }}</small> - {{ item.title }} - </span> - <span v-if="isMilestone"> - {{ item.title }} - </span> - <span v-if="isLabel" class="gl-display-flex gl-align-items-center"> - <span - data-testid="label-color-box" - class="gl-rounded-base gl-display-block gl-w-5 gl-h-5 gl-mr-3" - :style="{ backgroundColor: item.color }" - ></span> - {{ item.title }} - </span> - <span v-if="isCommand"> - /{{ item.name }} <small> {{ item.params[0] }} </small><br /> - <em> - <small> {{ item.description }} </small> - </em> - </span> - <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> - <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> - <div class="gl-flex-grow-1"> - {{ item.name }}<br /> - <small>{{ item.d }}</small> + <div class="gl-new-dropdown-inner"> + <ul class="gl-new-dropdown-contents" data-testid="content-editor-suggestions-dropdown"> + <li + v-for="(item, index) in items" + :key="index" + role="presentation" + class="gl-new-dropdown-item" + :class="{ focused: index === selectedIndex }" + > + <div + ref="dropdownItems" + type="button" + role="menuitem" + class="gl-new-dropdown-item-content" + @click="selectItem(index)" + > + <div class="gl-new-dropdown-item-text-wrapper"> + <gl-avatar-labeled + v-if="isUser" + :label="item.username" + :sub-label="avatarSubLabel(item)" + :src="item.avatar_url" + :entity-name="item.username" + :shape="item.type === 'Group' ? 'rect' : 'circle'" + :size="32" + /> + <span v-if="isIssue || isMergeRequest"> + <small>{{ item.iid }}</small> + {{ item.title }} + </span> + <span v-if="isVulnerability || isSnippet"> + <small>{{ item.id }}</small> + {{ item.title }} + </span> + <span v-if="isEpic"> + <small>{{ item.reference }}</small> + {{ item.title }} + </span> + <span v-if="isMilestone"> + {{ item.title }} + </span> + <span v-if="isLabel" class="gl-display-flex"> + <span + data-testid="label-color-box" + class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" + :style="{ backgroundColor: item.color }" + ></span> + {{ item.title }} + </span> + <div v-if="isCommand"> + <div class="gl-mb-1"> + <span class="gl-font-weight-bold">/{{ item.name }}</span> + <em class="gl-text-gray-500 gl-font-sm">{{ item.params[0] }}</em> + </div> + <small class="gl-text-gray-500"> {{ item.description }} </small> + </div> + <div v-if="isEmoji" class="gl-display-flex gl-align-items-center"> + <div class="gl-pr-4 gl-font-lg">{{ item.e }}</div> + <div class="gl-flex-grow-1"> + {{ item.name }}<br /> + <small>{{ item.d }}</small> + </div> + </div> + </div> </div> - </div> - </gl-dropdown-item> + </li> + </ul> </div> - </ul> - <div v-if="loading" class="gl-dropdown show dropdown-menu gl-relative gl-m-0!"> - <div class="gl-dropdown-inner gl-overflow-y-auto"> - <div class="gl-px-5"> + </div> + <div v-if="loading" class="gl-new-dropdown-panel gl-display-block! gl-absolute"> + <div class="gl-new-dropdown-inner"> + <div class="gl-px-4 gl-py-3"> <gl-loading-icon size="sm" class="gl-display-inline-block" /> {{ __('Loading...') }} </div> </div> diff --git a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue index 1e13c17bc38..4cf150dd948 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue @@ -47,6 +47,7 @@ export default { category="tertiary" icon="paperclip" size="small" + class="gl-mr-3" lazy @click="openFileUpload" /> diff --git a/app/assets/javascripts/content_editor/components/toolbar_button.vue b/app/assets/javascripts/content_editor/components/toolbar_button.vue index a62f66d8557..60bfaab25a5 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_button.vue @@ -49,6 +49,11 @@ export default { required: false, default: 'small', }, + showActiveState: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -78,7 +83,7 @@ export default { :variant="variant" :category="category" :size="size" - :class="{ 'gl-bg-gray-100!': isActive }" + :class="{ 'gl-bg-gray-100!': showActiveState && isActive }" :aria-label="label" :title="label" :icon="iconName" diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue index 99ba8c51948..b7f419d5840 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue @@ -15,10 +15,6 @@ export default { toggleId: uniqueId('dropdown-toggle-btn-'), items: [ { - text: __('Comment'), - action: () => this.insert('comment'), - }, - { text: __('Code block'), action: () => this.insert('codeBlock'), }, diff --git a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue index eb7985f628a..ab1546b9016 100644 --- a/app/assets/javascripts/content_editor/components/toolbar_table_button.vue +++ b/app/assets/javascripts/content_editor/components/toolbar_table_button.vue @@ -1,5 +1,6 @@ <script> -import { GlDisclosureDropdown, GlButton, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlButton, GlTooltip } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; import { __, sprintf } from '~/locale'; import { clamp } from '../services/utils'; @@ -14,13 +15,12 @@ export default { components: { GlButton, GlDisclosureDropdown, - }, - directives: { GlTooltip, }, inject: ['tiptapEditor'], data() { return { + toggleId: uniqueId('dropdown-toggle-btn-'), maxRows: MIN_ROWS, maxCols: MIN_COLS, rows: 1, @@ -82,43 +82,47 @@ export default { }; </script> <template> - <gl-disclosure-dropdown - ref="dropdown" - v-gl-tooltip - size="small" - category="tertiary" - icon="table" - :aria-label="__('Insert table')" - :toggle-text="__('Insert table')" - positioning-strategy="fixed" - class="content-editor-table-dropdown" - text-sr-only - :fluid-width="true" - @shown="setFocus(1, 1)" - > - <div - class="gl-p-3 gl-pt-2" - role="grid" - :aria-colcount="$options.MAX_COLS" - :aria-rowcount="$options.MAX_ROWS" + <div class="gl-display-inline-flex gl-vertical-align-middle"> + <gl-disclosure-dropdown + ref="dropdown" + :toggle-id="toggleId" + size="small" + category="tertiary" + icon="table" + no-caret + :aria-label="__('Insert table')" + :toggle-text="__('Insert table')" + positioning-strategy="fixed" + class="content-editor-table-dropdown gl-mr-3" + text-sr-only + :fluid-width="true" + @shown="setFocus(1, 1)" > - <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row"> - <div v-for="c of list(maxCols)" :key="c" role="gridcell"> - <gl-button - :ref="`table-${r}-${c}`" - :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" - :aria-label="getButtonLabel(r, c)" - class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" - @mouseover="setRowsAndCols(r, c)" - @focus="setRowsAndCols(r, c)" - @click="insertTable()" - @keydown="onKeydown($event.key)" - /> + <div + class="gl-p-3 gl-pt-2" + role="grid" + :aria-colcount="$options.MAX_COLS" + :aria-rowcount="$options.MAX_ROWS" + > + <div v-for="r of list(maxRows)" :key="r" class="gl-display-flex" role="row"> + <div v-for="c of list(maxCols)" :key="c" role="gridcell"> + <gl-button + :ref="`table-${r}-${c}`" + :class="{ 'active gl-bg-blue-50!': r <= rows && c <= cols }" + :aria-label="getButtonLabel(r, c)" + class="table-creator-grid-item gl-display-inline gl-rounded-0! gl-w-6! gl-h-6! gl-p-0!" + @mouseover="setRowsAndCols(r, c)" + @focus="setRowsAndCols(r, c)" + @click="insertTable()" + @keydown="onKeydown($event.key)" + /> + </div> </div> </div> - </div> - <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2"> - {{ getButtonLabel(rows, cols) }} - </div> - </gl-disclosure-dropdown> + <div class="gl-border-t gl-px-4 gl-pt-3 gl-pb-2"> + {{ getButtonLabel(rows, cols) }} + </div> + </gl-disclosure-dropdown> + <gl-tooltip :target="toggleId" placement="top">{{ __('Insert table') }}</gl-tooltip> + </div> </template> 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 4a3dfe3656c..efd0926d7ed 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/code_block.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/code_block.vue @@ -1,20 +1,33 @@ <script> import { debounce } from 'lodash'; +import { GlButton, GlTooltipDirective as GlTooltip, GlSprintf } from '@gitlab/ui'; 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'; +import { memoizedGet } from '../../services/utils'; +import { + lineOffsetToLangParams, + langParamsToLineOffset, + toAbsoluteLineOffset, + getLines, + appendNewlines, +} from '../../services/code_suggestion_utils'; export default { name: 'CodeBlock', components: { + GlButton, + GlSprintf, NodeViewWrapper, NodeViewContent, EditorStateObserver, SandboxedMermaid, }, + directives: { + GlTooltip, + }, inject: ['contentEditor'], props: { editor: { @@ -39,13 +52,54 @@ export default { return { diagramUrl: '', diagramSource: '', + + allLines: [], + deletedLines: [], + addedLines: [], }; }, + computed: { + isCodeSuggestion() { + return ( + this.node.attrs.isCodeSuggestion && + this.contentEditor.codeSuggestionsConfig?.canSuggest && + this.contentEditor.codeSuggestionsConfig?.diffFile + ); + }, + classList() { + return this.isCodeSuggestion + ? 'gl-p-0! suggestion-added-input' + : `gl-p-3 code highlight ${this.$options.userColorScheme}`; + }, + lineOffset() { + return langParamsToLineOffset(this.node.attrs.langParams); + }, + absoluteLineOffset() { + if (!this.contentEditor.codeSuggestionsConfig) return [0, 0]; + + const { new_line: n } = this.contentEditor.codeSuggestionsConfig.line; + return toAbsoluteLineOffset(this.lineOffset, n); + }, + disableDecrementLineStart() { + return this.absoluteLineOffset[0] <= 1; + }, + disableIncrementLineStart() { + return this.lineOffset[0] >= 0; + }, + disableDecrementLineEnd() { + return this.lineOffset[1] <= 0; + }, + disableIncrementLineEnd() { + return this.absoluteLineOffset[1] >= this.allLines.length - 1; + }, + }, async mounted() { - this.updateDiagramPreview = debounce( - this.updateDiagramPreview, - DEFAULT_DEBOUNCE_AND_THROTTLE_MS, - ); + if (this.isCodeSuggestion) { + await this.updateAllLines(); + this.updateCodeSuggestion(); + } + + this.updateCodeBlock = debounce(this.updateCodeBlock, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); const lang = codeBlockLanguageLoader.findOrCreateLanguageBySyntax(this.node.attrs.language); await codeBlockLanguageLoader.loadLanguage(lang.syntax); @@ -53,7 +107,26 @@ export default { this.updateAttributes({ language: this.node.attrs.language }); }, methods: { - async updateDiagramPreview() { + async updateAllLines() { + const { diffFile } = this.contentEditor.codeSuggestionsConfig; + this.allLines = (await memoizedGet(diffFile.view_path.replace('/blob/', '/raw/'))).split( + '\n', + ); + }, + updateCodeSuggestion() { + this.deletedLines = appendNewlines(getLines(this.absoluteLineOffset, this.allLines)); + this.addedLines = appendNewlines( + this.$refs.nodeViewContent?.$el.textContent.split('\n') || [], + ); + }, + updateNodeView() { + if (this.isCodeSuggestion) { + this.updateCodeSuggestion(); + } else { + this.updateCodeBlock(); + } + }, + async updateCodeBlock() { if (!this.node.attrs.showPreview) { this.diagramSource = ''; return; @@ -70,22 +143,34 @@ export default { ); } }, - }, - i18n: { - frontmatter: __('frontmatter'), + updateLineOffset(deltaStart = 0, deltaEnd = 0) { + const { lineOffset } = this; + + this.editor + .chain() + .updateAttributes('codeSuggestion', { + langParams: lineOffsetToLangParams([ + lineOffset[0] + deltaStart, + lineOffset[1] + deltaEnd, + ]), + }) + .run(); + }, }, userColorScheme: gon.user_color_scheme, }; </script> <template> - <editor-state-observer @transaction="updateDiagramPreview"> + <editor-state-observer :debounce="0" @transaction="updateNodeView"> <node-view-wrapper - :class="`content-editor-code-block gl-relative code highlight gl-p-3 ${$options.userColorScheme}`" + :class="classList" + class="content-editor-code-block gl-relative" as="pre" dir="auto" > <div v-if="node.attrs.showPreview" + contenteditable="false" 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" /> @@ -93,12 +178,108 @@ export default { </div> <span v-if="node.attrs.isFrontmatter" + contenteditable="false" data-testid="frontmatter-label" class="gl-absolute gl-top-0 gl-right-3" + >{{ __('frontmatter') }}:{{ node.attrs.language }}</span + > + <div + v-if="isCodeSuggestion" contenteditable="false" - >{{ $options.i18n.frontmatter }}:{{ node.attrs.language }}</span + class="gl-relative gl-z-index-0" + data-testid="code-suggestion-box" > - <node-view-content ref="nodeViewContent" as="code" /> + <div + class="md-suggestion-header gl-flex-wrap gl-z-index-1 gl-w-full gl-border-none! gl-font-regular gl-px-4 gl-py-3 gl-border-b-1! gl-border-b-solid! gl-mr-n10!" + > + <div class="gl-font-weight-bold gl-pr-3"> + {{ __('Suggested change') }} + </div> + + <div + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-pl-3 gl-gap-2 gl-white-space-nowrap" + > + <gl-sprintf :message="__('From line %{line1} to %{line2}')"> + <template #line1> + <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-mx-1"> + <gl-button + size="small" + icon="dash" + variant="confirm" + category="tertiary" + data-testid="decrement-line-start" + :aria-label="__('Decrement suggestion line start')" + :disabled="disableDecrementLineStart" + @click="updateLineOffset(-1, 0)" + /> + <div + class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace" + > + <strong>{{ absoluteLineOffset[0] }}</strong> + </div> + <gl-button + size="small" + icon="plus" + variant="confirm" + category="tertiary" + data-testid="increment-line-start" + :aria-label="__('Increment suggestion line start')" + :disabled="disableIncrementLineStart" + @click="updateLineOffset(1, 0)" + /> + </div> + </template> + <template #line2> + <div class="gl-display-flex gl-bg-gray-50 gl-rounded-base gl-ml-1"> + <gl-button + size="small" + icon="dash" + variant="confirm" + category="tertiary" + data-testid="decrement-line-end" + :aria-label="__('Decrement suggestion line end')" + :disabled="disableDecrementLineEnd" + @click="updateLineOffset(0, -1)" + /> + <div + class="flex gl-align-items-center gl-justify-content-center gl-px-3 monospace" + > + <strong>{{ absoluteLineOffset[1] }}</strong> + </div> + <gl-button + size="small" + icon="plus" + variant="confirm" + category="tertiary" + data-testid="increment-line-end" + :aria-label="__('Increment suggestion line end')" + :disabled="disableIncrementLineEnd" + @click="updateLineOffset(0, 1)" + /> + </div> + </template> + </gl-sprintf> + </div> + </div> + + <div class="suggestion-deleted" data-testid="suggestion-deleted"> + <code + v-for="(line, i) in deletedLines" + :key="i" + :data-line-number="absoluteLineOffset[0] + i" + >{{ line }}</code + > + </div> + <div class="suggestion-added gl-absolute" data-testid="suggestion-added"> + <code + v-for="(line, i) in addedLines" + :key="i" + :data-line-number="absoluteLineOffset[0] + i" + >{{ line }}</code + > + </div> + </div> + <node-view-content ref="nodeViewContent" as="code" class="gl-relative gl-z-index-1" /> </node-view-wrapper> </editor-state-observer> </template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/image.vue b/app/assets/javascripts/content_editor/components/wrappers/image.vue new file mode 100644 index 00000000000..0b80802d993 --- /dev/null +++ b/app/assets/javascripts/content_editor/components/wrappers/image.vue @@ -0,0 +1,103 @@ +<script> +import { NodeViewWrapper } from '@tiptap/vue-2'; + +export default { + name: 'ImageWrapper', + components: { + NodeViewWrapper, + }, + props: { + getPos: { + type: Function, + required: true, + }, + editor: { + type: Object, + required: true, + }, + node: { + type: Object, + required: true, + }, + selected: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + dragData: {}, + }; + }, + mounted() { + document.addEventListener('mousemove', this.onDrag); + document.addEventListener('mouseup', this.onDragEnd); + }, + destroyed() { + document.removeEventListener('mousemove', this.onDrag); + document.removeEventListener('mouseup', this.onDragEnd); + }, + methods: { + onDragStart(handle, event) { + this.dragData = { + handle, + startX: event.screenX, + startY: event.screenY, + width: this.$refs.image.width, + height: this.$refs.image.height, + }; + }, + onDrag(event) { + const { handle, startX, width, height } = this.dragData; + if (!handle) return; + + const deltaX = event.screenX - startX; + const newWidth = handle.includes('w') ? width - deltaX : width + deltaX; + const newHeight = (height / width) * newWidth; + + this.$refs.image.setAttribute('width', newWidth); + this.$refs.image.setAttribute('height', newHeight); + }, + onDragEnd() { + const { handle } = this.dragData; + if (!handle) return; + + this.dragData = {}; + + this.editor + .chain() + .focus() + .updateAttributes(this.node.type, { + width: this.$refs.image.width, + height: this.$refs.image.height, + }) + .setNodeSelection(this.getPos()) + .run(); + }, + }, + resizeHandles: ['ne', 'nw', 'se', 'sw'], +}; +</script> +<template> + <node-view-wrapper as="span" class="gl-relative gl-display-inline-block"> + <span + v-for="handle in $options.resizeHandles" + v-show="selected" + :key="handle" + class="image-resize" + :class="`image-resize-${handle}`" + :data-testid="`image-resize-${handle}`" + @mousedown="onDragStart(handle, $event)" + ></span> + <img + ref="image" + :src="node.attrs.src" + :alt="node.attrs.alt" + :title="node.attrs.title" + :width="node.attrs.width || 'auto'" + :height="node.attrs.height || 'auto'" + :class="{ 'ProseMirror-selectednode': selected }" + /> + </node-view-wrapper> +</template> diff --git a/app/assets/javascripts/content_editor/components/wrappers/reference.vue b/app/assets/javascripts/content_editor/components/wrappers/reference.vue index 2b4b9891c77..4ec477232d4 100644 --- a/app/assets/javascripts/content_editor/components/wrappers/reference.vue +++ b/app/assets/javascripts/content_editor/components/wrappers/reference.vue @@ -3,11 +3,12 @@ import { NodeViewWrapper } from '@tiptap/vue-2'; import { GlLink } from '@gitlab/ui'; export default { - name: 'DetailsWrapper', + name: 'ReferenceWrapper', components: { NodeViewWrapper, GlLink, }, + inject: ['contentEditor'], props: { node: { type: Object, @@ -19,6 +20,11 @@ export default { default: false, }, }, + data() { + return { + href: '#', + }; + }, computed: { text() { return this.node.attrs.text; @@ -33,6 +39,11 @@ export default { return gon.current_username === this.text.substring(1); }, }, + async mounted() { + const text = this.node.attrs.originalText || this.node.attrs.text; + const { href } = await this.contentEditor.resolveReference(text); + this.href = href || ''; + }, }; </script> <template> @@ -40,7 +51,7 @@ export default { <span v-if="isCommand">{{ text }}</span> <gl-link v-else - href="#" + :href="href" tabindex="-1" class="gfm gl-cursor-text" :class="{ |