feat(editor): 多选菜单支持复制粘贴删除 (#217)

* feat(editor): 多选菜单支持复制粘贴删除

* fix(editor): 编辑器选中节点统一为nodes数组,保留原node对象为nodes数组的第一个元素,将复制粘贴删除行为封装到editorservice中,支持键盘快捷键

* test(editor): 修改editor相关测试用例

* fix(editor): cr问题修改

* feat(editor): 将复制粘贴操作进行拆分封装

* fix(editor): cr修改

Co-authored-by: parisma <parisma@tencent.com>
This commit is contained in:
khuntoriia 2022-08-03 14:03:36 +08:00 committed by GitHub
parent a02fd2c695
commit b702857aad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 428 additions and 254 deletions

View File

@ -15,7 +15,7 @@
@dragover="dragoverHandler"
></div>
<teleport to="body">
<viewer-menu ref="menu"></viewer-menu>
<viewer-menu ref="menu" :is-multi-select="isMultiSelect"></viewer-menu>
</teleport>
</scroll-viewer>
</template>
@ -67,6 +67,9 @@ export default defineComponent({
},
setup() {
let stage: StageCore | null = null;
let runtime: Runtime | null = null;
const services = inject<Services>('services');
const stageOptions = inject<StageOptions>('stageOptions');
@ -74,6 +77,7 @@ export default defineComponent({
const stageContainer = ref<HTMLDivElement>();
const menu = ref<InstanceType<typeof ViewerMenu>>();
const isMultiSelect = computed(() => services?.editorService.get('nodes')?.length > 1);
const stageRect = computed(() => services?.uiService.get<StageRect>('stageRect'));
const uiSelectMode = computed(() => services?.uiService.get<boolean>('uiSelectMode'));
const root = computed(() => services?.editorService.get<MApp>('root'));
@ -81,9 +85,6 @@ export default defineComponent({
const zoom = computed(() => services?.uiService.get<number>('zoom') || 1);
const node = computed(() => services?.editorService.get<MNode>('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();

View File

@ -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>('services');
const editorService = services?.editorService;
const menu = ref<InstanceType<typeof ContentMenu>>();
const canPaste = ref(false);
const canCenter = ref(false);
const node = computed(() => editorService?.get('node'));
const node = computed(() => editorService?.get<MNode>('node'));
const nodes = computed(() => editorService?.get<MNode[]>('nodes'));
const parent = computed(() => editorService?.get('parent'));
const isPage = computed(() => node.value?.type === NodeType.PAGE);
const stage = computed(() => editorService?.get<StageCore>('stage'));
const stageContentMenu = inject<MenuItem[]>('stageContentMenu', []);
const menuData = reactive<MenuItem[]>([
{
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<StageCore>('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<MenuItem[]>([
{
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<StageCore>('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<StageCore>('stage').clearGuides();
},
},
...stageContentMenu,
]),
menuData,
show(e: MouseEvent) {
menu.value?.show(e);
},

View File

@ -34,7 +34,7 @@ export default defineComponent({
setup() {
const services = inject<Services>('services');
const workspace = ref<HTMLDivElement>();
const node = computed(() => services?.editorService.get<MNode>('node'));
const nodes = computed(() => services?.editorService.get<MNode[]>('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();

View File

@ -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<T = MNode>(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<T = MNode>(name: keyof StoreState): T {
@ -190,7 +192,7 @@ class Editor extends BaseService {
*/
public async select(config: MNode | Id): Promise<MNode> | 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<MNode[]> {
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<MNode> {
// 加入inputEvent是为给业务扩展时可以获取到更多的信息只有在使用拖拽添加组件时才有改对象
const { type, inputEvent, ...config } = addNode;
const curNode = this.get<MContainer>('node');
let parentNode: MContainer | undefined;
const isPage = type === NodeType.PAGE;
if (isPage) {
parentNode = this.get<MApp>('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<StageCore>('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<StageCore | null>('stage');
const root = this.get<MApp>('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<MNode | void> {
if (!node?.id) return;
const root = this.get<MApp | null>('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<StageCore | null>('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<StageCore | null>('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<void> {
globalThis.localStorage.setItem(COPY_STORAGE_KEY, serialize(config));
public async copy(config: MNode | MNode[]): Promise<void> {
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<MNode | void> {
public async paste(position: PastePosition = {}): Promise<MNode[] | void> {
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<MNode | void> {
const beforeRemoveRes = beforeRemove(node);
if (!beforeRemoveRes) return;
const { parent, root } = beforeRemoveRes;
const stage = this.get<StageCore | null>('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;

View File

@ -64,6 +64,7 @@ export interface StoreState {
parent: MContainer | null;
node: MNode | null;
highlightNode: MNode | null;
nodes: MNode[];
stage: StageCore | null;
modifiedNodeIds: Map<Id, Id>;
pageLength: number;
@ -129,6 +130,11 @@ export interface AddMNode {
[key: string]: any;
}
export interface PastePosition {
left?: number;
top?: number;
}
/**
*
*/

View File

@ -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<MContainer>('node');
// 将数组中第一个元素的坐标作为参照点
const { left: referenceLeft, top: referenceTop } = config[0].style;
// 坐标校准后的粘贴数据
const pasteConfigs: MNode[] = await Promise.all(
config.map(async (configItem: MNode): Promise<MNode> => {
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<MContainer>('node');
let parentNode: MContainer | undefined;
const isPage = type === NodeType.PAGE;
if (isPage) {
parentNode = editorService.get<MApp>('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<StageCore>('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<StageCore>('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<StageCore | null>('stage');
const root = editorService.get<MApp>('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<MApp | null>('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,
};
};

View File

@ -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]));
});
});

View File

@ -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;

View File

@ -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) => {

View File

@ -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);