mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-09-25 21:36:23 +00:00
fix(stage): 页面不可见时不调用 scrollIntoView
- 新增 isElementVisible / isDocumentVisible,判断页面、画布容器与浏览器 tab 是否可见 - scrollIntoView 在页面不可见时直接返回,避免记录错误的滚动偏移 - 页面不可见时保留 IntersectionObserver 监听,恢复可见后由回调自行决定是否滚动
This commit is contained in:
parent
a577d7cee3
commit
0b89e7e32e
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前页面同步蒙层宽高,并修正滚动偏移
|
||||
*/
|
||||
|
||||
@ -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 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器,
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user