Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/content_editor/components')
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/bubble_menu.vue2
-rw-r--r--app/assets/javascripts/content_editor/components/bubble_menus/media_bubble_menu.vue57
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue69
-rw-r--r--app/assets/javascripts/content_editor/components/formatting_toolbar.vue259
-rw-r--r--app/assets/javascripts/content_editor/components/suggestions_dropdown.vue146
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_attachment_button.vue1
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_button.vue7
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue4
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_table_button.vue82
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/code_block.vue207
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/image.vue103
-rw-r--r--app/assets/javascripts/content_editor/components/wrappers/reference.vue15
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="{