fix(stage): 修复切换页面后 mask 滚动重置与 scrollIntoView 失效

切页时重置页面与 mask 滚动并立即刷新 transform;page-el-update 后对仍挂载的选中节点重新触发 scrollIntoView,避免与 observe 重置竞态。
This commit is contained in:
roymondchen 2026-08-06 13:26:00 +08:00
parent 0d1a17dce3
commit cf007f9082
4 changed files with 244 additions and 10 deletions

View File

@ -368,6 +368,17 @@ export default class StageCore extends EventEmitter {
}
}
/**
* page-el-update scrollIntoView
*/
private scrollSelectedIntoViewAfterPageUpdate(): void {
const selected = this.actionManager?.getSelectedEl();
if (!selected?.isConnected) return;
if (!(this.autoScrollIntoView || selected.dataset.autoScrollIntoView)) return;
this.mask?.observerIntersection(selected);
}
private getActionManagerConfig(config: StageCoreConfig): ActionManagerConfig {
const actionManagerConfig: ActionManagerConfig = {
containerHighlightClassName: config.containerHighlightClassName,
@ -400,6 +411,10 @@ export default class StageCore extends EventEmitter {
this.mask?.observe(el);
this.observePageResize(el);
// observe 切页时会重置滚动;若 select + scrollIntoView 已先完成,这里对仍挂载的选中节点
// 重新触发一次,避免滚动被打回顶部
this.scrollSelectedIntoViewAfterPageUpdate();
this.emit('page-el-update', el);
});
}

View File

