diff --git a/packages/stage/src/Rule.ts b/packages/stage/src/Rule.ts new file mode 100644 index 00000000..0aeb83a8 --- /dev/null +++ b/packages/stage/src/Rule.ts @@ -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, + }); + }; +} diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index f297cd11..17c03b88 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -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 { + public async select(idOrEl: Id | HTMLElement, event?: MouseEvent): Promise { 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 { - const runtime = await this.renderer?.getRuntime(); - if (runtime?.beforeSelect) { - await runtime.beforeSelect(el); - } - } } diff --git a/packages/stage/src/StageDragResize.ts b/packages/stage/src/StageDragResize.ts index 8218dc12..2fcd292c 100644 --- a/packages/stage/src/StageDragResize.ts +++ b/packages/stage/src/StageDragResize.ts @@ -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 { - if (this.target === el) { - this.refresh(); - return; - } - - this.moveable?.destroy(); - + public async select(el: HTMLElement, event?: MouseEvent): Promise { 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 { - this.verticalGuidelines = verticalGuidelines; - this.target && (await this.select(this.target)); - } - - public async setHGuidelines(horizontalGuidelines: number[]): Promise { - 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 { 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; diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index 1126b2c1..669501c5 100644 --- a/packages/stage/src/StageMask.ts +++ b/packages/stage/src/StageMask.ts @@ -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 => { - 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 => { - 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); + }; } diff --git a/packages/stage/src/const.ts b/packages/stage/src/const.ts index 15ba3909..a18c050a 100644 --- a/packages/stage/src/const.ts +++ b/packages/stage/src/const.ts @@ -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', +} diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index ff73fce4..3593cdfa 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -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; @@ -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, -} diff --git a/packages/stage/src/util.ts b/packages/stage/src/util.ts index e8156770..626c7fd3 100644 --- a/packages/stage/src/util.ts +++ b/packages/stage/src/util.ts @@ -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; +};