From cf007f9082043589ce94fb3dd1c11dbf96ca0e1d Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 6 Aug 2026 13:26:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(stage):=20=E4=BF=AE=E5=A4=8D=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E9=A1=B5=E9=9D=A2=E5=90=8E=20mask=20=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E9=87=8D=E7=BD=AE=E4=B8=8E=20scrollIntoView=20?= =?UTF-8?q?=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切页时重置页面与 mask 滚动并立即刷新 transform;page-el-update 后对仍挂载的选中节点重新触发 scrollIntoView,避免与 observe 重置竞态。 --- packages/stage/src/StageCore.ts | 15 ++ packages/stage/src/StageMask.ts | 16 ++- packages/stage/tests/unit/StageCore.spec.ts | 73 ++++++++++ packages/stage/tests/unit/StageMask.spec.ts | 150 ++++++++++++++++++-- 4 files changed, 244 insertions(+), 10 deletions(-) diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index 3c9f9a2b..6d858a73 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -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); }); } diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index 0cf33216..00464809 100644 --- a/packages/stage/src/StageMask.ts +++ b/packages/stage/src/StageMask.ts @@ -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(); } diff --git a/packages/stage/tests/unit/StageCore.spec.ts b/packages/stage/tests/unit/StageCore.spec.ts index f4f4f03a..26133389 100644 --- a/packages/stage/tests/unit/StageCore.spec.ts +++ b/packages/stage/tests/unit/StageCore.spec.ts @@ -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); diff --git a/packages/stage/tests/unit/StageMask.spec.ts b/packages/stage/tests/unit/StageMask.spec.ts index e5fc653c..f4242f43 100644 --- a/packages/stage/tests/unit/StageMask.spec.ts +++ b/packages/stage/tests/unit/StageMask.spec.ts @@ -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', () => {