diff --git a/packages/editor/src/hooks/use-stage.ts b/packages/editor/src/hooks/use-stage.ts index 0fa0d2bb..21dc441b 100644 --- a/packages/editor/src/hooks/use-stage.ts +++ b/packages/editor/src/hooks/use-stage.ts @@ -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 = []; 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) => { diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index f00ab362..52170c60 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -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 { + ): Promise { + 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(); + 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; } diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index fbae55ce..514cd6a0 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -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(); + } + return this.state.pageSteps[targetPageId]; } private setCanUndoRedo(): void { diff --git a/packages/editor/src/utils/content-menu.ts b/packages/editor/src/utils/content-menu.ts index 6653288e..12b36d41 100644 --- a/packages/editor/src/utils/content-menu.ts +++ b/packages/editor/src/utils/content-menu.ts @@ -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, }); }; diff --git a/packages/editor/tests/unit/hooks/use-stage.spec.ts b/packages/editor/tests/unit/hooks/use-stage.spec.ts index 9a2bf1b7..62dcb8b0 100644 --- a/packages/editor/tests/unit/hooks/use-stage.spec.ts +++ b/packages/editor/tests/unit/hooks/use-stage.spec.ts @@ -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' }); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index e184973e..eeed378d 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -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); + }); }); diff --git a/packages/editor/tests/unit/utils/content-menu-utils.spec.ts b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts index c1be421a..49e59814 100644 --- a/packages/editor/tests/unit/utils/content-menu-utils.spec.ts +++ b/packages/editor/tests/unit/utils/content-menu-utils.spec.ts @@ -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'); }); });