Skip to content

Commit 7b20888

Browse files
authored
fix: comment scroll after painter refactor (#2629)
1 parent 57821b1 commit 7b20888

13 files changed

Lines changed: 1135 additions & 112 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ import { isHeaderFooterPartId } from '../parts/adapters/header-footer-part-descr
121121
import type { PartChangedEvent } from '../parts/types.js';
122122
import { isInRegisteredSurface } from './utils/uiSurfaceRegistry.js';
123123
import { buildSemanticFootnoteBlocks } from './semantic-flow-footnotes.js';
124+
125+
type ThreadAnchorScrollPlan = {
126+
achievedClientY: number;
127+
applyScroll: (behavior: ScrollBehavior) => void;
128+
};
124129
import { splitRunsAtDecorationBoundaries } from './layout/SplitRunsAtDecorationBoundaries.js';
125130

126131
import type { ResolveRangeOutput, DocumentApi } from '@superdoc/document-api';
@@ -2449,6 +2454,18 @@ export class PresentationEditor extends EventEmitter {
24492454
}
24502455
}
24512456

2457+
/**
2458+
* Return the viewport Y coordinate this thread anchor can actually reach after
2459+
* scroll bounds clamp the requested target.
2460+
*
2461+
* @param threadId - Comment or tracked-change identifier
2462+
* @param targetClientY - Desired top position in client/viewport coordinates
2463+
* @returns The reachable client Y, or null when the thread cannot be resolved
2464+
*/
2465+
getReachableThreadAnchorClientY(threadId: string, targetClientY: number): number | null {
2466+
return this.#buildThreadAnchorScrollPlan(threadId, targetClientY)?.achievedClientY ?? null;
2467+
}
2468+
24522469
/**
24532470
* Scroll a comment or tracked-change anchor so its top edge lands at the
24542471
* requested viewport Y coordinate.
@@ -2464,35 +2481,80 @@ export class PresentationEditor extends EventEmitter {
24642481
targetClientY: number,
24652482
options: { behavior?: ScrollBehavior } = {},
24662483
): boolean {
2467-
if (!threadId || !Number.isFinite(targetClientY)) return false;
2484+
const scrollPlan = this.#buildThreadAnchorScrollPlan(threadId, targetClientY);
2485+
if (!scrollPlan) return false;
2486+
2487+
const behavior = options.behavior ?? 'auto';
2488+
scrollPlan.applyScroll(behavior);
2489+
return true;
2490+
}
2491+
2492+
#buildThreadAnchorScrollPlan(threadId: string, targetClientY: number): ThreadAnchorScrollPlan | null {
2493+
if (!threadId || !Number.isFinite(targetClientY)) return null;
24682494

24692495
const threadPosition = this.#collectCommentPositions()[threadId];
2470-
if (!threadPosition) return false;
2496+
if (!threadPosition) return null;
24712497

24722498
const selectionBounds = this.getSelectionBounds(threadPosition.start, threadPosition.end);
24732499
const currentTop = selectionBounds?.bounds?.top;
2474-
if (!Number.isFinite(currentTop)) return false;
2500+
if (!Number.isFinite(currentTop)) return null;
24752501

2476-
const deltaY = currentTop - targetClientY;
2477-
if (Math.abs(deltaY) < 1) return true;
2478-
2479-
const behavior = options.behavior ?? 'auto';
2502+
const requestedScrollDelta = currentTop - targetClientY;
24802503
const scrollTarget = this.#scrollContainer ?? this.#visibleHost;
24812504

24822505
if (scrollTarget instanceof Window) {
2483-
const currentScrollY = scrollTarget.scrollY ?? scrollTarget.pageYOffset ?? 0;
2484-
scrollTarget.scrollTo({ top: currentScrollY + deltaY, behavior });
2485-
return true;
2506+
return this.#buildWindowThreadAnchorScrollPlan(scrollTarget, currentTop, requestedScrollDelta);
24862507
}
24872508

24882509
if (scrollTarget instanceof HTMLElement) {
2489-
const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight);
2490-
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scrollTarget.scrollTop + deltaY));
2491-
scrollTarget.scrollTo({ top: nextScrollTop, behavior });
2492-
return true;
2510+
return this.#buildElementThreadAnchorScrollPlan(scrollTarget, currentTop, requestedScrollDelta);
24932511
}
24942512

2495-
return false;
2513+
return null;
2514+
}
2515+
2516+
#buildWindowThreadAnchorScrollPlan(
2517+
scrollTarget: Window,
2518+
currentTop: number,
2519+
requestedScrollDelta: number,
2520+
): ThreadAnchorScrollPlan {
2521+
const scrollRoot =
2522+
scrollTarget.document.scrollingElement ??
2523+
scrollTarget.document.documentElement ??
2524+
scrollTarget.document.body ??
2525+
null;
2526+
const currentScrollTop = scrollTarget.scrollY ?? scrollTarget.pageYOffset ?? scrollRoot?.scrollTop ?? 0;
2527+
const viewportHeight = scrollTarget.innerHeight ?? scrollRoot?.clientHeight ?? 0;
2528+
const maxScrollTop = Math.max(0, (scrollRoot?.scrollHeight ?? 0) - viewportHeight);
2529+
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, currentScrollTop + requestedScrollDelta));
2530+
const appliedScrollDelta = nextScrollTop - currentScrollTop;
2531+
2532+
return {
2533+
achievedClientY: currentTop - appliedScrollDelta,
2534+
applyScroll: (behavior) => {
2535+
if (Math.abs(appliedScrollDelta) < 1) return;
2536+
scrollTarget.scrollTo({ top: nextScrollTop, behavior });
2537+
},
2538+
};
2539+
}
2540+
2541+
#buildElementThreadAnchorScrollPlan(
2542+
scrollTarget: HTMLElement,
2543+
currentTop: number,
2544+
requestedScrollDelta: number,
2545+
): ThreadAnchorScrollPlan {
2546+
const currentScrollTop = scrollTarget.scrollTop;
2547+
const maxScrollTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight);
2548+
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, currentScrollTop + requestedScrollDelta));
2549+
const appliedScrollDelta = nextScrollTop - currentScrollTop;
2550+
2551+
return {
2552+
achievedClientY: currentTop - appliedScrollDelta,
2553+
applyScroll: (behavior) => {
2554+
if (Math.abs(appliedScrollDelta) < 1) return;
2555+
scrollTarget.scrollTo({ top: nextScrollTop, behavior });
2556+
},
2557+
};
24962558
}
24972559

24982560
/**

packages/super-editor/src/editors/v1/core/presentation-editor/pointer-events/EditorInputManager.ts

Lines changed: 151 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,23 @@ const AUTO_SCROLL_MAX_SPEED_PX = 24;
5353
/** Tolerance for detecting scrollability to handle sub-pixel rounding in browsers */
5454
const SCROLL_DETECTION_TOLERANCE_PX = 1;
5555
const COMMENT_HIGHLIGHT_SELECTOR = '.superdoc-comment-highlight';
56+
const TRACK_CHANGE_SELECTOR = '[data-track-change-id]';
57+
const COMMENT_THREAD_HIT_TOLERANCE_PX = 3;
58+
const COMMENT_THREAD_HIT_SAMPLE_OFFSETS: ReadonlyArray<readonly [number, number]> = [
59+
[0, 0],
60+
[-COMMENT_THREAD_HIT_TOLERANCE_PX, 0],
61+
[COMMENT_THREAD_HIT_TOLERANCE_PX, 0],
62+
[0, -COMMENT_THREAD_HIT_TOLERANCE_PX],
63+
[0, COMMENT_THREAD_HIT_TOLERANCE_PX],
64+
];
5665

