From 0b89e7e32e68559d7e7887705c39ee3c9dcc7ed5 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Wed, 23 Sep 2026 12:47:03 +0800 Subject: [PATCH] =?UTF-8?q?fix(stage):=20=E9=A1=B5=E9=9D=A2=E4=B8=8D?= =?UTF-8?q?=E5=8F=AF=E8=A7=81=E6=97=B6=E4=B8=8D=E8=B0=83=E7=94=A8=20scroll?= =?UTF-8?q?IntoView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 isElementVisible / isDocumentVisible,判断页面、画布容器与浏览器 tab 是否可见 - scrollIntoView 在页面不可见时直接返回,避免记录错误的滚动偏移 - 页面不可见时保留 IntersectionObserver 监听,恢复可见后由回调自行决定是否滚动 --- packages/stage/src/StageMask.ts | 27 +++- packages/stage/src/util.ts | 29 ++++ packages/stage/tests/unit/StageMask.spec.ts | 148 ++++++++++++++++++++ packages/stage/tests/unit/util.spec.ts | 74 +++++++++- 4 files changed, 276 insertions(+), 2 deletions(-) diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index 00464809..87e47e3f 100644 --- a/packages/stage/src/StageMask.ts +++ b/packages/stage/src/StageMask.ts @@ -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); + } + /** * 从当前页面同步蒙层宽高,并修正滚动偏移 */ diff --git a/packages/stage/src/util.ts b/packages/stage/src/util.ts index 79ff4b1f..78eed376 100644 --- a/packages/stage/src/util.ts +++ b/packages/stage/src/util.ts @@ -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 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器, diff --git a/packages/stage/tests/unit/StageMask.spec.ts b/packages/stage/tests/unit/StageMask.spec.ts index f4242f43..a79bcf28 100644 --- a/packages/stage/tests/unit/StageMask.spec.ts +++ b/packages/stage/tests/unit/StageMask.spec.ts @@ -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; + unobserve: ReturnType; + disconnect: ReturnType; + }, + 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).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).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'); diff --git a/packages/stage/tests/unit/util.spec.ts b/packages/stage/tests/unit/util.spec.ts index d019cbf4..dcb023d2 100644 --- a/packages/stage/tests/unit/util.spec.ts +++ b/packages/stage/tests/unit/util.spec.ts @@ -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 }); + }); +});