From de0c6952c77fe8675c062f8237cd938531f22a82 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Thu, 14 Jul 2022 18:59:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=B0=86=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E6=8B=96=E5=8A=A8=E5=88=B0=E6=8C=87=E5=AE=9A=E5=AE=B9?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/editor/src/Editor.vue | 20 ++- .../layouts/sidebar/ComponentListPanel.vue | 54 +++++++- .../editor/src/layouts/workspace/Stage.vue | 63 +++++++-- packages/editor/src/services/editor.ts | 91 ++++++++++--- packages/editor/src/type.ts | 3 + packages/editor/src/utils/editor.ts | 78 ++++++----- .../editor/tests/unit/utils/editor.spec.ts | 22 ++- packages/stage/src/StageCore.ts | 20 ++- packages/stage/src/StageDragResize.ts | 125 ++++++++++++------ packages/stage/src/StageHighlight.ts | 1 - packages/stage/src/StageMask.ts | 10 +- packages/stage/src/StageRender.ts | 6 +- packages/stage/src/TargetCalibrate.ts | 1 - packages/stage/src/const.ts | 2 + packages/stage/src/index.ts | 1 + packages/stage/src/style.css | 10 ++ packages/stage/src/types.ts | 5 + packages/stage/src/util.ts | 35 +++-- packages/stage/src/vite-env.d.ts | 1 + packages/stage/tests/unit/util.spec.ts | 35 +---- packages/utils/src/dom.ts | 110 +++++++++++++++ packages/utils/src/index.ts | 93 ++----------- packages/utils/tests/unit/index.spec.ts | 24 ++++ vitest.config.ts | 1 + 24 files changed, 553 insertions(+), 258 deletions(-) create mode 100644 packages/stage/src/style.css create mode 100644 packages/stage/src/vite-env.d.ts create mode 100644 packages/utils/src/dom.ts diff --git a/packages/editor/src/Editor.vue b/packages/editor/src/Editor.vue index d001133a..ee6ac526 100644 --- a/packages/editor/src/Editor.vue +++ b/packages/editor/src/Editor.vue @@ -49,7 +49,7 @@ import { EventOption } from '@tmagic/core'; import type { FormConfig } from '@tmagic/form'; import type { MApp, MNode } from '@tmagic/schema'; import type StageCore from '@tmagic/stage'; -import type { MoveableOptions } from '@tmagic/stage'; +import { CONTAINER_HIGHLIGHT_CLASS, MoveableOptions } from '@tmagic/stage'; import Framework from '@editor/layouts/Framework.vue'; import NavMenu from '@editor/layouts/NavMenu.vue'; @@ -154,6 +154,21 @@ export default defineComponent({ default: (el: HTMLElement) => Boolean(el.id), }, + isContainer: { + type: Function as PropType<(el: HTMLElement) => boolean | Promise>, + default: (el: HTMLElement) => el.classList.contains('magic-ui-container'), + }, + + containerHighlightClassName: { + type: String, + default: CONTAINER_HIGHLIGHT_CLASS, + }, + + containerHighlightDuration: { + type: Number, + default: 800, + }, + stageRect: { type: [String, Object] as PropType, }, @@ -269,6 +284,9 @@ export default defineComponent({ moveableOptions: props.moveableOptions, canSelect: props.canSelect, updateDragEl: props.updateDragEl, + isContainer: props.isContainer, + containerHighlightClassName: props.containerHighlightClassName, + containerHighlightDuration: props.containerHighlightDuration, }), ); diff --git a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue index a366b521..9fb372d4 100644 --- a/packages/editor/src/layouts/sidebar/ComponentListPanel.vue +++ b/packages/editor/src/layouts/sidebar/ComponentListPanel.vue @@ -21,6 +21,8 @@ :key="item.type" @click="appendComponent(item)" @dragstart="dragstartHandler(item, $event)" + @dragend="dragendHandler" + @drag="dragHandler" > @@ -38,8 +40,12 @@ import { computed, defineComponent, inject, ref } from 'vue'; import serialize from 'serialize-javascript'; +import type StageCore from '@tmagic/stage'; +import { GHOST_EL_ID_PREFIX } from '@tmagic/stage'; +import { addClassName, removeClassNameByClassName } from '@tmagic/utils'; + import MIcon from '@editor/components/Icon.vue'; -import type { ComponentGroup, ComponentItem, Services } from '@editor/type'; +import type { ComponentGroup, ComponentItem, Services, StageOptions } from '@editor/type'; export default defineComponent({ name: 'ui-component-panel', @@ -49,6 +55,9 @@ export default defineComponent({ setup() { const searchText = ref(''); const services = inject('services'); + const stageOptions = inject('stageOptions'); + + const stage = computed(() => services?.editorService.get('stage')); const list = computed(() => services?.componentListService.getList().map((group: ComponentGroup) => ({ ...group, @@ -61,6 +70,10 @@ export default defineComponent({ .map((x, i) => i), ); + let timeout: NodeJS.Timeout | undefined; + let clientX: number; + let clientY: number; + return { searchText, collapseValue, @@ -87,6 +100,45 @@ export default defineComponent({ ); } }, + + dragendHandler() { + if (timeout) { + globalThis.clearTimeout(timeout); + timeout = undefined; + } + const doc = stage.value?.renderer.contentWindow?.document; + if (doc && stageOptions) { + removeClassNameByClassName(doc, stageOptions.containerHighlightClassName); + } + clientX = 0; + clientY = 0; + }, + + dragHandler(e: DragEvent) { + if (e.clientX !== clientX || e.clientY !== clientY) { + clientX = e.clientX; + clientY = e.clientY; + if (timeout) { + globalThis.clearTimeout(timeout); + timeout = undefined; + } + return; + } + + if (timeout) return; + + timeout = globalThis.setTimeout(async () => { + if (!stageOptions || !stage.value) return; + const doc = stage.value.renderer.contentWindow?.document; + const els = stage.value.getElementsFromPoint(e); + for (const el of els) { + if (doc && !el.id.startsWith(GHOST_EL_ID_PREFIX) && (await stageOptions.isContainer(el))) { + addClassName(el, doc, stageOptions?.containerHighlightClassName); + break; + } + } + }, stageOptions?.containerHighlightDuration); + }, }; }, }); diff --git a/packages/editor/src/layouts/workspace/Stage.vue b/packages/editor/src/layouts/workspace/Stage.vue index 7e538441..1deaec85 100644 --- a/packages/editor/src/layouts/workspace/Stage.vue +++ b/packages/editor/src/layouts/workspace/Stage.vue @@ -35,8 +35,15 @@ import { } from 'vue'; import { cloneDeep } from 'lodash-es'; -import type { MApp, MNode, MPage } from '@tmagic/schema'; -import StageCore, { GuidesType, Runtime, SortEventData, UpdateEventData } from '@tmagic/stage'; +import type { MApp, MContainer, MNode, MPage } from '@tmagic/schema'; +import StageCore, { + calcValueByFontsize, + getOffset, + GuidesType, + Runtime, + SortEventData, + UpdateEventData, +} from '@tmagic/stage'; import ScrollViewer from '@editor/components/ScrollViewer.vue'; import { @@ -90,6 +97,9 @@ export default defineComponent({ runtimeUrl: stageOptions.runtimeUrl, zoom: zoom.value, autoScrollIntoView: stageOptions.autoScrollIntoView, + isContainer: stageOptions.isContainer, + containerHighlightClassName: stageOptions.containerHighlightClassName, + containerHighlightDuration: stageOptions.containerHighlightDuration, canSelect: (el, event, stop) => { const elCanSelect = stageOptions.canSelect(el); // 在组件联动过程中不能再往下选择,返回并触发 ui-select @@ -122,6 +132,10 @@ export default defineComponent({ }); stage?.on('update', (ev: UpdateEventData) => { + if (ev.parentEl) { + services?.editorService.moveToContainer({ id: ev.el.id, style: ev.style }, ev.parentEl.id); + return; + } services?.editorService.update({ id: ev.el.id, style: ev.style }); }); @@ -207,23 +221,50 @@ export default defineComponent({ async dropHandler(e: DragEvent) { e.preventDefault(); - if (e.dataTransfer && page.value && stageContainer.value && stage) { + + const doc = stage?.renderer.contentWindow?.document; + const parentEl: HTMLElement | null | undefined = doc?.querySelector( + `.${stageOptions?.containerHighlightClassName}`, + ); + + let parent: MContainer | undefined = page.value; + if (parentEl) { + parent = services?.editorService.getNodeById(parentEl.id, false) as MContainer; + } + + if (e.dataTransfer && parent && stageContainer.value && stage) { // eslint-disable-next-line no-eval const config = eval(`(${e.dataTransfer.getData('data')})`); - const layout = await services?.editorService.getLayout(page.value); + const layout = await services?.editorService.getLayout(parent); const containerRect = stageContainer.value.getBoundingClientRect(); const { scrollTop, scrollLeft } = stage.mask; + const { style = {} } = config; + + let top = 0; + let left = 0; + let position = 'relative'; + if (layout === Layout.ABSOLUTE) { - config.style = { - ...(config.style || {}), - position: 'absolute', - top: e.clientY - containerRect.top + scrollTop, - left: e.clientX - containerRect.left + scrollLeft, - }; + position = 'absolute'; + top = e.clientY - containerRect.top + scrollTop; + left = e.clientX - containerRect.left + scrollLeft; + + if (parentEl && doc) { + const { left: parentLeft, top: parentTop } = getOffset(parentEl); + left = left - calcValueByFontsize(doc, parentLeft); + top = top - calcValueByFontsize(doc, parentTop); + } } - services?.editorService.add(config, page.value); + config.style = { + ...style, + position, + top, + left, + }; + + services?.editorService.add(config, parent); } }, }; diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index f494e309..5814fd0f 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -33,9 +33,10 @@ import { change2Fixed, COPY_STORAGE_KEY, Fixed2Other, + fixNodeLeft, generatePageNameByApp, + getInitPositionStyle, getNodeIndex, - initPosition, isFixed, setLayout, } from '@editor/utils/editor'; @@ -70,6 +71,7 @@ class Editor extends BaseService { 'paste', 'alignCenter', 'moveLayer', + 'moveToContainer', 'move', 'undo', 'redo', @@ -274,7 +276,7 @@ class Editor extends BaseService { const { type, ...config } = addNode; const curNode = this.get('node'); - let parentNode: MNode | undefined; + let parentNode: MContainer | undefined; const isPage = type === NodeType.PAGE; if (isPage) { @@ -291,12 +293,8 @@ class Editor extends BaseService { if (!parentNode) throw new Error('未找到父元素'); const layout = await this.getLayout(toRaw(parentNode), addNode as MNode); - const newNode = initPosition( - { ...toRaw(await propsService.getPropsValue(type, config)) }, - layout, - parentNode, - this.get('stage'), - ); + const newNode = { ...toRaw(await propsService.getPropsValue(type, config)) }; + newNode.style = getInitPositionStyle(newNode.style, layout, parentNode, this.get('stage')); if ((parentNode?.type === NodeType.ROOT || curNode.type === NodeType.ROOT) && newNode.type !== NodeType.PAGE) { throw new Error('app下不能添加组件'); @@ -305,8 +303,17 @@ class Editor extends BaseService { parentNode?.items?.push(newNode); const stage = this.get('stage'); + const root = this.get('root'); - await stage?.add({ config: cloneDeep(newNode), root: cloneDeep(this.get('root')) }); + await stage?.add({ config: cloneDeep(newNode), root: cloneDeep(root) }); + + if (layout === Layout.ABSOLUTE) { + const fixedLeft = fixNodeLeft(newNode, parentNode, stage?.renderer.contentWindow?.document); + if (typeof fixedLeft !== 'undefined') { + newNode.style.left = fixedLeft; + await stage?.update({ config: cloneDeep(newNode), root: cloneDeep(root) }); + } + } await this.select(newNode); @@ -348,7 +355,7 @@ class Editor extends BaseService { parent.items?.splice(index, 1); const stage = this.get('stage'); - stage?.remove({ id: node.id, root: this.get('root') }); + stage?.remove({ id: node.id, root: cloneDeep(this.get('root')) }); if (node.type === NodeType.PAGE) { this.state.pageLength -= 1; @@ -431,7 +438,7 @@ class Editor extends BaseService { this.set('node', newConfig); } - this.get('stage')?.update({ config: cloneDeep(newConfig), root: this.get('root') }); + this.get('stage')?.update({ config: cloneDeep(newConfig), root: cloneDeep(this.get('root')) }); if (newConfig.type === NodeType.PAGE) { this.set('page', newConfig); @@ -464,7 +471,7 @@ class Editor extends BaseService { await this.update(parent); await this.select(node); - this.get('stage')?.update({ config: cloneDeep(node), root: this.get('root') }); + this.get('stage')?.update({ config: cloneDeep(node), root: cloneDeep(this.get('root')) }); this.addModifiedNodeId(parent.id); this.pushHistoryState(); @@ -544,7 +551,10 @@ class Editor extends BaseService { } await this.update(node); - this.get('stage')?.update({ config: cloneDeep(toRaw(node)), root: this.get('root') }); + this.get('stage')?.update({ + config: cloneDeep(toRaw(node)), + root: cloneDeep(this.get('root')), + }); this.addModifiedNodeId(config.id); this.pushHistoryState(); @@ -569,7 +579,54 @@ class Editor extends BaseService { brothers.splice(index + parseInt(`${offset}`, 10), 0, brothers.splice(index, 1)[0]); } - this.get('stage')?.update({ config: cloneDeep(toRaw(parent)), root: this.get('root') }); + this.get('stage')?.update({ + config: cloneDeep(toRaw(parent)), + root: cloneDeep(this.get('root')), + }); + } + + /** + * 移动到指定容器中 + * @param config 需要移动的节点 + * @param targetId 容器ID + */ + public async moveToContainer(config: MNode, targetId: Id): Promise { + const { node, parent } = this.getNodeInfo(config.id, false); + const target = this.getNodeById(targetId, false) as MContainer; + + const stage = this.get('stage'); + + if (node && parent && stage) { + const root = cloneDeep(this.get('root')); + const index = getNodeIndex(node, parent); + parent.items?.splice(index, 1); + + await stage.remove({ id: node.id, root }); + + const layout = await this.getLayout(target); + + const newConfig = mergeWith(cloneDeep(node), config, (objValue, srcValue) => { + if (Array.isArray(srcValue)) { + return srcValue; + } + }); + newConfig.style = getInitPositionStyle(newConfig.style, layout, target, stage); + + target.items.push(newConfig); + + await stage.select(targetId); + + await stage.update({ config: cloneDeep(target), root }); + + await this.select(newConfig); + stage.select(newConfig.id); + + this.addModifiedNodeId(target.id); + this.addModifiedNodeId(parent.id); + this.pushHistoryState(); + + return newConfig; + } } /** @@ -656,13 +713,13 @@ class Editor extends BaseService { } private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) { - let newConfig = cloneDeep(dist); + const newConfig = cloneDeep(dist); if (!isPop(src) && newConfig.style?.position) { if (isFixed(newConfig) && !isFixed(src)) { - newConfig = change2Fixed(newConfig, root); + newConfig.style = change2Fixed(newConfig, root); } else if (!isFixed(newConfig) && isFixed(src)) { - newConfig = await Fixed2Other(newConfig, root, this.getLayout); + newConfig.style = await Fixed2Other(newConfig, root, this.getLayout); } } diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 4dd7fece..00a2287a 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -49,9 +49,12 @@ export interface Services { export interface StageOptions { runtimeUrl: string; autoScrollIntoView: boolean; + containerHighlightClassName: string; + containerHighlightDuration: number; render: () => HTMLDivElement; moveableOptions: MoveableOptions | ((core?: StageCore) => MoveableOptions); canSelect: (el: HTMLElement) => boolean | Promise; + isContainer: (el: HTMLElement) => boolean | Promise; updateDragEl: (el: HTMLDivElement) => void; } diff --git a/packages/editor/src/utils/editor.ts b/packages/editor/src/utils/editor.ts index a084366a..8eb8228a 100644 --- a/packages/editor/src/utils/editor.ts +++ b/packages/editor/src/utils/editor.ts @@ -81,21 +81,17 @@ export const getNodeIndex = (node: MNode, parent: MContainer | MApp): number => return items.findIndex((item: MNode) => `${item.id}` === `${node.id}`); }; -export const toRelative = (node: MNode) => { - node.style = { - ...(node.style || {}), - position: 'relative', - top: 0, - left: 0, - }; - return node; -}; +export const getRelativeStyle = (style: Record = {}): Record => ({ + ...style, + position: 'relative', + top: 0, + left: 0, +}); -const setTop2Middle = (node: MNode, parentNode: MNode, stage: StageCore) => { - const style = node.style || {}; +const getMiddleTop = (style: Record = {}, parentNode: MNode, stage: StageCore) => { let height = style.height || 0; - if (!stage || typeof style.top !== 'undefined' || !parentNode.style) return style; + if (!stage || typeof style.top !== 'undefined' || !parentNode.style) return style.top; if (!isNumber(height)) { height = 0; @@ -105,43 +101,45 @@ const setTop2Middle = (node: MNode, parentNode: MNode, stage: StageCore) => { if (isPage(parentNode)) { const { scrollTop = 0, wrapperHeight } = stage.mask; - style.top = (wrapperHeight - height) / 2 + scrollTop; - } else { - style.top = (parentHeight - height) / 2; + return (wrapperHeight - height) / 2 + scrollTop; } - - return style; + return (parentHeight - height) / 2; }; -export const initPosition = (node: MNode, layout: Layout, parentNode: MNode, stage: StageCore) => { +export const getInitPositionStyle = ( + style: Record = {}, + layout: Layout, + parentNode: MNode, + stage: StageCore, +) => { if (layout === Layout.ABSOLUTE) { - node.style = { + return { + ...style, position: 'absolute', - ...setTop2Middle(node, parentNode, stage), + top: getMiddleTop(style, parentNode, stage), }; - return node; } if (layout === Layout.RELATIVE) { - return toRelative(node); + return getRelativeStyle(style); } - return node; + return style; }; export const setLayout = (node: MNode, layout: Layout) => { node.items?.forEach((child: MNode) => { if (isPop(child)) return; - child.style = child.style || {}; + const style = child.style || {}; // 是 fixed 不做处理 - if (child.style.position === 'fixed') return; + if (style.position === 'fixed') return; if (layout !== Layout.RELATIVE) { - child.style.position = 'absolute'; + style.position = 'absolute'; } else { - toRelative(child); + child.style = getRelativeStyle(style); child.style.right = 'auto'; child.style.bottom = 'auto'; } @@ -161,11 +159,10 @@ export const change2Fixed = (node: MNode, root: MApp) => { offset.top = offset.top + globalThis.parseFloat(value.style?.top || 0); }); - node.style = { + return { ...(node.style || {}), ...offset, }; - return node; }; export const Fixed2Other = async ( @@ -186,23 +183,23 @@ export const Fixed2Other = async ( offset.left = offset.left - globalThis.parseFloat(value.style?.left || 0); offset.top = offset.top - globalThis.parseFloat(value.style?.top || 0); }); + const style = node.style || {}; const parent = path.pop(); if (!parent) { - return toRelative(node); + return getRelativeStyle(style); } const layout = await getLayout(parent); if (layout !== Layout.RELATIVE) { - node.style = { - ...(node.style || {}), + return { + ...style, ...offset, position: 'absolute', }; - return node; } - return toRelative(node); + return getRelativeStyle(style); }; export const getGuideLineFromCache = (key: string): number[] => { @@ -219,3 +216,16 @@ export const getGuideLineFromCache = (key: string): number[] => { return []; }; + +export const fixNodeLeft = (config: MNode, parent: MContainer, doc?: Document) => { + if (!doc || !config.style || !isNumber(config.style.left)) return config.style?.left; + + const el = doc.getElementById(`${config.id}`); + const parentEl = doc.getElementById(`${parent.id}`); + + if (el && parentEl && el.offsetWidth + config.style?.left > parentEl.offsetWidth) { + return parentEl.offsetWidth - el.offsetWidth; + } + + return config.style.left; +}; diff --git a/packages/editor/tests/unit/utils/editor.spec.ts b/packages/editor/tests/unit/utils/editor.spec.ts index 6576c2e4..4ae227ad 100644 --- a/packages/editor/tests/unit/utils/editor.spec.ts +++ b/packages/editor/tests/unit/utils/editor.spec.ts @@ -17,7 +17,6 @@ */ import { describe, expect, test } from 'vitest'; -import type { MNode } from '@tmagic/schema'; import { NodeType } from '@tmagic/schema'; import * as editor from '@editor/utils/editor'; @@ -138,19 +137,14 @@ describe('getNodeIndex', () => { }); }); -describe('toRelative', () => { +describe('getRelativeStyle', () => { test('正常', () => { - const config: MNode = { - type: 'text', - id: 1, - style: { - color: 'red', - }, - }; - editor.toRelative(config); - expect(config.style?.position).toBe('relative'); - expect(config.style?.top).toBe(0); - expect(config.style?.left).toBe(0); - expect(config.style?.color).toBe('red'); + const style = editor.getRelativeStyle({ + color: 'red', + }); + expect(style?.position).toBe('relative'); + expect(style?.top).toBe(0); + expect(style?.left).toBe(0); + expect(style?.color).toBe('red'); }); }); diff --git a/packages/stage/src/StageCore.ts b/packages/stage/src/StageCore.ts index 9a6ae18b..75315098 100644 --- a/packages/stage/src/StageCore.ts +++ b/packages/stage/src/StageCore.ts @@ -28,6 +28,7 @@ import StageRender from './StageRender'; import { CanSelect, GuidesEventData, + IsContainer, RemoveData, Runtime, SortEventData, @@ -38,16 +39,20 @@ import { import { addSelectedClassName, removeSelectedClassName } from './util'; export default class StageCore extends EventEmitter { + public container?: HTMLDivElement; + public selectedDom: Element | undefined; public highlightedDom: Element | undefined; - public renderer: StageRender; public mask: StageMask; public dr: StageDragResize; public highlightLayer: StageHighlight; public config: StageCoreConfig; public zoom = DEFAULT_ZOOM; - public container?: HTMLDivElement; + public containerHighlightClassName: string; + public containerHighlightDuration: number; + public isContainer: IsContainer; + private canSelect: CanSelect; constructor(config: StageCoreConfig) { @@ -57,6 +62,9 @@ export default class StageCore extends EventEmitter { this.setZoom(config.zoom); this.canSelect = config.canSelect || ((el: HTMLElement) => !!el.id); + this.isContainer = config.isContainer; + this.containerHighlightClassName = config.containerHighlightClassName; + this.containerHighlightDuration = config.containerHighlightDuration; this.renderer = new StageRender({ core: this }); this.mask = new StageMask({ core: this }); @@ -104,7 +112,7 @@ export default class StageCore extends EventEmitter { }); } - public async setElementFromPoint(event: MouseEvent) { + public getElementsFromPoint(event: MouseEvent) { const { renderer, zoom } = this; const doc = renderer.contentWindow?.document; @@ -119,7 +127,11 @@ export default class StageCore extends EventEmitter { } } - const els = doc?.elementsFromPoint(x / zoom, y / zoom) as HTMLElement[]; + return doc?.elementsFromPoint(x / zoom, y / zoom) as HTMLElement[]; + } + + public async setElementFromPoint(event: MouseEvent) { + const els = this.getElementsFromPoint(event); let stopped = false; const stop = () => (stopped = true); diff --git a/packages/stage/src/StageDragResize.ts b/packages/stage/src/StageDragResize.ts index c159dc09..d54a3d2b 100644 --- a/packages/stage/src/StageDragResize.ts +++ b/packages/stage/src/StageDragResize.ts @@ -23,10 +23,12 @@ import type { MoveableOptions } from 'moveable'; import Moveable from 'moveable'; import MoveableHelper from 'moveable-helper'; +import { addClassName, removeClassNameByClassName } from '@tmagic/utils'; + import { DRAG_EL_ID_PREFIX, GHOST_EL_ID_PREFIX, GuidesType, Mode, ZIndex } from './const'; import StageCore from './StageCore'; import type { SortEventData, StageDragResizeConfig } from './types'; -import { getAbsolutePosition, getMode, getOffset } from './util'; +import { calcValueByFontsize, getAbsolutePosition, getMode, getOffset } from './util'; /** 拖动状态 */ enum ActionStatus { @@ -179,22 +181,26 @@ export default class StageDragResize extends EventEmitter { this.elementGuidelines = []; if (this.mode === Mode.ABSOLUTE) { - const frame = document.createDocumentFragment(); - - for (const node of nodes) { - const { width, height } = node.getBoundingClientRect(); - if (node === this.target) continue; - const { left, top } = getOffset(node as HTMLElement); - const elementGuideline = document.createElement('div'); - elementGuideline.style.cssText = `position: absolute;width: ${width}px;height: ${height}px;top: ${top}px;left: ${left}px`; - this.elementGuidelines.push(elementGuideline); - frame.append(elementGuideline); - } - - this.container.append(frame); + this.container.append(this.createGuidelineElements(nodes)); } } + private createGuidelineElements(nodes: HTMLElement[]) { + const frame = globalThis.document.createDocumentFragment(); + + for (const node of nodes) { + const { width, height } = node.getBoundingClientRect(); + if (node === this.target) continue; + const { left, top } = getOffset(node as HTMLElement); + const elementGuideline = globalThis.document.createElement('div'); + elementGuideline.style.cssText = `position: absolute;width: ${width}px;height: ${height}px;top: ${top}px;left: ${left}px`; + this.elementGuidelines.push(elementGuideline); + frame.append(elementGuideline); + } + + return frame; + } + private initMoveable() { this.moveable?.destroy(); @@ -260,6 +266,11 @@ export default class StageDragResize extends EventEmitter { top: 0, }; + let timeout: NodeJS.Timeout | undefined; + + const { contentWindow } = this.core.renderer; + const doc = contentWindow?.document; + this.moveable .on('dragStart', (e) => { if (!this.target) throw new Error('未选中组件'); @@ -278,6 +289,26 @@ export default class StageDragResize extends EventEmitter { .on('drag', (e) => { if (!this.target || !this.dragEl) return; + if (timeout) { + globalThis.clearTimeout(timeout); + timeout = undefined; + } + + timeout = globalThis.setTimeout(async () => { + const els = this.core.getElementsFromPoint(e.inputEvent); + for (const el of els) { + if ( + doc && + !el.id.startsWith(GHOST_EL_ID_PREFIX) && + el !== this.target && + (await this.core.isContainer(el)) + ) { + addClassName(el, doc, this.core.containerHighlightClassName); + break; + } + } + }, this.core.containerHighlightDuration); + this.dragStatus = ActionStatus.ING; // 流式布局 @@ -292,14 +323,29 @@ export default class StageDragResize extends EventEmitter { this.target.style.top = `${frame.top + e.beforeTranslate[1]}px`; }) .on('dragEnd', () => { + if (timeout) { + globalThis.clearTimeout(timeout); + timeout = undefined; + } + + let parentEl: HTMLElement | null = null; + + if (doc) { + parentEl = removeClassNameByClassName(doc, this.core.containerHighlightClassName); + } + // 点击不拖动时会触发dragStart和dragEnd,但是不会有drag事件 if (this.dragStatus === ActionStatus.ING) { - switch (this.mode) { - case Mode.SORTABLE: - this.sort(); - break; - default: - this.update(); + if (parentEl) { + this.update(false, parentEl); + } else { + switch (this.mode) { + case Mode.SORTABLE: + this.sort(); + break; + default: + this.update(); + } } } @@ -381,19 +427,36 @@ export default class StageDragResize extends EventEmitter { } } - private update(isResize = false): void { + private update(isResize = false, parentEl: HTMLElement | null = null): void { if (!this.target) return; + const { contentWindow } = this.core.renderer; + const doc = contentWindow?.document; + + if (!doc) return; + const offset = this.mode === Mode.SORTABLE ? { left: 0, top: 0 } : { left: this.target.offsetLeft, top: this.target.offsetTop }; - const left = this.calcValueByFontsize(offset.left); - const top = this.calcValueByFontsize(offset.top); - const width = this.calcValueByFontsize(this.target.clientWidth); - const height = this.calcValueByFontsize(this.target.clientHeight); + let left = calcValueByFontsize(doc, offset.left); + let top = calcValueByFontsize(doc, offset.top); + const width = calcValueByFontsize(doc, this.target.clientWidth); + const height = calcValueByFontsize(doc, this.target.clientHeight); + + if (parentEl && this.mode === Mode.ABSOLUTE && this.dragEl) { + const [translateX, translateY] = this.moveableHelper?.getFrame(this.dragEl).properties.transform.translate.value; + const { left: parentLeft, top: parentTop } = getOffset(parentEl); + left = + calcValueByFontsize(doc, this.dragEl.offsetLeft) + + parseFloat(translateX) - + calcValueByFontsize(doc, parentLeft); + top = + calcValueByFontsize(doc, this.dragEl.offsetTop) + parseFloat(translateY) - calcValueByFontsize(doc, parentTop); + } this.emit('update', { el: this.target, + parentEl, style: isResize ? { left, top, width, height } : { left, top }, }); } @@ -511,18 +574,6 @@ export default class StageDragResize extends EventEmitter { ...moveableOptions, }; } - - private calcValueByFontsize(value: number) { - const { contentWindow } = this.core.renderer; - const fontSize = contentWindow?.document.documentElement.style.fontSize; - - if (fontSize) { - const times = globalThis.parseFloat(fontSize) / 100; - return (value / times).toFixed(2); - } - - return value; - } } /** diff --git a/packages/stage/src/StageHighlight.ts b/packages/stage/src/StageHighlight.ts index a2676825..943030c8 100644 --- a/packages/stage/src/StageHighlight.ts +++ b/packages/stage/src/StageHighlight.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -/* eslint-disable no-param-reassign */ import { EventEmitter } from 'events'; import Moveable from 'moveable'; diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index d8ddf53a..74add247 100644 --- a/packages/stage/src/StageMask.ts +++ b/packages/stage/src/StageMask.ts @@ -18,21 +18,19 @@ import { throttle } from 'lodash-es'; +import { createDiv, injectStyle } from '@tmagic/utils'; + import { Mode, MouseButton, ZIndex } from './const'; import Rule from './Rule'; import type StageCore from './StageCore'; import type { StageMaskConfig } from './types'; -import { createDiv, getScrollParent, isFixedParent } from './util'; +import { getScrollParent, isFixedParent } from './util'; const wrapperClassName = 'editor-mask-wrapper'; const throttleTime = 100; const hideScrollbar = () => { - const style = globalThis.document.createElement('style'); - style.innerHTML = ` - .${wrapperClassName}::-webkit-scrollbar { width: 0 !important; display: none } - `; - globalThis.document.head.appendChild(style); + injectStyle(globalThis.document, `.${wrapperClassName}::-webkit-scrollbar { width: 0 !important; display: none }`); }; const createContent = (): HTMLDivElement => diff --git a/packages/stage/src/StageRender.ts b/packages/stage/src/StageRender.ts index 84c44d19..eb822fd4 100644 --- a/packages/stage/src/StageRender.ts +++ b/packages/stage/src/StageRender.ts @@ -18,9 +18,11 @@ import { EventEmitter } from 'events'; +import { getHost, injectStyle, isSameDomain } from '@tmagic/utils'; + import StageCore from './StageCore'; +import style from './style.css?raw'; import type { Runtime, RuntimeWindow, StageRenderConfig } from './types'; -import { getHost, isSameDomain } from './util'; export default class StageRender extends EventEmitter { /** 组件的js、css执行的环境,直接渲染为当前window,iframe渲染则为iframe.contentWindow */ @@ -128,5 +130,7 @@ export default class StageRender extends EventEmitter { }, '*', ); + + injectStyle(this.contentWindow.document, style); }; } diff --git a/packages/stage/src/TargetCalibrate.ts b/packages/stage/src/TargetCalibrate.ts index 136352a9..b6efa2c3 100644 --- a/packages/stage/src/TargetCalibrate.ts +++ b/packages/stage/src/TargetCalibrate.ts @@ -16,7 +16,6 @@ * limitations under the License. */ -/* eslint-disable no-param-reassign */ import { EventEmitter } from 'events'; import { Mode } from './const'; diff --git a/packages/stage/src/const.ts b/packages/stage/src/const.ts index 4befc1de..027e7fb4 100644 --- a/packages/stage/src/const.ts +++ b/packages/stage/src/const.ts @@ -25,6 +25,8 @@ export const DRAG_EL_ID_PREFIX = 'drag_el_'; /** 高亮时需要在蒙层中创建一个占位节点,该节点的id前缀 */ export const HIGHLIGHT_EL_ID_PREFIX = 'highlight_el_'; +export const CONTAINER_HIGHLIGHT_CLASS = 'tmagic-stage-container-highlight'; + /** 默认放到缩小倍数 */ export const DEFAULT_ZOOM = 1; diff --git a/packages/stage/src/index.ts b/packages/stage/src/index.ts index 9184729b..8debedf4 100644 --- a/packages/stage/src/index.ts +++ b/packages/stage/src/index.ts @@ -24,4 +24,5 @@ export { default as StageMask } from './StageMask'; export { default as StageDragResize } from './StageDragResize'; export * from './types'; export * from './const'; +export * from './util'; export default StageCore; diff --git a/packages/stage/src/style.css b/packages/stage/src/style.css new file mode 100644 index 00000000..34809631 --- /dev/null +++ b/packages/stage/src/style.css @@ -0,0 +1,10 @@ +.tmagic-stage-container-highlight::after { + content: ''; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + background-color: #000; + opacity: .1; +} diff --git a/packages/stage/src/types.ts b/packages/stage/src/types.ts index 155ebd7f..a8d21890 100644 --- a/packages/stage/src/types.ts +++ b/packages/stage/src/types.ts @@ -27,6 +27,7 @@ import StageDragResize from './StageDragResize'; import StageMask from './StageMask'; export type CanSelect = (el: HTMLElement, event: MouseEvent, stop: () => boolean) => boolean | Promise; +export type IsContainer = (el: HTMLElement) => boolean | Promise; export type StageCoreConfig = { /** 需要对齐的dom节点的CSS选择器字符串 */ @@ -34,6 +35,9 @@ export type StageCoreConfig = { /** 放大倍数,默认1倍 */ zoom?: number; canSelect?: CanSelect; + isContainer: IsContainer; + containerHighlightClassName: string; + containerHighlightDuration: number; moveableOptions?: ((core?: StageCore) => MoveableOptions) | MoveableOptions; /** runtime 的HTML地址,可以是一个HTTP地址,如果和编辑器不同域,需要设置跨域,也可以是一个相对或绝对路径 */ runtimeUrl?: string; @@ -72,6 +76,7 @@ export interface GuidesEventData { export interface UpdateEventData { el: HTMLElement; + parentEl: HTMLElement | null; ghostEl: HTMLElement; style: { width?: number; diff --git a/packages/stage/src/util.ts b/packages/stage/src/util.ts index e7f2a554..f617550c 100644 --- a/packages/stage/src/util.ts +++ b/packages/stage/src/util.ts @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { removeClassName } from '@tmagic/utils'; import { Mode, SELECTED_CLASS } from './const'; import type { Offset } from './types'; @@ -63,16 +64,6 @@ export const getAbsolutePosition = (el: HTMLElement, { top, left }: Offset) => { return { left, top }; }; -export const getHost = (targetUrl: string) => targetUrl.match(/\/\/([^/]+)/)?.[1]; - -export const isSameDomain = (targetUrl = '', source = globalThis.location.host) => { - const isHttpUrl = /^(http[s]?:)?\/\//.test(targetUrl); - - if (!isHttpUrl) return true; - - return getHost(targetUrl) === source; -}; - export const isAbsolute = (style: CSSStyleDeclaration): boolean => style.position === 'absolute'; export const isRelative = (style: CSSStyleDeclaration): boolean => style.position === 'relative'; @@ -123,21 +114,14 @@ export const getScrollParent = (element: HTMLElement, includeHidden = false): HT 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; -}; - export const removeSelectedClassName = (doc: Document) => { const oldEl = doc.querySelector(`.${SELECTED_CLASS}`); if (oldEl) { - oldEl.classList.remove(SELECTED_CLASS); - (oldEl.parentNode as HTMLDivElement)?.classList.remove(`${SELECTED_CLASS}-parent`); + removeClassName(oldEl, SELECTED_CLASS); + if (oldEl.parentNode) removeClassName(oldEl.parentNode as Element, `${SELECTED_CLASS}-parent`); doc.querySelectorAll(`.${SELECTED_CLASS}-parents`).forEach((item) => { - item.classList.remove(`${SELECTED_CLASS}-parents`); + removeClassName(item, `${SELECTED_CLASS}-parents`); }); } }; @@ -149,3 +133,14 @@ export const addSelectedClassName = (el: Element, doc: Document) => { item.classList.add(`${SELECTED_CLASS}-parents`); }); }; + +export const calcValueByFontsize = (doc: Document, value: number) => { + const { fontSize } = doc.documentElement.style; + + if (fontSize) { + const times = globalThis.parseFloat(fontSize) / 100; + return Number((value / times).toFixed(2)); + } + + return value; +}; diff --git a/packages/stage/src/vite-env.d.ts b/packages/stage/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/stage/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/stage/tests/unit/util.spec.ts b/packages/stage/tests/unit/util.spec.ts index e9327eb8..7a283df4 100644 --- a/packages/stage/tests/unit/util.spec.ts +++ b/packages/stage/tests/unit/util.spec.ts @@ -15,6 +15,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { beforeEach, describe, expect, test } from 'vitest'; import * as util from '../../src/util'; @@ -76,7 +77,7 @@ describe('getOffset', () => { globalThis.document.body.appendChild(root); }); - it('没有offsetParent, 没有left、top', () => { + test('没有offsetParent, 没有left、top', () => { div.style.cssText = `width: 100px; height: 100px`; root.appendChild(div); const offset = util.getOffset(div); @@ -84,7 +85,7 @@ describe('getOffset', () => { expect(offset.top).toBe(0); }); - it('没有offsetParent, 有left、top', () => { + test('没有offsetParent, 有left、top', () => { const el = createElement(); root.appendChild(el); const offset = util.getOffset(el); @@ -92,7 +93,7 @@ describe('getOffset', () => { expect(offset.top).toBe(100); }); - it('有offsetParent, 没有left、top', () => { + test('有offsetParent, 没有left、top', () => { const parent = createElement(); div.style.cssText = `width: 100px; height: 100px`; parent.appendChild(div); @@ -116,7 +117,7 @@ describe('getAbsolutePosition', () => { globalThis.document.body.appendChild(root); }); - it('有offsetParent', () => { + test('有offsetParent', () => { const parent = createElement(); div.style.cssText = `width: 100px; height: 100px`; parent.appendChild(div); @@ -126,7 +127,7 @@ describe('getAbsolutePosition', () => { expect(offset.top).toBe(0); }); - it('没有offsetParent', () => { + test('没有offsetParent', () => { const el = createElement(); root.appendChild(el); const offset = util.getAbsolutePosition(el, { left: 100, top: 100 }); @@ -134,27 +135,3 @@ describe('getAbsolutePosition', () => { expect(offset.top).toBe(100); }); }); - -describe('getHost', () => { - it('正常', () => { - const host = util.getHost('https://film.qq.com/index.html'); - expect(host).toBe('film.qq.com'); - }); -}); - -describe('isSameDomain', () => { - it('正常', () => { - const flag = util.isSameDomain('https://film.qq.com/index.html', 'film.qq.com'); - expect(flag).toBeTruthy(); - }); - - it('不正常', () => { - const flag = util.isSameDomain('https://film.qq.com/index.html', 'test.film.qq.com'); - expect(flag).toBeFalsy(); - }); - - it('不是http', () => { - const flag = util.isSameDomain('ftp://film.qq.com/index.html', 'test.film.qq.com'); - expect(flag).toBeTruthy(); - }); -}); diff --git a/packages/utils/src/dom.ts b/packages/utils/src/dom.ts new file mode 100644 index 00000000..f14f4e0d --- /dev/null +++ b/packages/utils/src/dom.ts @@ -0,0 +1,110 @@ +export const asyncLoadJs = (() => { + // 正在加载或加载成功的存入此Map中 + const documentMap = new Map(); + + return (url: string, crossOrigin?: string, document = globalThis.document) => { + let loaded = documentMap.get(document); + if (!loaded) { + loaded = new Map(); + documentMap.set(document, loaded); + } + + // 正在加载或已经加载成功的,直接返回 + if (loaded.get(url)) return loaded.get(url); + + const load = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.type = 'text/javascript'; + if (crossOrigin) { + script.crossOrigin = crossOrigin; + } + script.src = url; + document.body.appendChild(script); + script.onload = () => { + resolve(); + }; + script.onerror = () => { + reject(new Error('加载失败')); + }; + setTimeout(() => { + reject(new Error('timeout')); + }, 60 * 1000); + }).catch((err) => { + // 加载失败的,从map中移除,第二次加载时,可以再次执行加载 + loaded.delete(url); + throw err; + }); + + loaded.set(url, load); + return loaded.get(url); + }; +})(); + +export const asyncLoadCss = (() => { + // 正在加载或加载成功的存入此Map中 + const documentMap = new Map(); + + return (url: string, document = globalThis.document) => { + let loaded = documentMap.get(document); + if (!loaded) { + loaded = new Map(); + documentMap.set(document, loaded); + } + + // 正在加载或已经加载成功的,直接返回 + if (loaded.get(url)) return loaded.get(url); + + const load = new Promise((resolve, reject) => { + const node = document.createElement('link'); + node.rel = 'stylesheet'; + node.href = url; + document.head.appendChild(node); + node.onload = () => { + resolve(); + }; + node.onerror = () => { + reject(new Error('加载失败')); + }; + setTimeout(() => { + reject(new Error('timeout')); + }, 60 * 1000); + }).catch((err) => { + // 加载失败的,从map中移除,第二次加载时,可以再次执行加载 + loaded.delete(url); + throw err; + }); + + loaded.set(url, load); + return loaded.get(url); + }; +})(); + +export const addClassName = (el: Element, doc: Document, className: string) => { + const oldEl = doc.querySelector(`.${className}`); + if (oldEl && oldEl !== el) removeClassName(oldEl, className); + if (!el.classList.contains(className)) el.classList.add(className); +}; + +export const removeClassName = (el: Element, className: string) => { + el.classList.remove(className); +}; + +export const removeClassNameByClassName = (doc: Document, className: string) => { + const el: HTMLElement | null = doc.querySelector(`.${className}`); + el?.classList.remove(className); + return el; +}; + +export const injectStyle = (doc: Document, style: string) => { + const styleEl = doc.createElement('style'); + styleEl.innerHTML = style; + doc.head.appendChild(styleEl); + return styleEl; +}; + +export const createDiv = ({ className, cssText }: { className: string; cssText: string }) => { + const el = globalThis.document.createElement('div'); + el.className = className; + el.style.cssText = cssText; + return el; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index cad0eb71..7604e739 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -21,6 +21,8 @@ import moment from 'moment'; import type { MNode } from '@tmagic/schema'; import { NodeType } from '@tmagic/schema'; +export * from './dom'; + export const sleep = (ms: number): Promise => new Promise((resolve) => { const timer = setTimeout(() => { @@ -56,87 +58,6 @@ export const datetimeFormatter = (v: string | Date, defaultValue = '-', f = 'YYY return defaultValue; }; -export const asyncLoadJs = (() => { - // 正在加载或加载成功的存入此Map中 - const documentMap = new Map(); - - return (url: string, crossOrigin?: string, document = globalThis.document) => { - let loaded = documentMap.get(document); - if (!loaded) { - loaded = new Map(); - documentMap.set(document, loaded); - } - - // 正在加载或已经加载成功的,直接返回 - if (loaded.get(url)) return loaded.get(url); - - const load = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.type = 'text/javascript'; - if (crossOrigin) { - script.crossOrigin = crossOrigin; - } - script.src = url; - document.body.appendChild(script); - script.onload = () => { - resolve(); - }; - script.onerror = () => { - reject(new Error('加载失败')); - }; - setTimeout(() => { - reject(new Error('timeout')); - }, 60 * 1000); - }).catch((err) => { - // 加载失败的,从map中移除,第二次加载时,可以再次执行加载 - loaded.delete(url); - throw err; - }); - - loaded.set(url, load); - return loaded.get(url); - }; -})(); - -export const asyncLoadCss = (() => { - // 正在加载或加载成功的存入此Map中 - const documentMap = new Map(); - - return (url: string, document = globalThis.document) => { - let loaded = documentMap.get(document); - if (!loaded) { - loaded = new Map(); - documentMap.set(document, loaded); - } - - // 正在加载或已经加载成功的,直接返回 - if (loaded.get(url)) return loaded.get(url); - - const load = new Promise((resolve, reject) => { - const node = document.createElement('link'); - node.rel = 'stylesheet'; - node.href = url; - document.head.appendChild(node); - node.onload = () => { - resolve(); - }; - node.onerror = () => { - reject(new Error('加载失败')); - }; - setTimeout(() => { - reject(new Error('timeout')); - }, 60 * 1000); - }).catch((err) => { - // 加载失败的,从map中移除,第二次加载时,可以再次执行加载 - loaded.delete(url); - throw err; - }); - - loaded.set(url, load); - return loaded.get(url); - }; -})(); - // 驼峰转换横线 export const toLine = (name = '') => name.replace(/\B([A-Z])/g, '-$1').toLowerCase(); @@ -209,3 +130,13 @@ export const isPop = (node: MNode): boolean => Boolean(node.type?.toLowerCase(). export const isPage = (node: MNode): boolean => Boolean(node.type?.toLowerCase() === NodeType.PAGE); export const isNumber = (value: string) => /^(-?\d+)(\.\d+)?$/.test(value); + +export const getHost = (targetUrl: string) => targetUrl.match(/\/\/([^/]+)/)?.[1]; + +export const isSameDomain = (targetUrl = '', source = globalThis.location.host) => { + const isHttpUrl = /^(http[s]?:)?\/\//.test(targetUrl); + + if (!isHttpUrl) return true; + + return getHost(targetUrl) === source; +}; diff --git a/packages/utils/tests/unit/index.spec.ts b/packages/utils/tests/unit/index.spec.ts index e3e3b6db..e37f7c48 100644 --- a/packages/utils/tests/unit/index.spec.ts +++ b/packages/utils/tests/unit/index.spec.ts @@ -275,3 +275,27 @@ describe('isPop', () => { ).toBeFalsy(); }); }); + +describe('getHost', () => { + test('正常', () => { + const host = util.getHost('https://film.qq.com/index.html'); + expect(host).toBe('film.qq.com'); + }); +}); + +describe('isSameDomain', () => { + test('正常', () => { + const flag = util.isSameDomain('https://film.qq.com/index.html', 'film.qq.com'); + expect(flag).toBeTruthy(); + }); + + test('不正常', () => { + const flag = util.isSameDomain('https://film.qq.com/index.html', 'test.film.qq.com'); + expect(flag).toBeFalsy(); + }); + + test('不是http', () => { + const flag = util.isSameDomain('ftp://film.qq.com/index.html', 'test.film.qq.com'); + expect(flag).toBeTruthy(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 39b6716e..7f1c21f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ include: [ './packages/editor/tests/unit/utils/**', './packages/editor/tests/unit/services/**', + './packages/stage/tests/**', './packages/utils/tests/**', ], environment: 'jsdom',