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" @dragover="dragoverHandler"
></div> ></div>
<teleport to="body"> <teleport to="body">
<viewer-menu ref="menu"></viewer-menu> <viewer-menu ref="menu" :is-multi-select="isMultiSelect"></viewer-menu>
</teleport> </teleport>
</scroll-viewer> </scroll-viewer>
</template> </template>
@ -67,6 +67,9 @@ export default defineComponent({
}, },
setup() { setup() {
let stage: StageCore | null = null;
let runtime: Runtime | null = null;
const services = inject<Services>('services'); const services = inject<Services>('services');
const stageOptions = inject<StageOptions>('stageOptions'); const stageOptions = inject<StageOptions>('stageOptions');
@ -74,6 +77,7 @@ export default defineComponent({
const stageContainer = ref<HTMLDivElement>(); const stageContainer = ref<HTMLDivElement>();
const menu = ref<InstanceType<typeof ViewerMenu>>(); const menu = ref<InstanceType<typeof ViewerMenu>>();
const isMultiSelect = computed(() => services?.editorService.get('nodes')?.length > 1);
const stageRect = computed(() => services?.uiService.get<StageRect>('stageRect')); const stageRect = computed(() => services?.uiService.get<StageRect>('stageRect'));
const uiSelectMode = computed(() => services?.uiService.get<boolean>('uiSelectMode')); const uiSelectMode = computed(() => services?.uiService.get<boolean>('uiSelectMode'));
const root = computed(() => services?.editorService.get<MApp>('root')); 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 zoom = computed(() => services?.uiService.get<number>('zoom') || 1);
const node = computed(() => services?.editorService.get<MNode>('node')); 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}`; const getGuideLineKey = (key: string) => `${key}_${root.value?.id}_${page.value?.id}`;
watchEffect(() => { watchEffect(() => {
@ -131,6 +132,10 @@ export default defineComponent({
services?.editorService.highlight(el.id); services?.editorService.highlight(el.id);
}); });
stage?.on('multiSelect', (els: HTMLElement[]) => {
services?.editorService.multiSelect(els.map((el) => el.id));
});
stage?.on('update', (ev: UpdateEventData) => { stage?.on('update', (ev: UpdateEventData) => {
if (ev.parentEl) { if (ev.parentEl) {
services?.editorService.moveToContainer({ id: ev.el.id, style: ev.style }, ev.parentEl.id); services?.editorService.moveToContainer({ id: ev.el.id, style: ev.style }, ev.parentEl.id);
@ -206,6 +211,7 @@ export default defineComponent({
menu, menu,
stageRect, stageRect,
zoom, zoom,
isMultiSelect,
contextmenuHandler(e: MouseEvent) { contextmenuHandler(e: MouseEvent) {
e.preventDefault(); e.preventDefault();

View File

@ -6,8 +6,9 @@
import { computed, defineComponent, inject, markRaw, onMounted, reactive, ref, watch } from 'vue'; import { computed, defineComponent, inject, markRaw, onMounted, reactive, ref, watch } from 'vue';
import { Bottom, Delete, DocumentCopy, Top } from '@element-plus/icons-vue'; import { Bottom, Delete, DocumentCopy, Top } from '@element-plus/icons-vue';
import { NodeType } from '@tmagic/schema'; import { MNode, NodeType } from '@tmagic/schema';
import type StageCore from '@tmagic/stage'; import StageCore from '@tmagic/stage';
import { isPage } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue'; import ContentMenu from '@editor/components/ContentMenu.vue';
import { LayerOffset, Layout, MenuItem, Services } from '@editor/type'; import { LayerOffset, Layout, MenuItem, Services } from '@editor/type';
@ -16,45 +17,35 @@ import { COPY_STORAGE_KEY } from '@editor/utils/editor';
export default defineComponent({ export default defineComponent({
components: { ContentMenu }, components: { ContentMenu },
setup() { props: {
isMultiSelect: {
type: Boolean,
default: false,
},
},
setup(props) {
const services = inject<Services>('services'); const services = inject<Services>('services');
const editorService = services?.editorService; const editorService = services?.editorService;
const menu = ref<InstanceType<typeof ContentMenu>>(); const menu = ref<InstanceType<typeof ContentMenu>>();
const canPaste = ref(false); const canPaste = ref(false);
const canCenter = 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 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 stageContentMenu = inject<MenuItem[]>('stageContentMenu', []);
onMounted(() => { const menuData = reactive<MenuItem[]>([
const data = globalThis.localStorage.getItem(COPY_STORAGE_KEY);
canPaste.value = data !== 'undefined' && !!data;
});
watch(
parent,
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}`);
},
{ immediate: true },
);
return {
menu,
menuData: reactive<MenuItem[]>([
{ {
type: 'button', type: 'button',
text: '水平居中', text: '水平居中',
display: () => canCenter.value, display: () => canCenter.value && !props.isMultiSelect,
handler: () => { handler: () => {
node.value && editorService?.alignCenter(node.value); if (!node.value) return;
editorService?.alignCenter(node.value);
}, },
}, },
{ {
@ -62,7 +53,7 @@ export default defineComponent({
text: '复制', text: '复制',
icon: markRaw(DocumentCopy), icon: markRaw(DocumentCopy),
handler: () => { handler: () => {
node.value && editorService?.copy(node.value); nodes.value && editorService?.copy(nodes.value);
canPaste.value = true; canPaste.value = true;
}, },
}, },
@ -71,33 +62,28 @@ export default defineComponent({
text: '粘贴', text: '粘贴',
display: () => canPaste.value, display: () => canPaste.value,
handler: () => { handler: () => {
const stage = editorService?.get<StageCore>('stage');
const rect = menu.value?.$el.getBoundingClientRect(); const rect = menu.value?.$el.getBoundingClientRect();
const parentRect = stage?.container?.getBoundingClientRect(); const parentRect = stage.value?.container?.getBoundingClientRect();
let left = (rect?.left || 0) - (parentRect?.left || 0); const initialLeft = (rect?.left || 0) - (parentRect?.left || 0);
let top = (rect?.top || 0) - (parentRect?.top || 0); const initialTop = (rect?.top || 0) - (parentRect?.top || 0);
if (node.value?.items && stage) { if (!nodes.value || nodes.value.length === 0) return;
const parentEl = stage.renderer.contentWindow?.document.getElementById(`${node.value.id}`); editorService?.paste({ left: initialLeft, top: initialTop });
const parentElRect = parentEl?.getBoundingClientRect();
left = left - (parentElRect?.left || 0);
top = top - (parentElRect?.top || 0);
}
editorService?.paste({ left, top });
}, },
}, },
{ {
type: 'divider', type: 'divider',
direction: 'horizontal', direction: 'horizontal',
display: () => !isPage.value, display: () => {
if (!node.value) return false;
return !isPage(node.value);
},
}, },
{ {
type: 'button', type: 'button',
text: '上移一层', text: '上移一层',
icon: markRaw(Top), icon: markRaw(Top),
display: () => !isPage.value, display: () => !isPage(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService?.moveLayer(1); editorService?.moveLayer(1);
}, },
@ -106,7 +92,7 @@ export default defineComponent({
type: 'button', type: 'button',
text: '下移一层', text: '下移一层',
icon: markRaw(Bottom), icon: markRaw(Bottom),
display: () => !isPage.value, display: () => !isPage(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService?.moveLayer(-1); editorService?.moveLayer(-1);
}, },
@ -114,7 +100,7 @@ export default defineComponent({
{ {
type: 'button', type: 'button',
text: '置顶', text: '置顶',
display: () => !isPage.value, display: () => !isPage(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService?.moveLayer(LayerOffset.TOP); editorService?.moveLayer(LayerOffset.TOP);
}, },
@ -122,7 +108,7 @@ export default defineComponent({
{ {
type: 'button', type: 'button',
text: '置底', text: '置底',
display: () => !isPage.value, display: () => !isPage(node.value) && !props.isMultiSelect,
handler: () => { handler: () => {
editorService?.moveLayer(LayerOffset.BOTTOM); editorService?.moveLayer(LayerOffset.BOTTOM);
}, },
@ -130,15 +116,15 @@ export default defineComponent({
{ {
type: 'divider', type: 'divider',
direction: 'horizontal', direction: 'horizontal',
display: () => !isPage.value, display: () => !isPage(node.value) && !props.isMultiSelect,
}, },
{ {
type: 'button', type: 'button',
text: '删除', text: '删除',
icon: Delete, icon: Delete,
display: () => !isPage.value, display: () => !isPage(node.value),
handler: () => { handler: () => {
node.value && editorService?.remove(node.value); nodes.value && editorService?.remove(nodes.value);
}, },
}, },
{ {
@ -153,8 +139,30 @@ export default defineComponent({
}, },
}, },
...stageContentMenu, ...stageContentMenu,
]), ]);
onMounted(() => {
const data = globalThis.localStorage.getItem(COPY_STORAGE_KEY);
canPaste.value = data !== 'undefined' && !!data;
});
watch(
parent,
async () => {
if (!parent.value || !editorService) return (canCenter.value = false);
const layout = await editorService.getLayout(parent.value);
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,
show(e: MouseEvent) { show(e: MouseEvent) {
menu.value?.show(e); menu.value?.show(e);
}, },

View File

@ -34,7 +34,7 @@ export default defineComponent({
setup() { setup() {
const services = inject<Services>('services'); const services = inject<Services>('services');
const workspace = ref<HTMLDivElement>(); const workspace = ref<HTMLDivElement>();
const node = computed(() => services?.editorService.get<MNode>('node')); const nodes = computed(() => services?.editorService.get<MNode[]>('nodes'));
let keycon: KeyController; let keycon: KeyController;
const mouseenterHandler = () => { const mouseenterHandler = () => {
@ -58,27 +58,27 @@ export default defineComponent({
keycon keycon
.keyup('delete', (e) => { .keyup('delete', (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();
if (!node.value || isPage(node.value)) return; if (!nodes.value || isPage(nodes.value[0])) return;
services?.editorService.remove(node.value); services?.editorService.remove(nodes.value);
}) })
.keyup('backspace', (e) => { .keyup('backspace', (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();
if (!node.value || isPage(node.value)) return; if (!nodes.value || isPage(nodes.value[0])) return;
services?.editorService.remove(node.value); services?.editorService.remove(nodes.value);
}) })
.keydown([ctrl, 'c'], (e) => { .keydown([ctrl, 'c'], (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();
node.value && services?.editorService.copy(node.value); nodes.value && services?.editorService.copy(nodes.value);
}) })
.keydown([ctrl, 'v'], (e) => { .keydown([ctrl, 'v'], (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();
node.value && services?.editorService.paste(); nodes.value && services?.editorService.paste();
}) })
.keydown([ctrl, 'x'], (e) => { .keydown([ctrl, 'x'], (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();
if (!node.value || isPage(node.value)) return; if (!nodes.value || isPage(nodes.value[0])) return;
services?.editorService.copy(node.value); services?.editorService.copy(nodes.value);
services?.editorService.remove(node.value); services?.editorService.remove(nodes.value);
}) })
.keydown([ctrl, 'z'], (e) => { .keydown([ctrl, 'z'], (e) => {
e.inputEvent.preventDefault(); e.inputEvent.preventDefault();

View File

@ -17,7 +17,7 @@
*/ */
import { reactive, toRaw } from 'vue'; import { reactive, toRaw } from 'vue';
import { cloneDeep, mergeWith } from 'lodash-es'; import { cloneDeep, mergeWith, uniq } from 'lodash-es';
import serialize from 'serialize-javascript'; import serialize from 'serialize-javascript';
import type { Id, MApp, MComponent, MContainer, MNode, MPage } from '@tmagic/schema'; 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 { getNodePath, isNumber, isPage, isPop } from '@tmagic/utils';
import historyService, { StepValue } from '@editor/services/history'; import historyService, { StepValue } from '@editor/services/history';
import propsService from '@editor/services/props'; import type { AddMNode, EditorNodeInfo, PastePosition, StoreState } from '@editor/type';
import type { AddMNode, EditorNodeInfo, StoreState } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type'; import { LayerOffset, Layout } from '@editor/type';
import { import {
change2Fixed, change2Fixed,
COPY_STORAGE_KEY, COPY_STORAGE_KEY,
Fixed2Other, Fixed2Other,
fixNodeLeft,
generatePageNameByApp,
getInitPositionStyle, getInitPositionStyle,
getNodeIndex, getNodeIndex,
isFixed, isFixed,
setLayout, setLayout,
} from '@editor/utils/editor'; } from '@editor/utils/editor';
import { beforeAdd, beforePaste, beforeRemove, notifyAddToStage } from '@editor/utils/operator';
import BaseService from './BaseService'; import BaseService from './BaseService';
@ -51,6 +49,7 @@ class Editor extends BaseService {
page: null, page: null,
parent: null, parent: null,
node: null, node: null,
nodes: [],
stage: null, stage: null,
highlightNode: null, highlightNode: null,
modifiedNodeIds: new Map(), 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 * @param value MNode
* @returns MNode * @returns MNode
*/ */
public set<T = MNode>(name: keyof StoreState, value: T) { public set<T = MNode>(name: keyof StoreState, value: T) {
this.state[name] = value as any; 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') { if (name === 'root') {
this.state.pageLength = (value as unknown as MApp)?.items?.length || 0; this.state.pageLength = (value as unknown as MApp)?.items?.length || 0;
this.emit('root-change', value); 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 * @returns MNode
*/ */
public get<T = MNode>(name: keyof StoreState): T { 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 { public async select(config: MNode | Id): Promise<MNode> | never {
const { node, page, parent } = this.selectedConfigExceptionHandler(config); const { node, page, parent } = this.selectedConfigExceptionHandler(config);
this.set('node', node); this.set('nodes', [node]);
this.set('page', page || null); this.set('page', page || null);
this.set('parent', parent || null); this.set('parent', parent || null);
@ -264,6 +266,31 @@ class Editor extends BaseService {
this.set('highlightNode', node); 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 * @param addConfig
@ -271,51 +298,13 @@ class Editor extends BaseService {
* @returns * @returns
*/ */
public async add(addNode: AddMNode, parent?: MContainer | null): Promise<MNode> { 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 stage = this.get<StageCore | null>('stage');
const root = this.get<MApp>('root'); const { parentNode, newNode, layout, isPage } = await beforeAdd(addNode, parent);
// 将新增元素事件通知到stage以更新渲染
await stage?.add({ config: cloneDeep(newNode), parent: cloneDeep(parentNode), root: cloneDeep(root) }); await notifyAddToStage(parentNode, newNode, layout);
// 触发选中样式
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); await this.select(newNode);
// 增加历史记录
this.addModifiedNodeId(newNode.id); this.addModifiedNodeId(newNode.id);
if (!isPage) { if (!isPage) {
this.pushHistoryState(); this.pushHistoryState();
@ -338,55 +327,9 @@ class Editor extends BaseService {
* @param {Object} node * @param {Object} node
* @return {Object} * @return {Object}
*/ */
public async remove(node: MNode): Promise<MNode | void> { public async remove(nodes: MNode | MNode[]): Promise<(MNode | void)[]> {
if (!node?.id) return; const removeNodes = Array.isArray(nodes) ? nodes : [nodes];
return await Promise.all(removeNodes.map(async (node) => await this.doRemove(node)));
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;
} }
/** /**
@ -434,9 +377,11 @@ class Editor extends BaseService {
parentNodeItems[index] = newConfig; parentNodeItems[index] = newConfig;
if (`${newConfig.id}` === `${this.get('node').id}`) { // 将update后的配置更新到nodes中
this.set('node', newConfig); 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')) }); this.get<StageCore | null>('stage')?.update({ config: cloneDeep(newConfig), root: cloneDeep(this.get('root')) });
@ -482,23 +427,22 @@ class Editor extends BaseService {
* @param config * @param config
* @returns * @returns
*/ */
public async copy(config: MNode): Promise<void> { public async copy(config: MNode | MNode[]): Promise<void> {
globalThis.localStorage.setItem(COPY_STORAGE_KEY, serialize(config)); globalThis.localStorage.setItem(COPY_STORAGE_KEY, serialize(Array.isArray(config) ? config : [config]));
} }
/** /**
* localStorage中获取节点 * localStorage中获取节点
* @param position * @param position
* @returns * @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); const configStr = globalThis.localStorage.getItem(COPY_STORAGE_KEY);
// eslint-disable-next-line prefer-const // eslint-disable-next-line prefer-const
let config: any = {}; let config: any = {};
if (!configStr) { if (!configStr) {
return; return;
} }
try { try {
// eslint-disable-next-line no-eval // eslint-disable-next-line no-eval
eval(`config = ${configStr}`); eval(`config = ${configStr}`);
@ -506,20 +450,9 @@ class Editor extends BaseService {
console.error(e); console.error(e);
return; return;
} }
const pasteConfigs = await beforePaste(position, config);
config = await propsService.setNewItemId(config, this.get('root')); return await this.multiAdd(pasteConfigs);
if (config.style) {
config.style = {
...config.style,
...position,
};
}
if (isPage(config)) {
config.name = generatePageNameByApp(this.get('root'));
}
return await this.add(config);
} }
/** /**
@ -673,6 +606,7 @@ class Editor extends BaseService {
this.removeAllListeners(); this.removeAllListeners();
this.set('root', null); this.set('root', null);
this.set('node', null); this.set('node', null);
this.set('nodes', []);
this.set('page', null); this.set('page', null);
this.set('parent', null); this.set('parent', null);
} }
@ -749,6 +683,47 @@ class Editor extends BaseService {
page, 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; export type EditorService = Editor;

View File

@ -64,6 +64,7 @@ export interface StoreState {
parent: MContainer | null; parent: MContainer | null;
node: MNode | null; node: MNode | null;
highlightNode: MNode | null; highlightNode: MNode | null;
nodes: MNode[];
stage: StageCore | null; stage: StageCore | null;
modifiedNodeIds: Map<Id, Id>; modifiedNodeIds: Map<Id, Id>;
pageLength: number; pageLength: number;
@ -129,6 +130,11 @@ export interface AddMNode {
[key: string]: any; [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); const node = editorService.getNodeById(NodeId.NODE_ID2);
await editorService.copy(node!); await editorService.copy(node!);
const str = globalThis.localStorage.getItem(COPY_STORAGE_KEY); 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[]; items: MPage[];
} }
export interface PastePosition {
left?: number;
top?: number;
}
export type MNode = MComponent | MContainer | MPage | MApp; export type MNode = MComponent | MContainer | MPage | MApp;

View File

@ -331,7 +331,9 @@ export default class StageMask extends Rule {
private mouseUpHandler = (): void => { private mouseUpHandler = (): void => {
globalThis.document.removeEventListener('mouseup', this.mouseUpHandler); globalThis.document.removeEventListener('mouseup', this.mouseUpHandler);
this.content.addEventListener('mousemove', this.highlightHandler); this.content.addEventListener('mousemove', this.highlightHandler);
if (!this.isMultiSelectStatus) {
this.emit('select'); this.emit('select');
}
}; };
private mouseWheelHandler = (event: WheelEvent) => { 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 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); export const isNumber = (value: string) => /^(-?\d+)(\.\d+)?$/.test(value);