@ -108,15 +108,29 @@ export default class StageMask extends Rule {
/**
* mask蒙层所在的wrapper大小变化
* @description
* @description DOM
* @param page Dom节点
*/
public observe(page: HTMLElement): void {
if (!page) return;
const pageChanged = this.page !== page;
this.page = page;
this.initObserverIntersection();
this.initObserverWrapper();
if (pageChanged) {
// runtime 按 pageId 重挂载后,滚动容器(多为 documentElement可能仍残留上一页偏移
// 新页默认从顶部展示:重置页面与 mask 滚动,并立刻 scroll() 刷新 transform/标尺。
// syncPageSize 在宽高为 0 时会提前返回,不能依赖它刷新视觉状态。
// 若 select 已先 scrollIntoView由 StageCore 在 page-el-update 后重新触发一次补回。
this.pageScrollParent?.scrollTo({ top: 0, left: 0 });
this.scrollTop = 0;
this.scrollLeft = 0;
this.scroll();
}
// 页面切换后立即同步一次ResizeObserver 首次回调可能仍上报旧页/0 尺寸
this.syncPageSize();
}

View File

@ -231,6 +231,79 @@ describe('StageCore', () => {
stage.destroy();
});
test('page-el-update 重置滚动后,对仍挂载的选中节点重新触发 scrollIntoView', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const page = globalThis.document.createElement('div');
page.className = 'magic-ui-page';
setIdToEl()(page, 'page_1');
const node = globalThis.document.createElement('div');
setIdToEl()(node, 'scroll-node');
node.style.cssText = 'position:absolute;width:10px;height:10px;';
page.appendChild(node);
const stage = new StageCore({
renderType: RenderType.NATIVE,
disabledRule: true,
autoScrollIntoView: true,
render: async () => page,
});
await stage.mount(host);
mountRuntime(stage);
await stage.select('scroll-node');
const spy = vi.spyOn(stage.mask!, 'observerIntersection');
spy.mockClear();
// 模拟切页:选中节点挂到仍在文档中的新 page 上observe 会重置滚动
const newPage = globalThis.document.createElement('div');
newPage.className = 'magic-ui-page';
setIdToEl()(newPage, 'page_2');
page.replaceWith(newPage);
newPage.appendChild(node);
expect(node.isConnected).toBe(true);
stage.renderer!.getMagicApi().onPageElUpdate(newPage);
expect(spy).toHaveBeenCalledWith(node);
stage.destroy();
});
test('page-el-update 时旧选中节点已卸载则不重新 scrollIntoView', async () => {
const host = globalThis.document.createElement('div');
globalThis.document.body.appendChild(host);
const page = globalThis.document.createElement('div');
page.className = 'magic-ui-page';
setIdToEl()(page, 'page_1');
const node = globalThis.document.createElement('div');
setIdToEl()(node, 'scroll-node');
node.style.cssText = 'position:absolute;width:10px;height:10px;';
page.appendChild(node);
const stage = new StageCore({
renderType: RenderType.NATIVE,
disabledRule: true,
autoScrollIntoView: true,
render: async () => page,
});
await stage.mount(host);
mountRuntime(stage);
await stage.select('scroll-node');
const spy = vi.spyOn(stage.mask!, 'observerIntersection');
spy.mockClear();
// 新页不包含旧选中节点(已随旧页卸载)
const newPage = globalThis.document.createElement('div');
newPage.className = 'magic-ui-page';
setIdToEl()(newPage, 'page_2');
globalThis.document.body.appendChild(newPage);
node.remove();
stage.renderer!.getMagicApi().onPageElUpdate(newPage);
expect(spy).not.toHaveBeenCalled();
stage.destroy();
});
test('getElementImage / reloadIframe / getAddContainerHighlightClassNameTimeout 代理', async () => {
const { host, page, stage } = createStage();
await stage.mount(host);

View File

@ -89,32 +89,164 @@ describe('StageMask', () => {
expect(mask.content.style.height).toBe('300px');
});
test('observe 立即同步尺寸,并从长页切到短页时修正滚动偏移', () => {
test('observe 切到新页面时重置页面与 mask 滚动', () => {
mask = new StageMask({ disabledRule: true });
const tallScrollParent = globalThis.document.createElement('div');
tallScrollParent.style.overflow = 'auto';
Object.defineProperty(tallScrollParent, 'scrollTop', { value: 0, writable: true, configurable: true });
Object.defineProperty(tallScrollParent, 'scrollLeft', { value: 0, writable: true, configurable: true });
tallScrollParent.scrollTo = vi.fn(({ top = 0, left = 0 }: ScrollToOptions = {}) => {
tallScrollParent.scrollTop = Number(top);
tallScrollParent.scrollLeft = Number(left);
});
globalThis.document.body.appendChild(tallScrollParent);
const tallPage = globalThis.document.createElement('div');
Object.defineProperty(tallPage, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(tallPage, 'clientHeight', { value: 1000, configurable: true });
globalThis.document.body.appendChild(tallPage);
tallScrollParent.appendChild(tallPage);
mask.observe(tallPage);
mask.wrapperWidth = 400;
mask.wrapperHeight = 300;
(mask as any).setMaxScrollLeft();
(mask as any).setMaxScrollTop();
mask.scrollTop = 500;
mask.scrollLeft = 80;
(mask as any).scroll();
expect(mask.scrollTop).toBe(500);
expect(tallScrollParent.scrollTop).toBe(500);
const shortScrollParent = globalThis.document.createElement('div');
shortScrollParent.style.overflow = 'auto';
// 模拟新页挂载前滚动容器仍残留偏移(如 iframe documentElement
Object.defineProperty(shortScrollParent, 'scrollTop', { value: 500, writable: true, configurable: true });
Object.defineProperty(shortScrollParent, 'scrollLeft', { value: 80, writable: true, configurable: true });
shortScrollParent.scrollTo = vi.fn(({ top = 0, left = 0 }: ScrollToOptions = {}) => {
shortScrollParent.scrollTop = Number(top);
shortScrollParent.scrollLeft = Number(left);
});
globalThis.document.body.appendChild(shortScrollParent);
const shortPage = globalThis.document.createElement('div');
Object.defineProperty(shortPage, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(shortPage, 'clientHeight', { value: 400, configurable: true });
shortScrollParent.appendChild(shortPage);
mask.observe(shortPage);
expect(mask.width).toBe(400);
expect(mask.height).toBe(400);
expect(mask.maxScrollTop).toBe(100);
expect(shortScrollParent.scrollTop).toBe(0);
expect(shortScrollParent.scrollLeft).toBe(0);
expect(mask.scrollTop).toBe(0);
expect(mask.scrollLeft).toBe(0);
expect(mask.content.style.transform).toBe('translate3d(0px, 0px, 0)');
});
test('observe 同一页面 DOM 时不重新同步滚动;同页尺寸变化仅修正偏移', () => {
mask = new StageMask({ disabledRule: true });
const page = globalThis.document.createElement('div');
Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(page, 'clientHeight', { value: 1000, configurable: true });
globalThis.document.body.appendChild(page);
mask.observe(page);
mask.wrapperWidth = 400;
mask.wrapperHeight = 300;
(mask as any).setMaxScrollLeft();
(mask as any).setMaxScrollTop();
mask.scrollTop = 500;
(mask as any).scroll();
expect(mask.scrollTop).toBe(500);
const shortPage = globalThis.document.createElement('div');
Object.defineProperty(shortPage, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(shortPage, 'clientHeight', { value: 400, configurable: true });
globalThis.document.body.appendChild(shortPage);
mask.observe(shortPage);
mask.observe(page);
expect(mask.scrollTop).toBe(500);
expect(mask.width).toBe(400);
Object.defineProperty(page, 'clientHeight', { value: 400, configurable: true });
mask.pageResize([makeResizeEntry(page)]);
expect(mask.height).toBe(400);
expect(mask.maxScrollTop).toBe(100);
expect(mask.scrollTop).toBe(100);
expect(mask.content.style.transform).toBe('translate3d(0px, -100px, 0)');
});
test('observe 切到 0 尺寸页面时仍重置 transform不依赖 syncPageSize', () => {
mask = new StageMask({ disabledRule: true });
const tallScrollParent = globalThis.document.createElement('div');
tallScrollParent.style.overflow = 'auto';
Object.defineProperty(tallScrollParent, 'scrollTop', { value: 0, writable: true, configurable: true });
tallScrollParent.scrollTo = vi.fn(({ top = 0 }: ScrollToOptions = {}) => {
tallScrollParent.scrollTop = Number(top);
});
globalThis.document.body.appendChild(tallScrollParent);
const tallPage = globalThis.document.createElement('div');
Object.defineProperty(tallPage, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(tallPage, 'clientHeight', { value: 1000, configurable: true });
tallScrollParent.appendChild(tallPage);
mask.observe(tallPage);
mask.wrapperWidth = 400;
mask.wrapperHeight = 300;
(mask as any).setMaxScrollTop();
mask.scrollTop = 500;
(mask as any).scroll();
expect(mask.content.style.transform).toBe('translate3d(0px, -500px, 0)');
const emptyScrollParent = globalThis.document.createElement('div');
emptyScrollParent.style.overflow = 'auto';
Object.defineProperty(emptyScrollParent, 'scrollTop', { value: 500, writable: true, configurable: true });
emptyScrollParent.scrollTo = vi.fn(({ top = 0 }: ScrollToOptions = {}) => {
emptyScrollParent.scrollTop = Number(top);
});
globalThis.document.body.appendChild(emptyScrollParent);
const emptyPage = globalThis.document.createElement('div');
Object.defineProperty(emptyPage, 'clientWidth', { value: 0, configurable: true });
Object.defineProperty(emptyPage, 'clientHeight', { value: 0, configurable: true });
emptyScrollParent.appendChild(emptyPage);
const customScrollHandler = vi.fn();
mask.content.addEventListener('customScroll', customScrollHandler);
mask.observe(emptyPage);
// 尺寸保持旧值syncPageSize 忽略 0但滚动视觉状态必须立刻归零
expect(mask.width).toBe(400);
expect(mask.height).toBe(1000);
expect(mask.scrollTop).toBe(0);
expect(emptyScrollParent.scrollTop).toBe(0);
expect(mask.content.style.transform).toBe('translate3d(0px, 0px, 0)');
expect(customScrollHandler).toHaveBeenCalled();
expect(customScrollHandler.mock.calls.at(-1)?.[0].detail).toEqual({
scrollLeft: 0,
scrollTop: 0,
});
});
test('observe 无 pageScrollParent 时仍重置 mask 滚动并刷新 transform', () => {
mask = new StageMask({ disabledRule: true });
// position: fixed 时 getScrollParent 返回 null
const page = globalThis.document.createElement('div');
page.style.position = 'fixed';
Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(page, 'clientHeight', { value: 1000, configurable: true });
globalThis.document.body.appendChild(page);
mask.observe(page);
expect((mask as any).pageScrollParent).toBeNull();
mask.wrapperWidth = 400;
mask.wrapperHeight = 300;
(mask as any).setMaxScrollTop();
mask.scrollTop = 500;
(mask as any).scroll();
expect(mask.content.style.transform).toBe('translate3d(0px, -500px, 0)');
const nextPage = globalThis.document.createElement('div');
nextPage.style.position = 'fixed';
Object.defineProperty(nextPage, 'clientWidth', { value: 400, configurable: true });
Object.defineProperty(nextPage, 'clientHeight', { value: 400, configurable: true });
globalThis.document.body.appendChild(nextPage);
mask.observe(nextPage);
expect((mask as any).pageScrollParent).toBeNull();
expect(mask.scrollTop).toBe(0);
expect(mask.content.style.transform).toBe('translate3d(0px, 0px, 0)');
});
test('pageResize 忽略非当前页、已卸载页与 0 尺寸,避免切换页面后 editor-mask 变为 0', () => {