From 026328fdaf3d8761943faec554407b8c58bc6d0a Mon Sep 17 00:00:00 2001 From: Tadeu Tupinamba Date: Tue, 3 Mar 2026 18:37:55 -0300 Subject: [PATCH 1/2] fix(comments): resolve double-click activation and edit mode issues (SD-2035) Combine cursor move and active comment activation into a single PM transaction to prevent position-based detection from clearing the active thread. Skip view.focus() for sidebar-initiated activations to avoid DOM selection sync overrides. Add changedActiveThread suppression flag in plugin apply to handle residual focus transactions. Also fix reply input staying open after send, edit mode UI using consistent reply-expanded styles, and removePendingComment only clearing activeComment when an actual pending comment existed. --- .../src/extensions/comment/comments-plugin.js | 27 ++++-- .../CommentsLayer/CommentDialog.test.js | 9 +- .../CommentsLayer/CommentDialog.vue | 87 +++++++++---------- .../CommentsLayer/FloatingComments.vue | 28 +++++- .../superdoc/src/stores/comments-store.js | 9 +- 5 files changed, 101 insertions(+), 59 deletions(-) diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 00ae95ac00..2292c31f7b 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -404,12 +404,23 @@ export const CommentsPlugin = Extension.create({ return true; }, setCursorById: - (id) => + (id, options) => ({ state, editor }) => { const { from } = findRangeById(state.doc, id) || {}; if (from != null) { - state.tr.setSelection(TextSelection.create(state.doc, from)); - if (editor.view && typeof editor.view.focus === 'function') { + const tr = state.tr; + tr.setSelection(TextSelection.create(state.doc, from)); + if (options?.activeCommentId) { + tr.setMeta(CommentsPluginKey, { + type: 'setActiveComment', + activeThreadId: options.activeCommentId, + forceUpdate: true, + }); + } + // Skip view.focus() when activating from the sidebar (activeCommentId set). + // Focusing the hidden PM view can trigger a DOM selection sync transaction + // that overwrites the activeThreadId via position-based detection. + if (!options?.activeCommentId && editor.view && typeof editor.view.focus === 'function') { editor.view.focus(); } return true; @@ -492,9 +503,13 @@ export const CommentsPlugin = Extension.create({ ); } - // Check for changes in the actively selected comment + // Check for changes in the actively selected comment. + // Skip position-based detection if the previous transaction explicitly set the + // active comment (changedActiveThread flag). This prevents focus-induced DOM + // selection sync transactions from overriding a sidebar-initiated activation. const trChangedActiveComment = meta?.type === 'setActiveComment'; - if ((!tr.docChanged && tr.selectionSet) || trChangedActiveComment) { + const suppressPositionDetection = pluginState.changedActiveThread && !trChangedActiveComment; + if ((!tr.docChanged && tr.selectionSet && !suppressPositionDetection) || trChangedActiveComment) { const { selection } = tr; let currentActiveThread = getActiveCommentId(newEditorState.doc, selection); if (trChangedActiveComment) currentActiveThread = meta.activeThreadId; @@ -513,7 +528,7 @@ export const CommentsPlugin = Extension.create({ } } - return pluginState; + return { ...pluginState, changedActiveThread: false }; }, }, diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index 43c28a499e..7fc639e9ef 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -227,8 +227,10 @@ describe('CommentDialog.vue', () => { const { wrapper, baseComment, superdocStub } = await mountDialog(); await nextTick(); - expect(baseComment.setActive).toHaveBeenCalledWith(superdocStub); - expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId); + // setFocus combines cursor move and active comment into a single PM transaction + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId, { + activeCommentId: baseComment.commentId, + }); expect(commentsStoreStub.activeComment.value).toBe(baseComment.commentId); // Click the reply pill to expand the editor @@ -476,7 +478,8 @@ describe('CommentDialog.vue', () => { const headers = wrapper.findAllComponents(CommentHeaderStub); headers[1].vm.$emit('overflow-select', 'edit'); expect(commentsStoreStub.editingCommentId.value).toBe(childComment.commentId); - expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, childComment.commentId); + // Edit activates the root thread (props.comment), not the individual child being edited + expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, baseComment.commentId); commentsStoreStub.currentCommentText.value = '

Updated

'; await nextTick(); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index fa912cabe6..82c05360ab 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -82,12 +82,7 @@ const isPendingNewComment = computed(() => { }); const showButtons = computed(() => { - return ( - !getConfig.readOnly && - isActiveComment.value && - !props.comment.resolvedTime && - editingCommentId.value !== props.comment.commentId - ); + return !getConfig.readOnly && isActiveComment.value && !props.comment.resolvedTime && !isEditingAnyComment.value; }); const showSeparator = computed(() => (index) => { @@ -97,12 +92,7 @@ const showSeparator = computed(() => (index) => { }); const showInputSection = computed(() => { - return ( - !getConfig.readOnly && - isActiveComment.value && - !props.comment.resolvedTime && - editingCommentId.value !== props.comment.commentId - ); + return !getConfig.readOnly && isActiveComment.value && !props.comment.resolvedTime && !isEditingAnyComment.value; }); // Reply pill → expanded editor toggle @@ -268,6 +258,11 @@ const isInternalDropdownDisabled = computed(() => { const isEditingThisComment = computed(() => (comment) => editingCommentId.value === comment.commentId); +const isEditingAnyComment = computed(() => { + if (!editingCommentId.value) return false; + return comments.value.some((c) => c.commentId === editingCommentId.value); +}); + const shouldShowInternalExternal = computed(() => { if (!proxy.$superdoc.config.isInternal) return false; return !suppressInternalExternal.value && !props.comment.trackedChange; @@ -280,20 +275,20 @@ const hasTextContent = computed(() => { const setFocus = () => { const editor = proxy.$superdoc.activeEditor; - // Only set as active if not resolved (resolved comments can't be edited) + // Update Vue store immediately for responsive UI if (!props.comment.resolvedTime) { activeComment.value = props.comment.commentId; - props.comment.setActive(proxy.$superdoc); } - // Always allow scrolling to the comment location, even for resolved comments + // Move cursor to the comment location and set active comment in a single PM + // transaction. This prevents a race where position-based comment detection in the + // plugin clears the activeThreadId before the setActiveComment meta is processed. if (editor) { - // For resolved comments, use commentId since prepareCommentsForImport rewrites - // commentRangeStart/End nodes' w:id to the internal commentId (not importedId) const cursorId = props.comment.resolvedTime ? props.comment.commentId : props.comment.importedId || props.comment.commentId; - editor.commands?.setCursorById(cursorId); + const activeCommentId = !props.comment.resolvedTime ? props.comment.commentId : null; + editor.commands?.setCursorById(cursorId, { activeCommentId }); } }; @@ -349,6 +344,8 @@ const handleAddComment = () => { const comment = commentsStore.getPendingComment(options); addComment({ superdoc: proxy.$superdoc, comment }); + isReplying.value = false; + nextTick(() => emit('resize')); }; const handleReject = () => { @@ -411,7 +408,7 @@ const handleOverflowSelect = (value, comment) => { switch (value) { case 'edit': currentCommentText.value = comment?.commentText?.value ?? comment?.commentText ?? ''; - activeComment.value = comment.commentId; + activeComment.value = props.comment.commentId; editingCommentId.value = comment.commentId; commentsStore.setActiveComment(proxy.$superdoc, activeComment.value); nextTick(() => { @@ -441,7 +438,7 @@ const handleInternalExternalSelect = (value) => { const getSidebarCommentStyle = computed(() => { const style = {}; - if (isActiveComment.value || isPendingNewComment.value) { + if (isActiveComment.value || isPendingNewComment.value || isEditingAnyComment.value) { style.zIndex = 50; } @@ -639,17 +636,26 @@ watch(editingCommentId, (commentId) => { editorCommentPositions[comment.importedId !== undefined ? comment.importedId : comment.commentId]?.bounds }} -
- -