From 931225117b1ac7e8ca7c0a3e02feb24dcaf3cd72 Mon Sep 17 00:00:00 2001 From: chaoxiaoche Date: Sat, 30 May 2026 18:51:23 +0800 Subject: [PATCH] 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 --- apps/web/src/comments.ts | 29 ++++++++++++++++++ apps/web/src/components/FileViewer.tsx | 42 +++++++++++++++++++------- apps/web/src/runtime/srcdoc.ts | 12 +++++--- apps/web/tests/comments.test.ts | 18 +++++++++++ 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/apps/web/src/comments.ts b/apps/web/src/comments.ts index 059e0144a..181621306 100644 --- a/apps/web/src/comments.ts +++ b/apps/web/src/comments.ts @@ -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, + next: Map, +): 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, diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index d9eac82ab..73a8580d1 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -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([ ...(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([ 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([ 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') { diff --git a/apps/web/src/runtime/srcdoc.ts b/apps/web/src/runtime/srcdoc.ts index 9c32fa3b9..7d40ee0e6 100644 --- a/apps/web/src/runtime/srcdoc.ts +++ b/apps/web/src/runtime/srcdoc.ts @@ -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(){ diff --git a/apps/web/tests/comments.test.ts b/apps/web/tests/comments.test.ts index 088b8c83f..26f839f70 100644 --- a/apps/web/tests/comments.test.ts +++ b/apps/web/tests/comments.test.ts @@ -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({