mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-08-07 05:18:38 +00:00
fix(stage): 修复切换页面后 mask 滚动重置与 scrollIntoView 失效
切页时重置页面与 mask 滚动并立即刷新 transform;page-el-update 后对仍挂载的选中节点重新触发 scrollIntoView,避免与 observe 重置竞态。
This commit is contained in:
parent
0d1a17dce3
commit
cf007f9082
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user