fix(stage): 修复选中组件时 stage 外层滚动

This commit is contained in:
roymondchen 2026-07-23 12:09:13 +08:00
parent ee5247ca94
commit 09eecd5bed
4 changed files with 257 additions and 14 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 } 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;

View File

@ -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}`);

View File

@ -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('scrollIntoViewpageScrollParent 不存在时不滚动', () => {
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');

View File

@ -321,3 +321,92 @@ describe('down / up 排序', () => {
expect(result.src).toBe('c');
});
});
describe('scrollElementIntoView', () => {
const makeDomRect = (partial: Partial<DOMRect>): 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<DOMRect>) => {
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 为 7681100 - 768 = 332
expect(docEl.scrollTop).toBe(globalThis.innerHeight > 0 ? 1100 - globalThis.innerHeight : 0);
} finally {
if (originalDescriptor) {
Object.defineProperty(docEl, 'scrollTop', originalDescriptor);
}
}
});
});