mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
51aa968d75
commit
931225117b
4 changed files with 86 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
if (i !== lastCommentTargetSlideIndex) {
|
||||
lastCommentTargetSlideIndex = i;
|
||||
try {
|
||||
if (typeof window.__odScheduleCommentTargets === 'function') window.__odScheduleCommentTargets();
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
window.__odDeckSlideState = function(){
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue