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:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-07-19 17:16:28 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-07-19 17:16:28 +0300
commite4384360a16dd9a19d4d2d25d0ef1f2b862ed2a6 (patch)
tree2fcdfa7dcdb9db8f5208b2562f4b4e803d671243 /app/assets/javascripts/content_editor
parentffda4e7bcac36987f936b4ba515995a6698698f0 (diff)
Add latest changes from gitlab-org/gitlab@16-2-stable-eev16.2.0-rc42
Diffstat (limited to 'app/assets/javascripts/content_editor')
-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
-rw-r--r--app/assets/javascripts/content_editor/extensions/code_suggestion.js81
-rw-r--r--app/assets/javascripts/content_editor/extensions/comment.js49
-rw-r--r--app/assets/javascripts/content_editor/extensions/copy_paste.js (renamed from app/assets/javascripts/content_editor/extensions/paste_markdown.js)62
-rw-r--r--app/assets/javascripts/content_editor/extensions/hard_break.js4
-rw-r--r--app/assets/javascripts/content_editor/extensions/image.js5
-rw-r--r--app/assets/javascripts/content_editor/extensions/loading.js23
-rw-r--r--app/assets/javascripts/content_editor/extensions/paragraph.js10
-rw-r--r--app/assets/javascripts/content_editor/extensions/reference.js6
-rw-r--r--app/assets/javascripts/content_editor/services/code_suggestion_utils.js32
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js14
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js17
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_serializer.js7
-rw-r--r--app/assets/javascripts/content_editor/services/serialization_helpers.js7
-rw-r--r--app/assets/javascripts/content_editor/services/utils.js8
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 &ZeroWidthSpace;, &#8203; or &#x200B;).
+// 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();
+});