fix(workspace): retain wheel motion during row correction
This commit is contained in:
@@ -216,9 +216,9 @@ async function observeLibrary(page, hold=false) {
|
||||
const NativeResizeObserver=window.ResizeObserver;
|
||||
const records=[];let holding=hold;
|
||||
const raf=window.requestAnimationFrame.bind(window),cancel=window.cancelAnimationFrame.bind(window);
|
||||
const pending=new Set();let requested=0,mutations=0,unmounted=0;
|
||||
window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{pending.delete(id);callback(time);});pending.add(id);return id;};
|
||||
window.cancelAnimationFrame=id=>{pending.delete(id);cancel(id);};
|
||||
const pending=new Set(),heldFrames=new Map();let requested=0,mutations=0,unmounted=0,holdingFrames=false;
|
||||
window.requestAnimationFrame=callback=>{requested++;const id=raf(time=>{const deliver=()=>{pending.delete(id);callback(time);};if(holdingFrames)heldFrames.set(id,deliver);else deliver();});pending.add(id);return id;};
|
||||
window.cancelAnimationFrame=id=>{pending.delete(id);heldFrames.delete(id);cancel(id);};
|
||||
window.ResizeObserver=class {
|
||||
constructor(callback) {
|
||||
const record={callback,targets:new Set(),entries:new Map(),latest:new Map(),disconnected:false};
|
||||
@@ -243,8 +243,10 @@ async function observeLibrary(page, hold=false) {
|
||||
window.libraryObservation={
|
||||
hold(){holding=true;},
|
||||
release(){holding=false;for(const record of records){const entries=[...record.entries.values()].filter(entry=>record.targets.has(entry.target));record.entries.clear();if(entries.length)record.callback(entries,record.native);}},
|
||||
holdFrames(){holdingFrames=true;},
|
||||
releaseFrames(){holdingFrames=false;const callbacks=[...heldFrames.values()];heldFrames.clear();for(const callback of callbacks)callback();},
|
||||
equal(){for(const record of records){const entries=[...record.latest.values()].filter(entry=>record.targets.has(entry.target));if(entries.length)record.callback(entries,record.native);}},
|
||||
stats(){return {requested,pending:pending.size,mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
|
||||
stats(){return {requested,pending:pending.size,heldFrames:heldFrames.size,measurements:records.reduce((sum,record)=>sum+record.entries.size,0),mutations,unmounted,targets:records.reduce((sum,record)=>sum+record.targets.size,0)};},
|
||||
};
|
||||
},{hold});
|
||||
}
|
||||
@@ -334,6 +336,57 @@ test('row measurement preserves anchor after width invalidation and append',asyn
|
||||
} finally {release();}
|
||||
});
|
||||
|
||||
test('queued row growth preserves newer native wheel movement and correction events',async({page,request},testInfo)=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
await page.setViewportSize({width:1600,height:1100});await page.goto(route);
|
||||
const more=page.getByRole('button',{name:'Load more prompts',exact:true});
|
||||
for(const count of [40,80,120]) {await expect(page.locator('.library-caption')).toContainText(`${count} of 203`);await more.evaluate(node=>node.click());}
|
||||
await expect(page.locator('.library-caption')).toContainText('160 of 203');
|
||||
await page.evaluate(()=>scrollTo(0,document.querySelector('.library-grid').getBoundingClientRect().top+scrollY+20*486+25));
|
||||
await settleLibrary(page);await page.mouse.move(1100,550);
|
||||
const pinned=cards(page).first(),pinnedId=await pinned.getAttribute('data-prompt-id');await pinned.locator('a').first().evaluate(node=>node.focus({preventScroll:true}));
|
||||
try {
|
||||
// The same queued-frame wheel sequence is stable without changed measurements.
|
||||
const control=await firstVisibleParent(page),controlY=await page.evaluate(()=>scrollY);
|
||||
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
|
||||
await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(controlY+160);
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
|
||||
expect(await page.evaluate(()=>scrollY)).toBe(controlY+160);await retainedParent(page,{...control,offset:control.offset-160});
|
||||
|
||||
const before=await firstVisibleParent(page),beforeY=await page.evaluate(()=>scrollY);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='45px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
const parent=page.locator(`article[data-prompt-id="${before.id}"]`);
|
||||
const growth=await parent.evaluate(node=>node.getBoundingClientRect().top)-before.offset;
|
||||
expect(growth).toBeGreaterThan(0);expect(await page.evaluate(()=>scrollY)).toBe(beforeY);
|
||||
await page.evaluate(()=>{window.libraryObservation.holdFrames();window.libraryObservation.release();});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
await page.mouse.wheel(0,160);await expect.poll(()=>page.evaluate(()=>scrollY)).toBe(beforeY+160);
|
||||
expect(await page.evaluate(()=>document.documentElement.scrollHeight-innerHeight-scrollY)).toBeGreaterThan(1000);
|
||||
await page.evaluate(()=>window.libraryObservation.releaseFrames());await settleLibrary(page);
|
||||
await testInfo.attach('queued-growth-wheel-geometry',{body:JSON.stringify({beforeY,offset:before.offset,growth,wheel:160,finalY:await page.evaluate(()=>scrollY),finalOffset:await parent.evaluate(node=>node.getBoundingClientRect().top)}),contentType:'application/json'});
|
||||
await retainedParent(page,{...before,offset:before.offset-160});
|
||||
expect(Math.abs(await page.evaluate(()=>scrollY)-(beforeY+160+growth))).toBeLessThanOrEqual(2);
|
||||
|
||||
// The correction's own native scroll event must not become new user motion.
|
||||
const corrected=await firstVisibleParent(page);
|
||||
await page.evaluate(()=>{window.libraryObservation.hold();for(const content of document.querySelectorAll('.library-card-content'))content.style.paddingBlockEnd='65px';});
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().measurements)).toBeGreaterThan(0);
|
||||
await page.evaluate(()=>window.libraryObservation.release());await settleLibrary(page);await retainedParent(page,corrected);
|
||||
await expect(page.locator(`article[data-prompt-id="${pinnedId}"] a`).first()).toBeFocused();
|
||||
const ids=await cards(page).evaluateAll(nodes=>nodes.map(node=>node.dataset.promptId));expect(ids.length).toBeLessThan(80);expect(new Set(ids).size).toBe(ids.length);
|
||||
await page.evaluate(()=>window.libraryObservation.holdFrames());await page.mouse.wheel(0,160);
|
||||
await expect.poll(()=>page.evaluate(()=>window.libraryObservation.stats().heldFrames)).toBeGreaterThan(0);
|
||||
await page.evaluate(()=>{location.hash='#/overview';});await expect(cards(page)).toHaveCount(0);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).targets).toBe(0);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).pending).toBe(0);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).heldFrames).toBe(0);
|
||||
expect((await page.evaluate(()=>window.libraryObservation.stats())).unmounted).toBe(0);
|
||||
} finally {await page.evaluate(()=>{window.libraryObservation.release();window.libraryObservation.releaseFrames();});}
|
||||
});
|
||||
|
||||
test('unchanged row observations settle without resize feedback',async({page,request,browserName})=>{
|
||||
await request.post('/__test__/reset',{data:{native_library:true,extra_prompts:200}});
|
||||
await observeLibrary(page);await page.addInitScript(()=>{window.IntersectionObserver=undefined;});
|
||||
|
||||
@@ -48,7 +48,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
const columns=()=>window.innerWidth>=1700?3:window.innerWidth<=700?1:2;
|
||||
const readInlineSize=()=>Math.round(grid.getBoundingClientRect().width*64)/64;
|
||||
let inlineSize=readInlineSize(), columnCount=columns(), layout={rows:[],height:0}, dirty=true, items, indexesById=new Map();
|
||||
let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, correctedScrollY=null;
|
||||
let anchor=visit?.layout?.anchor || null, pendingAnchor=anchor, observedScrollY=window.scrollY;
|
||||
let restoreScrollY=Number.isFinite(visit?.scrollY) && visit.scrollY>=0?visit.scrollY:null;
|
||||
if(visit?.layout?.inlineSize===inlineSize && visit.layout.columns===columnCount) for(const [id,height] of visit.layout.heights) heights.set(id,height);
|
||||
function removeCard(card) {measurementObserver?.unobserve(card.node);pendingMeasurements.delete(Number(card.node.dataset.promptId));card.blocks.dispose();card.node.remove();}
|
||||
@@ -86,6 +86,13 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function reconcileScroll() {
|
||||
const delta=window.scrollY-observedScrollY;observedScrollY=window.scrollY;
|
||||
if(!delta)return;
|
||||
// Keep pending height compensation while honoring movement since it was queued.
|
||||
if(pendingAnchor)pendingAnchor={...pendingAnchor,offset:pendingAnchor.offset-delta};
|
||||
else anchor=captureAnchor();
|
||||
}
|
||||
function spacer(key,height) {
|
||||
let node=spacers.get(key);
|
||||
if(!node) {node=el('div',undefined,'library-row-spacer');node.setAttribute('aria-hidden','true');spacers.set(key,node);}
|
||||
@@ -127,6 +134,7 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
}
|
||||
function flush() {
|
||||
frame=0;if(scope.signal.aborted)return;
|
||||
reconcileScroll();
|
||||
const width=readInlineSize(), size=columns();
|
||||
if(width!==inlineSize || size!==columnCount) {pendingAnchor ||= anchor;inlineSize=width;columnCount=size;heights.clear();dirty=true;}
|
||||
for(const [id,measurement] of pendingMeasurements) {
|
||||
@@ -140,12 +148,14 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
renderWindow(target);
|
||||
const card=index===undefined?null:cards.get(restore.id);
|
||||
if(card) {
|
||||
const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5){window.scrollBy(0,delta);correctedScrollY=window.scrollY;}
|
||||
const delta=card.node.getBoundingClientRect().top-restore.offset;if(Math.abs(delta)>.5)window.scrollBy(0,delta);
|
||||
anchor=restore;
|
||||
} else {
|
||||
if(restoreScrollY!==null) {window.scrollTo(0,restoreScrollY);correctedScrollY=window.scrollY;}
|
||||
if(restoreScrollY!==null)window.scrollTo(0,restoreScrollY);
|
||||
anchor=captureAnchor();
|
||||
}
|
||||
// Read the actual clamped result now; its later scroll event is not new motion.
|
||||
observedScrollY=window.scrollY;
|
||||
restoreScrollY=null;pendingAnchor=null;scheduleLoad();
|
||||
}
|
||||
const schedule=()=>{if(!frame)frame=requestAnimationFrame(flush);};
|
||||
@@ -208,7 +218,9 @@ export function mountLibrary(main,api,parentScope,{positiveId}={}) {
|
||||
scope.listen(tags,'input',scope.debounce(()=>{filters.tags=[...new Set(tags.value.split(',').map(tag=>tag.trim()).filter(Boolean))];reset();},200));
|
||||
scope.listen(category,'change',()=>{filters.category=category.value;reset();});scope.listen(sort,'change',()=>{filters.sort=sort.value;reset();});
|
||||
scope.listen(refresh,'click',()=>{loadCategories();reset();});scope.listen(more,'click',()=>stream.loadMore());scope.listen(retry,'click',()=>stream.loadMore());
|
||||
scope.listen(window,'scroll',()=>{if(window.scrollY===correctedScrollY)correctedScrollY=null;else if(!pendingAnchor)anchor=captureAnchor();schedule();});scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;schedule();});
|
||||
scope.listen(window,'scroll',()=>{reconcileScroll();schedule();});
|
||||
// Mobile viewport resizing can adjust scroll before delivering its resize event.
|
||||
scope.listen(window,'resize',()=>{pendingAnchor ||= anchor;observedScrollY=window.scrollY;schedule();});
|
||||
scope.cleanup(()=>{shelfVisit=state?{filters:{...filters,tags:[...filters.tags]},state,scrollY:window.scrollY,layout:{inlineSize,columns:columnCount,heights:[...heights],anchor:pendingAnchor || anchor || captureAnchor()}}:null;stream.dispose();optionsRequest?.abort();observer?.disconnect();measurementObserver?.disconnect();cancelAnimationFrame(frame);cancelAnimationFrame(loadFrame);for(const card of cards.values())removeCard(card);cards.clear();});
|
||||
loadCategories();if(visit) {render(stream.state);if(visit.state.loading || visit.state.error)stream.loadMore();} else reset();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user