refactor: 重新设计stage不同布局下的实现

This commit is contained in:
roymondchen 2022-03-28 15:37:00 +08:00 committed by jia000
parent 9252eb2ae0
commit 2ade3d03f7
7 changed files with 571 additions and 457 deletions

150
packages/stage/src/Rule.ts Normal file
View File

@ -0,0 +1,150 @@
import EventEmitter from 'events';
import Guides, { GuidesEvents } from '@scena/guides';
import { GuidesType } from './const';
export default class Rule extends EventEmitter {
public hGuides: Guides;
public vGuides: Guides;
public horizontalGuidelines: number[] = [];
public verticalGuidelines: number[] = [];
private container: HTMLDivElement;
private containerResizeObserver: ResizeObserver;
constructor(container: HTMLDivElement) {
super();
this.container = container;
this.hGuides = this.createGuides(GuidesType.HORIZONTAL);
this.vGuides = this.createGuides(GuidesType.VERTICAL);
this.hGuides.on('changeGuides', this.hGuidesChangeGuidesHandler);
this.vGuides.on('changeGuides', this.vGuidesChangeGuidesHandler);
this.containerResizeObserver = new ResizeObserver(() => {
this.vGuides.resize();
this.hGuides.resize();
});
this.containerResizeObserver.observe(this.container);
}
/**
*
* @param show
*/
public showGuides(show = true) {
this.hGuides.setState({
showGuides: show,
});
this.vGuides.setState({
showGuides: show,
});
}
/**
* 线
*/
public clearGuides() {
this.horizontalGuidelines = [];
this.verticalGuidelines = [];
this.vGuides.setState({
defaultGuides: [],
});
this.hGuides.setState({
defaultGuides: [],
});
this.emit('changeGuides', {
type: GuidesType.VERTICAL,
guides: [],
});
this.emit('changeGuides', {
type: GuidesType.HORIZONTAL,
guides: [],
});
}
/**
*
* @param show
*/
public showRule(show = true) {
// 当尺子隐藏时发现大小变化,显示后会变形,所以这里做重新初始化处理
if (show) {
this.hGuides.destroy();
this.hGuides = this.createGuides(GuidesType.HORIZONTAL, this.horizontalGuidelines);
this.vGuides.destroy();
this.vGuides = this.createGuides(GuidesType.VERTICAL, this.verticalGuidelines);
} else {
this.hGuides.setState({
rulerStyle: {
visibility: 'hidden',
},
});
this.vGuides.setState({
rulerStyle: {
visibility: 'hidden',
},
});
}
}
scrollRule(scrollTop: number) {
this.hGuides.scrollGuides(scrollTop);
this.hGuides.scroll(0);
this.vGuides.scrollGuides(0);
this.vGuides.scroll(scrollTop);
}
public destroy(): void {
this.hGuides.off('changeGuides', this.hGuidesChangeGuidesHandler);
this.vGuides.off('changeGuides', this.vGuidesChangeGuidesHandler);
this.containerResizeObserver.disconnect();
this.removeAllListeners();
}
private getGuidesStyle = (type: GuidesType) => ({
position: 'fixed',
zIndex: 1,
left: type === GuidesType.HORIZONTAL ? 0 : '-30px',
top: type === GuidesType.HORIZONTAL ? '-30px' : 0,
width: type === GuidesType.HORIZONTAL ? '100%' : '30px',
height: type === GuidesType.HORIZONTAL ? '30px' : '100%',
});
private createGuides = (type: GuidesType, defaultGuides: number[] = []): Guides =>
new Guides(this.container, {
type,
defaultGuides,
displayDragPos: true,
backgroundColor: '#fff',
lineColor: '#000',
textColor: '#000',
style: this.getGuidesStyle(type),
});
private hGuidesChangeGuidesHandler = (e: GuidesEvents['changeGuides']) => {
this.horizontalGuidelines = e.guides;
this.emit('changeGuides', {
type: GuidesType.HORIZONTAL,
guides: this.horizontalGuidelines,
});
};
private vGuidesChangeGuidesHandler = (e: GuidesEvents['changeGuides']) => {
this.verticalGuidelines = e.guides;
this.emit('changeGuides', {
type: GuidesType.VERTICAL,
guides: this.verticalGuidelines,
});
};
}

View File

