diff options
Diffstat (limited to 'app/assets/javascripts/content_editor')
26 files changed, 890 insertions, 387 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="{ diff --git a/app/assets/javascripts/content_editor/extensions/code_suggestion.js b/app/assets/javascripts/content_editor/extensions/code_suggestion.js new file mode 100644 index 00000000000..c70a96769fb --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/code_suggestion.js @@ -0,0 +1,81 @@ +import { lowlight } from 'lowlight/lib/core'; +import { textblockTypeInputRule } from '@tiptap/core'; +import { PARSE_HTML_PRIORITY_HIGHEST } from '../constants'; +import { memoizedGet } from '../services/utils'; +import CodeBlockHighlight from './code_block_highlight'; + +const backtickInputRegex = /^```suggestion[\s\n]$/; + +export default CodeBlockHighlight.extend({ + name: 'codeSuggestion', + + isolating: true, + + addOptions() { + return { + lowlight, + config: {}, + }; + }, + + addAttributes() { + return { + ...this.parent?.(), + language: { + default: 'suggestion', + }, + isCodeSuggestion: { + default: true, + }, + }; + }, + + addCommands() { + const ext = this; + + return { + insertCodeSuggestion: (attributes) => async ({ editor }) => { + // do not insert a new suggestion if already inside a suggestion + if (editor.isActive('codeSuggestion')) return false; + + const rawPath = ext.options.config.diffFile.view_path.replace('/blob/', '/raw/'); + const allLines = (await memoizedGet(rawPath)).split('\n'); + const { line } = ext.options.config; + let { lines } = ext.options.config; + + if (!lines.length) lines = [line]; + + const content = lines.map((l) => allLines[l.new_line - 1]).join('\n'); + const lineNumbers = `-${lines.length - 1}+0`; + + editor.commands.insertContent({ + type: 'codeSuggestion', + attrs: { langParams: lineNumbers, ...attributes }, + // empty strings are not allowed in text nodes + content: [{ type: 'text', text: content || ' ' }], + }); + + return true; + }, + }; + }, + + parseHTML() { + return [ + { + priority: PARSE_HTML_PRIORITY_HIGHEST, + tag: 'pre[lang="suggestion"]', + }, + ]; + }, + + addInputRules() { + return [ + textblockTypeInputRule({ + find: backtickInputRegex, + type: this.type, + getAttributes: () => ({ language: 'suggestion', langParams: '-0+0' }), + }), + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/comment.js b/app/assets/javascripts/content_editor/extensions/comment.js deleted file mode 100644 index 8e247e552a3..00000000000 --- a/app/assets/javascripts/content_editor/extensions/comment.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Node, textblockTypeInputRule } from '@tiptap/core'; - -export const commentInputRegex = /^<!--[\s\n]$/; - -export default Node.create({ - name: 'comment', - content: 'text*', - marks: '', - group: 'block', - code: true, - isolating: true, - defining: true, - - parseHTML() { - return [ - { - tag: 'comment', - preserveWhitespace: 'full', - getContent(element, schema) { - const node = schema.node('paragraph', {}, [ - schema.text( - element.textContent.replace(/&#x([0-9A-F]{2,4});/gi, (_, code) => - String.fromCharCode(parseInt(code, 16)), - ) || ' ', - ), - ]); - return node.content; - }, - }, - ]; - }, - - renderHTML() { - return [ - 'pre', - { class: 'gl-p-0 gl-border-0 gl-bg-transparent gl-text-gray-300' }, - ['span', { class: 'content-editor-comment' }, 0], - ]; - }, - - addInputRules() { - return [ - textblockTypeInputRule({ - find: commentInputRegex, - type: this.type, - }), - ]; - }, -}); diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index db13438de5e..f484ce98e90 100644 --- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -2,11 +2,13 @@ import OrderedMap from 'orderedmap'; import { Extension } from '@tiptap/core'; import { Plugin, PluginKey } from '@tiptap/pm/state'; import { Schema, DOMParser as ProseMirrorDOMParser, DOMSerializer } from '@tiptap/pm/model'; +import { uniqueId } from 'lodash'; import { __ } from '~/locale'; import { VARIANT_DANGER } from '~/alert'; import createMarkdownDeserializer from '../services/gl_api_markdown_deserializer'; import { ALERT_EVENT, EXTENSION_PRIORITY_HIGHEST } from '../constants'; import CodeBlockHighlight from './code_block_highlight'; +import CodeSuggestion from './code_suggestion'; import Diagram from './diagram'; import Frontmatter from './frontmatter'; @@ -14,7 +16,12 @@ const TEXT_FORMAT = 'text/plain'; const GFM_FORMAT = 'text/x-gfm'; const HTML_FORMAT = 'text/html'; const VS_CODE_FORMAT = 'vscode-editor-data'; -const CODE_BLOCK_NODE_TYPES = [CodeBlockHighlight.name, Diagram.name, Frontmatter.name]; +const CODE_BLOCK_NODE_TYPES = [ + CodeBlockHighlight.name, + CodeSuggestion.name, + Diagram.name, + Frontmatter.name, +]; function parseHTML(schema, html) { const parser = new DOMParser(); @@ -24,8 +31,23 @@ function parseHTML(schema, html) { return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; } +const findLoader = (editor, loaderId) => { + let position; + + editor.view.state.doc.descendants((descendant, pos) => { + if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) { + position = pos; + return false; + } + + return true; + }); + + return position; +}; + export default Extension.create({ - name: 'pasteMarkdown', + name: 'copyPaste', priority: EXTENSION_PRIORITY_HIGHEST, addOptions() { return { @@ -35,7 +57,7 @@ export default Extension.create({ }, addCommands() { return { - pasteContent: (content = '', processMarkdown = true) => async () => { + pasteContent: (content = '', processMarkdown = true) => () => { const { editor, options } = this; const { renderMarkdown, eventHub } = options; const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); @@ -43,23 +65,37 @@ export default Extension.create({ const pasteSchemaSpec = { ...editor.schema.spec }; pasteSchemaSpec.marks = OrderedMap.from(pasteSchemaSpec.marks).remove('span'); pasteSchemaSpec.nodes = OrderedMap.from(pasteSchemaSpec.nodes).remove('div').remove('pre'); - const schema = new Schema(pasteSchemaSpec); + const pasteSchema = new Schema(pasteSchemaSpec); const promise = processMarkdown - ? deserializer.deserialize({ schema, markdown: content }) - : Promise.resolve(parseHTML(schema, content)); - - promise - .then(({ document }) => { + ? deserializer.deserialize({ schema: pasteSchema, markdown: content }) + : Promise.resolve(parseHTML(pasteSchema, content)); + const loaderId = uniqueId('loading'); + + Promise.resolve() + .then(() => { + editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } }); + return promise; + }) + .then(async ({ document }) => { if (!document) return; - const { firstChild } = document.content; + const pos = findLoader(editor, loaderId); + if (!pos) return; + + const { firstChild, childCount } = document.content; const toPaste = - document.content.childCount === 1 && firstChild.type.name === 'paragraph' + childCount === 1 && firstChild.type.name === 'paragraph' ? firstChild.content : document.content; - editor.commands.insertContent(toPaste.toJSON()); + editor + .chain() + .deleteRange({ from: pos, to: pos + 1 }) + .insertContentAt(pos, toPaste.toJSON(), { + updateSelection: false, + }) + .run(); }) .catch(() => { eventHub.$emit(ALERT_EVENT, { @@ -94,7 +130,7 @@ export default Extension.create({ return [ new Plugin({ - key: new PluginKey('pasteMarkdown'), + key: new PluginKey('copyPaste'), props: { handleDOMEvents: { copy: handleCutAndCopy, diff --git a/app/assets/javascripts/content_editor/extensions/hard_break.js b/app/assets/javascripts/content_editor/extensions/hard_break.js index fb81c6b79b6..6d7ff92e64b 100644 --- a/app/assets/javascripts/content_editor/extensions/hard_break.js +++ b/app/assets/javascripts/content_editor/extensions/hard_break.js @@ -2,8 +2,6 @@ import { HardBreak } from '@tiptap/extension-hard-break'; export default HardBreak.extend({ addKeyboardShortcuts() { - return { - 'Shift-Enter': () => this.editor.commands.setHardBreak(), - }; + return {}; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/image.js b/app/assets/javascripts/content_editor/extensions/image.js index 58c16297886..d245b86543f 100644 --- a/app/assets/javascripts/content_editor/extensions/image.js +++ b/app/assets/javascripts/content_editor/extensions/image.js @@ -1,5 +1,7 @@ import { Image } from '@tiptap/extension-image'; +import { VueNodeViewRenderer } from '@tiptap/vue-2'; import { PARSE_HTML_PRIORITY_HIGH } from '../constants'; +import ImageWrapper from '../components/wrappers/image.vue'; const resolveImageEl = (element) => element.nodeName === 'IMG' ? element : element.querySelector('img'); @@ -97,4 +99,7 @@ export default Image.extend({ }, ]; }, + addNodeView() { + return VueNodeViewRenderer(ImageWrapper); + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js new file mode 100644 index 00000000000..0115fb10d5d --- /dev/null +++ b/app/assets/javascripts/content_editor/extensions/loading.js @@ -0,0 +1,23 @@ +import { Node } from '@tiptap/core'; + +export default Node.create({ + name: 'loading', + inline: true, + group: 'inline', + + addAttributes() { + return { + id: { + default: null, + }, + }; + }, + + renderHTML() { + return [ + 'span', + { class: 'gl-display-inline-flex gl-align-items-center' }, + ['span', { class: 'gl-dots-loader gl-mx-2' }, ['span']], + ]; + }, +}); diff --git a/app/assets/javascripts/content_editor/extensions/paragraph.js b/app/assets/javascripts/content_editor/extensions/paragraph.js index c63b64fd784..bddd8b38b06 100644 --- a/app/assets/javascripts/content_editor/extensions/paragraph.js +++ b/app/assets/javascripts/content_editor/extensions/paragraph.js @@ -9,4 +9,14 @@ export default Paragraph.extend({ }, }; }, + + addKeyboardShortcuts() { + return { + 'Shift-Enter': async () => { + // can only delegate one shortcut to another async + await Promise.resolve(); + this.editor.commands.enter(); + }, + }; + }, }); diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js index ef69b9bbda6..fd248709b5a 100644 --- a/app/assets/javascripts/content_editor/extensions/reference.js +++ b/app/assets/javascripts/content_editor/extensions/reference.js @@ -63,6 +63,12 @@ export default Node.create({ }; }, + addCommands() { + return { + insertQuickAction: () => ({ commands }) => commands.insertContent('<p>/</p>'), + }; + }, + addInputRules() { const { editor } = this; const { assetResolver } = this.options; diff --git a/app/assets/javascripts/content_editor/services/code_suggestion_utils.js b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js new file mode 100644 index 00000000000..836729790ae --- /dev/null +++ b/app/assets/javascripts/content_editor/services/code_suggestion_utils.js @@ -0,0 +1,32 @@ +export function langParamsToLineOffset(langParams) { + if (!langParams) return [0, 0]; + const match = langParams.match(/([-+]\d+)([-+]\d+)/); + return match ? [parseInt(match[1], 10), parseInt(match[2], 10)] : [0, 0]; +} + +export function lineOffsetToLangParams(lineOffset) { + let langParams = ''; + langParams += lineOffset[0] <= 0 ? `-${-lineOffset[0]}` : `+${lineOffset[0]}`; + langParams += lineOffset[1] < 0 ? lineOffset[1] : `+${lineOffset[1]}`; + return langParams; +} + +export function toAbsoluteLineOffset(lineOffset, lineNumber) { + return [lineOffset[0] + lineNumber, lineOffset[1] + lineNumber]; +} + +export function getLines(absoluteLineOffset, allLines) { + return allLines.slice(absoluteLineOffset[0] - 1, absoluteLineOffset[1]); +} + +// \u200b is a zero width space character (Alternatively ​, ​ or ​). +// Due to the nature of HTML, if you have a blank line in the deleted/inserted code, it would +// render with 0 height. (Each line is in its <code> element.) This means that blank lines +// would be skipped when rendering the diff. +// We append this character to the end of each line to make sure that the line is not empty +// and the line numbers are rendered correctly. +const ZERO_WIDTH_SPACE = '\u200b'; + +export function appendNewlines(lines) { + return lines.map((l, i, arr) => `${l}${ZERO_WIDTH_SPACE}${i === arr.length - 1 ? '' : '\n'}`); +} diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index ec0f2f028d9..bc1ee696323 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -1,6 +1,14 @@ /* eslint-disable no-underscore-dangle */ export class ContentEditor { - constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) { + constructor({ + tiptapEditor, + serializer, + deserializer, + assetResolver, + eventHub, + drawioEnabled, + codeSuggestionsConfig, + }) { this._tiptapEditor = tiptapEditor; this._serializer = serializer; this._deserializer = deserializer; @@ -8,9 +16,13 @@ export class ContentEditor { this._assetResolver = assetResolver; this._pristineDoc = null; + this.codeSuggestionsConfig = codeSuggestionsConfig; this.drawioEnabled = drawioEnabled; } + /** + * @type {import('@tiptap/core').Editor} + */ get tiptapEditor() { return this._tiptapEditor; } 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 ee1f706ec7e..51e41ceefaf 100644 --- a/app/assets/javascripts/content_editor/services/create_content_editor.js +++ b/app/assets/javascripts/content_editor/services/create_content_editor.js @@ -9,8 +9,9 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import CodeSuggestion from '../extensions/code_suggestion'; import ColorChip from '../extensions/color_chip'; -import Comment from '../extensions/comment'; +import CopyPaste from '../extensions/copy_paste'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; @@ -40,10 +41,10 @@ import InlineDiff from '../extensions/inline_diff'; 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 PasteMarkdown from '../extensions/paste_markdown'; import Reference from '../extensions/reference'; import ReferenceLabel from '../extensions/reference_label'; import ReferenceDefinition from '../extensions/reference_definition'; @@ -73,11 +74,6 @@ import trackInputRulesAndShortcuts from './track_input_rules_and_shortcuts'; const createTiptapEditor = ({ extensions = [], ...options } = {}) => new Editor({ extensions: [...extensions], - editorProps: { - attributes: { - class: 'gl-shadow-none!', - }, - }, ...options, }); @@ -90,6 +86,7 @@ export const createContentEditor = ({ drawioEnabled = false, enableAutocomplete, autocompleteDataSources = {}, + codeSuggestionsConfig = {}, } = {}) => { if (!isFunction(renderMarkdown)) { throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR); @@ -112,8 +109,8 @@ export const createContentEditor = ({ BulletList, Code, ColorChip, - Comment, CodeBlockHighlight, + CodeSuggestion.configure({ config: codeSuggestionsConfig }), DescriptionItem, DescriptionList, Details, @@ -142,10 +139,11 @@ export const createContentEditor = ({ ExternalKeydownHandler.configure({ eventHub }), Link, ListItem, + Loading, MathInline, OrderedList, Paragraph, - PasteMarkdown.configure({ eventHub, renderMarkdown, serializer }), + CopyPaste.configure({ eventHub, renderMarkdown, serializer }), Reference.configure({ assetResolver }), ReferenceLabel, ReferenceDefinition, @@ -181,5 +179,6 @@ export const createContentEditor = ({ deserializer, assetResolver, drawioEnabled, + codeSuggestionsConfig, }); }; diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 4dbafd1632d..972b4acf523 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -8,12 +8,12 @@ import Bold from '../extensions/bold'; import BulletList from '../extensions/bullet_list'; import Code from '../extensions/code'; import CodeBlockHighlight from '../extensions/code_block_highlight'; +import CodeSuggestion from '../extensions/code_suggestion'; import DescriptionItem from '../extensions/description_item'; import DescriptionList from '../extensions/description_list'; import Details from '../extensions/details'; import DetailsContent from '../extensions/details_content'; import DrawioDiagram from '../extensions/drawio_diagram'; -import Comment from '../extensions/comment'; import Diagram from '../extensions/diagram'; import Emoji from '../extensions/emoji'; import Figure from '../extensions/figure'; @@ -32,6 +32,7 @@ import InlineDiff from '../extensions/inline_diff'; 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'; @@ -52,7 +53,6 @@ import Text from '../extensions/text'; import Video from '../extensions/video'; import WordBreak from '../extensions/word_break'; import { - renderComment, renderCodeBlock, renderHardBreak, renderTable, @@ -134,8 +134,8 @@ const defaultSerializerConfig = { }), [BulletList.name]: preserveUnchanged(renderBulletList), [CodeBlockHighlight.name]: preserveUnchanged(renderCodeBlock), - [Comment.name]: renderComment, [Diagram.name]: preserveUnchanged(renderCodeBlock), + [CodeSuggestion.name]: preserveUnchanged(renderCodeBlock), [DrawioDiagram.name]: preserveUnchanged({ render: renderImage, inline: true, @@ -195,6 +195,7 @@ const defaultSerializerConfig = { inline: true, }), [ListItem.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.list_item), + [Loading.name]: () => {}, [OrderedList.name]: preserveUnchanged(renderOrderedList), [Paragraph.name]: preserveUnchanged(defaultMarkdownSerializer.nodes.paragraph), [Reference.name]: renderReference, diff --git a/app/assets/javascripts/content_editor/services/serialization_helpers.js b/app/assets/javascripts/content_editor/services/serialization_helpers.js index b2cbc9c3fed..17e650644b3 100644 --- a/app/assets/javascripts/content_editor/services/serialization_helpers.js +++ b/app/assets/javascripts/content_editor/services/serialization_helpers.js @@ -365,13 +365,6 @@ export function renderPlayable(state, node) { renderImage(state, node); } -export function renderComment(state, node) { - state.write('<!--'); - state.write(node.textContent); - state.write('-->'); - state.closeBlock(node); -} - export function renderCodeBlock(state, node) { state.write( `\`\`\`${ diff --git a/app/assets/javascripts/content_editor/services/utils.js b/app/assets/javascripts/content_editor/services/utils.js index 1c128b4aa19..391d3b1a665 100644 --- a/app/assets/javascripts/content_editor/services/utils.js +++ b/app/assets/javascripts/content_editor/services/utils.js @@ -1,3 +1,6 @@ +import axios from 'axios'; +import { memoize } from 'lodash'; + export const hasSelection = (tiptapEditor) => { const { from, to } = tiptapEditor.state.selection; @@ -5,3 +8,8 @@ export const hasSelection = (tiptapEditor) => { }; export const clamp = (n, min, max) => Math.max(Math.min(n, max), min); + +export const memoizedGet = memoize(async (path) => { + const { data } = await axios(path, { responseType: 'blob' }); + return data.text(); +}); |