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({