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; 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( export function overlayBoundsFromSnapshot(
snapshot: PreviewCommentSnapshot, snapshot: PreviewCommentSnapshot,
scale: number, scale: number,

View file

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

View file

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

View file

@ -5,6 +5,7 @@ import {
commentVisibleOnDeckSlide, commentVisibleOnDeckSlide,
commentsToAttachments, commentsToAttachments,
historyWithCommentAttachmentContext, historyWithCommentAttachmentContext,
liveCommentTargetMapsEqual,
liveSnapshotForComment, liveSnapshotForComment,
mergeAttachedComments, mergeAttachedComments,
messageContentWithCommentAttachments, messageContentWithCommentAttachments,
@ -265,6 +266,23 @@ describe('preview comment attachment helpers', () => {
expect(commentVisibleOnDeckSlide({}, 1)).toBe(true); 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', () => { it('serializes selected comments into API-mode prompt context without visible input', () => {
const attachments = commentsToAttachments([ const attachments = commentsToAttachments([
comment({ comment({