diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 4661481de8..f3411be9b1 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2191,6 +2191,52 @@ export class PresentationEditor extends EventEmitter { } } + /** + * Scroll a comment or tracked-change anchor so its top edge lands at the + * requested viewport Y coordinate. + * + * @param threadId - Comment or tracked-change identifier + * @param targetClientY - Desired top position in client/viewport coordinates + * @param options - Scrolling options + * @param options.behavior - Scroll behavior ('auto' | 'smooth') + * @returns True when the thread could be resolved and scrolling was applied + */ + scrollThreadAnchorToClientY( + threadId: string, + targetClientY: number, + options: { behavior?: ScrollBehavior } = {}, + ): boolean { + if (!threadId || !Number.isFinite(targetClientY)) return false; + + const threadPosition = this.#collectCommentPositions()[threadId]; + if (!threadPosition) return false; + + const selectionBounds = this.getSelectionBounds(threadPosition.start, threadPosition.end); + const currentTop = selectionBounds?.bounds?.top; + if (!Number.isFinite(currentTop)) return false; + + const deltaY = currentTop - targetClientY; + if (Math.abs(deltaY) < 1) return true; + + const behavior = options.behavior ?? 'auto'; + const scrollTarget = this.#scrollContainer ?? this.#visibleHost; + + if (scrollTarget instanceof Window) { + const currentScrollY = scrollTarget.scrollY ?? scrollTarget.pageYOffset ?? 0; + scrollTarget.scrollTo({ top: currentScrollY + deltaY, behavior }); + return true; + } + + if (scrollTarget instanceof HTMLElement) { + const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight); + const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scrollTarget.scrollTop + deltaY)); + scrollTarget.scrollTo({ top: nextScrollTop, behavior }); + return true; + } + + return false; + } + /** * Find the DOM element containing a specific document position. * Returns the most specific (smallest range) matching element. diff --git a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts index 39ff20ac36..7245232e0c 100644 --- a/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts +++ b/packages/super-editor/src/core/presentation-editor/pointer-events/EditorInputManager.ts @@ -17,6 +17,7 @@ import type { Node as ProseMirrorNode } from 'prosemirror-model'; import { CellSelection } from 'prosemirror-tables'; import type { Editor } from '../../Editor.js'; import type { Layout, FlowBlock, Measure } from '@superdoc/contracts'; +import { comments_module_events } from '@superdoc/common'; import type { CellAnchorState, PendingMarginClick, HeaderFooterRegion } from '../types.js'; import type { PositionHit, PageGeometryHelper, TableHitResult } from '@superdoc/layout-bridge'; import type { SelectionDebugHudState } from '../selection/SelectionDebug.js'; @@ -38,6 +39,7 @@ import { import { debugLog } from '../selection/SelectionDebug.js'; import { DOM_CLASS_NAMES, buildInlineImagePmSelector } from '@superdoc/painter-dom'; import { isSemanticFootnoteBlockId } from '../semantic-flow-constants.js'; +import { CommentsPluginKey } from '@extensions/comment/comments-plugin.js'; // ============================================================================= // Constants @@ -49,6 +51,7 @@ const AUTO_SCROLL_EDGE_PX = 32; const AUTO_SCROLL_MAX_SPEED_PX = 24; /** Tolerance for detecting scrollability to handle sub-pixel rounding in browsers */ const SCROLL_DETECTION_TOLERANCE_PX = 1; +const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight'; const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value)); @@ -61,6 +64,49 @@ function isFootnoteBlockId(blockId: string): boolean { return typeof blockId === 'string' && (blockId.startsWith('footnote-') || isSemanticFootnoteBlockId(blockId)); } +function getCommentHighlightThreadIds(target: EventTarget | null): string[] { + if (!(target instanceof Element)) { + return []; + } + + const highlight = target.closest(COMMENT_HIGHLIGHT_SELECTOR); + const threadIds = highlight?.getAttribute('data-comment-ids'); + + if (!threadIds) { + return []; + } + + return threadIds + .split(',') + .map((threadId) => threadId.trim()) + .filter(Boolean); +} + +function getActiveCommentThreadId(editor: Editor): string | null { + const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null; + const activeThreadId = pluginState?.activeThreadId; + + if (typeof activeThreadId !== 'string' || activeThreadId.length === 0) { + return null; + } + + return activeThreadId; +} + +function shouldIgnoreRepeatClickOnActiveComment(target: EventTarget | null, activeThreadId: string | null): boolean { + if (!activeThreadId) { + return false; + } + + const clickedThreadIds = getCommentHighlightThreadIds(target); + + if (clickedThreadIds.length !== 1) { + return false; + } + + return clickedThreadIds[0] === activeThreadId; +} + // ============================================================================= // Types // ============================================================================= @@ -878,6 +924,11 @@ export class EditorInputManager { return; } + const editor = this.#deps.getEditor(); + if (this.#handleRepeatClickOnActiveComment(event, target, editor)) { + return; + } + const layoutState = this.#deps.getLayoutState(); if (!layoutState.layout) { this.#handleClickWithoutLayout(event, isDraggableAnnotation); @@ -932,7 +983,6 @@ export class EditorInputManager { pageGeometryHelper ?? undefined, ); - const editor = this.#deps.getEditor(); const doc = editor.state?.doc; const epochMapper = this.#deps.getEpochMapper(); const mapped = @@ -2036,4 +2086,21 @@ export class EditorInputManager { editorDom.focus(); view?.focus(); } + + #handleRepeatClickOnActiveComment(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean { + const activeThreadId = getActiveCommentThreadId(editor); + + if (!shouldIgnoreRepeatClickOnActiveComment(target, activeThreadId)) { + return false; + } + + event.preventDefault(); + this.#focusEditor(); + editor.emit?.('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: activeThreadId, + }); + + return true; + } } diff --git a/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.activeCommentClick.test.ts b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.activeCommentClick.test.ts new file mode 100644 index 0000000000..0226734da3 --- /dev/null +++ b/packages/super-editor/src/core/presentation-editor/tests/EditorInputManager.activeCommentClick.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { comments_module_events } from '@superdoc/common'; +import { clickToPosition } from '@superdoc/layout-bridge'; +import { TextSelection } from 'prosemirror-state'; + +import { + EditorInputManager, + type EditorInputDependencies, + type EditorInputCallbacks, +} from '../pointer-events/EditorInputManager.js'; + +vi.mock('@superdoc/layout-bridge', () => ({ + clickToPosition: vi.fn(() => ({ pos: 24, layoutEpoch: 1, pageIndex: 0, blockId: 'body-1' })), + getFragmentAtPosition: vi.fn(() => null), +})); + +vi.mock('prosemirror-state', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + TextSelection: { + ...original.TextSelection, + create: vi.fn(() => ({ + empty: true, + $from: { parent: { inlineContent: true } }, + })), + }, + }; +}); + +describe('EditorInputManager - repeated active comment clicks', () => { + let manager: EditorInputManager; + let viewportHost: HTMLElement; + let visibleHost: HTMLElement; + let mockEditor: { + isEditable: boolean; + state: { + doc: { content: { size: number }; nodesBetween: Mock }; + tr: { setSelection: Mock; setStoredMarks: Mock }; + selection: { $anchor: null }; + storedMarks: null; + comments$: { activeThreadId: string | null }; + }; + view: { + dispatch: Mock; + dom: HTMLElement; + focus: Mock; + hasFocus: Mock; + }; + on: Mock; + off: Mock; + emit: Mock; + }; + let mockDeps: EditorInputDependencies; + let mockCallbacks: EditorInputCallbacks; + + beforeEach(() => { + viewportHost = document.createElement('div'); + viewportHost.className = 'presentation-editor__viewport'; + viewportHost.setPointerCapture = vi.fn(); + viewportHost.releasePointerCapture = vi.fn(); + viewportHost.hasPointerCapture = vi.fn(() => true); + + visibleHost = document.createElement('div'); + visibleHost.className = 'presentation-editor__visible'; + visibleHost.appendChild(viewportHost); + + const container = document.createElement('div'); + container.className = 'presentation-editor'; + container.appendChild(visibleHost); + document.body.appendChild(container); + + mockEditor = { + isEditable: true, + state: { + doc: { + content: { size: 100 }, + resolve: vi.fn(() => ({ depth: 0 })), + nodesBetween: vi.fn((from, to, callback) => { + callback({ isTextblock: true }, 0); + }), + }, + tr: { + setSelection: vi.fn().mockReturnThis(), + setStoredMarks: vi.fn().mockReturnThis(), + }, + selection: { $anchor: null }, + storedMarks: null, + comments$: { activeThreadId: 'comment-1' }, + }, + view: { + dispatch: vi.fn(), + dom: document.createElement('div'), + focus: vi.fn(), + hasFocus: vi.fn(() => false), + }, + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }; + + mockDeps = { + getActiveEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getEditor: vi.fn(() => mockEditor as unknown as ReturnType), + getLayoutState: vi.fn(() => ({ layout: {} as any, blocks: [], measures: [] })), + getEpochMapper: vi.fn(() => ({ + mapPosFromLayoutToCurrentDetailed: vi.fn(() => ({ ok: true, pos: 24, toEpoch: 1 })), + })) as unknown as EditorInputDependencies['getEpochMapper'], + getViewportHost: vi.fn(() => viewportHost), + getVisibleHost: vi.fn(() => visibleHost), + getLayoutMode: vi.fn(() => 'vertical'), + getHeaderFooterSession: vi.fn(() => null), + getPageGeometryHelper: vi.fn(() => null), + getZoom: vi.fn(() => 1), + isViewLocked: vi.fn(() => false), + getDocumentMode: vi.fn(() => 'editing'), + getPageElement: vi.fn(() => null), + isSelectionAwareVirtualizationEnabled: vi.fn(() => false), + }; + + mockCallbacks = { + normalizeClientPoint: vi.fn((clientX: number, clientY: number) => ({ x: clientX, y: clientY })), + scheduleSelectionUpdate: vi.fn(), + updateSelectionDebugHud: vi.fn(), + }; + + manager = new EditorInputManager(); + manager.setDependencies(mockDeps); + manager.setCallbacks(mockCallbacks); + manager.bind(); + }); + + afterEach(() => { + manager.destroy(); + document.body.innerHTML = ''; + vi.clearAllMocks(); + }); + + function getPointerEventImpl(): typeof PointerEvent | typeof MouseEvent { + return ( + (globalThis as unknown as { PointerEvent?: typeof PointerEvent; MouseEvent: typeof MouseEvent }).PointerEvent ?? + globalThis.MouseEvent + ); + } + + function dispatchPointerDown(target: HTMLElement): void { + const PointerEventImpl = getPointerEventImpl(); + target.dispatchEvent( + new PointerEventImpl('pointerdown', { + bubbles: true, + cancelable: true, + button: 0, + buttons: 1, + clientX: 10, + clientY: 10, + } as PointerEventInit), + ); + } + + it('treats a click on the already-active single-thread highlight as a no-op', () => { + const highlight = document.createElement('span'); + highlight.className = 'superdoc-comment-highlight'; + highlight.setAttribute('data-comment-ids', 'comment-1'); + viewportHost.appendChild(highlight); + + dispatchPointerDown(highlight); + + expect(mockEditor.emit).toHaveBeenCalledWith('commentsUpdate', { + type: comments_module_events.SELECTED, + activeCommentId: 'comment-1', + }); + expect(clickToPosition).not.toHaveBeenCalled(); + expect(TextSelection.create as unknown as Mock).not.toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).not.toHaveBeenCalled(); + expect(mockEditor.view.dispatch).not.toHaveBeenCalled(); + expect(viewportHost.setPointerCapture).not.toHaveBeenCalled(); + }); + + it('does not suppress clicks on overlapping highlights that contain multiple thread ids', () => { + const highlight = document.createElement('span'); + highlight.className = 'superdoc-comment-highlight'; + highlight.setAttribute('data-comment-ids', 'comment-1,comment-2'); + viewportHost.appendChild(highlight); + + dispatchPointerDown(highlight); + + expect(mockEditor.emit).not.toHaveBeenCalledWith( + 'commentsUpdate', + expect.objectContaining({ activeCommentId: 'comment-1' }), + ); + expect(clickToPosition).toHaveBeenCalled(); + expect(mockEditor.state.tr.setSelection).toHaveBeenCalled(); + expect(viewportHost.setPointerCapture).toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.js b/packages/super-editor/src/extensions/comment/comments-plugin.js index 49e7f212f4..b739ead610 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.js @@ -404,11 +404,17 @@ 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 (options.preferredActiveThreadId) { + state.tr.setMeta(CommentsPluginKey, { + type: 'setCursorById', + preferredActiveThreadId: options.preferredActiveThreadId, + }); + } if (editor.view && typeof editor.view.focus === 'function') { editor.view.focus(); } @@ -497,6 +503,13 @@ export const CommentsPlugin = Extension.create({ const { selection } = tr; let currentActiveThread = getActiveCommentId(newEditorState.doc, selection); if (trChangedActiveComment) currentActiveThread = meta.activeThreadId; + if ( + meta?.type === 'setCursorById' && + meta.preferredActiveThreadId && + selectionContainsThread(newEditorState.doc, selection, meta.preferredActiveThreadId) + ) { + currentActiveThread = meta.preferredActiveThreadId; + } const previousSelectionId = pluginState.activeThreadId; if (previousSelectionId !== currentActiveThread) { @@ -806,6 +819,17 @@ const getActiveCommentId = (doc, selection) => { return containingComments[0].commentId; }; +const selectionContainsThread = (doc, selection, threadId) => { + if (!selection || !threadId) return false; + const { $from, $to } = selection; + if ($from.pos !== $to.pos) return false; + + const range = findRangeById(doc, threadId); + if (!range) return false; + + return $from.pos >= range.from && $from.pos < range.to; +}; + const findTrackedMark = ({ doc, from, @@ -1143,6 +1167,7 @@ export { createOrUpdateTrackedChangeComment }; export const __test__ = { getActiveCommentId, + selectionContainsThread, findTrackedMark, handleTrackedChangeTransaction, getTrackedChangeText, diff --git a/packages/super-editor/src/extensions/comment/comments-plugin.test.js b/packages/super-editor/src/extensions/comment/comments-plugin.test.js index deed071577..7725197754 100644 --- a/packages/super-editor/src/extensions/comment/comments-plugin.test.js +++ b/packages/super-editor/src/extensions/comment/comments-plugin.test.js @@ -680,6 +680,24 @@ describe('CommentsPlugin state', () => { }), ); }); + + it('preserves the preferred tracked-change thread when cursor lands on overlapping comment text', () => { + const schema = createCommentSchema(); + const commentMark = schema.marks[CommentMarkName].create({ commentId: 'comment-1', internal: true }); + const trackedMark = schema.marks[TrackInsertMarkName].create({ id: 'tracked-1' }); + const paragraph = schema.node('paragraph', null, [schema.text('Hello', [commentMark, trackedMark])]); + const doc = schema.node('doc', null, [paragraph]); + const { view } = createPluginStateEnvironment({ schema, doc }); + + const tr = view.state.tr + .setSelection(TextSelection.create(doc, 2)) + .setMeta(CommentsPluginKey, { type: 'setCursorById', preferredActiveThreadId: 'tracked-1' }); + + view.dispatch(tr); + + const pluginState = CommentsPluginKey.getState(view.state); + expect(pluginState.activeThreadId).toBe('tracked-1'); + }); }); describe('normalizeCommentEventPayload', () => { diff --git a/packages/super-editor/src/extensions/types/comment-commands.ts b/packages/super-editor/src/extensions/types/comment-commands.ts index e24f516771..03a231515f 100644 --- a/packages/super-editor/src/extensions/types/comment-commands.ts +++ b/packages/super-editor/src/extensions/types/comment-commands.ts @@ -203,10 +203,12 @@ export interface CommentCommands { /** * Set cursor position to a comment by ID * @param id - The comment ID to navigate to + * @param options - Optional navigation settings + * @param options.preferredActiveThreadId - Preserve this thread as active when overlapping marks exist * @example * editor.commands.setCursorById('comment-123') */ - setCursorById: (id: string) => boolean; + setCursorById: (id: string, options?: { preferredActiveThreadId?: string }) => boolean; /** * Add a reply to an existing comment or tracked change diff --git a/packages/super-editor/src/index.d.ts b/packages/super-editor/src/index.d.ts index a8a3fdab23..273822b4cf 100644 --- a/packages/super-editor/src/index.d.ts +++ b/packages/super-editor/src/index.d.ts @@ -831,6 +831,15 @@ export declare class PresentationEditor { */ scrollToPosition(pos: number, options?: { behavior?: ScrollBehavior; block?: ScrollLogicalPosition }): boolean; + /** + * Scroll a comment or tracked-change anchor to a viewport Y coordinate. + */ + scrollThreadAnchorToClientY( + threadId: string, + targetClientY: number, + options?: { behavior?: ScrollBehavior }, + ): boolean; + /** * Scroll to a document position (async version). */ diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.js index 9978e17422..d6297298ec 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.js @@ -1041,7 +1041,7 @@ describe('SuperDoc.vue', () => { const handleToolClick = wrapper.vm.$.setupState.handleToolClick; handleToolClick('comments'); - expect(commentsStoreStub.showAddComment).toHaveBeenCalledWith(superdocStub); + expect(commentsStoreStub.showAddComment).toHaveBeenCalledWith(superdocStub, 20); handleToolClick('ai'); const aiMockResult = useAiMock.mock.results.at(-1)?.value; diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 768ac985c0..4b0442ac62 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -218,9 +218,20 @@ const handleDocumentReady = (documentId, container) => { proxy.$superdoc.broadcastPdfDocumentReady(); }; +const getPendingCommentTargetClientY = () => { + if (!selectionPosition.value || !layers.value) return null; + + const isPdf = selectionPosition.value.source === 'pdf'; + const zoom = isPdf ? (activeZoom.value ?? 100) / 100 : 1; + const top = Number(selectionPosition.value.top); + if (!Number.isFinite(top)) return null; + + return layers.value.getBoundingClientRect().top + top * zoom; +}; + const handleToolClick = (tool) => { const toolOptions = { - comments: () => showAddComment(proxy.$superdoc), + comments: () => showAddComment(proxy.$superdoc, getPendingCommentTargetClientY()), ai: () => handleAiToolClick(), }; @@ -1451,11 +1462,15 @@ const getPDFViewer = () => { .right-sidebar { min-width: 320px; + height: 100%; } .floating-comments { min-width: 300px; width: 300px; + height: 100%; + overflow-y: hidden; + overflow-x: hidden; } .superdoc__layers { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js index 0dc37e6905..48e12ad28f 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js @@ -24,6 +24,9 @@ vi.mock('@superdoc/super-editor', () => ({ return () => h('textarea', slots.default?.()); }, }), + PresentationEditor: { + getInstance: vi.fn(() => null), + }, })); const simpleStub = (name, emits = []) => @@ -134,6 +137,8 @@ const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], prop cancelComment: vi.fn(), deleteComment: vi.fn(), removePendingComment: vi.fn(), + requestInstantSidebarAlignment: vi.fn(), + clearInstantSidebarAlignment: vi.fn(), setActiveComment: vi.fn(), getPendingComment: vi.fn(() => ({ commentId: 'pending-1', @@ -222,7 +227,9 @@ describe('CommentDialog.vue', () => { await nextTick(); expect(baseComment.setActive).toHaveBeenCalledWith(superdocStub); - expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId); + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId, { + preferredActiveThreadId: baseComment.commentId, + }); expect(commentsStoreStub.activeComment.value).toBe(baseComment.commentId); // Click the reply pill to expand the editor @@ -246,6 +253,23 @@ describe('CommentDialog.vue', () => { }); }); + it('does not pass preferred thread override for resolved comments', async () => { + const { baseComment, superdocStub } = await mountDialog({ + baseCommentOverrides: { + resolvedTime: Date.now(), + }, + }); + + await nextTick(); + + expect(baseComment.setActive).not.toHaveBeenCalled(); + expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId); + expect(superdocStub.activeEditor.commands.setCursorById).not.toHaveBeenCalledWith( + baseComment.commentId, + expect.objectContaining({ preferredActiveThreadId: baseComment.commentId }), + ); + }); + it('handles resolve and reject for tracked change comments', async () => { const { wrapper, baseComment, superdocStub } = await mountDialog({ baseCommentOverrides: { diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue index 2c8026eecd..28803c189e 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.vue @@ -3,6 +3,7 @@ import { computed, ref, getCurrentInstance, onMounted, nextTick, watch } from 'v import { storeToRefs } from 'pinia'; import { useCommentsStore } from '@superdoc/stores/comments-store'; import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; +import { PresentationEditor } from '@superdoc/super-editor'; import { superdocIcons } from '@superdoc/icons.js'; import InternalDropdown from './InternalDropdown.vue'; import CommentHeader from './CommentHeader.vue'; @@ -30,7 +31,14 @@ const superdocStore = useSuperdocStore(); const commentsStore = useCommentsStore(); /* Comments store refs */ -const { addComment, cancelComment, deleteComment, removePendingComment } = commentsStore; +const { + addComment, + cancelComment, + deleteComment, + removePendingComment, + requestInstantSidebarAlignment, + clearInstantSidebarAlignment, +} = commentsStore; const { suppressInternalExternal, getConfig, @@ -279,6 +287,13 @@ const hasTextContent = computed(() => { const setFocus = () => { const editor = proxy.$superdoc.activeEditor; + const targetClientY = commentDialogElement.value?.getBoundingClientRect?.()?.top; + const willChangeActiveThread = !props.comment.resolvedTime && activeComment.value !== props.comment.commentId; + if (willChangeActiveThread) { + requestInstantSidebarAlignment(targetClientY); + } else { + clearInstantSidebarAlignment(); + } // Only set as active if not resolved (resolved comments can't be edited) if (!props.comment.resolvedTime) { @@ -293,7 +308,20 @@ const setFocus = () => { const cursorId = props.comment.resolvedTime ? props.comment.commentId : props.comment.importedId || props.comment.commentId; - editor.commands?.setCursorById(cursorId); + if (props.comment.resolvedTime) { + editor.commands?.setCursorById(cursorId); + } else { + editor.commands?.setCursorById(cursorId, { preferredActiveThreadId: cursorId }); + } + + const presentation = props.comment.fileId ? PresentationEditor.getInstance(props.comment.fileId) : null; + if (presentation && Number.isFinite(targetClientY)) { + const fallbackThreadId = props.comment.commentId; + const scrolled = presentation.scrollThreadAnchorToClientY(cursorId, targetClientY, { behavior: 'auto' }); + if (!scrolled && fallbackThreadId && fallbackThreadId !== cursorId) { + presentation.scrollThreadAnchorToClientY(fallbackThreadId, targetClientY, { behavior: 'auto' }); + } + } } }; diff --git a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue index 8fb9344545..5099343d97 100644 --- a/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue +++ b/packages/superdoc/src/components/CommentsLayer/FloatingComments.vue @@ -66,18 +66,36 @@ const props = defineProps({ const superdocStore = useSuperdocStore(); const commentsStore = useCommentsStore(); -const { getCommentAliasIds, getCommentPositionKey, resolveCommentPositionEntry } = commentsStore; +const { + getCommentAliasIds, + getCommentPositionKey, + resolveCommentPositionEntry, + peekInstantSidebarAlignment, + clearInstantSidebarAlignment, +} = commentsStore; const { getFloatingComments, activeComment, editorCommentPositions, pendingComment } = storeToRefs(commentsStore); const { activeZoom } = storeToRefs(superdocStore); const floatingCommentsContainer = ref(null); const commentsRenderKey = ref(0); +const sidebarOffsetY = ref(0); +const disableInstantLayoutTransitions = ref(false); + +const isPendingThread = (commentOrId) => { + const pendingId = pendingComment.value?.commentId; + if (!pendingId) return false; + if (typeof commentOrId === 'object') return commentOrId?.commentId === pendingId; + return commentOrId === pendingId || commentOrId === 'pending'; +}; // Resolve activeComment (which stores commentId) to the position key used by allPositions // (which prefers importedId). Without this, imported Word comments where importedId !== commentId // would fail the template guard and could unmount when scrolled out of the observer viewport. const resolveLayoutKey = (commentOrId, preferredId) => { + if (preferredId === 'pending' || isPendingThread(preferredId) || isPendingThread(commentOrId)) { + return 'pending'; + } const { key } = resolveCommentPositionEntry(commentOrId, preferredId); if (key) return key; return getCommentAliasIds(commentOrId)[0] ?? getCommentPositionKey(commentOrId); @@ -118,14 +136,14 @@ const getAnchorTop = (comment) => { // For editor docs, uses the 'pending' mark position from editorCommentPositions. // For PDF docs, falls back to selection bounds (same as getAnchorTop). const getPendingAnchorTop = () => { - if (props.currentDocument.type === 'application/pdf') { - const zoom = (activeZoom.value ?? 100) / 100; - const top = Number(pendingComment.value?.selection?.selectionBounds?.top); - return isNaN(top) ? null : top * zoom; + const positionEntry = editorCommentPositions.value['pending']; + if (typeof positionEntry?.bounds?.top === 'number' && !isNaN(positionEntry.bounds.top)) { + return positionEntry.bounds.top; } - const positionEntry = editorCommentPositions.value['pending']; - return positionEntry?.bounds?.top ?? null; + const zoom = props.currentDocument.type === 'application/pdf' ? (activeZoom.value ?? 100) / 100 : 1; + const top = Number(pendingComment.value?.selection?.selectionBounds?.top); + return isNaN(top) ? null : top * zoom; }; // Pre-compute all positions with collision avoidance @@ -269,6 +287,26 @@ const handleResize = (comment) => { }); }; +const setInstantLayoutTransitionsDisabled = (disabled) => { + disableInstantLayoutTransitions.value = disabled; +}; + +const alignCommentKeyToClientY = (key, targetY, onComplete) => { + if (!Number.isFinite(targetY)) { + onComplete?.(false); + return; + } + const el = placeholderRefs.value[key]; + if (!el) { + onComplete?.(false); + return; + } + + const currentTop = el.getBoundingClientRect().top; + sidebarOffsetY.value += targetY - currentTop; + onComplete?.(true); +}; + // Store placeholder ref by comment ID const setPlaceholderRef = (id, el) => { if (el) { @@ -297,8 +335,10 @@ watch(activeCommentKey, (newKey, oldKey) => { // Cancel stale timers from previous activation remeasureTimers.forEach(clearTimeout); remeasureTimers = []; + const instantAlignmentTargetY = newKey ? peekInstantSidebarAlignment() : null; + const instantAlignment = Number.isFinite(instantAlignmentTargetY); - const remeasure = () => { + const remeasure = (shouldAlign = false) => { for (const key of [newKey, oldKey].filter(Boolean)) { const el = placeholderRefs.value[key]; if (!el) continue; @@ -306,45 +346,71 @@ watch(activeCommentKey, (newKey, oldKey) => { if (!dialog) continue; storeHeight(key, dialog.getBoundingClientRect().height); } + + if (!shouldAlign || !newKey) return; + + nextTick(() => { + alignCommentKeyToClientY(newKey, instantAlignmentTargetY, () => { + clearInstantSidebarAlignment(); + requestAnimationFrame(() => { + setInstantLayoutTransitionsDisabled(false); + }); + }); + }); }; // 50ms: after Vue nextTick + browser rAF settle the initial DOM change // 350ms: after .comment-placeholder transition (300ms ease) completes nextTick(() => { - remeasureTimers.push(setTimeout(remeasure, 50)); - remeasureTimers.push(setTimeout(remeasure, 350)); + if (instantAlignment) { + remeasure(true); + } else { + remeasureTimers.push(setTimeout(remeasure, 50)); + remeasureTimers.push(setTimeout(remeasure, 350)); + } }); }); -// Scroll to the active comment ONLY when its anchor is off-screen. -// getBoundingClientRect() is viewport-relative (accounts for scroll + zoom). +// Align the active comment bubble with the same on-screen Y position as its +// document anchor by translating the inner sidebar layer. watch(activeComment, () => { if (scrollTimer) clearTimeout(scrollTimer); - if (!activeComment.value) return; - const comment = commentsStore.getComment(activeComment.value); + if (!activeComment.value) { + clearInstantSidebarAlignment(); + setInstantLayoutTransitionsDisabled(false); + sidebarOffsetY.value = 0; + return; + } + const comment = isPendingThread(activeComment.value) + ? pendingComment.value + : commentsStore.getComment(activeComment.value); if (!comment) return; const key = resolveLayoutKey(comment); if (!key) return; + const instantAlignment = Number.isFinite(peekInstantSidebarAlignment()); + if (instantAlignment) { + setInstantLayoutTransitionsDisabled(true); + return; + } nextTick(() => { - // 400ms: wait for .comment-placeholder CSS transition (300ms) + buffer - scrollTimer = setTimeout(() => { + const applyAlignment = () => { const el = placeholderRefs.value[key]; if (!el) return; + const parentRect = props.parent?.getBoundingClientRect?.(); + if (!parentRect) return; - const rect = el.getBoundingClientRect(); - const margin = 80; - const availableHeight = window.innerHeight - 2 * margin; - const isVisible = - rect.height > availableHeight - ? rect.top >= margin - : rect.top >= margin && rect.bottom <= window.innerHeight - margin; + const anchorTop = key === 'pending' ? getPendingAnchorTop() : getAnchorTop(comment); + if (typeof anchorTop !== 'number' || isNaN(anchorTop)) return; - if (!isVisible) { - el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - }, 400); + const currentTop = el.getBoundingClientRect().top; + const desiredTop = parentRect.top + anchorTop; + sidebarOffsetY.value += desiredTop - currentTop; + }; + + // 400ms: wait for .comment-placeholder CSS transition (300ms) + buffer + scrollTimer = setTimeout(applyAlignment, 400); }); }); @@ -455,17 +521,32 @@ onBeforeUnmount(() => {