@ -18,16 +18,22 @@
import { EventEmitter } from 'events';
import { GuidesEvents } from '@scena/guides';
import { Id } from '@tmagic/schema';
import { DEFAULT_ZOOM } from './const';
import { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX } from './const';
import StageDragResize from './StageDragResize';
import StageMask from './StageMask';
import StageRender from './StageRender';
import { RemoveData, Runtime, SortEventData, StageCoreConfig, UpdateData, UpdateEventData } from './types';
import { isFixed } from './util';
import {
CanSelect,
GuidesEventData,
RemoveData,
Runtime,
SortEventData,
StageCoreConfig,
UpdateData,
UpdateEventData,
} from './types';
export default class StageCore extends EventEmitter {
public selectedDom: Element | undefined;
@ -37,6 +43,7 @@ export default class StageCore extends EventEmitter {
public dr: StageDragResize;
public config: StageCoreConfig;
public zoom = DEFAULT_ZOOM;
private canSelect: CanSelect;
constructor(config: StageCoreConfig) {
super();
@ -44,6 +51,7 @@ export default class StageCore extends EventEmitter {
this.config = config;
this.setZoom(config.zoom);
this.canSelect = config.canSelect || ((el: HTMLElement) => !!el.id);
this.renderer = new StageRender({ core: this });
this.mask = new StageMask({ core: this });
@ -52,23 +60,61 @@ export default class StageCore extends EventEmitter {
this.renderer.on('runtime-ready', (runtime: Runtime) => this.emit('runtime-ready', runtime));
this.renderer.on('page-el-update', (el: HTMLElement) => this.mask?.observe(el));
this.mask.on('select', async (el: Element) => {
await this.dr?.select(el as HTMLElement);
});
this.mask.on('selected', (el: Element) => {
this.select(el as HTMLElement);
this.emit('select', el);
});
this.mask
.on('beforeSelect', (event: MouseEvent) => {
this.setElementFromPoint(event);
})
.on('select', () => {
this.emit('select', this.selectedDom);
})
.on('changeGuides', (data: GuidesEventData) => {
this.dr.setGuidelines(data.type, data.guides);
this.emit('changeGuides', data);
});
this.dr.on('update', (data: UpdateEventData) => this.emit('update', data));
this.dr.on('sort', (data: UpdateEventData) => this.emit('sort', data));
// 要先触发select在触发update
this.dr
.on('update', (data: UpdateEventData) => {
setTimeout(() => this.emit('update', data));
})
.on('sort', (data: UpdateEventData) => {
setTimeout(() => this.emit('sort', data));
});
}
public async setElementFromPoint(event: MouseEvent) {
const { renderer, zoom } = this;
const doc = renderer.contentWindow?.document;
let x = event.clientX;
let y = event.clientY;
if (renderer.iframe) {
const rect = renderer.iframe.getClientRects()[0];
if (rect) {
x = x - rect.left;
y = y - rect.top;
}
}
const els = doc?.elementsFromPoint(x / zoom, y / zoom) as HTMLElement[];
let stopped = false;
const stop = () => (stopped = true);
for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, stop))) {
if (stopped) break;
this.select(el, event);
break;
}
}
}
/**
*
* @param idOrEl Dom节点的id属性Dom节点
*/
public async select(idOrEl: Id | HTMLElement): Promise<void> {
public async select(idOrEl: Id | HTMLElement, event?: MouseEvent): Promise<void> {
let el;
if (typeof idOrEl === 'string' || typeof idOrEl === 'number') {
const runtime = await this.renderer?.getRuntime();
@ -78,15 +124,22 @@ export default class StageCore extends EventEmitter {
el = idOrEl;
}
if (this.selectedDom === el) return;
if (el === this.selectedDom) return;
await this.beforeSelect(el);
const runtime = await this.renderer?.getRuntime();
if (runtime?.beforeSelect) {
await runtime.beforeSelect(el);
}
this.mask.setLayout(el);
this.dr?.select(el, event);
this.selectedDom = el;
this.setMaskLayout(el);
this.dr?.select(el);
}
/**
*
* @param data
*/
public update(data: UpdateData): void {
const { config } = data;
@ -97,9 +150,9 @@ export default class StageCore extends EventEmitter {
const el = this.renderer.contentWindow?.document.getElementById(`${config.id}`);
if (el) {
// 更新了组件的布局需要重新设置mask是否可以滚动
this.setMaskLayout(el);
this.mask.setLayout(el);
this.dr?.select(el);
}
this.dr?.refresh();
}, 0);
});
}
@ -130,12 +183,6 @@ export default class StageCore extends EventEmitter {
renderer.mount(el);
mask.mount(el);
const { wrapper: maskWrapper, hGuides, vGuides } = mask;
maskWrapper.addEventListener('scroll', this.maskScrollHandler);
hGuides.on('changeGuides', this.hGuidesChangeGuidesHandler);
vGuides.on('changeGuides', this.vGuidesChangeGuidesHandler);
this.emit('mounted');
}
@ -144,8 +191,7 @@ export default class StageCore extends EventEmitter {
*/
public clearGuides() {
this.mask.clearGuides();
this.dr.setHGuidelines([]);
this.dr.setVGuidelines([]);
this.dr.clearGuides();
}
/**
@ -153,69 +199,11 @@ export default class StageCore extends EventEmitter {
*/
public destroy(): void {
const { mask, renderer, dr } = this;
const { wrapper: maskWrapper, hGuides, vGuides } = mask;
renderer.destroy();
mask.destroy();
dr.destroy();
maskWrapper.removeEventListener('scroll', this.maskScrollHandler);
hGuides.off('changeGuides', this.hGuidesChangeGuidesHandler);
vGuides.off('changeGuides', this.vGuidesChangeGuidesHandler);
this.removeAllListeners();
}
private maskScrollHandler = (event: Event) => {
const { mask, renderer } = this;
const { wrapper: maskWrapper, hGuides, vGuides } = mask;
const { scrollTop } = maskWrapper;
renderer?.contentWindow?.document.documentElement.scrollTo({ top: scrollTop });
hGuides.scrollGuides(scrollTop);
hGuides.scroll(0);
vGuides.scrollGuides(0);
vGuides.scroll(scrollTop);
this.emit('scroll', event);
};
private hGuidesChangeGuidesHandler = (e: GuidesEvents['changeGuides']) => {
this.dr.setHGuidelines(e.guides);
this.emit('changeGuides');
};
private vGuidesChangeGuidesHandler = (e: GuidesEvents['changeGuides']) => {
this.dr.setVGuidelines(e.guides);
this.emit('changeGuides');
};
private setMaskLayout(el: HTMLElement): void {
let fixed = false;
let dom = el;
while (dom) {
fixed = isFixed(dom);
if (fixed) {
break;
}
const { parentElement } = dom;
if (!parentElement || parentElement.tagName === 'BODY') {
break;
}
dom = parentElement;
}
if (fixed) {
this.mask?.setFixed();
} else {
this.mask?.setAbsolute();
}
}
private async beforeSelect(el: HTMLElement): Promise<void> {
const runtime = await this.renderer?.getRuntime();
if (runtime?.beforeSelect) {
await runtime.beforeSelect(el);
}
}
}

