diff options
Diffstat (limited to 'app/assets/javascripts/content_editor/components/wrappers')
3 files changed, 310 insertions, 15 deletions
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="{ |