fix(editor): 多选时对多个节点的操作合并入同一条历史记录

- moveToContainer 支持数组形参,多选移动整批只产生一条历史记录

- use-stage 拖动多选元素入容器 / 多选拖动缩放整批合成一次调用

- 右键移动至改走 moveToContainer,避免 remove+add 切成两条历史

- 跳过选中目标节点的分支清理 state.nodes 残留旧引用

- history.push 新增可选 pageId 参数,跨页操作正确落到目标页栈

- pushOpHistory 显式按 step.data.id 入栈,避免跨页操作错配
This commit is contained in:
roymondchen 2026-05-27 19:09:34 +08:00
parent de94a75803
commit a341c7d73e
7 changed files with 246 additions and 75 deletions

View File

@ -97,23 +97,37 @@ export const useStage = (stageOptions: StageOptions) => {
stage.on('update', (ev: UpdateEventData) => {
if (ev.parentEl) {
for (const data of ev.data) {
const id = getIdFromEl()(data.el);
const pId = getIdFromEl()(ev.parentEl);
id && pId && editorService.moveToContainer({ id, style: data.style }, pId);
// 拖动多选元素到一个新容器:整批合成一次 moveToContainer只产生一条历史记录
const pId = getIdFromEl()(ev.parentEl);
if (!pId) return;
const configs = ev.data
.map((data) => {
const id = getIdFromEl()(data.el);
if (!id) return null;
const cfg: MNode = { id, style: data.style };
return cfg;
})
.filter((cfg): cfg is MNode => Boolean(cfg));
if (configs.length > 0) {
editorService.moveToContainer(configs, pId);
}
return;
}
// 为每个元素单独更新,确保 changeRecords 与对应的元素关联
// 多选拖动 / 多选缩放:所有元素整批走一次 update避免历史栈被切成 N 条
const configs: MNode[] = [];
const changeRecordsAll: ReturnType<typeof buildChangeRecords> = [];
ev.data.forEach((data) => {
const id = getIdFromEl()(data.el);
if (!id) return;
const { style = {} } = data;
editorService.update({ id, style }, { changeRecords: buildChangeRecords(style, 'style') });
configs.push({ id, style });
changeRecordsAll.push(...buildChangeRecords(style, 'style'));
});
if (configs.length === 0) return;
editorService.update(configs, { changeRecords: changeRecordsAll });
});
stage.on('sort', (ev: SortEventData) => {

View File

@ -66,6 +66,8 @@ import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
type MoveItem = { cfg: MNode; node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@ -892,37 +894,76 @@ class Editor extends BaseService {
}
/**
*
* @param config
*
*
* config
* `updatedItems` +
* "多选移动到某容器" N
*
* @param config idstyle
* @param targetId ID
* @param options
* @param options.doNotSelect false
* @param options.doNotSwitchPage false true
*/
public async moveToContainer(
config: MNode,
config: MNode | MNode[],
targetId: Id,
{ doNotSelect = false, doNotSwitchPage = false }: DslOpOptions = {},
): Promise<MNode | undefined> {
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
if (configs.length === 0) {
throw new Error('没有可移动的节点');
}
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));
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 cfg of configs) {
const { node, parent, page } = this.getNodeInfo(cfg.id, false);
if (!node || !parent) continue;
moves.push({ cfg, 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 m of moves) {
if (!beforeSnapshots.has(m.parent.id)) {
beforeSnapshots.set(m.parent.id, cloneDeep(toRaw(m.parent)));
}
}
const layout = await this.getLayout(target);
const newConfigs: MNode[] = [];
for (const { cfg, node, parent } of moves) {
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) => {
const newConfig = mergeWith(cloneDeep(node), cfg, (_objValue, srcValue) => {
if (Array.isArray(srcValue)) {
return srcValue;
}
@ -930,42 +971,70 @@ class Editor extends BaseService {
newConfig.style = getInitPositionStyle(newConfig.style, layout);
target.items.push(newConfig);
newConfigs.push(newConfig);
// 目标容器是否在非当前页面:选中目标会触发当前页面切换
const targetWouldSwitchPage = this.isOnDifferentPage(target);
const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
if (!skipSelect) {
await stage.select(targetId);
}
const targetParent = this.getParentById(target.id);
await stage.update({
config: cloneDeep(target),
parentId: targetParent?.id,
root: cloneDeep(root),
});
if (!skipSelect) {
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;
}
this.addModifiedNodeId(target.id);
// 目标容器是否在非当前页面:选中目标会触发当前页面切换
const targetWouldSwitchPage = this.isOnDifferentPage(target);
const skipSelect = doNotSelect || (doNotSwitchPage && targetWouldSwitchPage);
if (!skipSelect) {
await stage.select(targetId);
}
const targetParent = this.getParentById(target.id);
await stage.update({
config: cloneDeep(target),
parentId: targetParent?.id,
root: cloneDeep(root),
});
if (!skipSelect) {
if (newConfigs.length > 1) {
const ids = newConfigs.map((n) => n.id);
await this.multiSelect(ids);
stage.multiSelect(ids);
} else {
await this.select(newConfigs[0]);
stage.select(newConfigs[0].id);
}
} else {
// 跳过选中目标节点(通常是因为目标位于其它页面),但 state.nodes 仍持有已经被
// 从源父容器中移除的旧节点引用 —— UI 上原页面会保留一个"指向不存在节点"的选中态。
// 这里需要把搬走的节点从 state.nodes 中剔除:
// - 如果剔除后还有剩余选中(部分被搬走、部分未动),保持新的多选状态;
// - 如果选中节点全部已被搬走,回退到第一个源父容器(与 `doRemove` 默认在原页面选中父节点的行为一致)。
const movedIds = new Set(moves.map((m) => `${m.node.id}`));
const selectedNodes = this.get('nodes');
const remained = selectedNodes.filter((n: MNode) => !movedIds.has(`${n.id}`));
if (remained.length === selectedNodes.length) {
// 当前选中根本不在被搬走的列表里,无需调整
} else if (remained.length > 0) {
this.multiSelect(remained.map((n: MNode) => n.id));
} else {
// 全部被搬走:选中源父容器,避免残留旧引用。多源父时取第一个,与单选场景默认表现一致。
const fallbackParent = moves[0].parent;
try {
await this.select(fallbackParent);
} catch {
this.set('nodes', []);
}
}
}
// 整批只入栈一条历史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', { updatedItems }, historyPage);
return isBatch ? newConfigs : newConfigs[0];
}
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
@ -1137,7 +1206,9 @@ class Editor extends BaseService {
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
};
historyService.push(step);
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
historyService.push(step, pageData.id);
this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
}

View File

@ -18,7 +18,7 @@
import { reactive } from 'vue';
import type { MPage, MPageFragment } from '@tmagic/core';
import type { Id, MPage, MPageFragment } from '@tmagic/core';
import type { HistoryState, StepValue } from '@editor/type';
import { UndoRedo } from '@editor/utils/undo-redo';
@ -71,11 +71,20 @@ class History extends BaseService {
this.state.canUndo = false;
}
public push(state: StepValue): StepValue | null {
const undoRedo = this.getUndoRedo();
/**
* pageId
*
* `moveToContainer` `pageId`
* / /
*/
public push(state: StepValue, pageId?: Id): StepValue | null {
const undoRedo = this.getUndoRedo(pageId);
if (!undoRedo) return null;
undoRedo.pushElement(state);
this.emit('change', state);
// 仅当推入的是当前活动页时才需要刷新 canUndo/canRedo —— 其它页栈对当前 UI 状态没影响。
if (pageId === undefined || `${pageId}` === `${this.state.pageId}`) {
this.emit('change', state);
}
return state;
}
@ -101,9 +110,19 @@ class History extends BaseService {
this.removeAllPlugins();
}
private getUndoRedo() {
if (!this.state.pageId) return null;
return this.state.pageSteps[this.state.pageId];
/**
* pageId
*
* push
* Ctrl+Z
*/
private getUndoRedo(pageId?: Id) {
const targetPageId = pageId ?? this.state.pageId;
if (!targetPageId) return null;
if (!this.state.pageSteps[targetPageId]) {
this.state.pageSteps[targetPageId] = new UndoRedo<StepValue>();
}
return this.state.pageSteps[targetPageId];
}
private setCanUndoRedo(): void {

View File

@ -63,13 +63,11 @@ const moveTo = async (id: Id, { editorService }: Services) => {
const nodes = editorService.get('nodes') || [];
const parent = editorService.getNodeById(id) as MContainer;
if (!parent) return;
const newNodes = cloneDeep(nodes);
if (!parent || nodes.length === 0) return;
await editorService.remove(nodes);
await editorService.add(newNodes, parent, {
doNotSelect: true,
// 直接调用 moveToContainer内部对多选场景已做合并整批只产生一条历史记录。
// 不要再走 remove + add 两步,否则会被切成两条历史(且语义也不正确)。
await editorService.moveToContainer(cloneDeep(nodes), parent.id, {
doNotSwitchPage: true,
});
};

View File

@ -163,7 +163,28 @@ describe('useStage', () => {
parentEl: { id: 'p_x' },
data: [{ el: { id: 'c1' }, style: { left: 1 } }],
});
expect(editorService.moveToContainer).toHaveBeenCalledWith({ id: 'c1', style: { left: 1 } }, 'p_x');
// 单选时整批仍只调用一次 moveToContainer传入数组形式的 configs
expect(editorService.moveToContainer).toHaveBeenCalledWith([{ id: 'c1', style: { left: 1 } }], 'p_x');
});
test('update 事件 (parentEl 存在 - 多选 moveToContainer 合并为单次调用)', () => {
useStage({} as any);
stageInstance.handlers.update[0]({
parentEl: { id: 'p_x' },
data: [
{ el: { id: 'c1' }, style: { left: 1 } },
{ el: { id: 'c2' }, style: { left: 2 } },
],
});
// 多选拖入容器:整批合并为一次 moveToContainer 调用,避免历史栈被切成两条
expect(editorService.moveToContainer).toHaveBeenCalledTimes(1);
expect(editorService.moveToContainer).toHaveBeenCalledWith(
[
{ id: 'c1', style: { left: 1 } },
{ id: 'c2', style: { left: 2 } },
],
'p_x',
);
});
test('update 事件 (无 parentEl - update)', () => {
@ -174,6 +195,24 @@ describe('useStage', () => {
expect(editorService.update).toHaveBeenCalled();
});
test('update 事件 (无 parentEl - 多选拖动 / 缩放合并为单次 update)', () => {
useStage({} as any);
(editorService.update as any).mockClear();
stageInstance.handlers.update[0]({
data: [
{ el: { id: 'c1' }, style: { width: 10 } },
{ el: { id: 'c2' }, style: { width: 20 } },
],
});
// 多选场景整批走一次 editorService.update避免历史栈被切成两条
expect(editorService.update).toHaveBeenCalledTimes(1);
const callArgs = (editorService.update as any).mock.calls[0];
expect(callArgs[0]).toEqual([
{ id: 'c1', style: { width: 10 } },
{ id: 'c2', style: { width: 20 } },
]);
});
test('sort 事件', () => {
useStage({} as any);
stageInstance.handlers.sort[0]({ src: 'a', dist: 'b' });

View File

@ -56,4 +56,32 @@ describe('history service', () => {
history.changePage(null as any);
expect(history.state.pageId).toBeUndefined();
});
test('push 指定 pageId 落到目标页栈,不影响当前页', () => {
// 当前激活在 p1
history.changePage({ id: 'p1' } as any);
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
// 跨页 push把记录推到 p2目标页p1 栈应保持为空
history.push(step, 'p2');
expect((history.state.pageSteps as any).p2).toBeDefined();
expect((history.state.pageSteps as any).p2.canUndo()).toBe(true);
// p1 栈虽然激活但没有 push 进来,仍不可撤销
expect((history.state.pageSteps as any).p1.canUndo()).toBe(false);
// 跨页 push 不应触发当前页p1的 canUndo 改变
expect(history.state.canUndo).toBe(false);
// 切到 p2 后能正常 undo 该跨页步骤
history.changePage({ id: 'p2' } as any);
expect(history.state.canUndo).toBe(true);
expect(history.undo()).toBeDefined();
});
test('push 不传 pageId 时落到当前活动页栈(向后兼容)', () => {
history.changePage({ id: 'p1' } as any);
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
expect(history.state.canUndo).toBe(true);
});
});

View File

@ -154,13 +154,12 @@ describe('content-menu utils', () => {
if (k === 'nodes') return [{ id: 'btn' }];
return undefined;
},
add: vi.fn(),
remove: vi.fn(),
moveToContainer: vi.fn(),
getNodeById: () => null,
};
const m = useMoveToMenu({ editorService } as any);
(m as any).items[0].handler({ editorService });
expect(editorService.add).not.toHaveBeenCalled();
expect(editorService.moveToContainer).not.toHaveBeenCalled();
});
test('useMoveToMenu - root 为空时 items 为空数组', () => {
@ -191,15 +190,18 @@ describe('content-menu utils', () => {
if (k === 'nodes') return [{ id: 'btn' }];
return undefined;
},
add: vi.fn(),
remove: vi.fn(),
moveToContainer: vi.fn(),
getNodeById: (id: string) => ({ id, items: [] }),
};
const m = useMoveToMenu({ editorService } as any);
expect((m as any).items).toHaveLength(1);
expect((m as any).items[0].text).toContain('P2');
(m as any).items[0].handler({ editorService });
expect(editorService.add).toHaveBeenCalled();
expect(editorService.remove).toHaveBeenCalled();
// 移动至 目标页:直接走 moveToContainer 单次调用,整批只产生一条历史
expect(editorService.moveToContainer).toHaveBeenCalledTimes(1);
const callArgs = (editorService.moveToContainer as any).mock.calls[0];
expect(Array.isArray(callArgs[0])).toBe(true);
expect(callArgs[0][0].id).toBe('btn');
expect(callArgs[1]).toBe('p2');
});
});