5766
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
5867

68+
type CommentThreadHit = {
69+
isAmbiguous: boolean;
70+
threadId: string | null;
71+
};
72+
5973
/**
6074
* Block IDs for footnote content use prefix "footnote-{id}-" (see FootnotesBuilder).
6175
* Semantic footnote blocks use the {@link isSemanticFootnoteBlockId} helper from
@@ -83,6 +97,104 @@ function getCommentHighlightThreadIds(target: EventTarget | null): string[] {
8397
.filter(Boolean);
8498
}
8599

100+
function resolveTrackChangeThreadId(target: EventTarget | null): string | null {
101+
if (!(target instanceof Element)) {
102+
return null;
103+
}
104+
105+
const trackedChangeElement = target.closest(TRACK_CHANGE_SELECTOR);
106+
const threadId = trackedChangeElement?.getAttribute('data-track-change-id')?.trim();
107+
108+
return threadId ? threadId : null;
109+
}
110+
111+
function resolveCommentThreadHit(target: EventTarget | null): CommentThreadHit {
112+
const threadIds = getCommentHighlightThreadIds(target);
113+
if (threadIds.length > 1) {
114+
return {
115+
isAmbiguous: true,
116+
threadId: null,
117+
};
118+
}
119+
120+
if (threadIds.length === 1) {
121+
return {
122+
isAmbiguous: false,
123+
threadId: threadIds[0],
124+
};
125+
}
126+
127+
return {
128+
isAmbiguous: false,
129+
threadId: resolveTrackChangeThreadId(target),
130+
};
131+
}
132+
133+
function collectElementsNearPointerTarget(target: EventTarget | null, clientX: number, clientY: number): Element[] {
134+
const candidates: Element[] = [];
135+
const seen = new Set<Element>();
136+
const ownerDocument = target instanceof Element ? target.ownerDocument : document;
137+
const ownerWindow = ownerDocument.defaultView;
138+
139+
const addCandidate = (candidate: Element | null): void => {
140+
if (!candidate || seen.has(candidate)) {
141+
return;
142+
}
143+
seen.add(candidate);
144+
candidates.push(candidate);
145+
};
146+
147+
if (target instanceof Element) {
148+
addCandidate(target);
149+
}
150+
151+
if (typeof ownerDocument.elementsFromPoint !== 'function' || !ownerWindow) {
152+
return candidates;
153+
}
154+
155+
const maxX = Math.max(ownerWindow.innerWidth - 1, 0);
156+
const maxY = Math.max(ownerWindow.innerHeight - 1, 0);
157+
158+
for (const [offsetX, offsetY] of COMMENT_THREAD_HIT_SAMPLE_OFFSETS) {
159+
const sampleX = clamp(clientX + offsetX, 0, maxX);
160+
const sampleY = clamp(clientY + offsetY, 0, maxY);
161+
const elements = ownerDocument.elementsFromPoint(sampleX, sampleY);
162+
163+
for (const element of elements) {
164+
addCandidate(element);
165+
}
166+
}
167+
168+
return candidates;
169+
}
170+
171+
function resolveCommentThreadIdNearPointer(
172+
target: EventTarget | null,
173+
clientX: number,
174+
clientY: number,
175+
): string | null {
176+
const directHit = resolveCommentThreadHit(target);
177+
if (directHit.isAmbiguous || directHit.threadId) {
178+
return directHit.threadId;
179+
}
180+
181+
// Painter output can split one visible annotation into adjacent runs. Sampling
182+
// a few nearby points keeps narrow gaps from falling through to generic caret
183+
// placement while still refusing ambiguous overlapping highlights.
184+
const nearbyElements = collectElementsNearPointerTarget(target, clientX, clientY);
185+
for (const element of nearbyElements) {
186+
const hit = resolveCommentThreadHit(element);
187+
if (hit.isAmbiguous) {
188+
return null;
189+
}
190+
if (hit.threadId) {
191+
return hit.threadId;
192+
}
193+
}
194+
195+
return null;
196+
}
197+
86198
function getActiveCommentThreadId(editor: Editor): string | null {
87199
const pluginState = CommentsPluginKey.getState(editor.state) as { activeThreadId?: unknown } | null;
88200
const activeThreadId = pluginState?.activeThreadId;
@@ -94,18 +206,17 @@ function getActiveCommentThreadId(editor: Editor): string | null {
94206
return activeThreadId;
95207
}
96208

97-
function shouldIgnoreRepeatClickOnActiveComment(target: EventTarget | null, activeThreadId: string | null): boolean {
209+
function shouldIgnoreRepeatClickOnActiveComment(
210+
target: EventTarget | null,
211+
clientX: number,
212+
clientY: number,
213+
activeThreadId: string | null,
214+
): boolean {
98215
if (!activeThreadId) {
99216
return false;
100217
}
101218

102-
const clickedThreadIds = getCommentHighlightThreadIds(target);
103-
104-
if (clickedThreadIds.length !== 1) {
105-
return false;
106-
}
107-
108-
return clickedThreadIds[0] === activeThreadId;
219+
return resolveCommentThreadIdNearPointer(target, clientX, clientY) === activeThreadId;
109220
}
110221

111222
// =============================================================================
@@ -924,6 +1035,10 @@ export class EditorInputManager {
9241035
}
9251036

9261037
const editor = this.#deps.getEditor();
1038+
if (this.#handleSingleCommentHighlightClick(event, target, editor)) {
1039+
return;
1040+
}
1041+
9271042
if (this.#handleRepeatClickOnActiveComment(event, target, editor)) {
9281043
return;
9291044
}
@@ -2098,17 +2213,43 @@ export class EditorInputManager {
20982213
#handleRepeatClickOnActiveComment(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean {
20992214
const activeThreadId = getActiveCommentThreadId(editor);
21002215

2101-
if (!shouldIgnoreRepeatClickOnActiveComment(target, activeThreadId)) {
2216+
if (!shouldIgnoreRepeatClickOnActiveComment(target, event.clientX, event.clientY, activeThreadId)) {
21022217
return false;
21032218
}
21042219

21052220
event.preventDefault();
2106-
this.#focusEditor();
21072221
editor.emit?.('commentsUpdate', {
21082222
type: comments_module_events.SELECTED,
21092223
activeCommentId: activeThreadId,
21102224
});
21112225

21122226
return true;
21132227
}
2228+
2229+
#handleSingleCommentHighlightClick(event: PointerEvent, target: HTMLElement | null, editor: Editor): boolean {
2230+
const clickedThreadId = resolveCommentThreadIdNearPointer(target, event.clientX, event.clientY);
2231+
if (!clickedThreadId) {
2232+
return false;
2233+
}
2234+
2235+
const activeThreadId = getActiveCommentThreadId(editor);
2236+
if (clickedThreadId === activeThreadId) {
2237+
return false;
2238+
}
2239+
2240+
event.preventDefault();
2241+
2242+
const didSetCursor = editor.commands?.setCursorById?.(clickedThreadId, {
2243+
activeCommentId: clickedThreadId,
2244+
});
2245+
2246+
if (!didSetCursor) {
2247+
editor.emit?.('commentsUpdate', {
2248+
type: comments_module_events.SELECTED,
2249+
activeCommentId: clickedThreadId,
2250+
});
2251+
}
2252+
2253+
return true;
2254+
}
21142255
}

0 commit comments

Comments
 (0)