mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
fix(editor): 多选时对多个节点的操作合并入同一条历史记录
- moveToContainer 支持数组形参,多选移动整批只产生一条历史记录 - use-stage 拖动多选元素入容器 / 多选拖动缩放整批合成一次调用 - 右键移动至改走 moveToContainer,避免 remove+add 切成两条历史 - 跳过选中目标节点的分支清理 state.nodes 残留旧引用 - history.push 新增可选 pageId 参数,跨页操作正确落到目标页栈 - pushOpHistory 显式按 step.data.id 入栈,避免跨页操作错配
This commit is contained in:
parent
de94a75803
commit
a341c7d73e
@ -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) => {
|
||||
|
||||
@ -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 需要移动的节点(或节点数组,各项需带 id;style 等字段会与原节点合并)
|
||||
* @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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@ -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' });
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user