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 { EventEmitter } from 'events';
import { GuidesEvents } from '@scena/guides';
import { Id } from '@tmagic/schema'; import { Id } from '@tmagic/schema';
import { DEFAULT_ZOOM } from './const'; import { DEFAULT_ZOOM, GHOST_EL_ID_PREFIX } from './const';
import StageDragResize from './StageDragResize'; import StageDragResize from './StageDragResize';
import StageMask from './StageMask'; import StageMask from './StageMask';
import StageRender from './StageRender'; import StageRender from './StageRender';
import { RemoveData, Runtime, SortEventData, StageCoreConfig, UpdateData, UpdateEventData } from './types'; import {
import { isFixed } from './util'; CanSelect,
GuidesEventData,
RemoveData,
Runtime,
SortEventData,
StageCoreConfig,
UpdateData,
UpdateEventData,
} from './types';
export default class StageCore extends EventEmitter { export default class StageCore extends EventEmitter {
public selectedDom: Element | undefined; public selectedDom: Element | undefined;
@ -37,6 +43,7 @@ export default class StageCore extends EventEmitter {
public dr: StageDragResize; public dr: StageDragResize;
public config: StageCoreConfig; public config: StageCoreConfig;
public zoom = DEFAULT_ZOOM; public zoom = DEFAULT_ZOOM;
private canSelect: CanSelect;
constructor(config: StageCoreConfig) { constructor(config: StageCoreConfig) {
super(); super();
@ -44,6 +51,7 @@ export default class StageCore extends EventEmitter {
this.config = config; this.config = config;
this.setZoom(config.zoom); this.setZoom(config.zoom);
this.canSelect = config.canSelect || ((el: HTMLElement) => !!el.id);
this.renderer = new StageRender({ core: this }); this.renderer = new StageRender({ core: this });
this.mask = new StageMask({ 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('runtime-ready', (runtime: Runtime) => this.emit('runtime-ready', runtime));
this.renderer.on('page-el-update', (el: HTMLElement) => this.mask?.observe(el)); this.renderer.on('page-el-update', (el: HTMLElement) => this.mask?.observe(el));
this.mask.on('select', async (el: Element) => { this.mask
await this.dr?.select(el as HTMLElement); .on('beforeSelect', (event: MouseEvent) => {
}); this.setElementFromPoint(event);
this.mask.on('selected', (el: Element) => { })
this.select(el as HTMLElement); .on('select', () => {
this.emit('select', el); 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)); // 要先触发select在触发update
this.dr.on('sort', (data: UpdateEventData) => this.emit('sort', data)); 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节点 * @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; let el;
if (typeof idOrEl === 'string' || typeof idOrEl === 'number') { if (typeof idOrEl === 'string' || typeof idOrEl === 'number') {
const runtime = await this.renderer?.getRuntime(); const runtime = await this.renderer?.getRuntime();
@ -78,15 +124,22 @@ export default class StageCore extends EventEmitter {
el = idOrEl; 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.selectedDom = el;
this.setMaskLayout(el);
this.dr?.select(el);
} }
/**
*
* @param data
*/
public update(data: UpdateData): void { public update(data: UpdateData): void {
const { config } = data; const { config } = data;
@ -97,9 +150,9 @@ export default class StageCore extends EventEmitter {
const el = this.renderer.contentWindow?.document.getElementById(`${config.id}`); const el = this.renderer.contentWindow?.document.getElementById(`${config.id}`);
if (el) { if (el) {
// 更新了组件的布局需要重新设置mask是否可以滚动 // 更新了组件的布局需要重新设置mask是否可以滚动
this.setMaskLayout(el); this.mask.setLayout(el);
this.dr?.select(el);
} }
this.dr?.refresh();
}, 0); }, 0);
}); });
} }
@ -130,12 +183,6 @@ export default class StageCore extends EventEmitter {
renderer.mount(el); renderer.mount(el);
mask.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'); this.emit('mounted');
} }
@ -144,8 +191,7 @@ export default class StageCore extends EventEmitter {
*/ */
public clearGuides() { public clearGuides() {
this.mask.clearGuides(); this.mask.clearGuides();
this.dr.setHGuidelines([]); this.dr.clearGuides();
this.dr.setVGuidelines([]);
} }
/** /**
@ -153,69 +199,11 @@ export default class StageCore extends EventEmitter {
*/ */
public destroy(): void { public destroy(): void {
const { mask, renderer, dr } = this; const { mask, renderer, dr } = this;
const { wrapper: maskWrapper, hGuides, vGuides } = mask;
renderer.destroy(); renderer.destroy();
mask.destroy(); mask.destroy();
dr.destroy(); dr.destroy();
maskWrapper.removeEventListener('scroll', this.maskScrollHandler);
hGuides.off('changeGuides', this.hGuidesChangeGuidesHandler);
vGuides.off('changeGuides', this.vGuidesChangeGuidesHandler);
this.removeAllListeners(); 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 type { MoveableOptions } from 'moveable';
import Moveable 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 StageCore from './StageCore';
import type { SortEventData, StageDragResizeConfig } from './types'; import type { SortEventData, StageDragResizeConfig } from './types';
import { getAbsolutePosition, getMode, getOffset, Mode } from './util'; import { getAbsolutePosition, getMode, getOffset } from './util';
enum ActionStatus { enum ActionStatus {
START = 'start', START = 'start',
@ -40,12 +40,13 @@ export default class StageDragResize extends EventEmitter {
public core: StageCore; public core: StageCore;
public container: HTMLElement; public container: HTMLElement;
public target?: HTMLElement; public target?: HTMLElement;
public dragEl?: HTMLElement;
public moveable?: Moveable; public moveable?: Moveable;
public horizontalGuidelines: number[] = []; public horizontalGuidelines: number[] = [];
public verticalGuidelines: number[] = []; public verticalGuidelines: number[] = [];
private moveableOptions: MoveableOptions = {};
private dragStatus: ActionStatus = ActionStatus.END; private dragStatus: ActionStatus = ActionStatus.END;
private elObserver?: ResizeObserver;
private ghostEl: HTMLElement | undefined; private ghostEl: HTMLElement | undefined;
private mode: Mode = Mode.ABSOLUTE; private mode: Mode = Mode.ABSOLUTE;
@ -54,81 +55,86 @@ export default class StageDragResize extends EventEmitter {
this.core = config.core; this.core = config.core;
this.container = config.container; this.container = config.container;
this.initObserver();
} }
/** /**
* Dom节点上方 * Dom节点上方
* absolute时
* @param el Dom节点元素 * @param el Dom节点元素
* @param event
*/ */
public async select(el: HTMLElement): Promise<void> { public async select(el: HTMLElement, event?: MouseEvent): Promise<void> {
if (this.target === el) {
this.refresh();
return;
}
this.moveable?.destroy();
this.target = el; this.target = el;
this.mode = getMode(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.moveableOptions = await this.getOptions({
this.bindResizeEvent(); target: this.dragEl || this.target,
this.bindDragEvent(); });
this.syncRect(el); this.initMoveable();
if (event) {
this.moveable?.dragStart(event);
}
} }
/** /**
* *
* @param param0
*/ */
public async refresh() { public async refresh() {
if (!this.moveable) throw new Error('未初始化moveable');
const options = await this.getOptions(); const options = await this.getOptions();
Object.entries(options).forEach(([key, value]) => { Object.entries(options).forEach(([key, value]) => {
(this.moveable as any)[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(); 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 { public destroy(): void {
this.destroyGhostEl();
this.moveable?.destroy(); this.moveable?.destroy();
this.destroyGhostEl();
this.destroyDragEl();
this.dragStatus = ActionStatus.END; this.dragStatus = ActionStatus.END;
this.elObserver?.disconnect();
this.removeAllListeners(); this.removeAllListeners();
} }
private initMoveable() {
this.moveable?.destroy();
this.moveable = new Moveable(this.container, {
...this.moveableOptions,
});
this.bindResizeEvent();
this.bindDragEvent();
}
private bindResizeEvent(): void { private bindResizeEvent(): void {
if (!this.moveable) throw new Error('moveable 为初始化'); if (!this.moveable) throw new Error('moveable 为初始化');
@ -142,50 +148,38 @@ export default class StageDragResize extends EventEmitter {
const rect = this.moveable!.getRect(); const rect = this.moveable!.getRect();
const offset = getAbsolutePosition(e.target as HTMLElement, rect); const offset = getAbsolutePosition(e.target as HTMLElement, rect);
e.dragStart.set([offset.left, offset.top]); e.dragStart.set([offset.left, offset.top]);
if (this.ghostEl) {
this.destroyGhostEl();
this.updateMoveableTarget(this.target);
}
} }
}) })
.on('resize', ({ target, width, height, drag }) => { .on('resize', ({ width, height, drag }) => {
if (!this.moveable) return; if (!this.moveable || !this.target) return;
if (!this.target) return;
const { beforeTranslate } = drag; const { beforeTranslate } = drag;
frame.translate = beforeTranslate; frame.translate = beforeTranslate;
this.dragStatus = ActionStatus.ING; this.dragStatus = ActionStatus.ING;
target.style.width = `${width}px`; this.target.style.width = `${width}px`;
target.style.height = `${height}px`; this.target.style.height = `${height}px`;
if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) { // 流式布局
target.style.left = `${beforeTranslate[0]}px`; if (this.mode === Mode.SORTABLE && this.ghostEl) {
target.style.top = `${beforeTranslate[1]}px`; 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; this.dragStatus = ActionStatus.END;
this.drag();
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),
}),
},
});
}); });
} }
@ -200,42 +194,43 @@ export default class StageDragResize extends EventEmitter {
if (this.mode === Mode.SORTABLE) { if (this.mode === Mode.SORTABLE) {
this.ghostEl = this.generateGhostEl(this.target); 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; 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; return;
} }
if ([Mode.ABSOLUTE, Mode.FIXED].includes(this.mode)) { // 固定布局
target.style.left = `${left}px`; if (this.dragEl) {
target.style.top = `${top}px`; this.dragEl.style.left = `${left}px`;
} else if (this.target) { this.dragEl.style.top = `${top}px`;
const offset = getAbsolutePosition(this.target, getOffset(this.target));
target.style.top = `${offset.top + top}px`;
} }
this.target.style.left = `${left}px`;
this.target.style.top = `${top}px`;
}) })
.on('dragEnd', () => { .on('dragEnd', () => {
// 点击不拖动时会触发dragStart和dragEnd但是不会有drag事件 // 点击不拖动时会触发dragStart和dragEnd但是不会有drag事件
if (this.dragStatus !== ActionStatus.ING) { if (this.dragStatus === ActionStatus.ING) {
return; switch (this.mode) {
case Mode.SORTABLE:
this.sort();
break;
default:
this.drag();
}
} }
if (!this.target) return;
this.dragStatus = ActionStatus.END; this.dragStatus = ActionStatus.END;
this.updateMoveableTarget(this.target);
switch (this.mode) {
case Mode.SORTABLE:
this.sort();
break;
default:
this.drag();
}
this.destroyGhostEl(); this.destroyGhostEl();
}); });
} }
@ -246,12 +241,13 @@ export default class StageDragResize extends EventEmitter {
(await renderer.getRuntime())?.getSnapElements || (await renderer.getRuntime())?.getSnapElements ||
(() => { (() => {
const doc = renderer.contentWindow?.document; const doc = renderer.contentWindow?.document;
const elementGuidelines = (doc ? Array.from(doc.querySelectorAll('[id]')) : []) return doc ? Array.from(doc.querySelectorAll('[id]')) : [];
// 排除掉当前组件本身
.filter((element) => element !== this.target && !this.target?.contains(element));
return elementGuidelines as HTMLElement[];
}); });
return getSnapElements(el); return (
getSnapElements(el)
// 排除掉当前组件本身
.filter((element) => element !== this.target && !this.target?.contains(element))
);
} }
private sort(): void { private sort(): void {
@ -275,13 +271,14 @@ export default class StageDragResize extends EventEmitter {
private drag(): void { private drag(): void {
const rect = this.moveable!.getRect(); 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', { this.emit('update', {
el: this.target, el: this.target,
style: { style: {
left: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.left : offset.left), left: this.calcValueByFontsize(offset.left),
top: this.calcValueByFontsize(this.mode === Mode.FIXED ? rect.top : offset.top), top: this.calcValueByFontsize(offset.top),
width: this.calcValueByFontsize(rect.width), width: this.calcValueByFontsize(rect.width),
height: this.calcValueByFontsize(rect.height), height: this.calcValueByFontsize(rect.height),
}, },
@ -310,14 +307,35 @@ export default class StageDragResize extends EventEmitter {
this.ghostEl = undefined; 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> { private async getOptions(options: MoveableOptions = {}): Promise<MoveableOptions> {
if (!this.target) return {}; if (!this.target) return {};
const isSortable = this.mode === Mode.SORTABLE; const isAbsolute = this.mode === Mode.ABSOLUTE;
const { config, renderer, mask } = this.core;
const { iframe } = renderer;
let { moveableOptions = {} } = config; let { moveableOptions = {} } = this.core.config;
if (typeof moveableOptions === 'function') { if (typeof moveableOptions === 'function') {
moveableOptions = moveableOptions(this.core); moveableOptions = moveableOptions(this.core);
@ -326,25 +344,26 @@ export default class StageDragResize extends EventEmitter {
const boundsOptions = { const boundsOptions = {
top: 0, top: 0,
left: 0, left: 0,
right: iframe?.clientWidth, right: this.container.clientWidth,
bottom: this.mode === Mode.FIXED ? iframe?.clientHeight : mask.page?.clientHeight, bottom: this.container.clientHeight,
...(moveableOptions.bounds || {}), ...(moveableOptions.bounds || {}),
}; };
return { return {
target: this.target,
scrollable: true, scrollable: true,
origin: true, origin: true,
zoom: 1, zoom: 1,
dragArea: true, dragArea: true,
draggable: true, draggable: true,
resizable: true, resizable: true,
snappable: !isSortable, snappable: isAbsolute,
snapGap: !isSortable, snapGap: isAbsolute,
snapCenter: !isSortable, snapElement: isAbsolute,
container: renderer.contentWindow?.document.body, snapVertical: isAbsolute,
snapHorizontal: isAbsolute,
snapCenter: isAbsolute,
elementGuidelines: isSortable ? [] : await this.getSnapElements(this.target), elementGuidelines: !isAbsolute ? [] : await this.getSnapElements(this.target),
horizontalGuidelines: this.horizontalGuidelines, horizontalGuidelines: this.horizontalGuidelines,
verticalGuidelines: this.verticalGuidelines, 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) { private calcValueByFontsize(value: number) {
const { contentWindow } = this.core.renderer; const { contentWindow } = this.core.renderer;
const fontSize = contentWindow?.document.documentElement.style.fontSize; const fontSize = contentWindow?.document.documentElement.style.fontSize;

View File

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

View File

@ -21,3 +21,24 @@ export const GHOST_EL_ID_PREFIX = 'ghost_el_';
// 默认放到缩小倍数 // 默认放到缩小倍数
export const DEFAULT_ZOOM = 1; 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 { MoveableOptions } from 'react-moveable/declaration/types';
import { Id, MApp, MNode } from '@tmagic/schema'; import { Id, MApp, MNode } from '@tmagic/schema';
import { GuidesType } from './const';
import StageCore from './StageCore'; import StageCore from './StageCore';
export type CanSelect = (el: HTMLElement, stop: () => boolean) => boolean | Promise<boolean>; export type CanSelect = (el: HTMLElement, stop: () => boolean) => boolean | Promise<boolean>;
@ -41,23 +60,10 @@ export interface Offset {
top: number; top: number;
} }
/* export interface GuidesEventData {
* Tencent is pleased to support the open source community by making TMagicEditor available. type: GuidesType;
* guides: number[];
* 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 UpdateEventData { export interface UpdateEventData {
el: HTMLElement; el: HTMLElement;
@ -108,14 +114,3 @@ export interface Magic {
export interface RuntimeWindow extends Window { export interface RuntimeWindow extends Window {
magic: Magic; 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. * limitations under the License.
*/ */
import { Mode } from './const';
import type { Offset } from './types'; import type { Offset } from './types';
export enum Mode {
ABSOLUTE = 'absolute',
FIXED = 'fixed',
SORTABLE = 'sortable',
}
export const getOffset = (el: HTMLElement): Offset => { export const getOffset = (el: HTMLElement): Offset => {
const { transform } = getComputedStyle(el); const { transform } = getComputedStyle(el);
const { offsetParent } = el; const { offsetParent } = el;
@ -118,3 +113,27 @@ export const getMode = (el: HTMLElement): Mode => {
if (isStatic(el) || isRelative(el)) return Mode.SORTABLE; if (isStatic(el) || isRelative(el)) return Mode.SORTABLE;
return Mode.ABSOLUTE; 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;
};