Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -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));

Expand All @@ -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
// =============================================================================
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<typeof import('prosemirror-state')>();
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<EditorInputDependencies['getActiveEditor']>),
getEditor: vi.fn(() => mockEditor as unknown as ReturnType<EditorInputDependencies['getEditor']>),
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();
});
});
Loading
Loading