fix(stage): 页面不可见时不调用 scrollIntoView

- 新增 isElementVisible / isDocumentVisible,判断页面、画布容器与浏览器 tab 是否可见
- scrollIntoView 在页面不可见时直接返回,避免记录错误的滚动偏移
- 页面不可见时保留 IntersectionObserver 监听,恢复可见后由回调自行决定是否滚动
This commit is contained in:
roymondchen 2026-09-23 12:47:03 +08:00
parent a577d7cee3
commit 0b89e7e32e
4 changed files with 276 additions and 2 deletions

View File

@ -21,7 +21,7 @@ import { createDiv, getDocument, injectStyle } from '@tmagic/core';
import { Mode, ZIndex } from './const';
import Rule from './Rule';
import type { MaskEvents, RuleOptions } from './types';
import { getScrollParent, isFixedParent, scrollElementIntoView } from './util';
import { getScrollParent, isDocumentVisible, isElementVisible, isFixedParent, scrollElementIntoView } from './util';
const wrapperClassName = 'editor-mask-wrapper';
@ -176,7 +176,14 @@ export default class StageMask extends Rule {
this.setMode(isFixedParent(el) ? Mode.FIXED : Mode.ABSOLUTE);
}
/**
* 将元素滚动到画布可视区域内
* @description 页面不可见时不做滚动:此时滚动既无视觉效果,又会记录错误的滚动偏移
* @param el 目标元素
*/
public scrollIntoView(el: Element): void {
if (!this.isPageVisible()) return;
// 不可以有横向滚动
if (!this.page || el.getBoundingClientRect().left >= this.page.scrollWidth) return;
@ -235,6 +242,10 @@ export default class StageMask extends Rule {
(entries) => {
entries.forEach((entry) => {
const { target, intersectionRatio } = entry;
// 页面不可见时不滚动,同时保留监听,等页面恢复可见后由 IntersectionObserver 重新回调
if (!this.isPageVisible()) return;
if (intersectionRatio <= 0) {
this.scrollIntoView(target);
}
@ -310,6 +321,20 @@ export default class StageMask extends Rule {
this.content.dispatchEvent(event);
}
/**
* 页面是否可见
* @description 页面未挂载、或画布容器被隐藏(如编辑器切到其他面板)时页面不可见;
* 蒙层与页面所在的 iframe 挂载在同一容器中,容器隐藏时页面内部元素仍可能是"可见"的,因此额外判断蒙层;
* 浏览器 tab 不可见(切到其他标签页、窗口最小化)时同样视为不可见
*/
private isPageVisible(): boolean {
if (!isDocumentVisible(this.page?.ownerDocument) || !isDocumentVisible(this.wrapper.ownerDocument)) return false;
if (!isElementVisible(this.page)) return false;
return !this.wrapper.isConnected || isElementVisible(this.wrapper);
}
/**
* 从当前页面同步蒙层宽高,并修正滚动偏移
*/

View File

@ -141,6 +141,35 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT
return null;
};
/**
* 判断文档是否可见
* @description 浏览器 tab 切到后台、窗口最小化时文档 visibilityState 为 hidden;
* 只将 hidden 视为不可见,避免 prerender 等中间态被误判
* @param doc 目标文档
*/
export const isDocumentVisible = (doc: Document | null | undefined): boolean => doc?.visibilityState !== 'hidden';
/**
* 判断元素是否可见
* @description 元素未挂载到文档、或其自身/祖先被 display:none、visibility:hidden 隐藏时视为不可见;
* 优先使用浏览器提供的 checkVisibility,不支持时退化为沿祖先链检查计算样式
* @param el 目标元素
*/
export const isElementVisible = (el: Element | null | undefined): boolean => {
if (!el?.isConnected) return false;
if (typeof el.checkVisibility === 'function') {
return el.checkVisibility({ checkVisibilityCSS: true });
}
for (let node: Element | null = el; node; node = node.parentElement) {
const { display, visibility } = getComputedStyle(node);
if (display === 'none' || visibility === 'hidden') return false;
}
return true;
};
/**
* 将元素滚动到指定滚动容器的可视区域内(仅垂直方向,滚动最小距离)
* @description 与原生 scrollIntoView 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器,

View File

@ -505,6 +505,154 @@ describe('StageMask', () => {
expect(mask.scrollTop).toBe(400);
});
test('scrollIntoView:页面未挂载时不滚动', () => {
mask = new StageMask({ disabledRule: true });
const page = globalThis.document.createElement('div');
Object.defineProperty(page, 'scrollWidth', { value: 400, configurable: true });
mask.observe(page);
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
mask.scrollIntoView(el);
expect(mask.scrollTop).toBe(0);
});
test('scrollIntoView:页面被隐藏时不滚动', () => {
mask = new StageMask({ disabledRule: true });
const { scrollParent, page } = setupScrollablePage(mask);
page.style.display = 'none';
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
mask.scrollIntoView(el);
expect(scrollParent.scrollTop).toBe(0);
expect(mask.scrollTop).toBe(0);
});
test('scrollIntoView:画布容器被隐藏(蒙层不可见)时不滚动', () => {
mask = new StageMask({ disabledRule: true });
const host = globalThis.document.createElement('div');
host.style.display = 'none';
globalThis.document.body.appendChild(host);
mask.mount(host);
const { scrollParent, page } = setupScrollablePage(mask);
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
mask.scrollIntoView(el);
expect(scrollParent.scrollTop).toBe(0);
expect(mask.scrollTop).toBe(0);
});
test('页面不可见时保留 IntersectionObserver 监听,恢复可见后回调仍会滚动', () => {
const originalIo = globalThis.IntersectionObserver;
const unobserve = vi.fn();
let trigger: ((target: Element, intersectionRatio: number) => void) | null = null;
const MockIntersectionObserver = vi.fn(function (
this: {
observe: ReturnType<typeof vi.fn>;
unobserve: ReturnType<typeof vi.fn>;
disconnect: ReturnType<typeof vi.fn>;
},
callback: IntersectionObserverCallback,
) {
trigger = (target: Element, intersectionRatio: number) => {
const entry: IntersectionObserverEntry = {
target,
intersectionRatio,
isIntersecting: intersectionRatio > 0,
boundingClientRect: makeDomRect({}),
intersectionRect: makeDomRect({}),
rootBounds: null,
time: 0,
};
callback([entry], this as unknown as IntersectionObserver);
};
this.observe = vi.fn();
this.unobserve = unobserve;
this.disconnect = vi.fn();
});
globalThis.IntersectionObserver = MockIntersectionObserver as unknown as typeof IntersectionObserver;
try {
mask = new StageMask({ disabledRule: true });
const { scrollParent, page } = setupScrollablePage(mask);
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () =>
makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
mask.observerIntersection(el);
Object.defineProperty(globalThis.document, 'visibilityState', { value: 'hidden', configurable: true });
try {
trigger?.(el, 0);
expect(scrollParent.scrollTop).toBe(0);
expect(mask.scrollTop).toBe(0);
// 不取消监听,等页面恢复可见后由 IntersectionObserver 重新回调
expect(unobserve).not.toHaveBeenCalled();
} finally {
delete (globalThis.document as unknown as Record<string, unknown>).visibilityState;
}
trigger?.(el, 0);
expect(scrollParent.scrollTop).toBe(300);
expect(mask.scrollTop).toBe(300);
expect(unobserve).toHaveBeenCalledWith(el);
} finally {
globalThis.IntersectionObserver = originalIo;
}
});
test('scrollIntoView:浏览器 tab 不可见时不滚动', () => {
mask = new StageMask({ disabledRule: true });
const { scrollParent, page } = setupScrollablePage(mask);
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
Object.defineProperty(globalThis.document, 'visibilityState', { value: 'hidden', configurable: true });
try {
mask.scrollIntoView(el);
} finally {
delete (globalThis.document as unknown as Record<string, unknown>).visibilityState;
}
expect(scrollParent.scrollTop).toBe(0);
expect(mask.scrollTop).toBe(0);
});
test('scrollIntoView:浏览器 tab 可见时正常滚动', () => {
mask = new StageMask({ disabledRule: true });
const { scrollParent, page } = setupScrollablePage(mask);
expect(globalThis.document.visibilityState).toBe('visible');
const el = globalThis.document.createElement('div');
page.appendChild(el);
el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 });
mask.scrollIntoView(el);
expect(scrollParent.scrollTop).toBe(300);
});
test('scrollIntoView:pageScrollParent 不存在时不滚动', () => {
mask = new StageMask({ disabledRule: true });
const page = globalThis.document.createElement('div');

View File

@ -15,7 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { beforeEach, describe, expect, test } from 'vitest';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import * as util from '../../src/util';
@ -410,3 +410,75 @@ describe('scrollElementIntoView', () => {
}
});
});
describe('isDocumentVisible', () => {
test('文档为空时视为可见', () => {
expect(util.isDocumentVisible(null)).toBe(true);
expect(util.isDocumentVisible(undefined)).toBe(true);
});
test('visibilityState 为 hidden 时不可见', () => {
const doc = { visibilityState: 'hidden' } as unknown as Document;
expect(util.isDocumentVisible(doc)).toBe(false);
});
test('visibilityState 为 visible / prerender 时可见', () => {
expect(util.isDocumentVisible({ visibilityState: 'visible' } as unknown as Document)).toBe(true);
expect(util.isDocumentVisible({ visibilityState: 'prerender' } as unknown as Document)).toBe(true);
});
});
describe('isElementVisible', () => {
beforeEach(() => {
globalThis.document.body.innerHTML = '';
});
test('未挂载到文档时不可见', () => {
const el = globalThis.document.createElement('div');
expect(util.isElementVisible(el)).toBe(false);
});
test('空值时不可见', () => {
expect(util.isElementVisible(null)).toBe(false);
expect(util.isElementVisible(undefined)).toBe(false);
});
test('挂载到文档时可见', () => {
const el = globalThis.document.createElement('div');
globalThis.document.body.appendChild(el);
expect(util.isElementVisible(el)).toBe(true);
});
test('自身 display: none 时不可见', () => {
const el = globalThis.document.createElement('div');
el.style.display = 'none';
globalThis.document.body.appendChild(el);
expect(util.isElementVisible(el)).toBe(false);
});
test('祖先 display: none 时不可见', () => {
const parent = globalThis.document.createElement('div');
const el = globalThis.document.createElement('div');
parent.appendChild(el);
globalThis.document.body.appendChild(parent);
parent.style.display = 'none';
expect(util.isElementVisible(el)).toBe(false);
});
test('visibility: hidden 时不可见', () => {
const el = globalThis.document.createElement('div');
el.style.visibility = 'hidden';
globalThis.document.body.appendChild(el);
expect(util.isElementVisible(el)).toBe(false);
});
test('支持 checkVisibility 时以浏览器结果为准', () => {
const el = globalThis.document.createElement('div');
globalThis.document.body.appendChild(el);
const checkVisibility = vi.fn(() => false);
Object.defineProperty(el, 'checkVisibility', { value: checkVisibility, configurable: true });
expect(util.isElementVisible(el)).toBe(false);
expect(checkVisibility).toHaveBeenCalledWith({ checkVisibilityCSS: true });
});
});