diff --git a/packages/editor/src/layouts/workspace/Stage.vue b/packages/editor/src/layouts/workspace/Stage.vue index b8fbd124..64e88a01 100644 --- a/packages/editor/src/layouts/workspace/Stage.vue +++ b/packages/editor/src/layouts/workspace/Stage.vue @@ -15,7 +15,7 @@ @dragover="dragoverHandler" > - + @@ -67,6 +67,9 @@ export default defineComponent({ }, setup() { + let stage: StageCore | null = null; + let runtime: Runtime | null = null; + const services = inject('services'); const stageOptions = inject('stageOptions'); @@ -74,6 +77,7 @@ export default defineComponent({ const stageContainer = ref(); const menu = ref>(); + const isMultiSelect = computed(() => services?.editorService.get('nodes')?.length > 1); const stageRect = computed(() => services?.uiService.get('stageRect')); const uiSelectMode = computed(() => services?.uiService.get('uiSelectMode')); const root = computed(() => services?.editorService.get('root')); @@ -81,9 +85,6 @@ export default defineComponent({ const zoom = computed(() => services?.uiService.get('zoom') || 1); const node = computed(() => services?.editorService.get('node')); - let stage: StageCore | null = null; - let runtime: Runtime | null = null; - const getGuideLineKey = (key: string) => `${key}_${root.value?.id}_${page.value?.id}`; watchEffect(() => { @@ -131,6 +132,10 @@ export default defineComponent({ services?.editorService.highlight(el.id); }); + stage?.on('multiSelect', (els: HTMLElement[]) => { + services?.editorService.multiSelect(els.map((el) => el.id)); + }); + stage?.on('update', (ev: UpdateEventData) => { if (ev.parentEl) { services?.editorService.moveToContainer({ id: ev.el.id, style: ev.style }, ev.parentEl.id); @@ -206,6 +211,7 @@ export default defineComponent({ menu, stageRect, zoom, + isMultiSelect, contextmenuHandler(e: MouseEvent) { e.preventDefault(); diff --git a/packages/editor/src/layouts/workspace/ViewerMenu.vue b/packages/editor/src/layouts/workspace/ViewerMenu.vue index 8d469194..54fc2154 100644 --- a/packages/editor/src/layouts/workspace/ViewerMenu.vue +++ b/packages/editor/src/layouts/workspace/ViewerMenu.vue @@ -6,8 +6,9 @@ import { computed, defineComponent, inject, markRaw, onMounted, reactive, ref, watch } from 'vue'; import { Bottom, Delete, DocumentCopy, Top } from '@element-plus/icons-vue'; -import { NodeType } from '@tmagic/schema'; -import type StageCore from '@tmagic/stage'; +import { MNode, NodeType } from '@tmagic/schema'; +import StageCore from '@tmagic/stage'; +import { isPage } from '@tmagic/utils'; import ContentMenu from '@editor/components/ContentMenu.vue'; import { LayerOffset, Layout, MenuItem, Services } from '@editor/type'; @@ -16,19 +17,130 @@ import { COPY_STORAGE_KEY } from '@editor/utils/editor'; export default defineComponent({ components: { ContentMenu }, - setup() { + props: { + isMultiSelect: { + type: Boolean, + default: false, + }, + }, + + setup(props) { const services = inject('services'); const editorService = services?.editorService; const menu = ref>(); const canPaste = ref(false); const canCenter = ref(false); - const node = computed(() => editorService?.get('node')); + const node = computed(() => editorService?.get('node')); + const nodes = computed(() => editorService?.get('nodes')); const parent = computed(() => editorService?.get('parent')); - const isPage = computed(() => node.value?.type === NodeType.PAGE); + const stage = computed(() => editorService?.get('stage')); const stageContentMenu = inject('stageContentMenu', []); + const menuData = reactive([ + { + type: 'button', + text: '水平居中', + display: () => canCenter.value && !props.isMultiSelect, + handler: () => { + if (!node.value) return; + editorService?.alignCenter(node.value); + }, + }, + { + type: 'button', + text: '复制', + icon: markRaw(DocumentCopy), + handler: () => { + nodes.value && editorService?.copy(nodes.value); + canPaste.value = true; + }, + }, + { + type: 'button', + text: '粘贴', + display: () => canPaste.value, + handler: () => { + const rect = menu.value?.$el.getBoundingClientRect(); + const parentRect = stage.value?.container?.getBoundingClientRect(); + const initialLeft = (rect?.left || 0) - (parentRect?.left || 0); + const initialTop = (rect?.top || 0) - (parentRect?.top || 0); + + if (!nodes.value || nodes.value.length === 0) return; + editorService?.paste({ left: initialLeft, top: initialTop }); + }, + }, + { + type: 'divider', + direction: 'horizontal', + display: () => { + if (!node.value) return false; + return !isPage(node.value); + }, + }, + { + type: 'button', + text: '上移一层', + icon: markRaw(Top), + display: () => !isPage(node.value) && !props.isMultiSelect, + handler: () => { + editorService?.moveLayer(1); + }, + }, + { + type: 'button', + text: '下移一层', + icon: markRaw(Bottom), + display: () => !isPage(node.value) && !props.isMultiSelect, + handler: () => { + editorService?.moveLayer(-1); + }, + }, + { + type: 'button', + text: '置顶', + display: () => !isPage(node.value) && !props.isMultiSelect, + handler: () => { + editorService?.moveLayer(LayerOffset.TOP); + }, + }, + { + type: 'button', + text: '置底', + display: () => !isPage(node.value) && !props.isMultiSelect, + handler: () => { + editorService?.moveLayer(LayerOffset.BOTTOM); + }, + }, + { + type: 'divider', + direction: 'horizontal', + display: () => !isPage(node.value) && !props.isMultiSelect, + }, + { + type: 'button', + text: '删除', + icon: Delete, + display: () => !isPage(node.value), + handler: () => { + nodes.value && editorService?.remove(nodes.value); + }, + }, + { + type: 'divider', + direction: 'horizontal', + }, + { + type: 'button', + text: '清空参考线', + handler: () => { + editorService?.get('stage').clearGuides(); + }, + }, + ...stageContentMenu, + ]); + onMounted(() => { const data = globalThis.localStorage.getItem(COPY_STORAGE_KEY); canPaste.value = data !== 'undefined' && !!data; @@ -39,122 +151,18 @@ export default defineComponent({ async () => { if (!parent.value || !editorService) return (canCenter.value = false); const layout = await editorService.getLayout(parent.value); - canCenter.value = - [Layout.ABSOLUTE, Layout.FIXED].includes(layout) && - ![NodeType.ROOT, NodeType.PAGE, 'pop'].includes(`${node.value?.type}`); + const isLayoutConform = [Layout.ABSOLUTE, Layout.FIXED].includes(layout); + const isTypeConform = nodes.value?.every( + (selectedNode) => ![NodeType.ROOT, NodeType.PAGE, 'pop'].includes(`${selectedNode?.type}`), + ); + canCenter.value = isLayoutConform && !!isTypeConform; }, { immediate: true }, ); return { menu, - menuData: reactive([ - { - type: 'button', - text: '水平居中', - display: () => canCenter.value, - handler: () => { - node.value && editorService?.alignCenter(node.value); - }, - }, - { - type: 'button', - text: '复制', - icon: markRaw(DocumentCopy), - handler: () => { - node.value && editorService?.copy(node.value); - canPaste.value = true; - }, - }, - { - type: 'button', - text: '粘贴', - display: () => canPaste.value, - handler: () => { - const stage = editorService?.get('stage'); - - const rect = menu.value?.$el.getBoundingClientRect(); - const parentRect = stage?.container?.getBoundingClientRect(); - let left = (rect?.left || 0) - (parentRect?.left || 0); - let top = (rect?.top || 0) - (parentRect?.top || 0); - - if (node.value?.items && stage) { - const parentEl = stage.renderer.contentWindow?.document.getElementById(`${node.value.id}`); - const parentElRect = parentEl?.getBoundingClientRect(); - left = left - (parentElRect?.left || 0); - top = top - (parentElRect?.top || 0); - } - - editorService?.paste({ left, top }); - }, - }, - { - type: 'divider', - direction: 'horizontal', - display: () => !isPage.value, - }, - { - type: 'button', - text: '上移一层', - icon: markRaw(Top), - display: () => !isPage.value, - handler: () => { - editorService?.moveLayer(1); - }, - }, - { - type: 'button', - text: '下移一层', - icon: markRaw(Bottom), - display: () => !isPage.value, - handler: () => { - editorService?.moveLayer(-1); - }, - }, - { - type: 'button', - text: '置顶', - display: () => !isPage.value, - handler: () => { - editorService?.moveLayer(LayerOffset.TOP); - }, - }, - { - type: 'button', - text: '置底', - display: () => !isPage.value, - handler: () => { - editorService?.moveLayer(LayerOffset.BOTTOM); - }, - }, - { - type: 'divider', - direction: 'horizontal', - display: () => !isPage.value, - }, - { - type: 'button', - text: '删除', - icon: Delete, - display: () => !isPage.value, - handler: () => { - node.value && editorService?.remove(node.value); - }, - }, - { - type: 'divider', - direction: 'horizontal', - }, - { - type: 'button', - text: '清空参考线', - handler: () => { - editorService?.get('stage').clearGuides(); - }, - }, - ...stageContentMenu, - ]), - + menuData, show(e: MouseEvent) { menu.value?.show(e); }, diff --git a/packages/editor/src/layouts/workspace/Workspace.vue b/packages/editor/src/layouts/workspace/Workspace.vue index d96f3ab0..47579326 100644 --- a/packages/editor/src/layouts/workspace/Workspace.vue +++ b/packages/editor/src/layouts/workspace/Workspace.vue @@ -34,7 +34,7 @@ export default defineComponent({ setup() { const services = inject('services'); const workspace = ref(); - const node = computed(() => services?.editorService.get('node')); + const nodes = computed(() => services?.editorService.get('nodes')); let keycon: KeyController; const mouseenterHandler = () => { @@ -58,27 +58,27 @@ export default defineComponent({ keycon .keyup('delete', (e) => { e.inputEvent.preventDefault(); - if (!node.value || isPage(node.value)) return; - services?.editorService.remove(node.value); + if (!nodes.value || isPage(nodes.value[0])) return; + services?.editorService.remove(nodes.value); }) .keyup('backspace', (e) => { e.inputEvent.preventDefault(); - if (!node.value || isPage(node.value)) return; - services?.editorService.remove(node.value); + if (!nodes.value || isPage(nodes.value[0])) return; + services?.editorService.remove(nodes.value); }) .keydown([ctrl, 'c'], (e) => { e.inputEvent.preventDefault(); - node.value && services?.editorService.copy(node.value); + nodes.value && services?.editorService.copy(nodes.value); }) .keydown([ctrl, 'v'], (e) => { e.inputEvent.preventDefault(); - node.value && services?.editorService.paste(); + nodes.value && services?.editorService.paste(); }) .keydown([ctrl, 'x'], (e) => { e.inputEvent.preventDefault(); - if (!node.value || isPage(node.value)) return; - services?.editorService.copy(node.value); - services?.editorService.remove(node.value); + if (!nodes.value || isPage(nodes.value[0])) return; + services?.editorService.copy(nodes.value); + services?.editorService.remove(nodes.value); }) .keydown([ctrl, 'z'], (e) => { e.inputEvent.preventDefault(); diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index d578f621..aae5d2e6 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -17,7 +17,7 @@ */ import { reactive, toRaw } from 'vue'; -import { cloneDeep, mergeWith } from 'lodash-es'; +import { cloneDeep, mergeWith, uniq } from 'lodash-es'; import serialize from 'serialize-javascript'; import type { Id, MApp, MComponent, MContainer, MNode, MPage } from '@tmagic/schema'; @@ -26,20 +26,18 @@ import StageCore from '@tmagic/stage'; import { getNodePath, isNumber, isPage, isPop } from '@tmagic/utils'; import historyService, { StepValue } from '@editor/services/history'; -import propsService from '@editor/services/props'; -import type { AddMNode, EditorNodeInfo, StoreState } from '@editor/type'; +import type { AddMNode, EditorNodeInfo, PastePosition, StoreState } from '@editor/type'; import { LayerOffset, Layout } from '@editor/type'; import { change2Fixed, COPY_STORAGE_KEY, Fixed2Other, - fixNodeLeft, - generatePageNameByApp, getInitPositionStyle, getNodeIndex, isFixed, setLayout, } from '@editor/utils/editor'; +import { beforeAdd, beforePaste, beforeRemove, notifyAddToStage } from '@editor/utils/operator'; import BaseService from './BaseService'; @@ -51,6 +49,7 @@ class Editor extends BaseService { page: null, parent: null, node: null, + nodes: [], stage: null, highlightNode: null, modifiedNodeIds: new Map(), @@ -83,13 +82,16 @@ class Editor extends BaseService { /** * 设置当前指点节点配置 - * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' + * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' * @param value MNode * @returns MNode */ public set(name: keyof StoreState, value: T) { this.state[name] = value as any; - + // set nodes时将node设置为nodes第一个元素 + if (name === 'nodes') { + this.set('node', (value as unknown as MNode[])[0]); + } if (name === 'root') { this.state.pageLength = (value as unknown as MApp)?.items?.length || 0; this.emit('root-change', value); @@ -98,7 +100,7 @@ class Editor extends BaseService { /** * 获取当前指点节点配置 - * @param name 'root' | 'page' | 'parent' | 'node' + * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' * @returns MNode */ public get(name: keyof StoreState): T { @@ -190,7 +192,7 @@ class Editor extends BaseService { */ public async select(config: MNode | Id): Promise | never { const { node, page, parent } = this.selectedConfigExceptionHandler(config); - this.set('node', node); + this.set('nodes', [node]); this.set('page', page || null); this.set('parent', parent || null); @@ -264,6 +266,31 @@ class Editor extends BaseService { this.set('highlightNode', node); } + /** + * 多选 + * @param ids 指定节点ID + * @returns 加入多选的节点配置 + */ + public multiSelect(ids: Id[]): void { + const nodes: MNode[] = []; + const idsUnique = uniq(ids); + idsUnique.forEach((id) => { + const { node } = this.getNodeInfo(id); + if (!node) return; + nodes.push(node); + }); + this.set('nodes', nodes); + } + + /** + * 批量向容器添加节点 + * @param configs 将要添加的节点数组 + * @returns 添加后的节点 + */ + public async multiAdd(configs: MNode[]): Promise { + return await Promise.all(configs.map((configItem) => this.add(configItem as AddMNode))); + } + /** * 向指点容器添加组件节点 * @param addConfig 将要添加的组件节点配置 @@ -271,51 +298,13 @@ class Editor extends BaseService { * @returns 添加后的节点 */ public async add(addNode: AddMNode, parent?: MContainer | null): Promise { - // 加入inputEvent是为给业务扩展时可以获取到更多的信息,只有在使用拖拽添加组件时才有改对象 - const { type, inputEvent, ...config } = addNode; - const curNode = this.get('node'); - - let parentNode: MContainer | undefined; - const isPage = type === NodeType.PAGE; - - if (isPage) { - parentNode = this.get('root'); - // 由于支持中间件扩展,在parent参数为undefined时,parent会变成next函数 - } else if (parent && typeof parent !== 'function') { - parentNode = parent; - } else if (curNode.items) { - parentNode = curNode; - } else { - parentNode = this.getParentById(curNode.id, false); - } - - if (!parentNode) throw new Error('未找到父元素'); - - const layout = await this.getLayout(toRaw(parentNode), addNode as MNode); - 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下不能添加组件'); - } - - parentNode?.items?.push(newNode); - const stage = this.get('stage'); - const root = this.get('root'); - - await stage?.add({ config: cloneDeep(newNode), parent: cloneDeep(parentNode), 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) }); - } - } - + const { parentNode, newNode, layout, isPage } = await beforeAdd(addNode, parent); + // 将新增元素事件通知到stage以更新渲染 + await notifyAddToStage(parentNode, newNode, layout); + // 触发选中样式 await this.select(newNode); - + // 增加历史记录 this.addModifiedNodeId(newNode.id); if (!isPage) { this.pushHistoryState(); @@ -338,55 +327,9 @@ class Editor extends BaseService { * @param {Object} node * @return {Object} 删除的组件配置 */ - public async remove(node: MNode): Promise { - if (!node?.id) return; - - const root = this.get('root'); - - if (!root) throw new Error('没有root'); - - const { parent, node: curNode } = this.getNodeInfo(node.id, false); - - if (!parent || !curNode) throw new Error('找不要删除的节点'); - - const index = getNodeIndex(curNode, parent); - - if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点'); - - parent.items?.splice(index, 1); - const stage = this.get('stage'); - stage?.remove({ id: node.id, root: cloneDeep(this.get('root')) }); - - if (node.type === NodeType.PAGE) { - this.state.pageLength -= 1; - - if (root.items[0]) { - await this.select(root.items[0]); - stage?.select(root.items[0].id); - } else { - this.set('node', null); - this.set('parent', null); - this.set('page', null); - this.set('stage', null); - this.set('highlightNode', null); - this.resetModifiedNodeId(); - historyService.reset(); - - this.emit('remove', node); - - return node; - } - } else { - await this.select(parent); - stage?.select(parent.id); - } - - this.addModifiedNodeId(parent.id); - this.pushHistoryState(); - - this.emit('remove', node); - - return node; + public async remove(nodes: MNode | MNode[]): Promise<(MNode | void)[]> { + const removeNodes = Array.isArray(nodes) ? nodes : [nodes]; + return await Promise.all(removeNodes.map(async (node) => await this.doRemove(node))); } /** @@ -434,9 +377,11 @@ class Editor extends BaseService { parentNodeItems[index] = newConfig; - if (`${newConfig.id}` === `${this.get('node').id}`) { - this.set('node', newConfig); - } + // 将update后的配置更新到nodes中 + const nodes = this.get('nodes'); + const targetIndex = nodes.findIndex((nodeItem: MNode) => `${nodeItem.id}` === `${newConfig.id}`); + nodes.splice(targetIndex, 1, newConfig); + this.set('nodes', nodes); this.get('stage')?.update({ config: cloneDeep(newConfig), root: cloneDeep(this.get('root')) }); @@ -482,23 +427,22 @@ class Editor extends BaseService { * @param config 组件节点配置 * @returns 组件节点配置 */ - public async copy(config: MNode): Promise { - globalThis.localStorage.setItem(COPY_STORAGE_KEY, serialize(config)); + public async copy(config: MNode | MNode[]): Promise { + globalThis.localStorage.setItem(COPY_STORAGE_KEY, serialize(Array.isArray(config) ? config : [config])); } /** * 从localStorage中获取节点,然后添加到当前容器中 - * @param position 如果设置,指定组件位置 + * @param position 粘贴的坐标 * @returns 添加后的组件节点配置 */ - public async paste(position: { left?: number; top?: number } = {}): Promise { + public async paste(position: PastePosition = {}): Promise { const configStr = globalThis.localStorage.getItem(COPY_STORAGE_KEY); // eslint-disable-next-line prefer-const let config: any = {}; if (!configStr) { return; } - try { // eslint-disable-next-line no-eval eval(`config = ${configStr}`); @@ -506,20 +450,9 @@ class Editor extends BaseService { console.error(e); return; } + const pasteConfigs = await beforePaste(position, config); - config = await propsService.setNewItemId(config, this.get('root')); - if (config.style) { - config.style = { - ...config.style, - ...position, - }; - } - - if (isPage(config)) { - config.name = generatePageNameByApp(this.get('root')); - } - - return await this.add(config); + return await this.multiAdd(pasteConfigs); } /** @@ -673,6 +606,7 @@ class Editor extends BaseService { this.removeAllListeners(); this.set('root', null); this.set('node', null); + this.set('nodes', []); this.set('page', null); this.set('parent', null); } @@ -749,6 +683,47 @@ class Editor extends BaseService { page, }; } + + private async doRemove(node: MNode): Promise { + const beforeRemoveRes = beforeRemove(node); + if (!beforeRemoveRes) return; + const { parent, root } = beforeRemoveRes; + + const stage = this.get('stage'); + stage?.remove({ id: node.id, root: cloneDeep(this.get('root')) }); + + if (node.type === NodeType.PAGE) { + this.state.pageLength -= 1; + + if (root.items[0]) { + await this.select(root.items[0]); + stage?.select(root.items[0].id); + } else { + this.set('node', null); + this.set('nodes', []); + this.set('parent', null); + this.set('page', null); + this.set('stage', null); + this.set('highlightNode', null); + this.resetModifiedNodeId(); + historyService.reset(); + + this.emit('remove', node); + + return node; + } + } else { + await this.select(parent); + stage?.select(parent.id); + } + + this.addModifiedNodeId(parent.id); + this.pushHistoryState(); + + this.emit('remove', node); + + return node; + } } export type EditorService = Editor; diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 50f0b074..1e6eb63d 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -64,6 +64,7 @@ export interface StoreState { parent: MContainer | null; node: MNode | null; highlightNode: MNode | null; + nodes: MNode[]; stage: StageCore | null; modifiedNodeIds: Map; pageLength: number; @@ -129,6 +130,11 @@ export interface AddMNode { [key: string]: any; } +export interface PastePosition { + left?: number; + top?: number; +} + /** * 菜单按钮 */ diff --git a/packages/editor/src/utils/operator.ts b/packages/editor/src/utils/operator.ts new file mode 100644 index 00000000..df58292a --- /dev/null +++ b/packages/editor/src/utils/operator.ts @@ -0,0 +1,169 @@ +import { toRaw } from 'vue'; +import { cloneDeep } from 'lodash-es'; + +import { Id, MApp, MContainer, MNode, NodeType } from '@tmagic/schema'; +import StageCore from '@tmagic/stage'; +import { isPage } from '@tmagic/utils'; + +import editorService from '@editor/services/editor'; +import propsService from '@editor/services/props'; +import { AddMNode, Layout, PastePosition } from '@editor/type'; +import { fixNodeLeft, generatePageNameByApp, getInitPositionStyle, getNodeIndex } from '@editor/utils/editor'; + +/** + * 粘贴前置操作:返回分配了新id以及校准了坐标的配置 + * @param position 粘贴的坐标,如果为空则默认在元素坐标基础上偏移10px + * @param config 待粘贴的元素配置(复制时保存的那份配置) + * @returns + */ +export const beforePaste = async (position: PastePosition, config: MNode[]) => { + if (!config[0]?.style) return config; + const curNode = editorService.get('node'); + // 将数组中第一个元素的坐标作为参照点 + const { left: referenceLeft, top: referenceTop } = config[0].style; + // 坐标校准后的粘贴数据 + const pasteConfigs: MNode[] = await Promise.all( + config.map(async (configItem: MNode): Promise => { + let pastePosition = position; + if (curNode.items) { + // 如果粘贴时选中了容器,则将元素粘贴到容器内,坐标需要转换为相对于容器的坐标 + pastePosition = getPositionInContainer(pastePosition, curNode.id); + } + + // 将所有待粘贴元素坐标相对于多选第一个元素坐标重新计算,以保证多选粘贴后元素间距不变 + if (pastePosition.left && configItem.style?.left) { + pastePosition.left = configItem.style.left - referenceLeft + pastePosition.left; + } + if (pastePosition.top && configItem.style?.top) { + pastePosition.top = configItem.style?.top - referenceTop + pastePosition.top; + } + + const pasteConfig = await propsService.setNewItemId(configItem, editorService.get('root')); + if (pasteConfig.style) { + pasteConfig.style = { + ...pasteConfig.style, + ...pastePosition, + }; + } + if (isPage(pasteConfig)) { + pasteConfig.name = generatePageNameByApp(editorService.get('root')); + } + return pasteConfig as MNode; + }), + ); + return pasteConfigs; +}; + +/** + * 新增元素前置操作,实现了在编辑器中新增元素节点,并返回新增元素信息以供stage更新 + * @param addNode 待添加元素的配置 + * @param parent 父级容器(可选) + * @returns 新增的元素,父级元素,布局方式,是否为根页面 + */ +export const beforeAdd = async ( + addNode: AddMNode, + parent?: MContainer | null, +): Promise<{ parentNode: MContainer; newNode: MNode; layout: Layout; isPage: boolean }> => { + // 加入inputEvent是为给业务扩展时可以获取到更多的信息,只有在使用拖拽添加组件时才有改对象 + const { type, inputEvent, ...config } = addNode; + const curNode = editorService.get('node'); + + let parentNode: MContainer | undefined; + const isPage = type === NodeType.PAGE; + + if (isPage) { + parentNode = editorService.get('root'); + // 由于支持中间件扩展,在parent参数为undefined时,parent会变成next函数 + } else if (parent && typeof parent !== 'function') { + parentNode = parent; + } else if (curNode.items) { + parentNode = curNode; + } else { + parentNode = editorService.getParentById(curNode.id, false); + } + + if (!parentNode) throw new Error('未找到父元素'); + + const layout = await editorService.getLayout(toRaw(parentNode), addNode as MNode); + const newNode = { ...toRaw(await propsService.getPropsValue(type, config)) }; + newNode.style = getInitPositionStyle(newNode.style, layout, parentNode, editorService.get('stage')); + + if ((parentNode?.type === NodeType.ROOT || curNode.type === NodeType.ROOT) && newNode.type !== NodeType.PAGE) { + throw new Error('app下不能添加组件'); + } + // 新增节点添加到配置中 + parentNode?.items?.push(newNode); + // 返回新增信息以供stage更新 + return { + parentNode, + newNode, + layout, + isPage, + }; +}; + +/** + * 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标 + * @param position PastePosition 粘贴时相对于画布的坐标 + * @param id 元素id + * @returns PastePosition 转换后的坐标 + */ +export const getPositionInContainer = (position: PastePosition = {}, id: Id) => { + let { left = 0, top = 0 } = position; + const parentEl = editorService.get('stage')?.renderer?.contentWindow?.document.getElementById(`${id}`); + const parentElRect = parentEl?.getBoundingClientRect(); + left = left - (parentElRect?.left || 0); + top = top - (parentElRect?.top || 0); + return { + left, + top, + }; +}; + +/** + * 将新增元素事件通知到stage以更新渲染 + * @param parentNode 父元素 + * @param newNode 当前新增元素 + * @param layout 布局方式 + */ +export const notifyAddToStage = async (parentNode: MContainer, newNode: MNode, layout: Layout) => { + const stage = editorService.get('stage'); + const root = editorService.get('root'); + + await stage?.add({ config: cloneDeep(newNode), parent: cloneDeep(parentNode), root: cloneDeep(root) }); + + if (layout === Layout.ABSOLUTE) { + const fixedLeft = fixNodeLeft(newNode, parentNode, stage?.renderer.contentWindow?.document); + if (typeof fixedLeft !== 'undefined' && newNode.style) { + newNode.style.left = fixedLeft; + await stage?.update({ config: cloneDeep(newNode), root: cloneDeep(root) }); + } + } +}; + +/** + * 删除前置操作:实现了在编辑器中删除元素节点,并返回父级元素信息以供stage更新 + * @param node 待删除的节点 + * @returns 父级元素,root根元素 + */ +export const beforeRemove = (node: MNode): { parent: MContainer; root: MApp } | void => { + if (!node?.id) return; + + const root = editorService.get('root'); + + if (!root) throw new Error('没有root'); + + const { parent, node: curNode } = editorService.getNodeInfo(node.id, false); + + if (!parent || !curNode) throw new Error('找不要删除的节点'); + + const index = getNodeIndex(curNode, parent); + + if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点'); + // 从配置中删除元素 + parent.items?.splice(index, 1); + return { + parent, + root, + }; +}; diff --git a/packages/editor/tests/unit/services/editor.spec.ts b/packages/editor/tests/unit/services/editor.spec.ts index 273ef6fe..5c8f5bcc 100644 --- a/packages/editor/tests/unit/services/editor.spec.ts +++ b/packages/editor/tests/unit/services/editor.spec.ts @@ -353,7 +353,7 @@ describe('copy', () => { const node = editorService.getNodeById(NodeId.NODE_ID2); await editorService.copy(node!); const str = globalThis.localStorage.getItem(COPY_STORAGE_KEY); - expect(str).toBe(JSON.stringify(node)); + expect(str).toBe(JSON.stringify([node])); }); }); diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index ec43699e..623e881c 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -70,4 +70,9 @@ export interface MApp extends MComponent { items: MPage[]; } +export interface PastePosition { + left?: number; + top?: number; +} + export type MNode = MComponent | MContainer | MPage | MApp; diff --git a/packages/stage/src/StageMask.ts b/packages/stage/src/StageMask.ts index fb7f4ad7..b7bf0e4f 100644 --- a/packages/stage/src/StageMask.ts +++ b/packages/stage/src/StageMask.ts @@ -331,7 +331,9 @@ export default class StageMask extends Rule { private mouseUpHandler = (): void => { globalThis.document.removeEventListener('mouseup', this.mouseUpHandler); this.content.addEventListener('mousemove', this.highlightHandler); - this.emit('select'); + if (!this.isMultiSelectStatus) { + this.emit('select'); + } }; private mouseWheelHandler = (event: WheelEvent) => { diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 7604e739..897c89e2 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -127,7 +127,10 @@ export const getUrlParam = (param: string, url?: string) => { export const isPop = (node: MNode): boolean => Boolean(node.type?.toLowerCase().endsWith('pop')); -export const isPage = (node: MNode): boolean => Boolean(node.type?.toLowerCase() === NodeType.PAGE); +export const isPage = (node: MNode | undefined): boolean => { + if (!node) return false; + return Boolean(node.type?.toLowerCase() === NodeType.PAGE); +}; export const isNumber = (value: string) => /^(-?\d+)(\.\d+)?$/.test(value);