fix(web): stop comment overlay jitter from redundant target sync

Skip React updates when live comment target bounds are unchanged, refresh
deck targets only on slide changes, and drop characterData from the
selection-bridge mutation observer to avoid streaming text churn.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
chaoxiaoche 2026-05-30 18:51:23 +08:00
parent 51aa968d75
commit 931225117b
4 changed files with 86 additions and 15 deletions

View file

@ -124,6 +124,35 @@ export function commentVisibleOnDeckSlide(
return comment.slideIndex === activeSlideIndex;
}
export function commentSnapshotOverlayEqual(
a: PreviewCommentSnapshot,
b: PreviewCommentSnapshot,
): boolean {
const positionA = normalizePosition(a.position);
const positionB = normalizePosition(b.position);
return (
a.elementId === b.elementId
&& a.filePath === b.filePath
&& positionA.x === positionB.x
&& positionA.y === positionB.y
&& positionA.width === positionB.width
&& positionA.height === positionB.height
&& (a.slideIndex ?? -1) === (b.slideIndex ?? -1)
);
}
export function liveCommentTargetMapsEqual(
current: Map<string, PreviewCommentSnapshot>,
next: Map<string, PreviewCommentSnapshot>,
): boolean {
if (current.size !== next.size) return false;
for (const [elementId, snapshot] of current) {
const candidate = next.get(elementId);
if (!candidate || !commentSnapshotOverlayEqual(snapshot, candidate)) return false;
}
return true;
}
export function overlayBoundsFromSnapshot(
snapshot: PreviewCommentSnapshot,
scale: number,

View file

@ -93,10 +93,12 @@ import { Toast } from './Toast';
import { PreviewDrawOverlay } from './PreviewDrawOverlay';
import {
buildBoardCommentAttachments,
commentSnapshotOverlayEqual,
commentTargetDisplayName,
commentVisibleOnDeckSlide,
commentsToAttachments,
isValidCommentOverlayPosition,
liveCommentTargetMapsEqual,
liveSnapshotForComment,
overlayBoundsFromSnapshot,
selectionKindLabel,
@ -4972,7 +4974,9 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
...(typeof item?.slideIndex === 'number' ? { slideIndex: item.slideIndex } : {}),
});
});
setLiveCommentTargets(next);
setLiveCommentTargets((current) => (
liveCommentTargetMapsEqual(current, next) ? current : next
));
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
@ -5096,32 +5100,42 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
next.set(snapshot.elementId, snapshot);
});
setLiveCommentTargets(next);
setLiveCommentTargets((current) => (
liveCommentTargetMapsEqual(current, next) ? current : next
));
setActiveCommentTarget((current) => {
if (!current) return null;
if (current.selectionKind === 'pod') return current;
const updated = next.get(current.elementId);
if (updated && isValidCommentOverlayPosition(updated.position)) return updated;
return null;
if (!updated || !isValidCommentOverlayPosition(updated.position)) return null;
return commentSnapshotOverlayEqual(current, updated) ? current : updated;
});
setHoveredCommentTarget((current) => {
if (!current) return null;
if (current.selectionKind === 'pod') return current;
const updated = next.get(current.elementId);
if (updated && isValidCommentOverlayPosition(updated.position)) return updated;
return null;
if (!updated || !isValidCommentOverlayPosition(updated.position)) return null;
return commentSnapshotOverlayEqual(current, updated) ? current : updated;
});
return;
}
if (data.type === 'od:comment-active-target-update') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
setLiveCommentTargets((current) => {
const existing = current.get(snapshot.elementId);
if (existing && commentSnapshotOverlayEqual(existing, snapshot)) return current;
return new Map(current).set(snapshot.elementId, snapshot);
});
setActiveCommentTarget((current) => (
current && current.elementId === snapshot.elementId ? snapshot : current
current && current.elementId === snapshot.elementId && !commentSnapshotOverlayEqual(current, snapshot)
? snapshot
: current
));
setHoveredCommentTarget((current) => (
current && current.elementId === snapshot.elementId ? snapshot : current
current && current.elementId === snapshot.elementId && !commentSnapshotOverlayEqual(current, snapshot)
? snapshot
: current
));
return;
}
@ -5132,8 +5146,14 @@ const [manualEditTargets, setManualEditTargets] = useState<ManualEditTarget[]>([
if (data.type === 'od:comment-hover') {
const snapshot = snapshotFromData(data);
if (!snapshot.elementId || !isValidCommentOverlayPosition(snapshot.position)) return;
setHoveredCommentTarget(snapshot);
setLiveCommentTargets((current) => new Map(current).set(snapshot.elementId, snapshot));
setHoveredCommentTarget((current) => (
current && commentSnapshotOverlayEqual(current, snapshot) ? current : snapshot
));
setLiveCommentTargets((current) => {
const existing = current.get(snapshot.elementId);
if (existing && commentSnapshotOverlayEqual(existing, snapshot)) return current;
return new Map(current).set(snapshot.elementId, snapshot);
});
return;
}
if (data.type === 'od:comment-target') {

View file

@ -1510,7 +1510,7 @@ function meaningfulDomFallbackTarget(el) {
schedulePostPreviewScroll();
}, true);
var mo = new MutationObserver(schedulePostTargets);
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true });
mo.observe(document.documentElement, { subtree: true, childList: true, attributes: true });
// Reflect the host-requested initial modes on the documentElement so
// the cursor/hover styles match what the bridge picks up on click.
if (commentEnabled) document.documentElement.toggleAttribute('data-od-comment-mode', true);
@ -1897,6 +1897,7 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
for (var k = 0; k < n; k++) dispatchKey(key);
setTimeout(report, 320);
}
var lastCommentTargetSlideIndex = -1;
function report(){
try {
var list = slides();
@ -1918,9 +1919,12 @@ function injectDeckBridge(doc: string, initialSlideIndex = 0): string {
if (el.querySelector('span,.bar')) return;
el.style.width=progressWidth;
});
try {
if (typeof window.__odScheduleCommentTargets === 'function') window.__odScheduleCommentTargets();
} catch (_) {}
if (i !== lastCommentTargetSlideIndex) {
lastCommentTargetSlideIndex = i;
try {
if (typeof window.__odScheduleCommentTargets === 'function') window.__odScheduleCommentTargets();
} catch (_) {}
}
} catch (e) {}
}
window.__odDeckSlideState = function(){

View file

@ -5,6 +5,7 @@ import {
commentVisibleOnDeckSlide,
commentsToAttachments,
historyWithCommentAttachmentContext,
liveCommentTargetMapsEqual,
liveSnapshotForComment,
mergeAttachedComments,
messageContentWithCommentAttachments,
@ -265,6 +266,23 @@ describe('preview comment attachment helpers', () => {
expect(commentVisibleOnDeckSlide({}, 1)).toBe(true);
});
it('treats live comment target maps with identical overlay bounds as equal', () => {
const base: PreviewCommentSnapshot = {
filePath: 'index.html',
elementId: 'hero-title',
selector: '[data-od-id="hero-title"]',
label: 'h1.hero-title',
text: 'Hello',
htmlHint: '',
position: { x: 12, y: 24, width: 120, height: 32 },
};
const current = new Map([['hero-title', base]]);
const next = new Map([['hero-title', { ...base, text: 'Hello world' }]]);
expect(liveCommentTargetMapsEqual(current, next)).toBe(true);
next.set('hero-title', { ...base, position: { x: 13, y: 24, width: 120, height: 32 } });
expect(liveCommentTargetMapsEqual(current, next)).toBe(false);
});
it('serializes selected comments into API-mode prompt context without visible input', () => {
const attachments = commentsToAttachments([
comment({