1
0
mirror of synced 2026-05-20 17:28:42 +08:00
Files
tmagic-editor/packages/editor/src/services/editor.ts
roymondchen 05e512b1fe feat(editor): 新增 DSL 修改方法的 doNotSelect 选项
- add/remove/sort/alignCenter/moveToContainer/paste 新增 doNotSelect 选项,控制操作后是否自动触发选中变化
- doUpdate/doRemove 改为始终同步当前选中列表中的节点引用,避免 state 持有已被替换/已删除的过期节点
- 顺手修复 doUpdate 在 splice(-1) 时误改最后一个选中项的 bug
- 移除 update/doUpdate 的 selectedAfterUpdate 参数(语义已内化),move 不再暴露无意义的 doNotSelect
- 新增 safeOptions / safeParent 辅助函数,兜底插件机制将 dispatch 注入到形参位置的场景

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 17:20:04 +08:00

1200 lines
37 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { reactive, toRaw } from 'vue';
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
import historyService from '@editor/services/history';
import storageService, { Protocol } from '@editor/services/storage';
import type {
AddMNode,
AsyncHookPlugin,
AsyncMethodName,
EditorEvents,
EditorNodeInfo,
HistoryOpType,
PastePosition,
StepValue,
StoreState,
StoreStateKey,
} from '@editor/type';
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import {
calcAlignCenterStyle,
calcLayerTargetIndex,
calcMoveStyle,
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY,
editorNodeMergeCustomizer,
fixNodePosition,
getInitPositionStyle,
getNodeIndex,
getPageFragmentList,
getPageList,
moveItemsInContainer,
resolveSelectedNode,
setChildrenLayout,
setLayout,
toggleFixedPosition,
} from '@editor/utils/editor';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
/**
* 经过 BaseService 的插件 / 中间件包装后,源方法的最后一个形参可能被注入为 dispatch 函数
* 当 options 形参位置被注入为函数(或为 null将其归一为空对象避免后续逻辑误读
*/
const safeOptions = <T extends object>(options: unknown): T => {
const empty = {};
if (!options || typeof options === 'function') return empty as T;
return options as T;
};
/**
* 经过 BaseService 的插件 / 中间件包装后,源方法的形参可能被注入为 dispatch 函数
* 当 parent 形参位置被注入为函数(或为空值)时,归一为 null由调用方继续走默认 parent 逻辑
*/
const safeParent = (parent: unknown): MContainer | null => {
if (!parent || typeof parent === 'function') return null;
return parent as MContainer;
};
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
page: null,
parent: null,
node: null,
nodes: [],
stage: null,
stageLoading: true,
highlightNode: null,
modifiedNodeIds: new Map(),
pageLength: 0,
pageFragmentLength: 0,
disabledMultiSelect: false,
alwaysMultiSelect: false,
});
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;
constructor() {
super(
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
['select', 'update', 'moveLayer'],
);
}
/**
* 设置当前指点节点配置
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength
* @param value MNode
*/
public set<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
const preValue = this.state[name];
this.state[name] = value;
// set nodes时将node设置为nodes第一个元素
if (name === 'nodes' && Array.isArray(value)) {
this.set('node', value[0]);
}
if (name === 'root') {
if (Array.isArray(value)) {
throw new Error('root 不能为数组');
}
if (value && isObject(value)) {
const app = value as MApp;
this.state.pageLength = getPageList(app).length || 0;
this.state.pageFragmentLength = getPageFragmentList(app).length || 0;
this.state.stageLoading = this.state.pageLength !== 0;
} else {
this.state.pageLength = 0;
this.state.pageFragmentLength = 0;
this.state.stageLoading = false;
}
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']);
}
}
/**
* 获取当前指点节点配置
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength'
* @returns MNode
*/
public get<K extends StoreStateKey>(name: K): StoreState[K] {
return this.state[name];
}
/**
* 根据id获取组件、组件的父组件以及组件所属的页面节点
* @param {number | string} id 组件id
* @param {boolean} raw 是否使用toRaw
* @returns {EditorNodeInfo}
*/
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
let root = this.get('root');
if (raw) {
root = toRaw(root);
}
return getNodeInfo(id, root);
}
/**
* 根据ID获取指点节点配置
* @param id 组件ID
* @param {boolean} raw 是否使用toRaw
* @returns 组件节点配置
*/
public getNodeById(id: Id, raw = true): MNode | null {
const { node } = this.getNodeInfo(id, raw);
return node;
}
/**
* 根据ID获取指点节点的父节点配置
* @param id 组件ID
* @param {boolean} raw 是否使用toRaw
* @returns 指点组件的父节点配置
*/
public getParentById(id: Id, raw = true): MContainer | null {
const { parent } = this.getNodeInfo(id, raw);
return parent;
}
/**
* 只有容器拥有布局
*/
public async getLayout(parent: MNode, node?: MNode | null): Promise<Layout> {
if (node && typeof node !== 'function' && isFixed(node.style || {})) return Layout.FIXED;
if (parent.layout) {
return parent.layout;
}
// 如果该节点没有设置position则认为是流式布局例如获取root的布局时
if (!parent.style?.position) {
return Layout.RELATIVE;
}
return Layout.ABSOLUTE;
}
/**
* 选中指定节点(将指定节点设置成当前选中状态)
* @param config 指定节点配置或者ID
* @returns 当前选中的节点配置
*/
public async select(config: MNode | Id): Promise<MNode> | never {
const { node, page, parent } = this.selectedConfigExceptionHandler(config);
this.set('nodes', node ? [node] : []);
this.set('page', page);
this.set('parent', parent);
if (page) {
historyService.changePage(toRaw(page));
} else {
historyService.resetState();
}
if (node?.id) {
this.get('stage')
?.renderer?.runtime?.getApp?.()
?.page?.emit(
'editor:select',
{
node,
page,
parent,
},
getNodePath(node.id, this.get('root')?.items),
);
}
this.emit('select', node);
return node!;
}
public async selectNextNode(): Promise<MNode | null> | never {
const node = toRaw(this.get('node'));
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
const parent = toRaw(this.getParentById(node.id));
if (!parent) return node;
const index = getNodeIndex(node.id, parent);
const nextNode = parent.items[index + 1] || parent.items[0];
await this.select(nextNode);
this.get('stage')?.select(nextNode.id);
return nextNode;
}
public async selectNextPage(): Promise<MNode> | never {
const root = toRaw(this.get('root'));
const page = toRaw(this.get('page'));
if (!page) throw new Error('page不能为空');
if (!root) throw new Error('root不能为空');
const index = getNodeIndex(page.id, root);
const nextPage = root.items[index + 1] || root.items[0];
await this.select(nextPage);
this.get('stage')?.select(nextPage.id);
return nextPage;
}
/**
* 高亮指定节点
* @param config 指定节点配置或者ID
* @returns 当前高亮的节点配置
*/
public highlight(config: MNode | Id): void {
const { node } = this.selectedConfigExceptionHandler(config);
const currentHighlightNode = this.get('highlightNode');
if (currentHighlightNode === node) return;
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);
}
public selectRoot() {
const root = this.get('root');
if (!root) return;
this.set('nodes', [root]);
this.set('parent', null);
this.set('page', null);
this.set('stage', null);
this.set('highlightNode', null);
}
public async doAdd(node: MNode, parent: MContainer): Promise<MNode> {
const root = this.get('root');
if (!root) throw new Error('root为空');
const curNode = this.get('node');
const stage = this.get('stage');
if (!curNode) throw new Error('当前选中节点为空');
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
const index = parent.items.indexOf(curNode);
parent.items?.splice(index + 1, 0, node);
} else {
// 新增节点添加到配置中
parent.items?.push(node);
}
const layout = await this.getLayout(toRaw(parent), node as MNode);
node.style = getInitPositionStyle(node.style, layout);
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
const newStyle = fixNodePosition(node, parent, stage);
if (newStyle && (newStyle.top !== node.style.top || newStyle.left !== node.style.left)) {
node.style = newStyle;
await stage?.update({ config: cloneDeep(node), parentId: parent.id, root: cloneDeep(root) });
}
this.addModifiedNodeId(node.id);
return node;
}
/**
* 向指点容器添加组件节点
* @param addConfig 将要添加的组件节点配置
* @param parent 要添加到的容器组件节点配置,如果不设置,默认为当前选中的组件的父节点
* @param options 可选配置
* @param options.doNotSelect 添加后是否不更新当前选中节点(默认 false添加后会选中新增的节点
* @returns 添加后的节点
*/
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options?: { doNotSelect?: boolean },
): Promise<MNode | MNode[]> {
const safeParentNode = safeParent(parent);
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const stage = this.get('stage');
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
const addNodes = [];
if (!Array.isArray(addNode)) {
const { type, inputEvent, ...config } = addNode;
if (!type) throw new Error('组件类型不能为空');
addNodes.push({ ...toRaw(await propsService.getPropsValue(type, config)) });
} else {
addNodes.push(...addNode);
}
const newNodes = await Promise.all(
addNodes.map((node) => {
const root = this.get('root');
if ((isPage(node) || isPageFragment(node)) && root) {
return this.doAdd(node, root);
}
const parentNode = safeParentNode ?? getAddParent(node);
if (!parentNode) throw new Error('未找到父元素');
return this.doAdd(node, parentNode);
}),
);
if (newNodes.length > 1) {
if (!doNotSelect) {
const newNodeIds = newNodes.map((node) => node.id);
// 触发选中样式
stage?.multiSelect(newNodeIds);
await this.multiSelect(newNodeIds);
}
} else {
if (!doNotSelect) {
await this.select(newNodes[0]);
}
if (isPage(newNodes[0])) {
this.state.pageLength += 1;
} else if (isPageFragment(newNodes[0])) {
this.state.pageFragmentLength += 1;
} else if (!doNotSelect) {
// 新增页面这个时候页面还有渲染出来此时select会出错在runtime-ready的时候回去select
stage?.select(newNodes[0].id);
}
}
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
this.pushOpHistory(
'add',
{
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
}
this.emit('add', newNodes);
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
public async doRemove(node: MNode, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
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.id, 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, parentId: parent.id, root: cloneDeep(root) });
if (doNotSelect) {
// 当被删除节点正好在当前选中列表中时,必须从 state 中移除引用,避免 state 持有已删除节点(与 doNotSelect 无关)
const selectedNodes = this.get('nodes');
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
if (removedSelectedIndex !== -1) {
const nextSelected = [...selectedNodes];
nextSelected.splice(removedSelectedIndex, 1);
this.set('nodes', nextSelected);
}
// 同理,如果被删除的是当前 page也清空 state.page避免持有已删除页面
if (isPage(node) || isPageFragment(node)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${node.id}`) {
this.set('page', null);
}
}
}
const selectDefault = async (pages: MNode[]) => {
if (pages[0]) {
await this.select(pages[0]);
stage?.select(pages[0].id);
} else {
this.selectRoot();
historyService.resetPage();
}
};
const rootItems = root.items || [];
if (isPage(node)) {
this.state.pageLength -= 1;
if (!doNotSelect) {
await selectDefault(rootItems);
}
} else if (isPageFragment(node)) {
this.state.pageFragmentLength -= 1;
if (!doNotSelect) {
await selectDefault(rootItems);
}
} else {
if (!doNotSelect) {
await this.select(parent);
stage?.select(parent.id);
}
this.addModifiedNodeId(parent.id);
}
if (!rootItems.length) {
this.resetModifiedNodeId();
historyService.reset();
}
}
/**
* 删除组件
* @param {Object} node 要删除的节点或节点集合
* @param options 可选配置
* @param options.doNotSelect 删除后是否不更新当前选中节点(默认 false删除后会选中父节点或首个页面
*/
public async remove(nodeOrNodeList: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
if (parent && curNode) {
if (!pageForOp && page) {
pageForOp = { name: page.name || '', id: page.id };
}
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
node: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
}
}
}
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect })));
if (removedItems.length > 0 && pageForOp) {
this.pushOpHistory('remove', { removedItems }, pageForOp);
}
this.emit('remove', nodes);
}
public async doUpdate(
config: MNode,
{ changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {},
): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> {
const root = this.get('root');
if (!root) throw new Error('root为空');
if (!config?.id) throw new Error('没有配置或者配置缺少id值');
const info = this.getNodeInfo(config.id, false);
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
const node = toRaw(info.node);
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (!newConfig.type) throw new Error('配置缺少type值');
if (newConfig.type === NodeType.ROOT) {
this.set('root', newConfig as MApp);
return {
oldNode: node,
newNode: newConfig,
changeRecords,
};
}
const { parent } = info;
if (!parent) throw new Error('获取不到父级节点');
const parentNodeItems = parent.items;
const index = getNodeIndex(newConfig.id, parent);
if (!parentNodeItems || typeof index === 'undefined' || index === -1) throw new Error('更新的节点未找到');
const newLayout = await this.getLayout(newConfig);
const layout = await this.getLayout(node);
if (Array.isArray(newConfig.items) && newLayout !== layout) {
newConfig = setChildrenLayout(newConfig as MContainer, newLayout);
}
parentNodeItems[index] = newConfig;
// 当被更新节点正好在当前选中列表中时,必须同步引用,否则 state 会持有已被替换的旧节点
const selectedNodes = this.get('nodes');
const targetIndex = selectedNodes.findIndex((nodeItem: MNode) => `${nodeItem.id}` === `${newConfig.id}`);
if (targetIndex !== -1) {
selectedNodes.splice(targetIndex, 1, newConfig);
this.set('nodes', [...selectedNodes]);
}
if (isPage(newConfig) || isPageFragment(newConfig)) {
this.set('page', newConfig as MPage | MPageFragment);
}
this.addModifiedNodeId(newConfig.id);
return {
oldNode: node,
newNode: newConfig,
changeRecords,
};
}
/**
* 更新节点
* update后会触发依赖收集收集完后会掉stage.update方法
* @param config 新的节点配置配置中需要有id信息
* @returns 更新后的节点配置
*/
public async update(
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes');
if (!this.isHistoryStateChange && curNodes.length) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
}
this.isHistoryStateChange = false;
}
this.emit('update', updateData);
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
}
/**
* 将id为id1的组件移动到id为id2的组件位置上例如[1,2,3,4] -> sort(1,3) -> [2,1,3,4]
* @param id1 组件ID
* @param id2 组件ID
* @param options 可选配置
* @param options.doNotSelect 排序后是否不更新当前选中节点(默认 false
* @returns void
*/
public async sort(id1: Id, id2: Id, options?: { doNotSelect?: boolean }): Promise<void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
const node = this.get('node');
if (!node) throw new Error('当前节点为空');
const parent = cloneDeep(toRaw(this.get('parent')));
if (!parent) throw new Error('父节点为空');
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
// 在 id1 的兄弟组件中若无 id2 则直接 return
if (index2 < 0) return;
const index1 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id1}`);
parent.items.splice(index2, 0, ...parent.items.splice(index1, 1));
await this.update(parent);
if (!doNotSelect) {
await this.select(node);
}
this.get('stage')?.update({
config: cloneDeep(node),
parentId: parent.id,
root: cloneDeep(root),
});
}
/**
* 将组件节点配置存储到localStorage中
* @param config 组件节点配置
* @returns
*/
public copy(config: MNode | MNode[]): void {
storageService.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
protocol: Protocol.OBJECT,
});
}
/**
* 复制时会带上组件关联的依赖
* @param config 组件节点配置
* @returns
*/
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
}
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
protocol: Protocol.OBJECT,
});
}
/**
* 从localStorage中获取节点然后添加到当前容器中
* @param position 粘贴的坐标
* @param collectorOptions 可选的依赖收集器配置
* @param options 可选配置
* @param options.doNotSelect 粘贴后是否不更新当前选中节点(默认 false
* @returns 添加后的组件节点配置
*/
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
options?: { doNotSelect?: boolean },
): Promise<MNode | MNode[] | void> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
if (!Array.isArray(config)) return;
const node = this.get('node');
let parent: MContainer | null = null;
// 粘贴的组件为当前选中组件的副本时,则添加到当前选中组件的父组件中
if (config.length === 1 && config[0].id === node?.id) {
parent = this.get('parent');
if (parent?.type === NodeType.ROOT) {
parent = this.get('page');
}
}
const pasteConfigs = await this.doPaste(config, position);
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
}
return this.add(pasteConfigs, parent, { doNotSelect });
}
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
propsService.clearRelateId();
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const pasteConfigs = beforePaste(position, cloneDeep(config), doc);
return pasteConfigs;
}
public async doAlignCenter(config: MNode): Promise<MNode> {
const parent = this.getParentById(config.id);
if (!parent) throw new Error('找不到父节点');
const node = cloneDeep(toRaw(config));
const layout = await this.getLayout(parent, node);
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
if (!newStyle) return config;
node.style = newStyle;
return node;
}
/**
* 将指点节点设置居中
* @param config 组件节点配置
* @param options 可选配置
* @param options.doNotSelect 居中后是否不更新当前选中节点(默认 false
* @returns 当前组件节点配置
*/
public async alignCenter(config: MNode | MNode[], options?: { doNotSelect?: boolean }): Promise<MNode | MNode[]> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
const nodes = Array.isArray(config) ? config : [config];
const stage = this.get('stage');
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
const newNode = await this.update(newNodes);
if (!doNotSelect) {
if (newNodes.length > 1) {
await stage?.multiSelect(newNodes.map((node) => node.id));
} else {
await stage?.select(newNodes[0].id);
}
}
return newNode;
}
/**
* 移动当前选中节点位置
* @param offset 偏移量
*/
public async moveLayer(offset: number | LayerOffset): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
const parent = this.get('parent');
if (!parent) throw new Error('父节点为空');
const node = this.get('node');
if (!node) throw new Error('当前节点为空');
const brothers: MNode[] = parent.items || [];
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
const layout = await this.getLayout(parent, node);
const isRelative = layout === Layout.RELATIVE;
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
return;
}
const oldParent = cloneDeep(toRaw(parent));
brothers.splice(index, 1);
brothers.splice(offsetIndex, 0, node);
const grandparent = this.getParentById(parent.id);
this.get('stage')?.update({
config: cloneDeep(toRaw(parent)),
parentId: grandparent?.id,
root: cloneDeep(root),
});
this.addModifiedNodeId(parent.id);
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
this.emit('move-layer', offset);
}
/**
* 移动到指定容器中
* @param config 需要移动的节点
* @param targetId 容器ID
* @param options 可选配置
* @param options.doNotSelect 移动后是否不更新当前选中节点(默认 false
*/
public async moveToContainer(
config: MNode,
targetId: Id,
options?: { doNotSelect?: boolean },
): Promise<MNode | undefined> {
const { doNotSelect = false } = safeOptions<{ doNotSelect?: boolean }>(options);
this.captureSelectionBeforeOp();
const root = this.get('root');
const { node, parent, page: pageForOp } = this.getNodeInfo(config.id, false);
const target = this.getNodeById(targetId, false) as MContainer;
const stage = this.get('stage');
if (root && node && parent && stage) {
const oldSourceParent = cloneDeep(toRaw(parent));
const oldTarget = cloneDeep(toRaw(target));
const index = getNodeIndex(node.id, parent);
parent.items?.splice(index, 1);
await stage.remove({ id: node.id, parentId: parent.id, root: cloneDeep(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.items.push(newConfig);
if (!doNotSelect) {
await stage.select(targetId);
}
const targetParent = this.getParentById(target.id);
await stage.update({
config: cloneDeep(target),
parentId: targetParent?.id,
root: cloneDeep(root),
});
if (!doNotSelect) {
await this.select(newConfig);
stage.select(newConfig.id);
}
this.addModifiedNodeId(target.id);
this.addModifiedNodeId(parent.id);
this.pushOpHistory(
'update',
{
updatedItems: [
{ oldNode: oldSourceParent, newNode: cloneDeep(toRaw(parent)) },
{ oldNode: oldTarget, newNode: cloneDeep(toRaw(target)) },
],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
return newConfig;
}
}
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
this.captureSelectionBeforeOp();
if (!targetParent || !Array.isArray(targetParent.items)) return;
const configs = Array.isArray(config) ? config : [config];
const beforeSnapshots = new Map<string, MNode>();
for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
}
}
if (!beforeSnapshots.has(`${targetParent.id}`)) {
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
}
const newLayout = await this.getLayout(targetParent);
const { sameParentIndices, crossParentConfigs, aborted } = classifyDragSources(configs, targetParent, (id, raw) =>
this.getNodeInfo(id, raw),
);
if (aborted) return;
for (const { config: crossConfig, parent } of crossParentConfigs) {
const layout = await this.getLayout(parent);
if (newLayout !== layout) {
setLayout(crossConfig, newLayout);
}
const index = getNodeIndex(crossConfig.id, parent);
parent.items?.splice(index, 1);
this.addModifiedNodeId(parent.id);
}
moveItemsInContainer(sameParentIndices, targetParent, targetIndex);
crossParentConfigs.forEach(({ config: crossConfig }, index) => {
targetParent.items?.splice(targetIndex + index, 0, crossConfig);
this.addModifiedNodeId(crossConfig.id);
});
const page = this.get('page');
const root = this.get('root');
const stage = this.get('stage');
if (stage && page && root) {
stage.update({
config: cloneDeep(page),
parentId: root.id,
root: cloneDeep(root),
});
}
const updatedItems: { oldNode: MNode; newNode: MNode }[] = [];
for (const oldNode of beforeSnapshots.values()) {
const newNode = this.getNodeById(oldNode.id, false);
if (newNode) {
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
}
}
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
this.emit('drag-to', { targetIndex, configs, targetParent });
}
/**
* 撤销当前操作
* @returns 被撤销的操作
*/
public async undo(): Promise<StepValue | null> {
const value = historyService.undo();
if (value) {
await this.applyHistoryOp(value, true);
}
return value;
}
/**
* 恢复到下一步
* @returns 被恢复的操作
*/
public async redo(): Promise<StepValue | null> {
const value = historyService.redo();
if (value) {
await this.applyHistoryOp(value, false);
}
return value;
}
public async move(left: number, top: number) {
const node = toRaw(this.get('node'));
if (!node || isPage(node)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!newStyle) return;
await this.update({ id: node.id, type: node.type, style: newStyle });
}
public resetState() {
this.set('root', null);
this.set('node', null);
this.set('nodes', []);
this.set('page', null);
this.set('parent', null);
this.set('stage', null);
this.set('highlightNode', null);
this.set('modifiedNodeIds', new Map());
this.set('pageLength', 0);
}
public destroy() {
this.removeAllListeners();
this.resetState();
this.removeAllPlugins();
}
public resetModifiedNodeId() {
this.get('modifiedNodeIds').clear();
}
public usePlugin(options: AsyncHookPlugin<AsyncMethodName, Editor>): void {
super.usePlugin(options);
}
public on<Name extends keyof EditorEvents, Param extends EditorEvents[Name]>(
eventName: Name,
listener: (...args: Param) => void | Promise<void>,
) {
return super.on(eventName, listener as any);
}
public once<Name extends keyof EditorEvents, Param extends EditorEvents[Name]>(
eventName: Name,
listener: (...args: Param) => void | Promise<void>,
) {
return super.once(eventName, listener as any);
}
public emit<Name extends keyof EditorEvents, Param extends EditorEvents[Name]>(eventName: Name, ...args: Param) {
return super.emit(eventName, ...args);
}
private addModifiedNodeId(id: Id) {
if (!this.isHistoryStateChange) {
this.get('modifiedNodeIds').set(id, id);
}
}
private captureSelectionBeforeOp() {
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
}
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
if (this.isHistoryStateChange) {
this.selectionBeforeOp = null;
return;
}
const step: StepValue = {
data: pageData,
opType,
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
};
historyService.push(step);
this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
}
/**
* 应用历史操作(撤销 / 重做)
* @param step 操作记录
* @param reverse true = 撤销false = 重做
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
this.isHistoryStateChange = true;
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;
const ctx: HistoryOpContext = {
root,
stage,
getNodeById: (id, raw) => this.getNodeById(id, raw),
getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
setRoot: (r) => this.set('root', r),
setPage: (p) => this.set('page', p),
getPage: () => this.get('page'),
};
switch (step.opType) {
case 'add':
await applyHistoryAddOp(step, reverse, ctx);
break;
case 'remove':
await applyHistoryRemoveOp(step, reverse, ctx);
break;
case 'update':
await applyHistoryUpdateOp(step, reverse, ctx);
break;
}
this.set('modifiedNodeIds', step.modifiedNodeIds);
const page = toRaw(this.get('page'));
if (page) {
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
setTimeout(() => {
if (!selectIds.length) return;
if (selectIds.length > 1) {
this.multiSelect(selectIds);
stage?.multiSelect(selectIds);
} else {
this.select(selectIds[0])
.then(() => stage?.select(selectIds[0]))
.catch(() => {});
}
}, 0);
this.emit('history-change', page as MPage | MPageFragment);
}
this.isHistoryStateChange = false;
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
}
export type EditorService = Editor;
export default new Editor();