From 90f39dda7ea396c02b4fe6117f91a101b363b044 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 23 Jul 2025 14:00:11 +0200 Subject: [PATCH 1/7] Add migration to Notes table --- ...5248_add_wiki_comment_metadata_to_notes.rb | 31 +++++++++++++++++++ db/schema_migrations/20250723105248 | 1 + 2 files changed, 32 insertions(+) create mode 100644 db/migrate/20250723105248_add_wiki_comment_metadata_to_notes.rb create mode 100644 db/schema_migrations/20250723105248 diff --git a/db/migrate/20250723105248_add_wiki_comment_metadata_to_notes.rb b/db/migrate/20250723105248_add_wiki_comment_metadata_to_notes.rb new file mode 100644 index 00000000000000..2d70b513cff6e5 --- /dev/null +++ b/db/migrate/20250723105248_add_wiki_comment_metadata_to_notes.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +# See https://docs.gitlab.com/ee/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddWikiCommentMetadataToNotes < Gitlab::Database::Migration[2.3] + # When using the methods "add_concurrent_index" or "remove_concurrent_index" + # you must disable the use of transactions + # as these methods can not run in an existing transaction. + # When using "add_concurrent_index" or "remove_concurrent_index" methods make sure + # that either of them is the _only_ method called in the migration, + # any other changes should go in a separate migration. + # This ensures that upon failure _only_ the index creation or removing fails + # and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + # + # Configure the `gitlab_schema` to perform data manipulation (DML). + # Visit: https://docs.gitlab.com/ee/development/database/migrations_for_multiple_databases.html + # restrict_gitlab_migration gitlab_schema: :gitlab_main + + # Add dependent 'batched_background_migrations.queued_migration_version' values. + # DEPENDENT_BATCHED_BACKGROUND_MIGRATIONS = [] + milestone '18.3' + + def change + add_column :notes, :wiki_comment_metadata, :jsonb + end +end diff --git a/db/schema_migrations/20250723105248 b/db/schema_migrations/20250723105248 new file mode 100644 index 00000000000000..6d114db7b8ddc8 --- /dev/null +++ b/db/schema_migrations/20250723105248 @@ -0,0 +1 @@ +34ae384a7b4b7f262d1c23771fcbae4aee0d09a64302420f622d85a90edfed4a \ No newline at end of file -- GitLab From 351dd1926e73663a5e5b7fd79ce4745b2cbab779 Mon Sep 17 00:00:00 2001 From: janis Date: Tue, 5 Aug 2025 06:28:37 +0200 Subject: [PATCH 2/7] dd the ability to highlight something --- .../wikis/components/wiki_content.vue | 45 +++++++++++++- .../javascripts/wikis/inlineComments.js | 62 +++++++++++++++++++ app/graphql/types/notes/note_type.rb | 2 + 3 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 app/assets/javascripts/wikis/inlineComments.js diff --git a/app/assets/javascripts/wikis/components/wiki_content.vue b/app/assets/javascripts/wikis/components/wiki_content.vue index fb5e3daa534df8..f72527e537842d 100644 --- a/app/assets/javascripts/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/wikis/components/wiki_content.vue @@ -9,6 +9,7 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { __ } from '~/locale'; import { getHeadingsFromDOM } from '~/content_editor/services/table_of_contents_utils'; import TableOfContents from './table_of_contents.vue'; +import { wrapTextRange } from '../inlineComments'; const TableOfContentsComponent = Vue.extend(TableOfContents); @@ -28,12 +29,54 @@ export default { isLoadingContent: false, loadingContentFailed: false, headings: [], + comment: { start: 105, end: 110, id: 1 }, + comments: [{ start: 105, end: 110, id: 1 }], }; }, + computed: { + contentWithCommentNodes() { + return wrapTextRange(this.content, this.comment.start, this.comment.end, 'span', { + 'data-comment-id': this.comment.id, + }); + // const doc = new DOMParser().parseFromString(this.content, 'text/html'); + // + // const container = doc.body.firstChild; + // + // // Create a Range and find the position + // const range = document.createRange(); + // range.selectNodeContents(container); + // + // // Walk through text nodes to find position + // const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); + // + // let textPos = 0; + // let node; + // + // while ((node = walker.nextNode())) { + // const nodeLength = node.textContent.length; + // if (textPos + nodeLength >= this.comment.start) { + // const offset = this.comment.start - textPos; + // range.setStart(node, offset); + // range.setEnd(node, offset); + // + // const commentNode = document.createElement('span'); + // commentNode.classList.add('comment'); + // + // range.insertNode(document.createTextNode('abc')); + // break; + // } + // textPos += nodeLength; + // } + // + // return container.innerHTML; + }, + }, mounted() { this.loadWikiContent(); }, methods: { + insertTag(node) {}, + insertComment(id, position) {}, async renderHeadingsInSidebar() { const headings = getHeadingsFromDOM(this.$refs.content); if (!headings.length) return; @@ -102,7 +145,7 @@ export default {
diff --git a/app/assets/javascripts/wikis/inlineComments.js b/app/assets/javascripts/wikis/inlineComments.js new file mode 100644 index 00000000000000..65e134f0f4b8e3 --- /dev/null +++ b/app/assets/javascripts/wikis/inlineComments.js @@ -0,0 +1,62 @@ +function findTextPosition(container, position) { + const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null, false); + + let textPos = 0; + let node; + + while ((node = walker.nextNode())) { + const nodeLength = node.textContent.length; + + if (textPos + nodeLength >= position) { + return { + node: node, + offset: position - textPos, + }; + } + + textPos += nodeLength; + } + + return null; // Position not found +} + +export function wrapTextRange(htmlString, startPos, endPos, wrapperTag = 'span', attributes = {}) { + const parser = new DOMParser(); + const doc = parser.parseFromString(`
${htmlString}
`, 'text/html'); + const container = doc.body.firstChild; + + // Create a Range to work with + const range = document.createRange(); + + // Find start and end positions in the DOM + const startLocation = findTextPosition(container, startPos); + const endLocation = findTextPosition(container, endPos); + + if (!startLocation || !endLocation) { + throw new Error('Position not found in text content'); + } + + // Set the range to encompass the text we want to wrap + range.setStart(startLocation.node, startLocation.offset); + range.setEnd(endLocation.node, endLocation.offset); + + // Extract the contents + const extractedContent = range.extractContents(); + + // Create the wrapper element + const wrapper = document.createElement(wrapperTag); + + wrapper.classList.add('gl-bg-red-100'); + + Object.entries(attributes).forEach(([key, value]) => { + wrapper.setAttribute(key, value); + }); + + // Put the extracted content inside the wrapper + wrapper.appendChild(extractedContent); + + // Insert the wrapper at the range position + range.insertNode(wrapper); + + return container.innerHTML; +} diff --git a/app/graphql/types/notes/note_type.rb b/app/graphql/types/notes/note_type.rb index ad4fc2a20e662f..b4b217497c0d76 100644 --- a/app/graphql/types/notes/note_type.rb +++ b/app/graphql/types/notes/note_type.rb @@ -70,6 +70,8 @@ def self.authorization_scopes null: true, description: 'Email address of non-GitLab user adding the note. For guests, the email address is obfuscated.' + field :wiki_comment_metadata, GraphQL::Types::JSON, null: true + def system_note_icon_name SystemNoteHelper.system_note_icon_name(object) if object.system? end -- GitLab From debdc023ca3d2a99e99f0de21007b2f01e529d72 Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 6 Aug 2025 13:11:37 +0200 Subject: [PATCH 3/7] highlight comments in rte --- .../services/serializer/comment.js | 0 .../markdown/inline_comment_extension.js | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 app/assets/javascripts/content_editor/services/serializer/comment.js create mode 100644 app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js diff --git a/app/assets/javascripts/content_editor/services/serializer/comment.js b/app/assets/javascripts/content_editor/services/serializer/comment.js new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js new file mode 100644 index 00000000000000..8da484b7dc4fd8 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js @@ -0,0 +1,33 @@ +import { Mark } from '@tiptap/core'; + +export default Mark.create({ + name: 'inlineComment', + addOptions() { + return { + HTMLAttributes: {}, + }; + }, + + parseHTML() { + return [ + { + tag: 'comment', + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return [ + 'comment', + { + class: 'gl-bg-red-50', + ...HTMLAttributes, + }, + 0, + ]; + }, + + // renderText({ text }) { + // return text; + // }, +}); -- GitLab From 3c89251bfe8c6ede41941dbf0a4a4782a3196b3c Mon Sep 17 00:00:00 2001 From: janis Date: Wed, 6 Aug 2025 13:16:25 +0200 Subject: [PATCH 4/7] highlight comments in rte --- .../components/content_editor.vue | 15 +++++ .../services/markdown_serializer.js | 2 + .../services/serializer/comment.js | 3 + .../markdown/inline_comment_extension.js | 18 ++++-- .../components/markdown/markdown_editor.vue | 8 +++ .../wikis/components/wiki_form.vue | 1 + .../wikis/wiki_notes/components/wiki_note.vue | 5 ++ .../graphql/wiki_page_note.fragment.graphql | 1 + db/structure.sql | 56 ++++++++----------- 9 files changed, 71 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 97f5ecd2f3ac38..97c07dea23d5ac 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -121,6 +121,11 @@ export default { required: false, default: () => [], }, + comments: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -196,6 +201,16 @@ export default { dom.classList.add('rte-text-box'); } } + + this.comments.forEach(({ start, end, id }) => { + this.contentEditor.tiptapEditor + .chain() + .focus() + .setTextSelection({ from: start, to: end }) + .setMark('comment', { id }) + .blur() + .run(); + }); }, beforeDestroy() { markdownEditorEventHub.$off(CONTENT_EDITOR_PASTE, this.pasteContent); diff --git a/app/assets/javascripts/content_editor/services/markdown_serializer.js b/app/assets/javascripts/content_editor/services/markdown_serializer.js index 9de79d050237f1..1c4afe249de54c 100644 --- a/app/assets/javascripts/content_editor/services/markdown_serializer.js +++ b/app/assets/javascripts/content_editor/services/markdown_serializer.js @@ -54,6 +54,7 @@ import tableRow from './serializer/table_row'; import table from './serializer/table'; import time from './serializer/time'; import htmlNode from './serializer/html_node'; +import comment from './serializer/comment'; const LIST_TYPES = [ extensions.BulletList.name, @@ -73,6 +74,7 @@ const defaultSerializerConfig = { [extensions.Link.name]: link, [extensions.MathInline.name]: mathInline, [extensions.Strike.name]: strike, + comment, ...extensions.HTMLMarks.reduce((acc, { name }) => ({ ...acc, [name]: htmlMark(name) }), {}), }, diff --git a/app/assets/javascripts/content_editor/services/serializer/comment.js b/app/assets/javascripts/content_editor/services/serializer/comment.js index e69de29bb2d1d6..908b86c34e117b 100644 --- a/app/assets/javascripts/content_editor/services/serializer/comment.js +++ b/app/assets/javascripts/content_editor/services/serializer/comment.js @@ -0,0 +1,3 @@ +const comment = () => {}; + +export default comment; diff --git a/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js index 8da484b7dc4fd8..529bf702708584 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js +++ b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js @@ -1,7 +1,9 @@ import { Mark } from '@tiptap/core'; export default Mark.create({ - name: 'inlineComment', + name: 'comment', + keepOnSplit: true, + spanning: true, addOptions() { return { HTMLAttributes: {}, @@ -11,14 +13,14 @@ export default Mark.create({ parseHTML() { return [ { - tag: 'comment', + tag: 'span', }, ]; }, renderHTML({ HTMLAttributes }) { return [ - 'comment', + 'span', { class: 'gl-bg-red-50', ...HTMLAttributes, @@ -27,7 +29,11 @@ export default Mark.create({ ]; }, - // renderText({ text }) { - // return text; - // }, + renderText({ text }) { + return text; + }, + + renderMarkdown({ text }) { + return text; + }, }); diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index f043f125fce0be..edef42a5d2541c 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -15,6 +15,8 @@ import { } from '../../constants'; import MarkdownField from './field.vue'; import eventHub from './eventhub'; +import { wrapTextRange } from '~/wikis/inlineComments'; +import CommentMark from './inline_comment_extension'; async function sleep(t = 10) { return new Promise((resolve) => { @@ -171,6 +173,7 @@ export default { markdown: initialValue || autosaveValue || '', editingMode, autofocused: false, + comments: [{ id: 1, start: 105, end: 217 }], }; }, computed: { @@ -192,6 +195,9 @@ export default { composerComponent() { return this.canUseComposer ? 'markdown-composer' : 'div'; }, + editorExtensions() { + return [CommentMark]; + }, }, watch: { value: 'updateValue', @@ -456,6 +462,7 @@ export default { :new-comment-template-paths="newCommentTemplatePaths" :uploads-path="uploadsPath" :markdown="markdown" + :extensions="editorExtensions" :supports-quick-actions="supportsQuickActions" :autofocus="contentEditorAutofocused" :placeholder="formFieldProps.placeholder" @@ -463,6 +470,7 @@ export default { :enable-autocomplete="enableAutocomplete" :autocomplete-data-sources="autocompleteDataSources" :editable="!disabled" + :comments="comments" :disable-attachments="disableAttachments" :code-suggestions-config="codeSuggestionsConfig" :data-testid="formFieldProps['data-testid'] || 'markdown-editor-form-field'" diff --git a/app/assets/javascripts/wikis/components/wiki_form.vue b/app/assets/javascripts/wikis/components/wiki_form.vue index b5ce65427dd659..19c0c2f47c814d 100644 --- a/app/assets/javascripts/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/wikis/components/wiki_form.vue @@ -27,6 +27,7 @@ import { import { isTemplate } from '../utils'; import WikiTemplate from './wiki_template.vue'; import DeleteWikiModal from './delete_wiki_modal.vue'; +import { wrapTextRange } from '~/wikis/inlineComments'; const trackingMixin = Tracking.mixin({ label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, diff --git a/app/assets/javascripts/wikis/wiki_notes/components/wiki_note.vue b/app/assets/javascripts/wikis/wiki_notes/components/wiki_note.vue index 102ded233c66c4..f945b4baee83b2 100644 --- a/app/assets/javascripts/wikis/wiki_notes/components/wiki_note.vue +++ b/app/assets/javascripts/wikis/wiki_notes/components/wiki_note.vue @@ -121,10 +121,14 @@ export default { }, }, mounted() { + this.highlight(); if (getDraft(this.autosaveKey)?.trim()) this.isEditing = true; this.updatedNote = { ...this.note }; }, methods: { + highlight() { + // window.alert(document); + }, toggleDeleting(value) { this.isDeleting = value; }, @@ -307,6 +311,7 @@ export default {
+ {{ note.wikiCommentMetadata }} Date: Mon, 11 Aug 2025 07:09:00 +0200 Subject: [PATCH 5/7] write comments from within RTE --- .../bubble_menus/comment_bubble_menu.vue | 86 +++++++++++++++++++ .../components/content_editor.vue | 12 ++- .../services/serializer/comment.js | 4 +- .../components/markdown/markdown_editor.vue | 12 ++- .../wikis/components/wiki_content.vue | 31 ------- .../wikis/components/wiki_form.vue | 32 ++++++- app/assets/javascripts/wikis/show.js | 11 +++ .../components/wiki_comment_form.vue | 6 ++ app/graphql/mutations/notes/create/base.rb | 8 +- app/services/notes/create_service.rb | 4 + app/views/shared/wikis/show.html.haml | 1 + 11 files changed, 168 insertions(+), 39 deletions(-) create mode 100644 app/assets/javascripts/content_editor/components/bubble_menus/comment_bubble_menu.vue diff --git a/app/assets/javascripts/content_editor/components/bubble_menus/comment_bubble_menu.vue b/app/assets/javascripts/content_editor/components/bubble_menus/comment_bubble_menu.vue new file mode 100644 index 00000000000000..eb7510e7e6fcca --- /dev/null +++ b/app/assets/javascripts/content_editor/components/bubble_menus/comment_bubble_menu.vue @@ -0,0 +1,86 @@ + + diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index 97c07dea23d5ac..310671fb89656d 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -21,9 +21,13 @@ import MediaBubbleMenu from './bubble_menus/media_bubble_menu.vue'; import ReferenceBubbleMenu from './bubble_menus/reference_bubble_menu.vue'; import TableBubbleMenu from './bubble_menus/table_bubble_menu.vue'; import FormattingToolbar from './formatting_toolbar.vue'; +import CommentBubbleMenu from './bubble_menus/comment_bubble_menu.vue'; +import WikiCommentForm from '~/wikis/wiki_notes/components/wiki_comment_form.vue'; export default { components: { + WikiCommentForm, + CommentBubbleMenu, GlButton, GlLoadingIcon, ContentEditorAlert, @@ -126,6 +130,11 @@ export default { required: false, default: () => [], }, + noteableId: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -208,7 +217,7 @@ export default { .focus() .setTextSelection({ from: start, to: end }) .setMark('comment', { id }) - .blur() + .setTextSelection({ from: 0, to: 0 }) .run(); }); }, @@ -321,6 +330,7 @@ export default { +
{}; - -export default comment; +export { default } from './text'; diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index edef42a5d2541c..8602e177cf29f4 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -150,6 +150,16 @@ export default { required: false, default: () => [], }, + comments: { + type: Array, + required: false, + default: () => [], + }, + noteableId: { + type: String, + required: false, + default: '', + }, }, data() { let editingMode; @@ -173,7 +183,6 @@ export default { markdown: initialValue || autosaveValue || '', editingMode, autofocused: false, - comments: [{ id: 1, start: 105, end: 217 }], }; }, computed: { @@ -474,6 +483,7 @@ export default { :disable-attachments="disableAttachments" :code-suggestions-config="codeSuggestionsConfig" :data-testid="formFieldProps['data-testid'] || 'markdown-editor-form-field'" + :noteable-id="noteableId" @initialized="setEditorAsAutofocused" @change="updateMarkdownFromContentEditor" @keydown="onKeydown" diff --git a/app/assets/javascripts/wikis/components/wiki_content.vue b/app/assets/javascripts/wikis/components/wiki_content.vue index f72527e537842d..094b8c687da2bf 100644 --- a/app/assets/javascripts/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/wikis/components/wiki_content.vue @@ -38,37 +38,6 @@ export default { return wrapTextRange(this.content, this.comment.start, this.comment.end, 'span', { 'data-comment-id': this.comment.id, }); - // const doc = new DOMParser().parseFromString(this.content, 'text/html'); - // - // const container = doc.body.firstChild; - // - // // Create a Range and find the position - // const range = document.createRange(); - // range.selectNodeContents(container); - // - // // Walk through text nodes to find position - // const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT); - // - // let textPos = 0; - // let node; - // - // while ((node = walker.nextNode())) { - // const nodeLength = node.textContent.length; - // if (textPos + nodeLength >= this.comment.start) { - // const offset = this.comment.start - textPos; - // range.setStart(node, offset); - // range.setEnd(node, offset); - // - // const commentNode = document.createElement('span'); - // commentNode.classList.add('comment'); - // - // range.insertNode(document.createTextNode('abc')); - // break; - // } - // textPos += nodeLength; - // } - // - // return container.innerHTML; }, }, mounted() { diff --git a/app/assets/javascripts/wikis/components/wiki_form.vue b/app/assets/javascripts/wikis/components/wiki_form.vue index 19c0c2f47c814d..68277f782b9831 100644 --- a/app/assets/javascripts/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/wikis/components/wiki_form.vue @@ -1,6 +1,6 @@ diff --git a/app/assets/javascripts/content_editor/services/serializer/comment.js b/app/assets/javascripts/content_editor/services/serializer/comment.js index 2178acad72b5dc..e8958068fd894d 100644 --- a/app/assets/javascripts/content_editor/services/serializer/comment.js +++ b/app/assets/javascripts/content_editor/services/serializer/comment.js @@ -1 +1 @@ -export { default } from './text'; +export default { open: '', close: '', mixable: true }; diff --git a/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js index 529bf702708584..c2384899a9b547 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js +++ b/app/assets/javascripts/vue_shared/components/markdown/inline_comment_extension.js @@ -4,20 +4,29 @@ export default Mark.create({ name: 'comment', keepOnSplit: true, spanning: true, - addOptions() { - return { - HTMLAttributes: {}, - }; - }, parseHTML() { return [ { - tag: 'span', + tag: 'span[data-discussion-id]', }, ]; }, + addAttributes() { + return { + discussionId: { + default: null, + parseHTML: (element) => element.dataset.discussionId, + renderHTML: (attributes) => { + return { + 'data-discussion-id': attributes.discussionId, + }; + }, + }, + }; + }, + renderHTML({ HTMLAttributes }) { return [ 'span', diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 8602e177cf29f4..92c68186c330d8 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -490,6 +490,7 @@ export default { @enableMarkdownEditor="onEditingModeChange('markdownField')" @focus="$emit('focus')" @blur="$emit('blur')" + @annotationChanged="$emit('annotationChanged', $event)" > diff --git a/app/assets/javascripts/wikis/components/wiki_content.vue b/app/assets/javascripts/wikis/components/wiki_content.vue index 094b8c687da2bf..ae86ecc96f904f 100644 --- a/app/assets/javascripts/wikis/components/wiki_content.vue +++ b/app/assets/javascripts/wikis/components/wiki_content.vue @@ -10,6 +10,8 @@ import { __ } from '~/locale'; import { getHeadingsFromDOM } from '~/content_editor/services/table_of_contents_utils'; import TableOfContents from './table_of_contents.vue'; import { wrapTextRange } from '../inlineComments'; +import wikiPageQuery from '~/wikis/graphql/wiki_page.query.graphql'; +import { cloneDeep } from 'lodash'; const TableOfContentsComponent = Vue.extend(TableOfContents); @@ -22,22 +24,48 @@ export default { SafeHtml, }, - inject: ['contentApi'], + inject: ['contentApi', 'queryVariables'], data() { return { content: '', isLoadingContent: false, loadingContentFailed: false, headings: [], - comment: { start: 105, end: 110, id: 1 }, - comments: [{ start: 105, end: 110, id: 1 }], + comments: [], + wikiPage: null, }; }, + apollo: { + wikiPage: { + // TODO: Only fetch the data that's actually needed here + query: wikiPageQuery, + variables() { + return this.queryVariables; + }, + update(data) { + return data?.wikiPage?.discussions?.nodes || []; + }, + error() { + this.loadingFailed = true; + return []; + }, + result({ data }) { + this.noteableId = data?.wikiPage?.id || ''; + const discussions = cloneDeep(data?.wikiPage?.discussions?.nodes) || []; + this.comments = discussions.map(({ notes }) => notes.nodes[0].wikiCommentMetadata); + this.userPermissions = data?.wikiPage?.userPermissions || {}; + }, + }, + }, computed: { contentWithCommentNodes() { - return wrapTextRange(this.content, this.comment.start, this.comment.end, 'span', { - 'data-comment-id': this.comment.id, + let { content } = this; + this.comments.forEach((comment) => { + content = wrapTextRange(content, comment.start, comment.end, 'span', { + 'data-comment-id': comment.id, + }); }); + return content; }, }, mounted() { diff --git a/app/assets/javascripts/wikis/components/wiki_form.vue b/app/assets/javascripts/wikis/components/wiki_form.vue index 68277f782b9831..bc52a0fd5340fb 100644 --- a/app/assets/javascripts/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/wikis/components/wiki_form.vue @@ -176,7 +176,8 @@ export default { initialTitleValue: '', wikiPage: null, noteableId: null, - comments: [], + annotations: [], + newAnnotationPositions: {}, }; }, computed: { @@ -298,7 +299,13 @@ export default { result({ data }) { this.noteableId = data?.wikiPage?.id || ''; const discussions = cloneDeep(data?.wikiPage?.discussions?.nodes) || []; - this.comments = discussions.map(({ notes }) => notes.nodes[0].wikiCommentMetadata); + this.annotations = discussions.map(({ id, notes }) => { + const { wikiCommentMetadata } = notes.nodes[0]; + return { + id, + ...wikiCommentMetadata, + }; + }); this.userPermissions = data?.wikiPage?.userPermissions || {}; }, }, @@ -499,6 +506,12 @@ export default { this.positionCursorAfterParentPath(); } }, + + onSubmit() {}, + + onAnnotationChange(data) { + this.newAnnotationPositions = data; + }, }, }; @@ -591,6 +604,10 @@ export default {
+
+      {{ newAnnotationPositions }}
+    
+
@@ -608,8 +625,9 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :drawio-enabled="drawioEnabled" :disable-attachments="isTemplate" - :comments="comments" + :comments="annotations" :noteable-id="noteableId" + @annotationChanged="onAnnotationChange" @contentEditor="notifyContentEditorActive" @markdownField="notifyContentEditorInactive" @keydown.ctrl.enter="submitFormWithShortcut" @@ -665,6 +683,7 @@ export default { variant="confirm" type="submit" data-testid="wiki-submit-button" + @click="onSubmit" >{{ submitButtonText }} Date: Tue, 12 Aug 2025 11:25:35 +0200 Subject: [PATCH 7/7] Save annotation changes on page update --- .../components/content_editor.vue | 8 +++-- .../wikis/components/wiki_form.vue | 29 +++++++++++++++---- app/graphql/mutations/wikis/annotations.rb | 27 +++++++++++++++++ app/graphql/types/annotation_type.rb | 14 +++++++++ app/graphql/types/mutation_type.rb | 1 + 5 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 app/graphql/mutations/wikis/annotations.rb create mode 100644 app/graphql/types/annotation_type.rb diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue index f4f6218a1cf4b9..5bb55cf639721b 100644 --- a/app/assets/javascripts/content_editor/components/content_editor.vue +++ b/app/assets/javascripts/content_editor/components/content_editor.vue @@ -297,9 +297,11 @@ export default { node.marks.forEach((mark) => { if (mark.type.name === 'comment') { instances.push({ - id: mark.attrs.discussionId, - start: pos, - end: pos + node.nodeSize, + discussionId: mark.attrs.discussionId, + metadata: { + start: pos, + end: pos + node.nodeSize, + }, }); } }); diff --git a/app/assets/javascripts/wikis/components/wiki_form.vue b/app/assets/javascripts/wikis/components/wiki_form.vue index bc52a0fd5340fb..313627a46428a3 100644 --- a/app/assets/javascripts/wikis/components/wiki_form.vue +++ b/app/assets/javascripts/wikis/components/wiki_form.vue @@ -28,6 +28,7 @@ import { import { isTemplate } from '../utils'; import WikiTemplate from './wiki_template.vue'; import DeleteWikiModal from './delete_wiki_modal.vue'; +import { gql } from '@apollo/client/core'; const trackingMixin = Tracking.mixin({ label: WIKI_CONTENT_EDITOR_TRACKING_LABEL, @@ -321,10 +322,10 @@ export default { }, methods: { async handleFormSubmit(e) { + console.log('handleFormSubmit'); + await this.updateAnnotations(); this.isFormDirty = false; - e.preventDefault(); - this.trackFormSubmit(); this.trackWikiFormat(); @@ -507,7 +508,26 @@ export default { } }, - onSubmit() {}, + async updateAnnotations() { + console.log('submit'); + + // TODO: This is probably better off as a hidden input; or the form submission should be + // graphql. I've created the whole mutation shebang, so this POC is hacking it. + await this.$apollo.mutate({ + mutation: gql` + mutation UpdateAnnotations($input: UpdateNoteAnnotationsInput!) { + updateNoteAnnotations(input: $input) { + errors + } + } + `, + variables: { + input: { + annotations: this.newAnnotationPositions, + }, + }, + }); + }, onAnnotationChange(data) { this.newAnnotationPositions = data; @@ -523,7 +543,7 @@ export default { method="post" class="wiki-form common-note-form js-quick-submit" :class="{ 'gl-mt-5': !isEditingPath }" - @submit="handleFormSubmit" + @submit.prevent="handleFormSubmit" @input="isFormDirty = true" >

{{ pageTitle }}

@@ -683,7 +703,6 @@ export default { variant="confirm" type="submit" data-testid="wiki-submit-button" - @click="onSubmit" >{{ submitButtonText }}