diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index ea54f60f..04cc7f92 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 } from './util'; +import { getScrollParent, isFixedParent, scrollElementIntoView } from './util'; const wrapperClassName = 'editor-mask-wrapper'; @@ -165,10 +165,12 @@ export default class StageMask extends Rule { return; } - el.scrollIntoView(); - if (!this.pageScrollParent) return; + // 不使用原生 scrollIntoView,避免编辑器画布外层滚动容器被浏览器连带滚动导致整个 stage 位移, + // 只滚动页面所在的滚动容器 + scrollElementIntoView(el, this.pageScrollParent); + this.scrollLeft = this.pageScrollParent.scrollLeft; this.scrollTop = this.pageScrollParent.scrollTop; diff --git a/packages/stage/src/util.ts b/packages/stage/src/util.ts index a2dfabc3..79ff4b1f 100644 --- a/packages/stage/src/util.ts +++ b/packages/stage/src/util.ts @@ -141,6 +141,48 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT return null; }; +/** + * 将元素滚动到指定滚动容器的可视区域内(仅垂直方向,滚动最小距离) + * @description 与原生 scrollIntoView 不同,只滚动指定的容器自身,不会连带滚动外层祖先滚动容器, + * 避免编辑器画布外层容器(如 stage 外层使用 transform 模拟滚动的容器)被浏览器自动滚动导致整个画布位移 + * @param el 目标元素 + * @param container 滚动容器 + */ +export const scrollElementIntoView = (el: Element, container: HTMLElement): void => { + const { ownerDocument } = container; + const win = ownerDocument?.defaultView; + if (!ownerDocument || !win) return; + + const elRect = el.getBoundingClientRect(); + + let viewTop = 0; + let viewHeight = 0; + + if (container === ownerDocument.documentElement || container === ownerDocument.body) { + // 文档滚动元素的可视区域为窗口视口 + viewTop = 0; + viewHeight = win.innerHeight; + } else { + const containerRect = container.getBoundingClientRect(); + viewTop = containerRect.top + container.clientTop; + viewHeight = container.clientHeight; + } + + const viewBottom = viewTop + viewHeight; + + let delta = 0; + if (elRect.top < viewTop) { + delta = elRect.top - viewTop; + } else if (elRect.bottom > viewBottom) { + // 元素高度超过可视区域高度时,优先保证元素顶部可见 + delta = Math.min(elRect.bottom - viewBottom, elRect.top - viewTop); + } + + if (delta !== 0) { + container.scrollTop += delta; + } +}; + export const removeSelectedClassName = (doc: Document) => { const oldEl = doc.querySelector(`.${SELECTED_CLASS}`); diff --git a/packages/stage/tests/unit/StageMask.spec.ts b/packages/stage/tests/unit/StageMask.spec.ts index fcd19bd8..f48931b0 100644 --- a/packages/stage/tests/unit/StageMask.spec.ts +++ b/packages/stage/tests/unit/StageMask.spec.ts @@ -114,7 +114,33 @@ describe('StageMask', () => { expect(fn).toHaveBeenCalled(); }); - test('observe 后 observerIntersection 触发 scrollIntoView', () => { + // 构造一个带滚动容器的页面结构:body > scrollParent(overflow: auto) > page + const setupScrollablePage = (m: StageMask) => { + const scrollParent = globalThis.document.createElement('div'); + scrollParent.style.overflow = 'auto'; + scrollParent.style.position = 'relative'; + globalThis.document.body.appendChild(scrollParent); + Object.defineProperty(scrollParent, 'clientHeight', { value: 300, configurable: true }); + Object.defineProperty(scrollParent, 'scrollTop', { value: 0, writable: true, configurable: true }); + scrollParent.scrollTo = vi.fn(); + scrollParent.getBoundingClientRect = () => + makeDomRect({ left: 0, top: 0, right: 400, bottom: 300, width: 400, height: 300 }); + + const page = globalThis.document.createElement('div'); + Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true }); + Object.defineProperty(page, 'clientHeight', { value: 1000, configurable: true }); + Object.defineProperty(page, 'scrollWidth', { value: 400, configurable: true }); + scrollParent.appendChild(page); + + m.observe(page); + m.pageResize([makeResizeEntry(page)]); + m.wrapperWidth = 400; + m.wrapperHeight = 300; + + return { scrollParent, page }; + }; + + test('observe 后 observerIntersection 触发 scrollIntoView,只滚动页面滚动容器', () => { const originalIo = globalThis.IntersectionObserver; const MockIntersectionObserver = vi.fn(function ( this: { @@ -143,27 +169,111 @@ describe('StageMask', () => { try { mask = new StageMask({ disabledRule: true }); - const page = globalThis.document.createElement('div'); - Object.defineProperty(page, 'clientWidth', { value: 400, configurable: true }); - Object.defineProperty(page, 'clientHeight', { value: 300, configurable: true }); - Object.defineProperty(page, 'scrollWidth', { value: 400, configurable: true }); - mask.observe(page); - mask.pageResize([makeResizeEntry(page)]); - mask.wrapperWidth = 400; - mask.wrapperHeight = 300; + const { scrollParent, page } = setupScrollablePage(mask); const el = globalThis.document.createElement('div'); page.appendChild(el); el.scrollIntoView = vi.fn(); - el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 0, width: 10, height: 10 }); + // 元素在可视区域(300)下方 + el.getBoundingClientRect = () => + makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 }); mask.observerIntersection(el); - expect(el.scrollIntoView).toHaveBeenCalled(); + + // 不调用原生 scrollIntoView,避免编辑器外层滚动容器被连带滚动 + expect(el.scrollIntoView).not.toHaveBeenCalled(); + // 只滚动页面所在的滚动容器:600 - 300 = 300 + expect(scrollParent.scrollTop).toBe(300); + expect(mask.scrollTop).toBe(300); } finally { globalThis.IntersectionObserver = originalIo; } }); + test('scrollIntoView:元素已在可视区域内时不滚动', () => { + 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: 50, right: 10, bottom: 150, 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 { scrollParent, page } = setupScrollablePage(mask); + scrollParent.scrollTop = 200; + + const el = globalThis.document.createElement('div'); + page.appendChild(el); + el.getBoundingClientRect = () => makeDomRect({ left: 0, top: -100, right: 10, bottom: 0, width: 10, height: 100 }); + + mask.scrollIntoView(el); + + // 200 - 100 = 100 + expect(scrollParent.scrollTop).toBe(100); + expect(mask.scrollTop).toBe(100); + }); + + test('scrollIntoView:元素高于可视区域时优先让顶部可见', () => { + mask = new StageMask({ disabledRule: true }); + const { scrollParent, page } = setupScrollablePage(mask); + + const el = globalThis.document.createElement('div'); + page.appendChild(el); + // 元素高度 700,超过可视区域高度 300 + el.getBoundingClientRect = () => + makeDomRect({ left: 0, top: 500, right: 10, bottom: 1200, width: 10, height: 700 }); + + mask.scrollIntoView(el); + + // 对齐顶部:滚动 500,而不是 1200 - 300 = 900 + expect(scrollParent.scrollTop).toBe(500); + expect(mask.scrollTop).toBe(500); + }); + + test('scrollIntoView:元素在内部滚动容器中时,滚动页面滚动容器使内部容器可见', () => { + mask = new StageMask({ disabledRule: true }); + const { scrollParent, page } = setupScrollablePage(mask); + + const innerScroll = globalThis.document.createElement('div'); + innerScroll.style.overflow = 'auto'; + innerScroll.style.position = 'relative'; + page.appendChild(innerScroll); + innerScroll.getBoundingClientRect = () => + makeDomRect({ left: 0, top: 500, right: 100, bottom: 700, width: 100, height: 200 }); + + const el = globalThis.document.createElement('div'); + innerScroll.appendChild(el); + el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 }); + + mask.scrollIntoView(el); + + // 递归滚动页面滚动容器,使内部滚动容器完整可见:700 - 300 = 400 + expect(scrollParent.scrollTop).toBe(400); + expect(mask.scrollTop).toBe(400); + }); + + test('scrollIntoView:pageScrollParent 不存在时不滚动', () => { + 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.scrollIntoView = vi.fn(); + el.getBoundingClientRect = () => makeDomRect({ left: 0, top: 500, right: 10, bottom: 600, width: 10, height: 100 }); + + expect(() => mask.scrollIntoView(el)).not.toThrow(); + expect(el.scrollIntoView).not.toHaveBeenCalled(); + }); + test('destroy 清理 observer 与 page', () => { 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 0616c453..d019cbf4 100644 --- a/packages/stage/tests/unit/util.spec.ts +++ b/packages/stage/tests/unit/util.spec.ts @@ -321,3 +321,92 @@ describe('down / up 排序', () => { expect(result.src).toBe('c'); }); }); + +describe('scrollElementIntoView', () => { + const makeDomRect = (partial: Partial): DOMRect => ({ + x: partial.x ?? partial.left ?? 0, + y: partial.y ?? partial.top ?? 0, + width: partial.width ?? 0, + height: partial.height ?? 0, + top: partial.top ?? 0, + left: partial.left ?? 0, + right: partial.right ?? 0, + bottom: partial.bottom ?? 0, + toJSON: () => ({}), + }); + + const createScrollContainer = () => { + const container = globalThis.document.createElement('div'); + container.style.overflow = 'auto'; + globalThis.document.body.appendChild(container); + Object.defineProperty(container, 'clientHeight', { value: 300, configurable: true }); + Object.defineProperty(container, 'scrollTop', { value: 0, writable: true, configurable: true }); + container.getBoundingClientRect = () => + makeDomRect({ left: 0, top: 0, right: 400, bottom: 300, width: 400, height: 300 }); + return container; + }; + + const createTarget = (rect: Partial) => { + const el = globalThis.document.createElement('div'); + el.getBoundingClientRect = () => makeDomRect(rect); + return el; + }; + + test('元素在可视区域下方时,向下滚动最小距离', () => { + const container = createScrollContainer(); + const el = createTarget({ top: 500, bottom: 600, height: 100 }); + + util.scrollElementIntoView(el, container); + + // 600 - 300 = 300 + expect(container.scrollTop).toBe(300); + }); + + test('元素在可视区域上方时,向上滚动', () => { + const container = createScrollContainer(); + container.scrollTop = 200; + const el = createTarget({ top: -100, bottom: 0, height: 100 }); + + util.scrollElementIntoView(el, container); + + expect(container.scrollTop).toBe(100); + }); + + test('元素已在可视区域内时不滚动', () => { + const container = createScrollContainer(); + const el = createTarget({ top: 50, bottom: 150, height: 100 }); + + util.scrollElementIntoView(el, container); + + expect(container.scrollTop).toBe(0); + }); + + test('元素高于可视区域时优先让顶部可见', () => { + const container = createScrollContainer(); + const el = createTarget({ top: 500, bottom: 1200, height: 700 }); + + util.scrollElementIntoView(el, container); + + // 对齐顶部滚动 500,而不是 1200 - 300 = 900 + expect(container.scrollTop).toBe(500); + }); + + test('容器为文档滚动元素时,以窗口视口为可视区域', () => { + const docEl = globalThis.document.documentElement; + const originalDescriptor = Object.getOwnPropertyDescriptor(docEl, 'scrollTop'); + Object.defineProperty(docEl, 'scrollTop', { value: 0, writable: true, configurable: true }); + + try { + const el = createTarget({ top: 1000, bottom: 1100, height: 100 }); + + util.scrollElementIntoView(el, docEl); + + // jsdom 默认 innerHeight 为 768:1100 - 768 = 332 + expect(docEl.scrollTop).toBe(globalThis.innerHeight > 0 ? 1100 - globalThis.innerHeight : 0); + } finally { + if (originalDescriptor) { + Object.defineProperty(docEl, 'scrollTop', originalDescriptor); + } + } + }); +});