View File

@ -22,10 +22,10 @@ import { EventEmitter } from 'events';
import type { MoveableOptions } from 'moveable';
import Moveable from 'moveable';
import { GHOST_EL_ID_PREFIX } from './const';
import { GHOST_EL_ID_PREFIX, GuidesType, Mode } from './const';
import StageCore from './StageCore';
import type { SortEventData, StageDragResizeConfig } from './types';
import { getAbsolutePosition, getMode, getOffset, Mode } from './util';
import { getAbsolutePosition, getMode, getOffset } from './util';
enum ActionStatus {
START = 'start',
@ -40,12 +40,13 @@ export default class StageDragResize extends EventEmitter {
public core: StageCore;
public container: HTMLElement;
public target?: HTMLElement;
public dragEl?: HTMLElement;
public moveable?: Moveable;
public horizontalGuidelines: number[] = [];
public verticalGuidelines: number[] = [];
private moveableOptions: MoveableOptions = {};
private dragStatus: ActionStatus = ActionStatus.END;
private elObserver?: ResizeObserver;
private ghostEl: HTMLElement | undefined;
private mode: Mode = Mode.ABSOLUTE;
@ -54,81 +55,86 @@ export default class StageDragResize extends EventEmitter {
this.core = config.core;
this.container = config.container;
this.initObserver();
}
/**
* Dom节点上方
* absolute时
* @param el Dom节点元素
* @param event
*/
public async select(el: HTMLElement): Promise<void> {
if (this.target === el) {
this.refresh();
return;
}
this.moveable?.destroy();
public async select(el: HTMLElement, event?: MouseEvent): Promise<void> {
this.target = el;
this.mode = getMode(el);
this.destroyDragEl();
this.destroyGhostEl();
const options = await this.getOptions();
if (this.mode !== Mode.ABSOLUTE) {
this.dragEl = this.generateDragEl(el);
}
this.moveable = new Moveable(this.container, options);
this.bindResizeEvent();
this.bindDragEvent();
this.moveableOptions = await this.getOptions({
target: this.dragEl || this.target,
});
this.syncRect(el);
this.initMoveable();
if (event) {
this.moveable?.dragStart(event);
}
}
/**
*
* @param param0
*/
public async refresh() {
if (!this.moveable) throw new Error('未初始化moveable');
const options = await this.getOptions();
Object.entries(options).forEach(([key, value]) => {
(this.moveable as any)[key] = value;
});
this.updateMoveableTarget();
}
public async setVGuidelines(verticalGuidelines: number[]): Promise<void> {
this.verticalGuidelines = verticalGuidelines;
this.target && (await this.select(this.target));
}
public async setHGuidelines(horizontalGuidelines: number[]): Promise<void> {
this.horizontalGuidelines = horizontalGuidelines;
this.target && (await this.select(this.target));
}
public updateMoveableTarget(target?: HTMLElement): void {
if (!this.moveable) throw new Error('为初始化目标');
if (target) {
this.moveable.target = target;
}
if (this.target) {
this.mode = getMode(this.target);
}
this.moveable.updateTarget();
}
public setGuidelines(type: GuidesType, guidelines: number[]): void {
if (type === GuidesType.HORIZONTAL) {
this.horizontalGuidelines = guidelines;
} else if (type === GuidesType.VERTICAL) {
this.verticalGuidelines = guidelines;
}
this.refresh();
}
public clearGuides() {
this.verticalGuidelines = [];
this.horizontalGuidelines = [];
this.refresh();
}
/**
*
*/
public destroy(): void {
this.destroyGhostEl();
this.moveable?.destroy();
this.destroyGhostEl();
this.destroyDragEl();
this.dragStatus = ActionStatus.END;
this.elObserver?.disconnect();
this.removeAllListeners();
}
private initMoveable() {
this.moveable?.destroy();
this.moveable = new Moveable(this.container, {
...this.moveableOptions,
});
this.bindResizeEvent();
this.bindDragEvent();
}
private bindResizeEvent(): void {
if (!this.moveable) throw new Error('moveable 为初始化');
@ -142,50 +148,38 @@ export default class StageDragResize extends EventEmitter {
const rect = this.moveable!.getRect();
const offset = getAbsolutePosition(e.target as HTMLElement, rect);
e.dragStart.set([offset.left, offset.top]);
if (this.ghostEl) {
this.destroyGhostEl();
this.updateMoveableTarget(this.target);
}
}
})
.on('resize', ({ target, width, height, drag }) => {
if (!this.moveable) return;
if (!this.target) return;
.on('resize', ({ width, height, drag }) => {
if (!this.moveable || !this.target) return;
const { beforeTranslate } = drag;
frame.translate = beforeTranslate;
this.dragStatus = ActionStatus.ING;
target.style.width = `${width}px`;
target.style.height = `${height}px`;
this.target.style.width = `${width}px`;
this.target.style.height = `${height}px`;
if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) {
target.style.left = `${beforeTranslate[0]}px`;
target.style.top = `${beforeTranslate[1]}px`;
// 流式布局
if (this.mode === Mode.SORTABLE && this.ghostEl) {
this.target.style.top = '0';
return;
}
this.target.style.left = `${beforeTranslate[0]}px`;
this.target.style.top = `${beforeTranslate[1]}px`;
if (this.dragEl) {
this.dragEl.style.width = `${width}px`;
this.dragEl.style.height = `${height}px`;
const offset = getAbsolutePosition(this.target, { left: beforeTranslate[0], top: beforeTranslate[1] });
this.dragEl.style.left = `${offset.left}px`;
this.dragEl.style.top = `${offset.top}px`;
}
})
.on('resizeEnd', ({ target }) => {
.on('resizeEnd', () => {
this.dragStatus = ActionStatus.END;
const rect = this.moveable!.getRect();
const offset = getAbsolutePosition(target as HTMLElement, rect);
this.updateMoveableTarget(this.target);
this.emit('update', {
el: this.target,
style: {
width: this.calcValueByFontsize(rect.width),
height: this.calcValueByFontsize(rect.height),
position: this.target?.style.position,
...(this.mode === Mode.SORTABLE
? {}
: {
left: this.calcValueByFontsize(offset.left),
top: this.calcValueByFontsize(offset.top),
}),
},
});
this.drag();
});
}
@ -200,42 +194,43 @@ export default class StageDragResize extends EventEmitter {
if (this.mode === Mode.SORTABLE) {
this.ghostEl = this.generateGhostEl(this.target);
this.updateMoveableTarget(this.ghostEl);
}
})
.on('drag', ({ target, left, top }) => {
.on('drag', ({ left, top }) => {
if (!this.target) return;
this.dragStatus = ActionStatus.ING;
if (this.mode === Mode.SORTABLE && (!this.ghostEl || target !== this.ghostEl)) {
const offset = getAbsolutePosition(this.target, { left, top });
// 流式布局
if (this.ghostEl && this.dragEl) {
this.dragEl.style.top = `${top}px`;
this.ghostEl.style.top = `${offset.top}px`;
return;
}
if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) {
target.style.left = `${left}px`;
target.style.top = `${top}px`;
} else if (this.target) {
const offset = getAbsolutePosition(this.target, getOffset(this.target));
target.style.top = `${offset.top + top}px`;
// 固定布局
if (this.dragEl) {
this.dragEl.style.left = `${left}px`;
this.dragEl.style.top = `${top}px`;
}
this.target.style.left = `${left}px`;
this.target.style.top = `${top}px`;
})
.on('dragEnd', () => {
// 点击不拖动时会触发dragStart和dragEnd但是不会有drag事件
if (this.dragStatus !== ActionStatus.ING) {
return;
if (this.dragStatus === ActionStatus.ING) {
switch (this.mode) {
case Mode.SORTABLE:
this.sort();
break;
default:
this.drag();
}
}
if (!this.target) return;
this.dragStatus = ActionStatus.END;
this.updateMoveableTarget(this.target);
switch (this.mode) {
case Mode.SORTABLE:
this.sort();
break;
default:
this.drag();
}
this.destroyGhostEl();
});
}
@ -246,12 +241,13 @@ export default class StageDragResize extends EventEmitter {
(await renderer.getRuntime())?.getSnapElements ||
(() => {
const doc = renderer.contentWindow?.document;
const elementGuidelines = (doc ? Array.from(doc.querySelectorAll('[id]')) : [])
// 排除掉当前组件本身
.filter((element) => element !== this.target && !this.target?.contains(element));
return elementGuidelines as HTMLElement[];
return doc ? Array.from(doc.querySelectorAll('[id]')) : [];
});
return getSnapElements(el);
return (
getSnapElements(el)
// 排除掉当前组件本身
.filter((element) => element !== this.target && !this.target?.contains(element))
);
}
private sort(): void {
@ -275,13 +271,14 @@ export default class StageDragResize extends EventEmitter {
private drag(): void {
const rect = this.moveable!.getRect();
const offset = getAbsolutePosition(this.target as HTMLElement, rect);
const offset =
this.mode === Mode.SORTABLE ? { left: 0, top: 0 } : getAbsolutePosition(this.target as HTMLElement, rect);
this.emit('update', {
el: this.target,
style: {
left: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.left : offset.left),
top: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.top : offset.top),
left: this.calcValueByFontsize(offset.left),
top: this.calcValueByFontsize(offset.top),
width: this.calcValueByFontsize(rect.width),
height: this.calcValueByFontsize(rect.height),
},
@ -310,14 +307,35 @@ export default class StageDragResize extends EventEmitter {
this.ghostEl = undefined;
}
private generateDragEl(el: HTMLElement): HTMLElement {
if (this.dragEl) {
this.destroyDragEl();
}
const { left, top, width, height } = el.getBoundingClientRect();
const dragEl = globalThis.document.createElement('div');
dragEl.style.cssText = `
position: absolute;
left: ${left}px;
top: ${top}px;
width: ${width}px;
height: ${height}px;
`;
this.container.append(dragEl);
return dragEl;
}
private destroyDragEl(): void {
this.dragEl?.remove();
this.dragEl = undefined;
}
private async getOptions(options: MoveableOptions = {}): Promise<MoveableOptions> {
if (!this.target) return {};
const isSortable = this.mode === Mode.SORTABLE;
const { config, renderer, mask } = this.core;
const { iframe } = renderer;
const isAbsolute = this.mode === Mode.ABSOLUTE;
let { moveableOptions = {} } = config;
let { moveableOptions = {} } = this.core.config;
if (typeof moveableOptions === 'function') {
moveableOptions = moveableOptions(this.core);
@ -326,25 +344,26 @@ export default class StageDragResize extends EventEmitter {
const boundsOptions = {
top: 0,
left: 0,
right: iframe?.clientWidth,
bottom: this.mode === Mode.FIXED ? iframe?.clientHeight : mask.page?.clientHeight,
right: this.container.clientWidth,
bottom: this.container.clientHeight,
...(moveableOptions.bounds || {}),
};
return {
target: this.target,
scrollable: true,
origin: true,
zoom: 1,
dragArea: true,
draggable: true,
resizable: true,
snappable: !isSortable,
snapGap: !isSortable,
snapCenter: !isSortable,
container: renderer.contentWindow?.document.body,
snappable: isAbsolute,
snapGap: isAbsolute,
snapElement: isAbsolute,
snapVertical: isAbsolute,
snapHorizontal: isAbsolute,
snapCenter: isAbsolute,
elementGuidelines: isSortable ? [] : await this.getSnapElements(this.target),
elementGuidelines: !isAbsolute ? [] : await this.getSnapElements(this.target),
horizontalGuidelines: this.horizontalGuidelines,
verticalGuidelines: this.verticalGuidelines,
@ -354,36 +373,6 @@ export default class StageDragResize extends EventEmitter {
};
}
private initObserver(): void {
if (typeof ResizeObserver === 'undefined') {
return;
}
this.elObserver = new ResizeObserver(() => {
const doc = this.core.renderer.contentWindow?.document;
if (!doc || !this.target || !this.moveable) return;
/** 组件可能已经重新渲染了所以需要重新获取新的dom */
const target = doc.getElementById(this.target.id);
if (this.ghostEl) {
this.destroyGhostEl();
}
if (target && target !== this.target) {
this.syncRect(target);
this.target = target;
}
this.updateMoveableTarget(this.target);
});
}
private syncRect(el: HTMLElement): void {
this.elObserver?.disconnect();
this.elObserver?.observe(el);
}
private calcValueByFontsize(value: number) {
const { contentWindow } = this.core.renderer;
const fontSize = contentWindow?.document.documentElement.style.fontSize;

View File

@ -16,15 +16,11 @@
* limitations under the License.
*/
import { EventEmitter } from 'events';
import Guides from '@scena/guides';
import { isNumber } from 'lodash';
import { GHOST_EL_ID_PREFIX } from './const';
import { Mode, MouseButton, ZIndex } from './const';
import Rule from './Rule';
import type StageCore from './StageCore';
import type { CanSelect, StageMaskConfig } from './types';
import { MouseButton, ZIndex } from './types';
import type { StageMaskConfig } from './types';
import { createDiv, getScrollParent, isFixed } from './util';
const wrapperClassName = 'editor-mask-wrapper';
@ -36,30 +32,30 @@ const hideScrollbar = () => {
globalThis.document.head.appendChild(style);
};
const createContent = (): HTMLDivElement => {
const el = globalThis.document.createElement('div');
el.className = 'editor-mask';
el.style.cssText = `
const createContent = (): HTMLDivElement =>
createDiv({
className: 'editor-mask',
cssText: `
position: absolute;
top: 0;
left: 0;
transform: translate3d(0, 0, 0);
`;
return el;
};
`,
});
const createWrapper = (): HTMLDivElement => {
const el = globalThis.document.createElement('div');
el.className = wrapperClassName;
el.style.cssText = `
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: auto;
z-index: ${ZIndex.MASK};
`;
const el = createDiv({
className: wrapperClassName,
cssText: `
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 100%;
overflow: hidden;
z-index: ${ZIndex.MASK};
`,
});
hideScrollbar();
@ -70,54 +66,45 @@ const createWrapper = (): HTMLDivElement => {
*
* @description
*/
export default class StageMask extends EventEmitter {
export default class StageMask extends Rule {
public content: HTMLDivElement = createContent();
public wrapper: HTMLDivElement = createWrapper();
public wrapper: HTMLDivElement;
public core: StageCore;
public page: HTMLElement | null = null;
public pageScrollParent: HTMLElement | null = null;
public scrollTop = 0;
public scrollLeft = 0;
public width = 0;
public height = 0;
public wrapperHeight = 0;
public wrapperWidth = 0;
public hGuides: Guides;
public vGuides: Guides;
private target: Element | null = null;
private resizeObserver: ResizeObserver | null = null;
private parentResizeObserver: ResizeObserver | null = null;
private canSelect: CanSelect;
private mode: Mode = Mode.ABSOLUTE;
private pageResizeObserver: ResizeObserver | null = null;
private wrapperResizeObserver: ResizeObserver | null = null;
constructor(config: StageMaskConfig) {
super();
const wrapper = createWrapper();
super(wrapper);
this.wrapper = wrapper;
this.core = config.core;
const { config: coreConfig } = config.core;
this.canSelect = coreConfig.canSelect || ((el: HTMLElement) => !!el.id);
this.content.addEventListener('mousedown', this.mouseDownHandler);
this.content.addEventListener('contextmenu', this.contextmenuHandler);
this.wrapper.appendChild(this.content);
this.hGuides = this.createGuides('horizontal');
this.vGuides = this.createGuides('vertical');
this.content.addEventListener('wheel', this.mouseWheelHandler);
}
/**
*
*/
public setFixed(): void {
this.wrapper.scrollTo({
top: 0,
});
this.wrapper.style.overflow = 'hidden';
// 要等滚动条滚上去,才刷新选中框
setTimeout(() => {
this.core.dr.refresh();
});
}
/**
*
*/
public setAbsolute(): void {
this.wrapper.style.overflow = 'auto';
public setMode(mode: Mode) {
this.mode = mode;
this.scroll();
if (mode === Mode.FIXED) {
this.content.style.width = `${this.wrapperWidth}px`;
this.content.style.height = `${this.wrapperHeight}px`;
} else {
this.content.style.width = `${this.width}px`;
this.content.style.height = `${this.height}px`;
}
}
/**
@ -129,17 +116,26 @@ export default class StageMask extends EventEmitter {
if (!page) return;
this.page = page;
this.resizeObserver?.disconnect();
this.pageScrollParent = getScrollParent(page);
this.pageResizeObserver?.disconnect();
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver((entries) => {
this.pageResizeObserver = new ResizeObserver((entries) => {
const [entry] = entries;
const { clientHeight, clientWidth } = entry.target;
this.setHeight(clientHeight);
this.setWidth(clientWidth);
});
this.resizeObserver.observe(page);
this.pageResizeObserver.observe(page);
this.wrapperResizeObserver = new ResizeObserver((entries) => {
const [entry] = entries;
const { clientHeight, clientWidth } = entry.target;
this.wrapperHeight = clientHeight;
this.wrapperWidth = clientWidth;
});
this.wrapperResizeObserver.observe(this.wrapper);
}
}
@ -151,12 +147,24 @@ export default class StageMask extends EventEmitter {
if (!this.content) throw new Error('content 不存在');
el.appendChild(this.wrapper);
}
this.parentResizeObserver = new ResizeObserver(() => {
this.vGuides.resize();
this.hGuides.resize();
});
this.parentResizeObserver.observe(el);
public setLayout(el: HTMLElement): void {
let fixed = false;
let dom = el;
while (dom) {
fixed = isFixed(dom);
if (fixed) {
break;
}
const { parentElement } = dom;
if (!parentElement || parentElement.tagName === 'BODY') {
break;
}
dom = parentElement;
}
this.setMode(fixed ? Mode.FIXED : Mode.ABSOLUTE);
}
/**
@ -165,78 +173,44 @@ export default class StageMask extends EventEmitter {
public destroy(): void {
this.content?.remove();
this.page = null;
this.resizeObserver?.disconnect();
this.parentResizeObserver?.disconnect();
this.removeAllListeners();
this.pageScrollParent = null;
this.pageResizeObserver?.disconnect();
this.wrapperResizeObserver?.disconnect();
super.destroy();
}
/**
*
* @param show
*/
public showGuides(show = true) {
this.hGuides.setState({
showGuides: show,
});
private scroll() {
let { scrollLeft, scrollTop } = this;
this.vGuides.setState({
showGuides: show,
});
}
/**
*
* @param show
*/
public showRule(show = true) {
// 当尺子隐藏时发现大小变化,显示后会变形,所以这里做重新初始化处理
if (show) {
this.hGuides.destroy();
this.hGuides = this.createGuides('horizontal', this.core.dr.horizontalGuidelines);
this.vGuides.destroy();
this.vGuides = this.createGuides('vertical', this.core.dr.verticalGuidelines);
} else {
this.hGuides.setState({
rulerStyle: {
visibility: 'hidden',
},
});
this.vGuides.setState({
rulerStyle: {
visibility: 'hidden',
},
});
if (this.mode === Mode.FIXED) {
scrollLeft = 0;
scrollTop = 0;
}
this.scrollRule(scrollTop);
this.scrollTo(scrollLeft, scrollTop);
}
/**
* 线
*/
public clearGuides() {
this.vGuides.setState({
defaultGuides: [],
});
this.hGuides.setState({
defaultGuides: [],
});
private scrollTo(scrollLeft: number, scrollTop: number): void {
this.content.style.transform = `translate3d(${-scrollLeft}px, ${-scrollTop}px, 0)`;
}
/**
*
* @param height
*/
private setHeight(height: number | string): void {
this.content.style.height = isNumber(height) ? `${height}px` : height;
private setHeight(height: number): void {
this.height = height;
this.content.style.height = `${height}px`;
}
/**
*
* @param width
*/
private setWidth(width: number | string): void {
this.content.style.width = isNumber(width) ? `${width}px` : width;
private setWidth(width: number): void {
this.width = width;
this.content.style.width = `${width}px`;
}
/**
@ -244,86 +218,64 @@ export default class StageMask extends EventEmitter {
* @param event
*/
private mouseDownHandler = async (event: MouseEvent): Promise<void> => {
if (event.button !== MouseButton.LEFT) return;
event.stopImmediatePropagation();
event.stopPropagation();
if (event.button !== MouseButton.LEFT && event.button !== MouseButton.RIGHT) return;
// 点击的对象如果是选中框,则不需要再触发选中了,而可能是拖动行为
if ((event.target as HTMLDivElement).className.indexOf('moveable-control') !== -1) {
return;
}
this.content.addEventListener('mousemove', this.mouseMoveHandler);
this.emit('beforeSelect', event);
this.select(event);
// 如果是右键点击这里的mouseup事件监听没有效果
globalThis.document.addEventListener('mouseup', this.mouseUpHandler);
};
private mouseUpHandler = (): void => {
this.content.removeEventListener('mousemove', this.mouseMoveHandler);
this.content.removeEventListener('mouseup', this.mouseUpHandler);
this.emit('selected', this.target);
this.target = null;
globalThis.document.removeEventListener('mouseup', this.mouseUpHandler);
this.emit('select');
};
private mouseMoveHandler = (event: MouseEvent): void => {
// 避免触摸板轻触移动拖动组件
if (event.buttons) {
this.core.dr.moveable?.dragStart(event);
}
this.content.removeEventListener('mousemove', this.mouseMoveHandler);
};
private mouseWheelHandler = (event: WheelEvent) => {
if (!this.page) throw new Error('page 未初始化');
private contextmenuHandler = async (event: MouseEvent): Promise<void> => {
await this.select(event);
this.mouseUpHandler();
};
const { deltaY, deltaX } = event;
const { height, wrapperHeight, width, wrapperWidth } = this;
private async select(event: MouseEvent) {
const { renderer, zoom } = this.core;
const maxScrollTop = height - wrapperHeight;
const maxScrollLeft = width - wrapperWidth;
const doc = renderer.contentWindow?.document;
let x = event.clientX;
let y = event.clientY;
if (renderer.iframe) {
const rect = renderer.iframe.getClientRects()[0];
if (rect) {
x = x - rect.left;
y = y - rect.top;
if (maxScrollTop > 0) {
if (deltaY > 0) {
this.scrollTop = this.scrollTop + Math.min(maxScrollTop - this.scrollTop, deltaY);
} else {
this.scrollTop = Math.max(this.scrollTop + deltaY, 0);
}
}
const els = doc?.elementsFromPoint(x / zoom, y / zoom) as HTMLElement[];
let stopped = false;
const stop = () => (stopped = true);
for (const el of els) {
if (!el.id.startsWith(GHOST_EL_ID_PREFIX) && (await this.canSelect(el, stop))) {
if (stopped) break;
this.emit('select', el, event);
this.target = el;
// 如果是右键点击这里的mouseup事件监听没有效果
this.content.addEventListener('mouseup', this.mouseUpHandler);
break;
if (width > wrapperWidth) {
if (deltaX > 0) {
this.scrollLeft = this.scrollLeft + Math.min(maxScrollLeft - this.scrollLeft, deltaX);
} else {
this.scrollLeft = Math.max(this.scrollLeft + deltaX, 0);
}
}
}
private getGuidesStyle = (type: 'horizontal' | 'vertical') => ({
position: 'fixed',
left: type === 'horizontal' ? 0 : '-30px',
top: type === 'horizontal' ? '-30px' : 0,
width: type === 'horizontal' ? '100%' : '30px',
height: type === 'horizontal' ? '30px' : '100%',
});
if (this.mode !== Mode.FIXED) {
this.scrollTo(this.scrollLeft, this.scrollTop);
}
private createGuides = (type: 'horizontal' | 'vertical', defaultGuides: number[] = []): Guides =>
new Guides(this.wrapper, {
type,
defaultGuides,
displayDragPos: true,
backgroundColor: '#fff',
lineColor: '#000',
textColor: '#000',
style: this.getGuidesStyle(type),
});
if (this.pageScrollParent) {
this.pageScrollParent.scrollTo({
top: this.scrollTop,
left: this.scrollLeft,
});
}
this.scroll();
this.emit('scroll', event);
};
}

View File

@ -21,3 +21,24 @@ export const GHOST_EL_ID_PREFIX = 'ghost_el_';
// 默认放到缩小倍数
export const DEFAULT_ZOOM = 1;
export enum GuidesType {
HORIZONTAL = 'horizontal',
VERTICAL = 'vertical',
}
export enum ZIndex {
MASK = '99999',
}
export enum MouseButton {
LEFT = 0,
MIDDLE = 1,
RIGHT = 2,
}
export enum Mode {
ABSOLUTE = 'absolute',
FIXED = 'fixed',
SORTABLE = 'sortable',
}

View File

@ -1,7 +1,26 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MoveableOptions } from 'react-moveable/declaration/types';
import { Id, MApp, MNode } from '@tmagic/schema';
import { GuidesType } from './const';
import StageCore from './StageCore';
export type CanSelect = (el: HTMLElement, stop: () => boolean) => boolean | Promise<boolean>;
@ -41,23 +60,10 @@ export interface Offset {
top: number;
}
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2021 THL A29 Limited, a Tencent company. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface GuidesEventData {
type: GuidesType;
guides: number[];
}
export interface UpdateEventData {
el: HTMLElement;
@ -108,14 +114,3 @@ export interface Magic {
export interface RuntimeWindow extends Window {
magic: Magic;
}
export enum ZIndex {
MASK = '99999',
GHOST_EL = '99998',
}
export enum MouseButton {
LEFT = 0,
MIDDLE = 1,
RIGHT = 2,
}

View File

@ -16,14 +16,9 @@
* limitations under the License.
*/
import { Mode } from './const';
import type { Offset } from './types';
export enum Mode {
ABSOLUTE = 'absolute',
FIXED = 'fixed',
SORTABLE = 'sortable',
}
export const getOffset = (el: HTMLElement): Offset => {
const { transform } = getComputedStyle(el);
const { offsetParent } = el;
@ -118,3 +113,27 @@ export const getMode = (el: HTMLElement): Mode => {
if (isStatic(el) || isRelative(el)) return Mode.SORTABLE;
return Mode.ABSOLUTE;
};
export const getScrollParent = (element: HTMLElement, includeHidden = false): HTMLElement | null => {
let style = getComputedStyle(element);
const overflowRegex = includeHidden ? /(auto|scroll|hidden)/ : /(auto|scroll)/;
if (isFixed(element)) return null;
for (let parent = element; parent.parentElement; ) {
parent = parent.parentElement;
style = getComputedStyle(parent);
if (isAbsolute(element) && isStatic(element)) {
continue;
}
if (overflowRegex.test(style.overflow + style.overflowY + style.overflowX)) return parent;
}
return null;
};
export const createDiv = ({ className, cssText }: { className: string; cssText: string }) => {
const el = globalThis.document.createElement('div');
el.className = className;
el.style.cssText = cssText;
return el;
};