mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-25 12:05:24 +08:00
fix(stage): 修复选中组件时 stage 外层滚动
This commit is contained in:
parent
ee5247ca94
commit
09eecd5bed
@ -21,7 +21,7 @@ import { createDiv, getDocument, injectStyle } from '@tmagic/core';
|
|||||||
import { Mode, ZIndex } from './const';
|
import { Mode, ZIndex } from './const';
|
||||||
import Rule from './Rule';
|
import Rule from './Rule';
|
||||||
import type { MaskEvents, RuleOptions } from './types';
|
import type { MaskEvents, RuleOptions } from './types';
|
||||||
import { getScrollParent, isFixedParent } from './util';
|
import { getScrollParent, isFixedParent, scrollElementIntoView } from './util';
|
||||||
|
|
||||||
const wrapperClassName = 'editor-mask-wrapper';
|
const wrapperClassName = 'editor-mask-wrapper';
|
||||||
|
|
||||||
@ -165,10 +165,12 @@ export default class StageMask extends Rule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
el.scrollIntoView();
|
|
||||||
|
|
||||||
if (!this.pageScrollParent) return;
|
if (!this.pageScrollParent) return;
|
||||||
|
|
||||||
|
// 不使用原生 scrollIntoView,避免编辑器画布外层滚动容器被浏览器连带滚动导致整个 stage 位移,
|
||||||
|
// 只滚动页面所在的滚动容器
|
||||||
|
scrollElementIntoView(el, this.pageScrollParent);
|
||||||
|
|
||||||
this.scrollLeft = this.pageScrollParent.scrollLeft;
|
this.scrollLeft = this.pageScrollParent.scrollLeft;
|
||||||
this.scrollTop = this.pageScrollParent.scrollTop;
|
this.scrollTop = this.pageScrollParent.scrollTop;
|
||||||
|
|
||||||
|
|||||||
@ -141,6 +141,48 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT
|
|||||||
return null;
|
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) => {
|
export const removeSelectedClassName = (doc: Document) => {
|
||||||
const oldEl = doc.querySelector(`.${SELECTED_CLASS}`);
|
const oldEl = doc.querySelector(`.${SELECTED_CLASS}`);
|
||||||
|
|
||||||
|
|||||||
@ -114,7 +114,33 @@ describe('StageMask', () => {
|
|||||||
expect(fn).toHaveBeenCalled();
|
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 originalIo = globalThis.IntersectionObserver;
|
||||||
const MockIntersectionObserver = vi.fn(function (
|
const MockIntersectionObserver = vi.fn(function (
|
||||||
this: {
|
this: {
|
||||||
@ -143,27 +169,111 @@ describe('StageMask', () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mask = new StageMask({ disabledRule: true });
|
mask = new StageMask({ disabledRule: true });
|
||||||
const page = globalThis.document.createElement('div');
|
const { scrollParent, page } = setupScrollablePage(mask);
|
||||||
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 el = globalThis.document.createElement('div');
|
const el = globalThis.document.createElement('div');
|
||||||
page.appendChild(el);
|
page.appendChild(el);
|
||||||
el.scrollIntoView = vi.fn();
|
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);
|
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 {
|
} finally {
|
||||||
globalThis.IntersectionObserver = originalIo;
|
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', () => {
|
test('destroy 清理 observer 与 page', () => {
|
||||||
mask = new StageMask({ disabledRule: true });
|
mask = new StageMask({ disabledRule: true });
|
||||||
const page = globalThis.document.createElement('div');
|
const page = globalThis.document.createElement('div');
|
||||||
|
|||||||
@ -321,3 +321,92 @@ describe('down / up 排序', () => {
|
|||||||
expect(result.src).toBe('c');
|
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 为 768:1100 - 768 = 332
|
||||||
|
expect(docEl.scrollTop).toBe(globalThis.innerHeight > 0 ? 1100 - globalThis.innerHeight : 0);
|
||||||
|
} finally {
|
||||||
|
if (originalDescriptor) {
|
||||||
|
Object.defineProperty(docEl, 'scrollTop', originalDescriptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user