roymondchen 6f0a41f2db feat(editor): 支持 update replace 整节点替换,源码编辑走 replace 模式
新增 replace 选项跳过 mergeWith 等变换,PropsPanel 源码保存时使用整节点覆盖,避免已删除字段被 merge 保留。
2026-08-04 19:13:33 +08:00

2129 lines
78 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 { nextTick, reactive, toRaw } from 'vue';
import { cloneDeep, isEmpty, isEqual, 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,
getValueByKeyPath,
guid,
isPage,
isPageFragment,
isPageOrFragment,
setValueByKeyPath,
traverseNode,
} 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,
DslOpOptions,
DslOpWithHistoryIdsResult,
EditorEvents,
EditorNodeInfo,
HistoryOpSource,
HistoryOpType,
NodeInvalidInfo,
NodeInvalidSource,
PastePosition,
StepDiffItem,
StepValue,
StoreState,
StoreStateKey,
} from '@editor/type';
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import {
calcAlignCenterStyle,
calcLayerTargetIndex,
calcMoveStyle,
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY,
describeStepForRevert,
editorNodeMergeCustomizer,
fixNodePosition,
getInitPositionStyle,
getNodeIndex,
getPageFragmentList,
getPageList,
moveItemsInContainer,
resolveSelectedNode,
setChildrenLayout,
setLayout,
toggleFixedPosition,
} from '@editor/utils/editor';
import { getLastPushedHistoryIds } from '@editor/utils/history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
/**
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
* `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新;
* 缺省(未传 / 空数组)才退化为整节点替换。
*/
const buildUpdateDiff = (
items: { oldNode: MNode; newNode: MNode; changeRecords?: ChangeRecord[] }[],
): StepDiffItem<MNode>[] =>
items.map(({ oldNode, newNode, changeRecords }) => ({
oldSchema: oldNode,
newSchema: newNode,
...(changeRecords?.length ? { changeRecords } : {}),
}));
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(),
invalidNodeIds: new Map(),
pageLength: 0,
pageFragmentLength: 0,
disabledMultiSelect: false,
alwaysMultiSelect: false,
});
private selectionBeforeOp: Id[] | null = null;
/**
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
* 供 pushOpHistory 写入 step.extra.invalidNodeIdsBefore用于撤销时还原到「操作前」的错误状态。
*/
private invalidNodeIdsBeforeOp: Map<Id, NodeInvalidInfo> | null = null;
/**
* 最近一次 pushOpHistory 写入的历史记录 uuid。
* 供 *AndGetHistoryId 系列方法在调用普通操作后取回本次产生的历史记录 id
* 普通操作不会读取它,调用前由 *AndGetHistoryId 重置为 null。
*/
private lastPushedHistoryId: string | 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' | 'invalidNodeIds' | 'pageLength' | 'pageFragmentLength
* @param value MNode
* @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效)
*/
public set<K extends StoreStateKey, T extends StoreState[K]>(
name: K,
value: T,
options: { historySource?: HistoryOpSource } = {},
) {
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;
if (preValue && !isEmpty(preValue)) {
// 编辑期间再次整体设置 root源码保存 / 外部重设 DSL / root 节点更新):与上一次 root
// 做页面级 diff按 update / add / remove 入栈,作为正常历史记录体现整体替换。
this.pushRootDiffHistory(preValue as MApp, app, options.historySource);
} else {
// 首次设置 root仅当该页面 / 页面片尚无基线标记时,才写入「未修改的初始状态」基线。
// 配合「先恢复历史再 set root」若基线已随历史恢复建立恢复后已有基线则此处不再
// 重复创建set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
app.items?.forEach((pageNode) => {
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
historyService.setMarker('page', pageNode.id, {
name: pageNode.name,
source: options.historySource,
});
}
});
}
} 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'], options);
// 整体替换 DSL 后,清理不再存在于新 DSL 中的失效节点错误记录,避免残留误报。
this.pruneInvalidNodeIds();
}
}
/**
* 获取当前指点节点配置
* @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'invalidNodeIds' | '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);
}
if (!root) {
return { node: null, parent: null, page: null, path: [] };
}
if (id === root.id) {
return { node: root, parent: null, page: null, path: [] };
}
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
// 注意:不能直接使用 state.page它可能与当前 root 不同步(指向已脱离的旧页面对象),
// 因此仅借用其 id再从当前 root 中取回真正的页面对象(页面均为 root 的直接子节点,数量很少)。
const pageIdStr = `${this.get('page')?.id || ''}`;
const currentPageNode = root.items?.find((item) => `${item.id}` === pageIdStr);
if (currentPageNode && `${id}` !== pageIdStr) {
// util 仅读取 root.id 与 root.items按容器结构传入当前页面是安全的
const info = getNodeInfo(id, currentPageNode);
if (info.node) {
return info;
}
}
// 回退:在完整 root 上查找;当前页面已搜索过,用 skip 跳过其子树避免重复遍历,
// 同时保留真实的 parent / page 引用id 命中当前页面节点本身时会在跳过子树前先匹配到)
return getNodeInfo(id, root, currentPageNode);
}
/**
* 根据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;
}
/**
* 判断给定节点是否位于非当前页面(即选中该节点将会引起当前页面切换)
* @param node 节点
* @returns true 表示该节点位于非当前页面
*/
public isOnDifferentPage(node: MNode): boolean {
const currentPageId = this.get('page')?.id;
if (currentPageId === undefined || currentPageId === null) return false;
if (isPageOrFragment(node)) {
return `${node.id}` !== `${currentPageId}`;
}
const nodePage = this.getNodeInfo(node.id, false).page;
if (!nodePage) return false;
return `${nodePage.id}` !== `${currentPageId}`;
}
/**
* 只有容器拥有布局
*/
public async getLayout(parent: MNode, node?: MNode | null): Promise<Layout> {
if (node && 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 (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) && !isPageOrFragment(node)) {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !isPageOrFragment(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添加后会选中新增的节点
* @param options.doNotSwitchPage 添加后是否不切换当前页面(默认 false新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
* @returns 添加后的节点
*/
public async add(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
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 (isPageOrFragment(node) && root) {
return this.doAdd(node, root);
}
const parentNode = parent ?? getAddParent(node);
if (!parentNode) throw new Error('未找到父元素');
return this.doAdd(node, parentNode);
}),
);
if (newNodes.length > 1) {
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
if (!doNotSelect && !(doNotSwitchPage && wouldSwitchPage)) {
const newNodeIds = newNodes.map((node) => node.id);
// 触发选中样式
stage?.multiSelect(newNodeIds);
await this.multiSelect(newNodeIds);
}
} else {
const wouldSwitchPage = this.isOnDifferentPage(newNodes[0]);
const skipSelect = doNotSelect || (doNotSwitchPage && wouldSwitchPage);
if (!skipSelect) {
await this.select(newNodes[0]);
}
if (isPage(newNodes[0])) {
this.state.pageLength += 1;
} else if (isPageFragment(newNodes[0])) {
this.state.pageFragmentLength += 1;
} else if (!skipSelect) {
// 新增页面这个时候页面还有渲染出来此时select会出错在runtime-ready的时候回去select
stage?.select(newNodes[0].id);
}
}
if (!isPageOrFragment(newNodes[0])) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) {
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
this.pushOpHistory('add', {
diff: newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
const idx = p ? getNodeIndex(n.id, p) : -1;
return {
newSchema: cloneDeep(toRaw(n)),
parentId,
index: typeof idx === 'number' ? idx : -1,
};
}),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
}
this.emit('add', newNodes);
this.emit('change', {
type: 'add',
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource,
doNotPushHistory,
});
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (addedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
}
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
public async doRemove(
node: MNode,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
): Promise<void> {
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('找不要删除的节点');
const stage = this.get('stage');
const currentPage = this.get('page');
const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
parent.items?.splice(index, 1);
// 始终清理已删除节点在 state 中的残留引用:
// - 即使后续会调用 select 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
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);
}
const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
const rootItems = root.items || [];
// 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
// 还有剩余页面才能切过去,否则退回选中 root
const shouldSwitchPage = shouldReselect && rootItems.length > 0;
if (isPageOrFragment(node)) {
if (isPage(node)) {
this.state.pageLength -= 1;
} else {
this.state.pageFragmentLength -= 1;
}
if (shouldSwitchPage) {
// runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除,
// 否则画布会被清空,且要等下一次 updatePageId 才能恢复。
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
await this.select(rootItems[0]);
stage?.select(rootItems[0].id);
await nextTick();
stage?.remove(removeData);
} else {
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer删除通知必须在卸载前发出
// 且不能 awaitruntime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
stage?.remove(removeData);
if (shouldReselect) {
// 页面已全部删完
this.selectRoot();
} else if (isDeletingCurrentPage) {
this.set('page', null);
}
}
} else {
stage?.remove(removeData);
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删除后会选中父节点或首个页面
* @param options.doNotSwitchPage 删除后是否不切换当前页面(默认 false删除页面 / 页面片段时为 true 会跳过自动切换到首个剩余页面)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
*/
public async remove(
nodeOrNodeList: MNode | MNode[],
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<void> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
// 删除后节点已从树中移除,无法再反查所属 page这里在删除前先逐个捕获用于 `change` 事件
const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) }));
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!isPageOrFragment(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({
oldSchema: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
}
}
}
await Promise.all(nodes.map((node) => this.doRemove(node, { doNotSelect, doNotSwitchPage })));
// 删除节点时同步清理其(含子树)的校验错误记录;置于 pushOpHistory 之前,使历史快照与本次删除对齐。
this.removeInvalidNodesBySubtree(nodes);
if (removedItems.length > 0 && pageForOp) {
if (!doNotPushHistory) {
this.pushOpHistory('remove', {
diff: removedItems,
pageData: pageForOp,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
}
this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (removedPages.length) {
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
}
}
public async doUpdate(
config: MNode,
{
changeRecords = [],
historySource,
replace = false,
}: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource; replace?: boolean } = {},
): 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);
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout直接用传入配置整节点替换
let newConfig = replace
? cloneDeep(toRaw(config))
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
if (!replace) {
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
}
if (!newConfig.type) throw new Error('配置缺少type值');
if (newConfig.type === NodeType.ROOT) {
this.set('root', newConfig as MApp, { historySource });
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('更新的节点未找到');
if (!replace) {
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]);
}
// 只有被更新节点正好是当前选中页面时才同步 state.page避免「更新非当前页」误将编辑器切到该页
if (isPageOrFragment(newConfig)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
this.set('page', newConfig as MPage | MPageFragment);
}
}
this.addModifiedNodeId(newConfig.id);
return {
oldNode: node,
newNode: newConfig,
changeRecords,
};
}
/**
* 更新节点
* update后会触发依赖收集收集完后会掉stage.update方法
* @param config 新的节点配置配置中需要有id信息
* @param data 额外数据
* @param data.changeRecords 单节点 form 端变更记录(多节点场景下被忽略,使用 changeRecordList
* @param data.changeRecordList 多节点 form 端变更记录列表,按 config 数组同序对应每个节点;优先级高于 changeRecords
* @param data.doNotPushHistory 是否不写入历史记录(默认 false
* @param data.historyDescription 入栈时附带的人类可读描述,用于历史面板展示(不影响 undo/redo 行为)
* @param data.replace 是否整节点替换:为 true 时跳过 mergeWith / toggleFixedPosition / setChildrenLayout直接用传入配置覆盖默认 false
* @returns 更新后的节点配置
*/
public async update(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
/**
* 为 true 时不做深合并等变换,直接用传入配置整节点替换现有节点。
* 适用于源码编辑、历史整节点快照回放等「完整 DSL」场景默认 false局部属性更新走 merge
*/
replace?: boolean;
/**
* 属性面板提交时携带的校验错误信息,在写入历史记录之前落库,
* 使历史快照与本次变更对齐,从而 undo/redo 能正确还原错误标记。
*/
invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string };
} = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const {
doNotPushHistory = false,
changeRecordList,
changeRecords,
historyDescription,
historySource,
replace = false,
invalidInfo,
} = data;
const nodes = Array.isArray(config) ? config : [config];
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
// 否则同一份 changeRecords 会被复用到每个节点上nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
const updateData = await Promise.all(
nodes.map((node, index) => {
const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []);
return this.doUpdate(node, { changeRecords: recordsForNode, historySource, replace });
}),
);
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
this.applyInvalidInfo(config, invalidInfo);
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
const curNodes = this.get('nodes');
if (curNodes.length) {
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory('update', {
// 每个节点单独保留自己的 changeRecords便于撤销/重做时按 propPath 精细化更新;
// 没有 changeRecords 的(如内部 sort/moveLayer 等整节点替换操作)会退化为全节点替换。
diff: buildUpdateDiff(
updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(d.newNode),
changeRecords: d.changeRecords?.length ? cloneDeep(d.changeRecords) : undefined,
})),
),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
}
}
this.emit('update', updateData);
this.emit('change', {
type: 'update',
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
historySource,
doNotPushHistory,
});
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
* @param options.doNotSwitchPage 排序后是否不切换当前页面(排序只发生在同一父节点内,方法内为空操作;保留以与其它 DSL 操作 API 一致)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
* @returns void
*/
public async sort(
id1: Id,
id2: Id,
{ doNotSelect = false, doNotPushHistory = false, historySource }: DslOpOptions = {},
): Promise<void> {
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, { doNotPushHistory, historySource });
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
* @param options.doNotSwitchPage 粘贴后是否不切换当前页面(默认 false跨页粘贴时为 true 会跳过页面切换)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
* @returns 添加后的组件节点配置
*/
public async paste(
position: PastePosition = {},
collectorOptions?: TargetOptions,
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[] | void> {
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,
doNotSwitchPage,
doNotPushHistory,
historyDescription,
historySource,
});
}
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
* @param options.doNotSwitchPage 居中后是否不切换当前页面(居中只更新节点 style方法内为空操作保留以与其它 DSL 操作 API 一致)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
* @returns 当前组件节点配置
*/
public async alignCenter(
config: MNode | MNode[],
{ doNotSelect = false, doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): Promise<MNode | MNode[]> {
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, { doNotPushHistory, historyDescription, historySource });
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 偏移量
* @param options 可选配置
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
*/
public async moveLayer(
offset: number | LayerOffset,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
): 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);
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
},
);
} else {
this.selectionBeforeOp = null;
}
this.emit('move-layer', offset);
this.emit('change', {
type: 'move-layer',
data: [{ node, page: this.getPageOfNode(node.id) }],
offset,
historySource,
doNotPushHistory,
});
}
/**
* 移动一个或多个节点到指定容器中。
*
* 多选场景config 是数组)只会产生一条历史记录,
* `updatedItems` 涵盖所有源父容器 + 目标容器的前后快照。
* 这避免了"多选移动到某容器"在历史栈里被切成 N 条记录。
*
* @param config 需要移动的节点(或节点数组,各项需带 idstyle 等字段会与原节点合并)
* @param targetId 容器ID
* @param options 可选配置
* @param options.doNotSelect 移动后是否不更新当前选中节点(默认 false
* @param options.doNotSwitchPage 移动后是否不切换当前页面(默认 false目标容器位于其它页面时为 true 会跳过自动选中以避免页面切换)
* @param options.doNotPushHistory 是否不写入历史记录(默认 false
*/
public async moveToContainer(
config: MNode | MNode[],
targetId: Id,
{
doNotSelect = false,
doNotSwitchPage = false,
doNotPushHistory = false,
historyDescription,
historySource,
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
if (configs.length === 0) {
throw new Error('没有可移动的节点');
}
this.captureSelectionBeforeOp();
const target = this.getNodeById(targetId, false) as MContainer;
if (!target) {
throw new Error('目标容器不存在');
}
const root = this.get('root');
const stage = this.get('stage');
if (!root || !stage) {
throw new Error('root 或 stage为空');
}
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
const moves: MoveItem[] = [];
for (const { id } of configs) {
const { node, parent, page } = this.getNodeInfo(id, false);
if (!node || !parent) continue;
moves.push({ node, parent, pageForOp: page ? { name: page.name || '', id: page.id } : null });
}
if (moves.length === 0) {
throw new Error('没有可移动的节点');
}
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
const beforeSnapshots = new Map<Id, MNode>();
beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
for (const { parent } of moves) {
if (!beforeSnapshots.has(parent.id)) {
beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
}
}
let newConfigs: MNode[] = [];
const moveNodes = moves.map(({ node }) => node);
await this.remove(moveNodes, { doNotPushHistory: true, doNotSelect, doNotSwitchPage: true });
newConfigs = (await this.add(moveNodes, target, {
doNotPushHistory: true,
doNotSelect,
doNotSwitchPage,
})) as MNode[];
if (!doNotPushHistory) {
// 整批只入栈一条历史updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
oldNode,
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
}));
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
this.pushOpHistory('update', {
diff: buildUpdateDiff(updatedItems),
pageData: historyPage,
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
return isBatch ? newConfigs : newConfigs[0];
}
public async dragTo(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
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)) });
}
}
if (!doNotPushHistory) {
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', {
diff: buildUpdateDiff(updatedItems),
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
historyDescription,
source: historySource,
});
} else {
this.selectionBeforeOp = null;
}
this.emit('drag-to', { targetIndex, configs, targetParent });
this.emit('change', {
type: 'drag-to',
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
targetIndex,
targetParent,
historySource,
doNotPushHistory,
});
}
// #region AndGetHistoryId
/**
* 下列 *AndGetHistoryId 方法与对应的普通操作add / remove / update ...)行为完全一致,
* 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link StepValue.uuid}
* 可用于精确引用 / 定位该条历史记录埋点、revert、跨端同步等
*
* 当本次操作未写入历史doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时 historyIds 为 `[]`。
*/
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async addAndGetHistoryId(
addNode: AddMNode | MNode[],
parent?: MContainer | null,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.add(addNode, parent, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async removeAndGetHistoryId(
nodeOrNodeList: MNode | MNode[],
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.remove(nodeOrNodeList, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async updateAndGetHistoryId(
config: MNode | MNode[],
data: {
changeRecords?: ChangeRecord[];
changeRecordList?: ChangeRecord[][];
doNotPushHistory?: boolean;
historyDescription?: string;
historySource?: HistoryOpSource;
replace?: boolean;
} = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.update(config, data);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link moveLayer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async moveLayerAndGetHistoryId(
offset: number | LayerOffset,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.moveLayer(offset, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link moveToContainer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async moveToContainerAndGetHistoryId(
config: MNode | MNode[],
targetId: Id,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
this.lastPushedHistoryId = null;
const result = await this.moveToContainer(config, targetId, options);
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
/** 等价于 {@link dragTo},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
public async dragToAndGetHistoryId(
config: MNode | MNode[],
targetParent: MContainer,
targetIndex: number,
options: DslOpOptions = {},
): Promise<DslOpWithHistoryIdsResult<void>> {
this.lastPushedHistoryId = null;
await this.dragTo(config, targetParent, targetIndex, options);
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
}
// #endregion AndGetHistoryId
/**
* 撤销当前操作
* @returns 被撤销的操作
*/
public async undo(): Promise<StepValue | null> {
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.undo('page', pageId);
if (value) {
await this.applyHistoryOp(value, true);
}
return value;
}
/**
* 恢复到下一步
* @returns 被恢复的操作
*/
public async redo(): Promise<StepValue | null> {
const pageId = this.get('page')?.id;
if (pageId === undefined) return null;
const value = historyService.redo('page', pageId);
if (value) {
await this.applyHistoryOp(value, false);
}
return value;
}
/**
* 「回滚」指定页面历史步骤(类 git revert 语义):
* - 不动原始历史栈结构(不移动 cursor、不丢弃任何步骤
* - 取出 `index` 对应的 step**反向应用**一次add→remove / remove→add / update→旧值
* - 把这次反向应用作为一条**新步骤**追加到栈顶,可被普通 undo / redo。
*
* 与 `gotoPageStep`(类 git reset的区别在于此操作**不丢弃**目标之后的历史。
* 与 `applyHistoryOp(reverse=true)` 的区别在于:本方法**不带** `doNotPushHistory`
* 反向应用会以一条新 step 入栈;并且不实施 step 中保存的选区与 modifiedNodeIds 状态,
* 选区由用户当前位置决定,符合"新提交"语义。
*
* 仅对处于「已应用」状态的步骤生效——未应用的步骤本身就不存在于当前 DSL 中,反向无意义。
*
* @param index 目标 step 在所属页面栈中的索引0 为最早),通常由历史面板传入
* @returns 反向后产生的新 step目标不存在 / 未应用 / 反向失败时返回 null
*/
public async revertPageStep(index: number): Promise<StepValue | null> {
const list = historyService.getStepList('page', this.get('page')?.id);
const entry = list[index];
if (!entry?.applied) return null;
const { step } = entry;
// 初始基线index 0 的 initial step是栈底线不可回滚。
if (step.opType === 'initial') return null;
const root = this.get('root');
if (!root) return null;
// 更新类步骤必须带 changeRecords 才支持回滚:缺失时只能整节点替换,会冲掉后续无关变更,故不支持。
if (step.opType === 'update') {
const items = step.diff ?? [];
if (!items.length || !items.every((item) => item.changeRecords?.length)) return null;
}
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
let revertedStep: StepValue | null = null;
// page 的 `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
const captureRevert = (_pageId: Id, s: StepValue) => {
revertedStep = s;
};
historyService.once('change', captureRevert);
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
// revert 走 public add/remove/update让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
try {
switch (step.opType) {
case 'add': {
// 原本是新增 → revert 即删除当时被加入的节点
for (const { newSchema } of step.diff ?? []) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, opts);
}
}
break;
}
case 'remove': {
// 原本是删除 → revert 即把节点按原父容器加回来。
// 按原 index 升序逐个插回,先小后大避免索引漂移。
const items = step.diff ?? [];
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
for (const { oldSchema, parentId } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent) {
await this.add([cloneDeep(oldSchema)] as MNode[], parent, opts);
}
}
break;
}
case 'update': {
// 原本是更新 → revert 即把 oldSchema 的值写回;
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免冲掉同节点上其它无关变更)。
const items = step.diff ?? [];
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(oldNode);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, oldNode));
setValueByKeyPath(record.propPath, value, patch);
}
return patch;
}
return cloneDeep(oldNode);
});
if (configs.length) {
await this.update(configs, { historyDescription, historySource: 'rollback' });
}
break;
}
}
} finally {
historyService.off('change', captureRevert);
}
// 通知一次 history-change让上层如属性面板按当前最新 DSL 刷新
const page = toRaw(this.get('page'));
if (page) {
this.emit('history-change', page as MPage | MPageFragment);
}
return revertedStep;
}
/**
* 通过历史记录 uuid 回滚当前页面的历史步骤,语义与 {@link revertPageStep} 完全一致,
* 仅入参从 index 改为 uuid 列表({@link StepValue.uuid})。按数组顺序依次回滚,
* 返回与入参同序的结果列表(某项失败时为 `null`)。
*
* @param uuids 目标历史记录的 uuid 列表,通常由 *AndGetHistoryId 方法返回的 `historyIds`
*/
public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
const results: (StepValue | null)[] = [];
const pageId = this.get('page')?.id;
for (const uuid of uuids) {
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
results.push(!location ? null : await this.revertPageStep(location.index));
}
return results;
}
/**
* 跳转当前页面历史栈到指定游标位置。
*
* `targetCursor` 与 `UndoRedo.getCursor()` 同义:表示"已应用步骤数量"
* 取值范围 `[0, length]`。当目标 < 当前游标时循环 undo否则循环 redo。
* 通常由历史面板传入「点击的 step.index + 1」作为目标。
*
* @returns 实际移动到的最终游标位置
*/
public async gotoPageStep(targetCursor: number): Promise<number> {
const pageId = this.get('page')?.id;
let cursor = historyService.getCursor('page', pageId);
const { length } = historyService.getStepList('page', pageId);
const target = Math.max(0, Math.min(targetCursor, length));
while (cursor > target) {
const step = await this.undo();
if (!step) break;
cursor -= 1;
}
while (cursor < target) {
const step = await this.redo();
if (!step) break;
cursor += 1;
}
return cursor;
}
public async move(
left: number,
top: number,
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
) {
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 },
{ doNotPushHistory, historyDescription, historySource },
);
}
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('invalidNodeIds', new Map());
this.set('pageLength', 0);
}
public destroy() {
this.removeAllListeners();
this.resetState();
this.removeAllPlugins();
}
public resetModifiedNodeId() {
this.get('modifiedNodeIds').clear();
}
/**
* 记录(或覆盖)某个节点在指定来源(属性表单 / 样式表单)上的校验错误信息。
* @param id 节点 id
* @param source 错误来源:'props'(属性表单)| 'style'(样式表单)
* @param message 错误文案(可能是包含 <br> 的 HTML
*/
public setInvalidNode(id: Id, source: NodeInvalidSource, message: string) {
const map = this.get('invalidNodeIds');
const info: NodeInvalidInfo = { ...(map.get(id) || {}) };
info[source] = message;
map.set(id, info);
this.emit('invalid-node-change', map);
}
/**
* 删除节点的校验错误记录。
* @param id 节点 id
* @param source 指定来源则仅删除该来源;不传则删除该节点全部来源的错误
*/
public deleteInvalidNode(id: Id, source?: NodeInvalidSource) {
const map = this.get('invalidNodeIds');
if (!map.has(id)) return;
if (!source) {
map.delete(id);
} else {
const info: NodeInvalidInfo = { ...(map.get(id) || {}) };
delete info[source];
if (info.props || info.style) {
map.set(id, info);
} else {
map.delete(id);
}
}
this.emit('invalid-node-change', map);
}
/** 获取当前存在校验错误的节点错误 Mapkey 为节点 id */
public getInvalidNodeIds(): Map<Id, NodeInvalidInfo> {
return this.get('invalidNodeIds');
}
/** 获取指定节点的校验错误信息 */
public getInvalidNodeInfo(id: Id): NodeInvalidInfo | undefined {
return this.get('invalidNodeIds').get(id);
}
/** 清空全部校验错误记录 */
public resetInvalidNodeId() {
const map = this.get('invalidNodeIds');
if (map.size === 0) return;
map.clear();
this.emit('invalid-node-change', map);
}
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);
}
/**
* 应用一次属性面板提交携带的校验错误信息(在写入历史记录之前调用,使历史快照与本次变更对齐)。
* - invalidInfo 为空(调用方未携带):认为本次更新对应的节点已无校验错误,清除其全部来源的错误;
* - error 非空则记录错误,为空则清除对应来源的错误。
*/
private applyInvalidInfo(
config: MNode | MNode[],
invalidInfo?: { id: Id; source: NodeInvalidSource; error?: string },
) {
if (!invalidInfo) {
// 调用方未携带 invalidInfo本次更新对应节点不应再保留校验错误清除其全部来源。
const ids = (Array.isArray(config) ? config : [config])
.map((node) => node.id)
.filter((id): id is Id => id !== undefined);
ids.forEach((id) => this.deleteInvalidNode(id));
return;
}
const { id, source, error } = invalidInfo;
if (error) {
this.setInvalidNode(id, source, error);
} else {
this.deleteInvalidNode(id, source);
}
}
/** 删除被移除节点(含其子树)的校验错误记录,避免残留误报 */
private removeInvalidNodesBySubtree(nodes: MNode[]) {
const map = this.get('invalidNodeIds');
if (map.size === 0) return;
let changed = false;
nodes.forEach((node) => {
traverseNode(node, (n) => {
if (n.id !== undefined && map.delete(n.id)) {
changed = true;
}
});
});
if (changed) {
this.emit('invalid-node-change', map);
}
}
/** 清理不在当前 DSL 中的失效节点错误记录(用于整体替换 root 后) */
private pruneInvalidNodeIds() {
const map = this.get('invalidNodeIds');
if (map.size === 0) return;
let changed = false;
for (const id of [...map.keys()]) {
if (!this.getNodeById(id, false)) {
map.delete(id);
changed = true;
}
}
if (changed) {
this.emit('invalid-node-change', map);
}
}
private addModifiedNodeId(id: Id) {
this.get('modifiedNodeIds').set(id, id);
}
/**
* 获取指定节点所属的页面 / 页面片:
* - 普通节点返回其所在的 page
* - 节点本身就是 page / pageFragment 时返回它自己;
* - 找不到时返回 null。
* 供 `change` 事件携带「变更节点对应的 page」而非编辑器当前选中页
*/
private getPageOfNode(id: Id): MPage | MPageFragment | null {
const { node, page } = this.getNodeInfo(id, false);
if (page) return page;
if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
return null;
}
private captureSelectionBeforeOp() {
if (this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
// 与选区快照同步捕获「操作前」的校验错误状态;因 applyInvalidInfo 会在 pushOpHistory 前修改 invalidNodeIds
// 必须在此(操作最开始)留存操作前快照,供 undo 还原。
this.invalidNodeIdsBeforeOp = new Map(this.get('invalidNodeIds'));
}
/**
* 比较「上一次 root」与「新 root」的页面 / 页面片,按页面粒度把整体替换拆成历史记录:
* - 新旧都存在且内容变化的页面 → 一条 `update`(整页快照替换,无 changeRecords
* - 仅新 root 存在的页面 → 一条 `add`
* - 仅旧 root 存在的页面 → 一条 `remove`。
*
* 每条记录落到对应页面自己的历史栈(与普通节点操作一致),并标记来源 `source`。
* 内容未变化的页面不产生记录,避免重复设置相同 DSL 时产生噪声。
*/
private pushRootDiffHistory(preRoot: MApp, nextRoot: MApp, source?: HistoryOpSource): void {
const prevPages = preRoot?.items || [];
const nextPages = nextRoot?.items || [];
const prevMap = new Map(prevPages.map((p) => [`${p.id}`, p]));
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
// 收集本次整体替换中增删的页面,循环结束后合并为一次结构变更通知(避免逐页派发多个事件)
const addedPages: (MPage | MPageFragment)[] = [];
const removedPages: (MPage | MPageFragment)[] = [];
nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) {
this.pushPageDiffStep(
'add',
nextPage,
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
addedPages.push(nextPage);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
nextPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
source,
);
}
});
prevPages.forEach((prevPage) => {
if (!nextMap.has(`${prevPage.id}`)) {
this.pushPageDiffStep(
'remove',
prevPage,
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
removedPages.push(prevPage);
}
});
if (addedPages.length || removedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
}
}
/**
* 构造一条页面级「set root」历史记录不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。
*
* 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同),
* 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录;
* 来源不同则照常新增initial 基线不是 rootStep不在此列
*/
private pushPageDiffStep(
opType: HistoryOpType,
page: MPage | MPageFragment,
diffItem: StepDiffItem<MNode>,
source?: HistoryOpSource,
): void {
const step: StepValue = {
uuid: guid(),
data: { name: page.name || '', id: page.id },
opType,
extra: {
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
},
diff: [diffItem],
rootStep: true,
};
if (source) step.source = source;
const top = historyService.getCurrentPageStep(page.id);
if (top?.rootStep && top.source === source) {
historyService.replaceCurrentStep('page', step, page.id);
} else {
historyService.push('page', step, page.id);
}
}
private pushOpHistory(
opType: HistoryOpType,
{
diff,
pageData,
historyDescription,
source,
}: {
diff: StepDiffItem<MNode>[];
pageData: { name: string; id: Id };
historyDescription?: string;
source?: HistoryOpSource;
},
): string | null {
const step: StepValue = {
uuid: guid(),
data: pageData,
opType,
extra: {
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
// 方向性双快照undo 还原「操作前」错误状态redo 还原「操作后」错误状态。
invalidNodeIdsBefore: new Map(this.invalidNodeIdsBeforeOp ?? this.get('invalidNodeIds')),
invalidNodeIdsAfter: new Map(this.get('invalidNodeIds')),
},
diff,
};
if (historyDescription) step.historyDescription = historyDescription;
if (source) step.source = source;
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
const pushed = historyService.push('page', step, pageData.id);
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
const historyId = pushed ? step.uuid : null;
this.lastPushedHistoryId = historyId;
this.selectionBeforeOp = null;
this.invalidNodeIdsBeforeOp = null;
return historyId;
}
/**
* 应用历史操作(撤销 / 重做)
*
* 删除 / 更新类修改走 `editor.remove / update`,并通过 `doNotPushHistory` 阻止再次入栈、
* `doNotSelect / doNotSwitchPage` 让选区由方法末尾的统一逻辑兜底;
* 「重新插回节点」(撤销 remove / 重做 add需按 step 记录的 parentId / index 精确还原,
* 不走 this.add由手工 splice + stage.add 完成后补发等价的 add / change 事件(见 emitHistoryInsertEvents
*
* 注意:这些路径都会发出 add / remove / update / change 事件,业务侧若需要区分"用户操作"与"撤销重做触发"
* 请监听 `history-change` 事件配合判断。
*
* @param step 操作记录
* @param reverse true = 撤销false = 重做
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
// 初始基线 step 仅作展示,不承载任何变更,撤销/重做时无需应用(正常流程下也不会被触达)。
if (step.opType === 'initial') return;
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;
// 撤销/重做内部复用 add/remove/update透传被应用 step 上记录的 source
// 使其触发的 change 事件同样携带「历史来源」
const commonOpts = {
doNotSelect: true,
doNotSwitchPage: true,
doNotPushHistory: true,
historySource: step.source,
} as const;
switch (step.opType) {
case 'add': {
const items = step.diff ?? [];
if (reverse) {
// 撤销 add把当时加入的节点删除
for (const { newSchema } of items) {
if (!newSchema) continue;
const existing = this.getNodeById(newSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
} else {
// 重做 add按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移。
// 不走 this.adddoAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
// 无法按 step 记录的 parentId / index 精确还原,还会重算 style 破坏已记录的终态位置。
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, addedNode);
} else {
parent.items.push(addedNode);
}
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
}
break;
}
case 'remove': {
const items = step.diff ?? [];
if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
parentId,
root: cloneDeep(root),
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
} else {
// 重做 remove再删一次
for (const { oldSchema } of items) {
if (!oldSchema) continue;
const existing = this.getNodeById(oldSchema.id, false);
if (existing) {
await this.remove(existing, commonOpts);
}
}
}
break;
}
case 'update': {
const items = step.diff ?? [];
// 优先按 changeRecords 局部 patch仅触达 propPath 下的字段,避免整节点替换冲掉同节点上其它无关变更。
// 没有 changeRecords 的(如内部 sort/moveLayer/拖动等整节点快照场景)才退化为整节点替换。
const configs = items
.filter((item) => item.oldSchema && item.newSchema)
.map(({ oldSchema, newSchema, changeRecords }) => {
const oldNode = oldSchema!;
const newNode = newSchema!;
if (changeRecords?.length) {
const sourceForValues = reverse ? oldNode : newNode;
// 仅保留 id / type 作为最小骨架,再按 propPath 写入需要回滚/重做的字段;
// 后续 update -> mergeWith 会与现有节点深合并patch 中未涉及的字段不会被改动。
const patch: MNode = { id: newNode.id, type: newNode.type };
for (const record of changeRecords) {
if (!record.propPath) {
// 没有 propPath 视为整节点替换
return cloneDeep(sourceForValues);
}
const value = cloneDeep(getValueByKeyPath(record.propPath, sourceForValues));
setValueByKeyPath(record.propPath, value, patch);
}
return patch;
}
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true, historySource: step.source });
}
break;
}
}
this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
// 还原校验错误标记:因 undo/redo 复用同一 step需按方向取「操作前 / 操作后」快照——
// undo(reverse=true) 还原到操作前的错误状态(撤销一个「校验失败」的改动后错误消失),
// redo(reverse=false) 还原到操作后的错误状态(重做后错误恢复)。
// 浅拷贝一份以隔离历史快照,避免后续 set/deleteInvalidNode 反向污染 step.extra。
const invalidToRestore =
(reverse ? step.extra?.invalidNodeIdsBefore : step.extra?.invalidNodeIdsAfter) ?? new Map();
this.set('invalidNodeIds', new Map(invalidToRestore));
this.emit('invalid-node-change', this.get('invalidNodeIds'));
const page = toRaw(this.get('page'));
if (page) {
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.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);
}
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
/**
* 撤销 remove / 重做 add 通过手工 splice 按 step 记录的 parentId / index 精确还原节点(不走 this.add原因见调用处注释
* 这里补齐与 this.add 等价的 add / change 事件,保证撤销/重做路径的事件行为与正向操作一致。
*/
private emitHistoryInsertEvents(nodes: MNode[], source?: HistoryOpSource) {
if (!nodes.length) return;
this.emit('add', nodes);
this.emit('change', {
type: 'add',
data: nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource: source,
// 撤销/重做本身不再写入历史记录
doNotPushHistory: true,
});
}
}
export type EditorService = Editor;
export default new Editor();