diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 869fa4da..03f79f4a 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -72,6 +72,7 @@ export { default as Resizer } from './components/Resizer.vue'; export { default as CodeBlockEditor } from './components/CodeBlockEditor.vue'; export { default as CompareForm } from './components/CompareForm.vue'; export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue'; +export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue'; export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue'; export { default as FloatingBox } from './components/FloatingBox.vue'; export { default as Tree } from './components/Tree.vue'; diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 97059a6a..8065c730 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -55,7 +55,7 @@ export const initServiceState = ( watch( () => props.modelValue, (modelValue) => { - editorService.set('root', modelValue || null); + editorService.set('root', modelValue || null, { historySource: 'initial' }); }, { immediate: true, diff --git a/packages/editor/src/layouts/Framework.vue b/packages/editor/src/layouts/Framework.vue index 8d6f4fd2..e5f2518a 100644 --- a/packages/editor/src/layouts/Framework.vue +++ b/packages/editor/src/layouts/Framework.vue @@ -168,7 +168,7 @@ onBeforeUnmount(() => { const saveCode = (value: string) => { try { const parseDSL = getEditorConfig('parseDSL'); - editorService.set('root', parseDSL(value)); + editorService.set('root', parseDSL(value), { historySource: 'root-code' }); } catch (e: any) { console.error(e); } diff --git a/packages/editor/src/layouts/history-list/Bucket.vue b/packages/editor/src/layouts/history-list/Bucket.vue index 93baee46..b8ea1c6f 100644 --- a/packages/editor/src/layouts/history-list/Bucket.vue +++ b/packages/editor/src/layouts/history-list/Bucket.vue @@ -11,7 +11,7 @@ v-for="group in groups" :key="rowKey(group)" :group="toRow(group)" - :expanded="!!expanded[rowKey(group)]" + :expanded="isHistoryGroupExpanded(expanded, rowKey(group))" :goto-enabled="config.gotoEnabled" @toggle="(key: string) => $emit('toggle', key)" @goto="(index: number) => $emit('goto', bucketId, index)" @@ -36,10 +36,10 @@ diff --git a/packages/editor/src/layouts/history-list/composables.ts b/packages/editor/src/layouts/history-list/composables.ts index 3b2ea8dc..318e3ba1 100644 --- a/packages/editor/src/layouts/history-list/composables.ts +++ b/packages/editor/src/layouts/history-list/composables.ts @@ -11,6 +11,7 @@ import type { DataSourceStepValue, HistoryOpSource, HistoryOpType, + HistoryRowDescriptor, PageHistoryGroup, StepValue, } from '@editor/type'; @@ -30,37 +31,6 @@ export interface HistoryBucketGroup { steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[]; } -/** - * 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史 - * 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。 - */ -export interface HistoryRowDescriptor { - /** 组级描述文案生成器,接收一个 group,返回展示文本。 */ - describeGroup: (_group: any) => string; - /** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */ - describeStep: (_step: T) => string; - /** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */ - isStepDiffable?: (_step: T) => boolean; - /** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。不传则已应用即可回滚。 */ - isStepRevertable?: (_step: T) => boolean; -} - -/** - * 通用 bucket(数据源 / 代码块 / 业务自定义历史)的整体渲染配置。 - * 把原先散落在 Bucket / BucketTab 上的 title / prefix / describe* / isStep* / showInitial / gotoEnabled - * 收敛成一个对象作为单一 prop 传递,调用方一次配齐、组件内部按需读取。 - */ -export interface HistoryBucketConfig extends HistoryRowDescriptor { - /** bucket 头部标题,例如 "数据源" / "代码块"。 */ - title: string; - /** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */ - prefix: string; - /** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */ - showInitial?: boolean; - /** 是否支持「跳转到该记录」(goto),默认 true。 */ - gotoEnabled?: boolean; -} - /** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */ export interface HistoryRowStep { /** 该子步在所属栈中的稳定索引。 */ @@ -110,6 +80,9 @@ export interface HistoryRowGroup { subSteps: HistoryRowStep[]; } +/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */ +export const isHistoryGroupExpanded = (expanded: Record, key: string) => expanded[key] !== false; + /** * 历史记录面板共享逻辑: * - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块); @@ -124,10 +97,11 @@ export const useHistoryList = () => { /** * 折叠状态:key 形如 `pg-${组内首步 index}` / `ds-${id}-${组内首步 index}` / `cb-${id}-${组内首步 index}`。 * 用组内首步的稳定 index(而非展示位置)作为 key,确保历史数据更新后已展开的分组状态保持不变。 + * 合并组默认展开;仅当值为 `false` 时表示收起。 */ const expanded = reactive>({}); const toggleGroup = (key: string) => { - expanded[key] = !expanded[key]; + expanded[key] = expanded[key] === false; }; const pageGroups = computed(() => historyService.getPageHistoryGroups()); @@ -211,6 +185,7 @@ const HISTORY_SOURCE_LABELS: Record = { 'component-panel': '组件面板', props: '配置面板', code: '源码', + 'root-code': 'DSL源码', 'stage-contextmenu': '画布菜单', 'tree-contextmenu': '树菜单', toolbar: '工具栏', @@ -218,6 +193,8 @@ const HISTORY_SOURCE_LABELS: Record = { rollback: '回滚', api: '接口', ai: 'AI', + initial: '初始值', + sync: '同步', unknown: '未知', }; diff --git a/packages/editor/src/services/editor.ts b/packages/editor/src/services/editor.ts index ee69ba99..62b5a2b7 100644 --- a/packages/editor/src/services/editor.ts +++ b/packages/editor/src/services/editor.ts @@ -17,7 +17,7 @@ */ import { reactive, toRaw } from 'vue'; -import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es'; +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'; @@ -60,6 +60,7 @@ import { classifyDragSources, collectRelatedNodes, COPY_STORAGE_KEY, + describeStepForRevert, editorNodeMergeCustomizer, fixNodePosition, getInitPositionStyle, @@ -76,46 +77,6 @@ import { beforePaste, getAddParent } from '@editor/utils/operator'; type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null }; -/** - * 给「回滚」生成的新 step 用的简短描述生成器。 - * 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故在此本地实现。 - */ -const describeStepForRevert = (step: StepValue): string => { - const items = step.diff ?? []; - // 在可读名后拼接组件 id,便于在历史面板中精确定位被回滚的组件;id 缺失时退化为仅展示名称。 - const withId = (node: MNode | undefined, label: string): string => { - const id = node?.id; - if (id === undefined || id === null || `${id}` === '') return label; - return label ? `${label}(id: ${id})` : `id: ${id}`; - }; - switch (step.opType) { - case 'add': { - const count = items.length; - const node = items[0]?.newSchema; - const label = node?.name || node?.type || ''; - return `撤回新增 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; - } - case 'remove': { - const count = items.length; - const node = items[0]?.oldSchema; - const label = node?.name || node?.type || ''; - return `还原已删除的 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; - } - case 'update': - default: { - if (items.length === 1) { - const { newSchema, oldSchema, changeRecords } = items[0]; - const node = newSchema || oldSchema; - const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || ''; - const target = withId(node, label); - const propPath = changeRecords?.[0]?.propPath; - return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`; - } - return `还原 ${items.length} 个节点的修改`; - } - } -}; - /** * 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。 * `changeRecords` 来自 form 端的 propPath/value 列表,撤销/重做时只对这些 propPath 做局部更新; @@ -166,8 +127,13 @@ class Editor extends BaseService { * 设置当前指点节点配置 * @param name 'root' | 'page' | 'parent' | 'node' | 'highlightNode' | 'nodes' | 'stage' | 'modifiedNodeIds' | 'pageLength' | 'pageFragmentLength * @param value MNode + * @param options.historySource 设置 root 时,本次变更写入历史记录的「操作来源」(仅 name === 'root' 时生效) */ - public set(name: K, value: T) { + public set( + name: K, + value: T, + options: { historySource?: HistoryOpSource } = {}, + ) { const preValue = this.state[name]; this.state[name] = value; @@ -186,6 +152,25 @@ class Editor extends BaseService { 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.getPageMarker(pageNode.id)) { + historyService.setPageMarker(pageNode.id, { + name: pageNode.name, + source: options.historySource, + }); + } + }); + } } else { this.state.pageLength = 0; this.state.pageFragmentLength = 0; @@ -695,7 +680,7 @@ class Editor extends BaseService { public async doUpdate( config: MNode, - { changeRecords = [] }: { changeRecords?: ChangeRecord[] } = {}, + { changeRecords = [], historySource }: { changeRecords?: ChangeRecord[]; historySource?: HistoryOpSource } = {}, ): Promise<{ newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }> { const root = this.get('root'); if (!root) throw new Error('root为空'); @@ -715,7 +700,7 @@ class Editor extends BaseService { if (!newConfig.type) throw new Error('配置缺少type值'); if (newConfig.type === NodeType.ROOT) { - this.set('root', newConfig as MApp); + this.set('root', newConfig as MApp, { historySource }); return { oldNode: node, newNode: newConfig, @@ -796,7 +781,7 @@ class Editor extends BaseService { const updateData = await Promise.all( nodes.map((node, index) => { const recordsForNode = changeRecordList ? (changeRecordList[index] ?? []) : (changeRecords ?? []); - return this.doUpdate(node, { changeRecords: recordsForNode }); + return this.doUpdate(node, { changeRecords: recordsForNode, historySource }); }), ); @@ -1374,6 +1359,8 @@ class Editor extends BaseService { 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; @@ -1573,6 +1560,86 @@ class Editor extends BaseService { this.selectionBeforeOp = this.get('nodes').map((n) => n.id); } + /** + * 比较「上一次 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}`); + + 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, + ); + } 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, + ); + } + }); + } + + /** + * 构造一条页面级「set root」历史记录(不携带选区 / modifiedNodeIds 上下文)并落到该页面自己的栈。 + * + * 连续 set root 替换:若该页栈最新一条已是**同来源**的 set root 记录({@link StepValue.rootStep} 且 `source` 相同), + * 则用本次记录**替换**它而非新增,避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录; + * 来源不同则照常新增(initial 基线不是 rootStep,不在此列)。 + */ + private pushPageDiffStep( + opType: HistoryOpType, + page: MPage | MPageFragment, + diffItem: StepDiffItem, + source?: HistoryOpSource, + ): void { + const step: StepValue = { + uuid: guid(), + data: { name: page.name || '', id: page.id }, + opType, + 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.replaceCurrentPageStep(step, page.id); + } else { + historyService.push(step, page.id); + } + } + private pushOpHistory( opType: HistoryOpType, { @@ -1621,6 +1688,8 @@ class Editor extends BaseService { * @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; diff --git a/packages/editor/src/services/history.ts b/packages/editor/src/services/history.ts index 3257b815..957bc128 100644 --- a/packages/editor/src/services/history.ts +++ b/packages/editor/src/services/history.ts @@ -17,7 +17,6 @@ */ import { reactive } from 'vue'; -import { cloneDeep } from 'lodash-es'; import serialize from 'serialize-javascript'; import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core'; @@ -25,13 +24,11 @@ import type { ChangeRecord } from '@tmagic/form'; import { guid } from '@tmagic/utils'; import type { - BaseStepValue, CodeBlockHistoryGroup, CodeBlockStepValue, DataSourceHistoryGroup, DataSourceStepValue, HistoryOpSource, - HistoryOpType, HistoryPersistOptions, HistoryState, PageHistoryGroup, @@ -40,6 +37,16 @@ import type { StepValue, } from '@editor/type'; import { getEditorConfig } from '@editor/utils/config'; +import { + createStackStep, + deserializeStacks, + getOrCreateStack, + markStackSaved, + mergePageSteps, + mergeStackSteps, + serializeStacks, + undoFloor, +} from '@editor/utils/history'; import { idbGet, idbSet } from '@editor/utils/indexed-db'; import { UndoRedo } from '@editor/utils/undo-redo'; @@ -53,199 +60,6 @@ const DEFAULT_KEY: IDBValidKey = 'default'; const PERSIST_VERSION = 1; class History extends BaseService { - /** - * 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group: - * - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并); - * - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。 - * - * 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。 - */ - private static mergeStackSteps( - kind: K, - id: Id, - list: S[], - cursor: number, - ): { - kind: K; - id: Id; - opType: HistoryOpType; - steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[]; - applied: boolean; - isCurrent?: boolean; - }[] { - type Group = { - kind: K; - id: Id; - opType: HistoryOpType; - steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[]; - applied: boolean; - isCurrent?: boolean; - }; - const groups: Group[] = []; - let current: Group | null = null; - const currentIndex = cursor - 1; - list.forEach((step, index) => { - const { opType } = step; - const applied = index < cursor; - const isCurrent = index === currentIndex; - if (opType === 'update' && current?.opType === 'update') { - current.steps.push({ step, index, applied, isCurrent }); - current.applied = applied; - if (isCurrent) current.isCurrent = true; - } else { - current = { - kind, - id, - opType, - steps: [{ step, index, applied, isCurrent }], - applied, - isCurrent, - }; - groups.push(current); - } - }); - return groups; - } - - /** - * 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。 - */ - private static detectOpType(oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' { - if (oldVal === null && newVal !== null) return 'add'; - if (oldVal !== null && newVal === null) return 'remove'; - return 'update'; - } - - /** - * 把页面栈拆成若干 group: - * - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group; - * - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组); - * - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。 - */ - private static mergePageSteps(pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] { - const groups: PageHistoryGroup[] = []; - let current: PageHistoryGroup | null = null; - const currentIndex = cursor - 1; - list.forEach((step, index) => { - const applied = index < cursor; - const isCurrent = index === currentIndex; - const targetId = History.detectPageTargetId(step); - const targetName = History.detectPageTargetName(step); - const entry: PageHistoryStepEntry = { step, index, applied, isCurrent }; - - // 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。 - const mergeable = step.opType === 'update' && targetId !== undefined; - if (mergeable && current?.opType === 'update' && current.targetId === targetId) { - current.steps.push(entry); - current.applied = applied; - if (isCurrent) current.isCurrent = true; - // 保持目标名为最近一次的(节点重命名时也能反映) - if (targetName) current.targetName = targetName; - } else { - current = { - kind: 'page', - pageId, - opType: step.opType, - targetId: mergeable ? targetId : undefined, - targetName, - steps: [entry], - applied, - isCurrent, - }; - groups.push(current); - } - }); - return groups; - } - - /** - * 解析 StepValue 中的"目标节点 id"用于合并: - * - 单节点 update:取唯一一项 updatedItems 的节点 id; - * - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。 - */ - private static detectPageTargetId(step: StepValue): Id | undefined { - if (step.opType !== 'update') return undefined; - const items = step.diff; - if (items?.length !== 1) return undefined; - return items[0].newSchema?.id ?? items[0].oldSchema?.id; - } - - /** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */ - private static detectPageTargetName(step: StepValue): string | undefined { - const items = step.diff; - if (step.opType === 'update') { - if (items?.length === 1) { - const node = items[0].newSchema || items[0].oldSchema; - return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined); - } - return items?.length ? `${items.length} 个节点` : undefined; - } - if (step.opType === 'add') { - if (items?.length === 1) { - const n = items[0].newSchema; - return (n?.name as string) || (n?.type as string) || `${n?.id}`; - } - return items?.length ? `${items.length} 个节点` : undefined; - } - if (step.opType === 'remove') { - if (items?.length === 1) { - const n = items[0].oldSchema; - return (n?.name as string) || (n?.type as string) || `${n?.id}`; - } - return items?.length ? `${items.length} 个节点` : undefined; - } - return undefined; - } - - /** - * 把单个栈当前游标所在记录标记为已保存:先清除该栈内全部旧标记,保证同一栈最多一条 `saved`。 - * 栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,恢复时其游标回到 0。 - */ - private static markStackSaved(undoRedo?: UndoRedo): void { - if (!undoRedo) return; - undoRedo.updateElements((element) => { - element.saved = false; - }); - undoRedo.updateCurrentElement((element) => { - element.saved = true; - }); - } - - /** 把 `Record` 整体序列化为 `Record`。 */ - private static serializeStacks(stacks: Record>) { - const result: Record['serialize']>> = {}; - Object.entries(stacks).forEach(([id, undoRedo]) => { - if (undoRedo) result[id] = undoRedo.serialize(); - }); - return result; - } - - /** - * 把 `Record` 整体还原为 `Record`。 - * 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。 - */ - private static deserializeStacks( - stacks: Record['serialize']>> = {}, - ): Record> { - const result: Record> = {}; - Object.entries(stacks).forEach(([id, serialized]) => { - if (serialized) { - result[id] = UndoRedo.fromSerialized(serialized, { isSavedStep: (element) => element.saved === true }); - } - }); - return result; - } - - /** - * 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。 - */ - private static getOrCreateStack(stacks: Record>, id: Id): UndoRedo { - if (!stacks[id]) { - stacks[id] = new UndoRedo(); - } - return stacks[id]; - } - public state = reactive({ pageSteps: {}, pageId: undefined, @@ -297,6 +111,59 @@ class History extends BaseService { this.state.dataSourceState = {}; } + /** + * 为指定页面 / 页面片种入一条「初始基线」记录(如加载 DSL 时的「初始 / 加载」基线)。 + * + * 该记录是一条 `opType: 'initial'` 的 {@link StepValue},作为页面历史栈 **index 0 的固定底线**: + * - 它是一条真实入栈的 step(随栈一起持久化),但被钉为撤销/回滚的下限——cursor 永不低于它, + * 因此不会被 undo / goto / revert 触达(详见 {@link undo} / {@link setCanUndoRedo}); + * - 历史面板把它过滤出分组列表(见 {@link getPageHistoryGroups}),改由底部「初始」行展示。 + * + * 仅当目标页面栈为空时种入(保证 initial 一定位于 index 0);已存在 initial 底线时默认不重复种入, + * 传 `force=true` 且栈为空时按新基线种入。 + */ + public setPageMarker( + pageId: Id, + options: { name?: string; description?: string; source?: HistoryOpSource } = {}, + ): StepValue | null { + if (pageId === undefined || pageId === null || `${pageId}` === '') return null; + + const existing = this.getPageMarker(pageId); + if (existing) return existing; + + const stack = getOrCreateStack(this.state.pageSteps, pageId); + // initial 必须是 index 0;栈非空(已有真实记录、却无 initial,如旧数据)时不强行前插,优雅降级为无基线。 + if (stack.getLength() > 0) return null; + + const marker: StepValue = { + uuid: guid(), + opType: 'initial', + diff: [], + data: { name: options.name || '', id: pageId }, + selectedBefore: [], + selectedAfter: [], + modifiedNodeIds: new Map(), + historyDescription: options.description || '未修改的初始状态', + timestamp: Date.now(), + ...(options.source ? { source: options.source } : {}), + }; + stack.pushElement(marker); + if (`${pageId}` === `${this.state.pageId}`) this.setCanUndoRedo(); + this.emit('page-marker-change', marker); + return marker; + } + + /** + * 读取指定页面(缺省当前活动页)的初始基线 step(页面栈 index 0 且 `opType: 'initial'`); + * 不存在时返回 undefined。 + */ + public getPageMarker(pageId?: Id): StepValue | undefined { + const targetPageId = pageId ?? this.state.pageId; + if (!targetPageId) return undefined; + const first = this.state.pageSteps[targetPageId]?.getElementList()[0]; + return first?.opType === 'initial' ? first : undefined; + } + /** * 把一条步骤推入指定页面的栈;不指定 pageId 时落到当前活动页。 * @@ -316,6 +183,27 @@ class History extends BaseService { return state; } + /** 读取指定页面(缺省当前活动页)历史栈当前游标所在的 step(cursor - 1);无则返回 null。 */ + public getCurrentPageStep(pageId?: Id): StepValue | null { + const targetPageId = pageId ?? this.state.pageId; + if (!targetPageId) return null; + return this.state.pageSteps[targetPageId]?.getCurrentElement() ?? null; + } + + /** + * 用 `state` 替换指定页面栈当前游标所在的 step(并丢弃其后的重做尾部),游标不变。 + * 用于「连续 set root 记录合并」等就地替换最新一条的场景;替换成功后按需刷新 / 通知。 + */ + public replaceCurrentPageStep(state: StepValue, pageId?: Id): StepValue | null { + const undoRedo = this.getUndoRedo(pageId); + if (!undoRedo) return null; + if (state.uuid === undefined) state.uuid = guid(); + if (state.timestamp === undefined) state.timestamp = Date.now(); + if (!undoRedo.replaceCurrentElement(state)) return null; + this.emit('change', state); + return state; + } + /** * 推入一条代码块变更记录(与页面/节点完全无关),按 `codeBlockId` 维度独立一份 UndoRedo 栈。 * @@ -337,7 +225,7 @@ class History extends BaseService { source?: HistoryOpSource; }, ): CodeBlockStepValue | null { - const step = this.createStackStep(codeBlockId, { + const step = createStackStep(codeBlockId, { oldValue: payload.oldContent, newValue: payload.newContent, changeRecords: payload.changeRecords, @@ -345,7 +233,7 @@ class History extends BaseService { source: payload.source, }); if (!step) return null; - History.getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step); + getOrCreateStack(this.state.codeBlockState, codeBlockId).pushElement(step); this.emit('code-block-history-change', codeBlockId, step); return step; } @@ -366,7 +254,7 @@ class History extends BaseService { source?: HistoryOpSource; }, ): DataSourceStepValue | null { - const step = this.createStackStep(dataSourceId, { + const step = createStackStep(dataSourceId, { oldValue: payload.oldSchema, newValue: payload.newSchema, changeRecords: payload.changeRecords, @@ -374,7 +262,7 @@ class History extends BaseService { source: payload.source, }); if (!step) return null; - History.getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step); + getOrCreateStack(this.state.dataSourceState, dataSourceId).pushElement(step); this.emit('data-source-history-change', dataSourceId, step); return step; } @@ -438,6 +326,8 @@ class History extends BaseService { public undo(): StepValue | null { const undoRedo = this.getUndoRedo(); if (!undoRedo) return null; + // 不允许撤销越过初始基线(index 0 的 initial step)。 + if (undoRedo.getCursor() <= undoFloor(undoRedo)) return null; const state = undoRedo.undo(); this.emit('change', state); return state; @@ -464,7 +354,16 @@ class History extends BaseService { public clearPage(pageId?: Id): void { const targetPageId = pageId ?? this.state.pageId; if (!targetPageId) return; + // 保留该页原 initial 基线的文案 / 来源(仅清空其后的真实操作记录),无基线时清空成空栈。 + const marker = this.getPageMarker(targetPageId); this.state.pageSteps[targetPageId] = new UndoRedo(); + if (marker) { + this.setPageMarker(targetPageId, { + name: marker.data?.name, + description: marker.historyDescription, + source: marker.source, + }); + } if (`${targetPageId}` === `${this.state.pageId}`) { this.setCanUndoRedo(); this.emit('change', null); @@ -501,9 +400,9 @@ class History extends BaseService { * {@link markPageSaved} / {@link markCodeBlockSaved} / {@link markDataSourceSaved}。 */ public markSaved(): void { - Object.values(this.state.pageSteps).forEach(History.markStackSaved); - Object.values(this.state.codeBlockState).forEach(History.markStackSaved); - Object.values(this.state.dataSourceState).forEach(History.markStackSaved); + Object.values(this.state.pageSteps).forEach(markStackSaved); + Object.values(this.state.codeBlockState).forEach(markStackSaved); + Object.values(this.state.dataSourceState).forEach(markStackSaved); this.emit('mark-saved', { kind: 'all' }); } @@ -514,21 +413,21 @@ class History extends BaseService { public markPageSaved(pageId?: Id): void { const targetPageId = pageId ?? this.state.pageId; if (!targetPageId) return; - History.markStackSaved(this.state.pageSteps[targetPageId]); + markStackSaved(this.state.pageSteps[targetPageId]); this.emit('mark-saved', { kind: 'page', id: targetPageId }); } /** 标记指定代码块的历史栈当前记录为已保存,仅影响该代码块自己的栈。 */ public markCodeBlockSaved(codeBlockId: Id): void { if (!codeBlockId) return; - History.markStackSaved(this.state.codeBlockState[codeBlockId]); + markStackSaved(this.state.codeBlockState[codeBlockId]); this.emit('mark-saved', { kind: 'code-block', id: codeBlockId }); } /** 标记指定数据源的历史栈当前记录为已保存,仅影响该数据源自己的栈。 */ public markDataSourceSaved(dataSourceId: Id): void { if (!dataSourceId) return; - History.markStackSaved(this.state.dataSourceState[dataSourceId]); + markStackSaved(this.state.dataSourceState[dataSourceId]); this.emit('mark-saved', { kind: 'data-source', id: dataSourceId }); } @@ -541,20 +440,20 @@ class History extends BaseService { * - 不支持 IndexedDB 的环境(如 SSR)会 reject。 */ public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise { - const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options; + const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options; const snapshot: PersistedHistoryState = { version: PERSIST_VERSION, pageId: this.state.pageId, - pageSteps: History.serializeStacks(this.state.pageSteps), - codeBlockState: History.serializeStacks(this.state.codeBlockState), - dataSourceState: History.serializeStacks(this.state.dataSourceState), + pageSteps: serializeStacks(this.state.pageSteps), + codeBlockState: serializeStacks(this.state.codeBlockState), + dataSourceState: serializeStacks(this.state.dataSourceState), savedAt: Date.now(), }; // 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法),IndexedDB 的结构化克隆无法写入函数, // 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。 - await idbSet(this.resolveDbName(dbName), storeName, key, serialize(snapshot)); + await idbSet(this.resolveDbName(dbName, appId), storeName, key, serialize(snapshot)); this.emit('save-to-indexed-db', snapshot); return snapshot; } @@ -568,18 +467,19 @@ class History extends BaseService { * - 不支持 IndexedDB 的环境(如 SSR)会 reject。 */ public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise { - const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY } = options; + const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options; - const raw = await idbGet(this.resolveDbName(dbName), storeName, key); + const raw = await idbGet(this.resolveDbName(dbName, appId), storeName, key); if (!raw) return null; // 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。 const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState; if (!snapshot) return null; - this.state.pageSteps = History.deserializeStacks(snapshot.pageSteps); - this.state.codeBlockState = History.deserializeStacks(snapshot.codeBlockState); - this.state.dataSourceState = History.deserializeStacks(snapshot.dataSourceState); + this.state.pageSteps = deserializeStacks(snapshot.pageSteps); + this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState); + this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState); + // initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。 this.state.pageId = snapshot.pageId; this.setCanUndoRedo(); @@ -621,7 +521,9 @@ class History extends BaseService { const list = undoRedo.getElementList(); if (!list.length) return []; const cursor = undoRedo.getCursor(); - return History.mergePageSteps(targetPageId, list, cursor); + // initial 基线(index 0)不作为普通操作组展示,过滤掉;其余真实 step 的 index 保持不变, + // 以便面板 goto(index+1) / revert(index) 仍直接对应栈内位置。底部「初始」行由 getPageMarker 驱动。 + return mergePageSteps(targetPageId, list, cursor).filter((group) => group.opType !== 'initial'); } /** @@ -638,7 +540,7 @@ class History extends BaseService { const list = undoRedo.getElementList(); if (!list.length) return; const cursor = undoRedo.getCursor(); - groups.push(...History.mergeStackSteps('code-block', id, list, cursor)); + groups.push(...mergeStackSteps('code-block', id, list, cursor)); }); return groups; } @@ -730,7 +632,7 @@ class History extends BaseService { const list = undoRedo.getElementList(); if (!list.length) return; const cursor = undoRedo.getCursor(); - groups.push(...History.mergeStackSteps('data-source', id, list, cursor)); + groups.push(...mergeStackSteps('data-source', id, list, cursor)); }); return groups; } @@ -754,59 +656,17 @@ class History extends BaseService { * 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。 * 取不到 app id(如尚未加载 DSL)时退回基础 dbName。 */ - private resolveDbName(dbName: string): string { - const appId = editorService.get('root')?.id; - return appId ? `${dbName}-${appId}` : dbName; + private resolveDbName(dbName: string, appId?: Id): string { + // 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。 + const resolvedAppId = appId ?? editorService.get('root')?.id; + return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName; } private setCanUndoRedo(): void { const undoRedo = this.getUndoRedo(); this.state.canRedo = undoRedo?.canRedo() || false; - this.state.canUndo = undoRedo?.canUndo() || false; - } - - /** - * 构造一条代码块 / 数据源「按 id 分栈」的历史记录:两者除 payload 字段命名外完全一致。 - * - * - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。 - * - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。 - * - 仅负责构造 step 并返回,入栈与事件 emit 由各公共方法(pushCodeBlock / pushDataSource)自行处理。 - * - 不直接驱动业务 service,调用方负责实际写回。 - */ - private createStackStep & { id: Id }>( - id: Id, - payload: { - oldValue: T | null; - newValue: T | null; - changeRecords?: ChangeRecord[]; - historyDescription?: string; - source?: HistoryOpSource; - }, - ): S | null { - if (!id) return null; - - const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null; - const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null; - const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined; - const opType = History.detectOpType(payload.oldValue, payload.newValue); - - const step: BaseStepValue & { id: Id } = { - uuid: guid(), - id, - opType, - diff: [ - { - ...(newSchema !== null ? { newSchema } : {}), - ...(oldSchema !== null ? { oldSchema } : {}), - ...(opType === 'update' && changeRecords ? { changeRecords } : {}), - }, - ], - historyDescription: payload.historyDescription, - source: payload.source, - timestamp: Date.now(), - }; - - return step as S; + // 初始基线之上才可撤销:cursor 必须高于底线(有 initial 时为 1)。 + this.state.canUndo = undoRedo ? undoRedo.getCursor() > undoFloor(undoRedo) : false; } } diff --git a/packages/editor/src/type.ts b/packages/editor/src/type.ts index 5e596165..088be110 100644 --- a/packages/editor/src/type.ts +++ b/packages/editor/src/type.ts @@ -680,7 +680,13 @@ export interface CodeParamStatement { } // #region HistoryOpType -export type HistoryOpType = 'add' | 'remove' | 'update'; +/** + * 历史记录操作类型: + * - `add` / `remove` / `update`:普通可撤销/重做的节点变更; + * - `initial`:页面「未修改的初始状态」基线(设置 root 时生成),作为页面栈 index 0 的固定底线 step。 + * 该 step 不可被撤销/回滚(cursor 不会低于它),仅用于历史面板底部的初始行展示。 + */ +export type HistoryOpType = 'add' | 'remove' | 'update' | 'initial'; // #endregion HistoryOpType // #region HistoryOpSource @@ -705,11 +711,13 @@ export type HistoryOpType = 'add' | 'remove' | 'update'; * 通过 `(string & {})` 允许业务侧扩展自定义途径字符串,同时保留内置值的自动补全。 */ export type HistoryOpSource = + | 'initial' | 'stage' | 'tree' | 'component-panel' | 'props' | 'code' + | 'root-code' | 'stage-contextmenu' | 'tree-contextmenu' | 'toolbar' @@ -717,6 +725,8 @@ export type HistoryOpSource = | 'rollback' | 'api' | 'ai' + // 同步 + | 'sync' | 'unknown' | (string & {}); // #endregion HistoryOpSource @@ -790,6 +800,12 @@ export interface BaseStepValue { * 同一栈内任意时刻最多只有一条记录为 true;从 IndexedDB 恢复时游标会被定位到最近一条已保存记录之后。 */ saved?: boolean; + /** + * 是否为「整体设置 root」(set root)产生的记录(由 {@link Editor.pushRootDiffHistory} 写入)。 + * 用于「连续 set root 合并」:当某页栈最新一条已是 root 记录时,下一条 set root 会替换它而非新增, + * 避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录。 + */ + rootStep?: boolean; } // #endregion BaseStepValue @@ -880,6 +896,12 @@ export interface HistoryPersistOptions { storeName?: string; /** 记录 key,用于区分不同活动页 / 项目,默认 `default`。 */ key?: IDBValidKey; + /** + * 显式指定用于库名隔离的 DSL app id。 + * 缺省时回退到当前 editorService 的 `root.id`;在「先恢复历史再 set root」场景下 root 尚未设置, + * 需由调用方(如从待加载 DSL 取 id)显式传入,否则会读 / 写到未按 app 隔离的默认库。 + */ + appId?: Id; } // #endregion HistoryPersistOptions @@ -1248,3 +1270,34 @@ export interface DiffDialogPayload { /** 用于标题展示的目标 id */ id?: string | number; } + +/** + * 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史 + * 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。 + */ +export interface HistoryRowDescriptor { + /** 组级描述文案生成器,接收一个 group,返回展示文本。 */ + describeGroup: (_group: any) => string; + /** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */ + describeStep: (_step: T) => string; + /** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */ + isStepDiffable?: (_step: T) => boolean; + /** 判断某个 step 是否支持回滚(如更新需带 changeRecords)。不传则已应用即可回滚。 */ + isStepRevertable?: (_step: T) => boolean; +} + +/** + * 通用 bucket(数据源 / 代码块 / 业务自定义历史)的整体渲染配置。 + * 把原先散落在 Bucket / BucketTab 上的 title / prefix / describe* / isStep* / showInitial / gotoEnabled + * 收敛成一个对象作为单一 prop 传递,调用方一次配齐、组件内部按需读取。 + */ +export interface HistoryBucketConfig extends HistoryRowDescriptor { + /** bucket 头部标题,例如 "数据源" / "代码块"。 */ + title: string; + /** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */ + prefix: string; + /** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */ + showInitial?: boolean; + /** 是否支持「跳转到该记录」(goto),默认 true。 */ + gotoEnabled?: boolean; +} diff --git a/packages/editor/src/utils/editor.ts b/packages/editor/src/utils/editor.ts index 00444304..c5ad3f48 100644 --- a/packages/editor/src/utils/editor.ts +++ b/packages/editor/src/utils/editor.ts @@ -35,7 +35,7 @@ import { isValueIncludeDataSource, } from '@tmagic/utils'; -import type { EditorNodeInfo } from '@editor/type'; +import type { EditorNodeInfo, StepValue } from '@editor/type'; import { LayerOffset, Layout } from '@editor/type'; export const COPY_STORAGE_KEY = '$MagicEditorCopyData'; @@ -684,3 +684,43 @@ export const classifyDragSources = ( return { sameParentIndices, crossParentConfigs, aborted: false }; }; + +/** + * 给「回滚」生成的新 step 用的简短描述生成器。 + * 与 UI 层 `describePageStep` 同义,但避免 service 反向依赖 layouts/,故放在此工具函数中。 + */ +export const describeStepForRevert = (step: StepValue): string => { + const items = step.diff ?? []; + // 在可读名后拼接组件 id,便于在历史面板中精确定位被回滚的组件;id 缺失时退化为仅展示名称。 + const withId = (node: MNode | undefined, label: string): string => { + const id = node?.id; + if (id === undefined || id === null || `${id}` === '') return label; + return label ? `${label}(id: ${id})` : `id: ${id}`; + }; + switch (step.opType) { + case 'add': { + const count = items.length; + const node = items[0]?.newSchema; + const label = node?.name || node?.type || ''; + return `撤回新增 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; + } + case 'remove': { + const count = items.length; + const node = items[0]?.oldSchema; + const label = node?.name || node?.type || ''; + return `还原已删除的 ${count} 个节点${count === 1 ? `(${withId(node, label)})` : ''}`; + } + case 'update': + default: { + if (items.length === 1) { + const { newSchema, oldSchema, changeRecords } = items[0]; + const node = newSchema || oldSchema; + const label = newSchema?.name || newSchema?.type || oldSchema?.name || oldSchema?.type || ''; + const target = withId(node, label); + const propPath = changeRecords?.[0]?.propPath; + return propPath ? `还原 ${target} · ${propPath}` : `还原 ${target}`; + } + return `还原 ${items.length} 个节点的修改`; + } + } +}; diff --git a/packages/editor/src/utils/history.ts b/packages/editor/src/utils/history.ts index f7c1b23f..f2d8985d 100644 --- a/packages/editor/src/utils/history.ts +++ b/packages/editor/src/utils/history.ts @@ -16,9 +16,23 @@ * limitations under the License. */ -import type { Id } from '@tmagic/core'; +import { cloneDeep } from 'lodash-es'; -import type { StepDiffItem } from '@editor/type'; +import type { Id } from '@tmagic/core'; +import type { ChangeRecord } from '@tmagic/form'; +import { guid } from '@tmagic/utils'; + +import type { + BaseStepValue, + HistoryOpSource, + HistoryOpType, + PageHistoryGroup, + PageHistoryStepEntry, + StepDiffItem, + StepValue, +} from '@editor/type'; + +import { UndoRedo } from './undo-redo'; /** * 「回滚」生成的新 step 简短描述。代码块 / 数据源共用。 @@ -41,3 +55,244 @@ export const describeRevertStep = ( const propPath = changeRecords?.[0]?.propPath; return propPath ? `还原 ${label} · ${propPath}` : `还原 ${label}`; }; + +/** + * 根据 old/new 是否为 null 推断 opType(与 push 时的约定一致)。 + */ +export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 'remove' | 'update' => { + if (oldVal === null && newVal !== null) return 'add'; + if (oldVal !== null && newVal === null) return 'remove'; + return 'update'; +}; + +/** + * 构造一条代码块 / 数据源「按 id 分栈」的历史记录:两者除 payload 字段命名外完全一致。 + * + * - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。 + * - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。 + * - 仅负责构造 step 并返回,入栈与事件 emit 由各公共方法(pushCodeBlock / pushDataSource)自行处理。 + * - 不直接驱动业务 service,调用方负责实际写回。 + */ +export const createStackStep = & { id: Id }>( + id: Id, + payload: { + oldValue: T | null; + newValue: T | null; + changeRecords?: ChangeRecord[]; + historyDescription?: string; + source?: HistoryOpSource; + }, +): S | null => { + if (!id) return null; + + const oldSchema = payload.oldValue ? cloneDeep(payload.oldValue) : null; + const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null; + const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined; + const opType = detectStackOpType(payload.oldValue, payload.newValue); + + const step: BaseStepValue & { id: Id } = { + uuid: guid(), + id, + opType, + diff: [ + { + ...(newSchema !== null ? { newSchema } : {}), + ...(oldSchema !== null ? { oldSchema } : {}), + ...(opType === 'update' && changeRecords ? { changeRecords } : {}), + }, + ], + historyDescription: payload.historyDescription, + source: payload.source, + timestamp: Date.now(), + }; + + return step as S; +}; + +export const markStackSaved = (undoRedo?: UndoRedo): void => { + if (!undoRedo) return; + undoRedo.updateElements((element) => { + element.saved = false; + }); + undoRedo.updateCurrentElement((element) => { + element.saved = true; + }); +}; + +/** + * 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group: + * - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并); + * - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。 + * + * 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。 + */ +export const mergeStackSteps = ( + kind: K, + id: Id, + list: S[], + cursor: number, +): { + kind: K; + id: Id; + opType: HistoryOpType; + steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[]; + applied: boolean; + isCurrent?: boolean; +}[] => { + type Group = { + kind: K; + id: Id; + opType: HistoryOpType; + steps: { step: S; index: number; applied: boolean; isCurrent?: boolean }[]; + applied: boolean; + isCurrent?: boolean; + }; + const groups: Group[] = []; + let current: Group | null = null; + const currentIndex = cursor - 1; + list.forEach((step, index) => { + const { opType } = step; + const applied = index < cursor; + const isCurrent = index === currentIndex; + if (opType === 'update' && current?.opType === 'update') { + current.steps.push({ step, index, applied, isCurrent }); + current.applied = applied; + if (isCurrent) current.isCurrent = true; + } else { + current = { + kind, + id, + opType, + steps: [{ step, index, applied, isCurrent }], + applied, + isCurrent, + }; + groups.push(current); + } + }); + return groups; +}; + +/** + * 把页面栈拆成若干 group: + * - 单节点的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group; + * - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单节点修改组); + * - 多节点 'update'(如批量改属性)也独立成组(无明确单一目标,避免误合并)。 + */ +export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): PageHistoryGroup[] => { + const groups: PageHistoryGroup[] = []; + let current: PageHistoryGroup | null = null; + const currentIndex = cursor - 1; + list.forEach((step, index) => { + const applied = index < cursor; + const isCurrent = index === currentIndex; + const targetId = detectPageTargetId(step); + const targetName = detectPageTargetName(step); + const entry: PageHistoryStepEntry = { step, index, applied, isCurrent }; + + // 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。 + const mergeable = step.opType === 'update' && targetId !== undefined; + if (mergeable && current?.opType === 'update' && current.targetId === targetId) { + current.steps.push(entry); + current.applied = applied; + if (isCurrent) current.isCurrent = true; + // 保持目标名为最近一次的(节点重命名时也能反映) + if (targetName) current.targetName = targetName; + } else { + current = { + kind: 'page', + pageId, + opType: step.opType, + targetId: mergeable ? targetId : undefined, + targetName, + steps: [entry], + applied, + isCurrent, + }; + groups.push(current); + } + }); + return groups; +}; + +/** + * 解析 StepValue 中的"目标节点 id"用于合并: + * - 单节点 update:取唯一一项 updatedItems 的节点 id; + * - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。 + */ +export const detectPageTargetId = (step: StepValue): Id | undefined => { + if (step.opType !== 'update') return undefined; + const items = step.diff; + if (items?.length !== 1) return undefined; + return items[0].newSchema?.id ?? items[0].oldSchema?.id; +}; + +/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */ +export const detectPageTargetName = (step: StepValue): string | undefined => { + const items = step.diff; + if (step.opType === 'update') { + if (items?.length === 1) { + const node = items[0].newSchema || items[0].oldSchema; + return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined); + } + return items?.length ? `${items.length} 个节点` : undefined; + } + if (step.opType === 'add') { + if (items?.length === 1) { + const n = items[0].newSchema; + return (n?.name as string) || (n?.type as string) || `${n?.id}`; + } + return items?.length ? `${items.length} 个节点` : undefined; + } + if (step.opType === 'remove') { + if (items?.length === 1) { + const n = items[0].oldSchema; + return (n?.name as string) || (n?.type as string) || `${n?.id}`; + } + return items?.length ? `${items.length} 个节点` : undefined; + } + return undefined; +}; + +/** 把 `Record` 整体序列化为 `Record`。 */ +export const serializeStacks = (stacks: Record>) => { + const result: Record['serialize']>> = {}; + Object.entries(stacks).forEach(([id, undoRedo]) => { + if (undoRedo) result[id] = undoRedo.serialize(); + }); + return result; +}; + +/** + * 把 `Record` 整体还原为 `Record`。 + * 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。 + */ +export const deserializeStacks = ( + stacks: Record['serialize']>> = {}, +): Record> => { + const result: Record> = {}; + Object.entries(stacks).forEach(([id, serialized]) => { + if (serialized) { + result[id] = UndoRedo.fromSerialized(serialized, { isSavedStep: (element) => element.saved === true }); + } + }); + return result; +}; + +/** + * 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。 + */ +export const getOrCreateStack = (stacks: Record>, id: Id): UndoRedo => { + if (!stacks[id]) { + stacks[id] = new UndoRedo(); + } + return stacks[id]; +}; + +/** + * 撤销下限:当页面栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。 + * 用于把 cursor 钉在基线之上,保证 undo / canUndo / goto 都不会越过初始基线。 + */ +export const undoFloor = (undoRedo: UndoRedo): number => { + return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0; +}; diff --git a/packages/editor/src/utils/undo-redo.ts b/packages/editor/src/utils/undo-redo.ts index b8d751ef..e9c55ced 100644 --- a/packages/editor/src/utils/undo-redo.ts +++ b/packages/editor/src/utils/undo-redo.ts @@ -139,6 +139,17 @@ export class UndoRedo { return cloneDeep(this.elementList[this.listCursor - 1]); } + /** + * 用 `element` 替换当前游标所在元素(cursor - 1),并丢弃其后的重做尾部(与 {@link pushElement} 的丢尾一致), + * 游标位置保持不变(元素数量不增)。cursor 为 0(无已应用元素)时不做任何操作并返回 false。 + * 用于「连续同类记录合并」等就地替换最新一条的场景。 + */ + public replaceCurrentElement(element: T): boolean { + if (this.listCursor < 1) return false; + this.elementList.splice(this.listCursor - 1, this.elementList.length - (this.listCursor - 1), cloneDeep(element)); + return true; + } + /** * 对当前游标所在元素(cursor - 1)做就地更新;cursor 为 0(全部已撤销)时不做任何操作。 * 用于给「当前步骤」打标记(如标记为已保存)等元数据写入场景。 diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index 7f85724b..f30d2d6b 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -179,7 +179,7 @@ describe('initServiceState', () => { test('modelValue 变化设置 editor root', () => { const props = { modelValue: { id: 'a' } } as any; mount(Wrap(props, services)); - expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }); + expect(services.editorService.set).toHaveBeenCalledWith('root', { id: 'a' }, { historySource: 'initial' }); }); test('disabledMultiSelect/alwaysMultiSelect 设置', () => { diff --git a/packages/editor/tests/unit/layouts/Framework.spec.ts b/packages/editor/tests/unit/layouts/Framework.spec.ts index 442b95ba..ea8856b4 100644 --- a/packages/editor/tests/unit/layouts/Framework.spec.ts +++ b/packages/editor/tests/unit/layouts/Framework.spec.ts @@ -145,7 +145,7 @@ describe('Framework', () => { }); const wrapper = mount(Framework, { props: { disabledPageFragment: false } as any }); await wrapper.find('.fake-code-editor').trigger('click'); - expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }); + expect(editorService.set).toHaveBeenCalledWith('root', { id: 'x' }, { historySource: 'root-code' }); }); test('SplitView change 写入 uiService 和 storage', async () => { diff --git a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts index 254c1478..437a80f8 100644 --- a/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/Bucket.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, test } from 'vitest'; import { mount } from '@vue/test-utils'; import Bucket from '@editor/layouts/history-list/Bucket.vue'; -import type { HistoryBucketConfig } from '@editor/layouts/history-list/composables'; +import type { HistoryBucketConfig } from '@editor/type'; const buildGroup = (opType: 'add' | 'remove' | 'update', stepCount: number, applied = true): any => ({ applied, @@ -137,8 +137,8 @@ describe('Bucket.vue', () => { // 第二组只有 1 步,不应渲染 substeps(即使 expanded 为 true) const rows = wrapper.findAll('.m-editor-history-list-group'); expect(rows[1].find('.m-editor-history-list-substeps').exists()).toBe(false); - // 第一组未展开,也不应有 substeps - expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(false); + // 第一组为合并组,默认展开 + expect(rows[0].find('.m-editor-history-list-substeps').exists()).toBe(true); }); test('groups 非空时底部追加初始项;点击透传 goto-initial 携带 bucketId', async () => { diff --git a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts index 3c169476..94dd327d 100644 --- a/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/HistoryListPanel.spec.ts @@ -152,17 +152,17 @@ describe('HistoryListPanel.vue', () => { const head = wrapper.find('.m-editor-history-list-group-head'); expect(head.exists()).toBe(true); - // 默认未展开 - expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); - // 点击展开 - await head.trigger('click'); + // 默认展开 expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2); // 合并组头部点击不应触发 goto expect(editorService.gotoPageStep).not.toHaveBeenCalled(); - // 再点击折叠 - await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + // 点击收起 + await head.trigger('click'); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); + // 再点击展开 + await wrapper.find('.m-editor-history-list-group-head').trigger('click'); + expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); }); test('点击页面 group 头部调用 editorService.gotoPageStep', async () => { diff --git a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts index 680ef97d..688d2836 100644 --- a/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/PageTab.spec.ts @@ -124,11 +124,11 @@ describe('PageTab.vue', () => { 'btn', ); - const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: { 'pg-0': true } } }); + const wrapper = mount(PageTab, { props: { list: [mergedGroup], expanded: {} } }); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(true); expect(wrapper.findAll('.m-editor-history-list-substeps li')).toHaveLength(2); - await wrapper.setProps({ list: [mergedGroup], expanded: {} }); + await wrapper.setProps({ list: [mergedGroup], expanded: { 'pg-0': false } }); expect(wrapper.find('.m-editor-history-list-substeps').exists()).toBe(false); }); diff --git a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts index 6d42e07d..62a56079 100644 --- a/packages/editor/tests/unit/layouts/history-list/composables.spec.ts +++ b/packages/editor/tests/unit/layouts/history-list/composables.spec.ts @@ -565,9 +565,11 @@ describe('useHistoryList', () => { return { api, wrapper }; }; - test('toggleGroup 切换 expanded[key]', () => { + test('toggleGroup 切换 expanded[key](默认展开)', () => { const { api } = mountWithHost(); - expect(api.expanded.foo).toBeFalsy(); + expect(api.expanded.foo).toBeUndefined(); + api.toggleGroup('foo'); + expect(api.expanded.foo).toBe(false); api.toggleGroup('foo'); expect(api.expanded.foo).toBe(true); api.toggleGroup('foo'); diff --git a/packages/editor/tests/unit/services/history-persist.spec.ts b/packages/editor/tests/unit/services/history-persist.spec.ts index 2a251e2c..a91135bd 100644 --- a/packages/editor/tests/unit/services/history-persist.spec.ts +++ b/packages/editor/tests/unit/services/history-persist.spec.ts @@ -56,19 +56,6 @@ afterEach(() => { const pageStep = (id = 'p1') => ({ data: { id, name: '' }, modifiedNodeIds: new Map() }) as any; describe('history service - markSaved', () => { - test('markSaved 标记页面 / 代码块 / 数据源各栈的当前记录', () => { - history.changePage({ id: 'p1' } as any); - history.push(pageStep()); - history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); - history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any }); - - history.markSaved(); - - expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBe(true); - expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true); - expect((history.state.dataSourceState as any).ds_1.getCurrentElement().saved).toBe(true); - }); - test('markSaved 派发 mark-saved 事件并带 kind=all', () => { const fn = vi.fn(); history.on('mark-saved', fn); @@ -77,29 +64,23 @@ describe('history service - markSaved', () => { history.off('mark-saved', fn); }); - test('同一栈最多保留一条 saved:再次标记会清除旧标记', () => { - history.changePage({ id: 'p1' } as any); - history.push(pageStep()); - history.markPageSaved(); - history.push(pageStep()); - history.markPageSaved(); - - const list = (history.state.pageSteps as any).p1.getElementList(); - expect(list.filter((s: any) => s.saved)).toHaveLength(1); - // 最新一条才是 saved - expect(list[list.length - 1].saved).toBe(true); - expect(list[0].saved).toBeFalsy(); - }); - - test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 仅影响对应栈', () => { + test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 派发对应 kind 事件', () => { history.changePage({ id: 'p1' } as any); history.push(pageStep()); history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any }); + const pageFn = vi.fn(); + const codeFn = vi.fn(); + history.on('mark-saved', (payload) => { + if (payload.kind === 'page') pageFn(payload); + if (payload.kind === 'code-block') codeFn(payload); + }); + + history.markPageSaved(); history.markCodeBlockSaved('code_1'); - expect((history.state.codeBlockState as any).code_1.getCurrentElement().saved).toBe(true); - // 页面栈未被标记 - expect((history.state.pageSteps as any).p1.getCurrentElement().saved).toBeFalsy(); + + expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' }); + expect(codeFn).toHaveBeenCalledWith({ kind: 'code-block', id: 'code_1' }); }); }); diff --git a/packages/editor/tests/unit/services/history.spec.ts b/packages/editor/tests/unit/services/history.spec.ts index f1b738b6..91c1eeea 100644 --- a/packages/editor/tests/unit/services/history.spec.ts +++ b/packages/editor/tests/unit/services/history.spec.ts @@ -129,6 +129,40 @@ describe('history service', () => { expect(s2?.uuid).toBeTruthy(); expect(s1?.uuid).not.toBe(s2?.uuid); }); + + test('setPageMarker 在空栈时种入 initial 基线', () => { + history.changePage({ id: 'p1' } as any); + const marker = history.setPageMarker('p1', { name: '首页', description: '初始' }); + expect(marker?.opType).toBe('initial'); + expect(history.getPageMarker('p1')?.uuid).toBe(marker?.uuid); + expect((history.state.pageSteps as any).p1.getLength()).toBe(1); + }); + + test('有 initial 基线时不可撤销越过基线', () => { + history.changePage({ id: 'p1' } as any); + history.setPageMarker('p1'); + history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any); + + expect(history.state.canUndo).toBe(true); + history.undo(); + expect(history.state.canUndo).toBe(false); + expect(history.undo()).toBeNull(); + expect(history.getPageCursor('p1')).toBe(1); + }); + + test('getPageHistoryGroups 过滤 initial 基线', () => { + history.changePage({ id: 'p1' } as any); + history.setPageMarker('p1'); + history.push({ + opType: 'add', + diff: [{ newSchema: { id: 'n1', name: 'A' } }], + modifiedNodeIds: new Map(), + } as any); + + const groups = history.getPageHistoryGroups('p1'); + expect(groups).toHaveLength(1); + expect(groups[0].opType).toBe('add'); + }); }); describe('history service - codeBlock', () => { diff --git a/packages/editor/tests/unit/utils/history.spec.ts b/packages/editor/tests/unit/utils/history.spec.ts new file mode 100644 index 00000000..cbd48637 --- /dev/null +++ b/packages/editor/tests/unit/utils/history.spec.ts @@ -0,0 +1,313 @@ +/* + * Tencent is pleased to support the open source community by making TMagicEditor available. + * + * Copyright (C) 2025 Tencent. + */ +import { describe, expect, test } from 'vitest'; + +import type { CodeBlockStepValue, StepValue } from '@editor/type'; +import { + createStackStep, + describeRevertStep, + deserializeStacks, + detectPageTargetId, + detectPageTargetName, + detectStackOpType, + getOrCreateStack, + markStackSaved, + mergePageSteps, + mergeStackSteps, + serializeStacks, + undoFloor, +} from '@editor/utils/history'; +import { UndoRedo } from '@editor/utils/undo-redo'; + +describe('detectStackOpType', () => { + test('old=null new=有值 → add', () => { + expect(detectStackOpType(null, {})).toBe('add'); + }); + + test('old=有值 new=null → remove', () => { + expect(detectStackOpType({}, null)).toBe('remove'); + }); + + test('old/new 都有值 → update', () => { + expect(detectStackOpType({}, {})).toBe('update'); + }); +}); + +describe('createStackStep', () => { + test('空 id 返回 null', () => { + expect(createStackStep('', { oldValue: null, newValue: { name: 'A' } as any })).toBeNull(); + }); + + test('新增:oldValue=null,推断 opType=add', () => { + const step = createStackStep('code_1', { + oldValue: null, + newValue: { name: 'A', content: 'x' } as any, + }); + expect(step?.opType).toBe('add'); + expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' }); + expect(step?.diff?.[0]?.oldSchema).toBeUndefined(); + }); + + test('内容 cloneDeep,外部修改不影响 step', () => { + const content = { name: 'A' }; + const step = createStackStep('code_1', { oldValue: null, newValue: content as any }); + content.name = 'B'; + expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A' }); + }); + + test('update 可携带 changeRecords', () => { + const step = createStackStep('code_1', { + oldValue: { name: 'A' } as any, + newValue: { name: 'B' } as any, + changeRecords: [{ propPath: 'name' }], + }); + expect(step?.opType).toBe('update'); + expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name' }]); + }); +}); + +describe('describeRevertStep', () => { + test('撤回新增', () => { + expect(describeRevertStep('code_1', { newSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('撤回新增 fn'); + }); + + test('还原已删除', () => { + expect(describeRevertStep('code_1', { oldSchema: { name: 'fn' } as any }, (s) => s.name)).toBe('还原已删除的 fn'); + }); + + test('还原修改 + propPath', () => { + expect( + describeRevertStep( + 'code_1', + { + oldSchema: { name: 'fn' } as any, + newSchema: { name: 'fn' } as any, + changeRecords: [{ propPath: 'content' }], + }, + (s) => s.name, + ), + ).toBe('还原 fn · content'); + }); + + test('还原修改无 propPath', () => { + expect( + describeRevertStep( + 'code_1', + { oldSchema: { name: 'fn' } as any, newSchema: { name: 'fn' } as any }, + (s) => s.name, + ), + ).toBe('还原 fn'); + }); +}); + +describe('markStackSaved', () => { + test('undoRedo 为空时不抛错', () => { + expect(() => markStackSaved(undefined)).not.toThrow(); + }); + + test('标记当前记录为 saved,并清除其它记录的 saved', () => { + const undoRedo = new UndoRedo<{ saved?: boolean }>(); + undoRedo.pushElement({ saved: false }); + undoRedo.pushElement({ saved: false }); + markStackSaved(undoRedo); + + const list = undoRedo.getElementList(); + expect(list.filter((s) => s.saved)).toHaveLength(1); + expect(list[list.length - 1].saved).toBe(true); + expect(list[0].saved).toBeFalsy(); + }); +}); + +describe('mergeStackSteps', () => { + test('连续 update 合并为一组', () => { + const list = [ + { opType: 'update', uuid: '1' }, + { opType: 'update', uuid: '2' }, + ] as CodeBlockStepValue[]; + const groups = mergeStackSteps('code-block', 'code_1', list, 2); + expect(groups).toHaveLength(1); + expect(groups[0].steps).toHaveLength(2); + expect(groups[0].opType).toBe('update'); + }); + + test('add / update 不合并', () => { + const list = [ + { opType: 'add', uuid: '1' }, + { opType: 'update', uuid: '2' }, + ] as CodeBlockStepValue[]; + const groups = mergeStackSteps('code-block', 'code_1', list, 2); + expect(groups).toHaveLength(2); + }); + + test('正确标记 applied / isCurrent', () => { + const list = [ + { opType: 'update', uuid: '1' }, + { opType: 'update', uuid: '2' }, + ] as CodeBlockStepValue[]; + const groups = mergeStackSteps('code-block', 'code_1', list, 1); + expect(groups[0].applied).toBe(false); + expect(groups[0].steps[0].applied).toBe(true); + expect(groups[0].steps[0].isCurrent).toBe(true); + expect(groups[0].steps[1].applied).toBe(false); + }); +}); + +describe('detectPageTargetId / detectPageTargetName', () => { + test('单节点 update 返回 targetId 与名称', () => { + const step = { + opType: 'update', + diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }], + } as unknown as StepValue; + expect(detectPageTargetId(step)).toBe('btn_1'); + expect(detectPageTargetName(step)).toBe('按钮'); + }); + + test('多节点 update 不参与合并', () => { + const step = { + opType: 'update', + diff: [ + { newSchema: { id: 'a' }, oldSchema: { id: 'a' } }, + { newSchema: { id: 'b' }, oldSchema: { id: 'b' } }, + ], + } as unknown as StepValue; + expect(detectPageTargetId(step)).toBeUndefined(); + expect(detectPageTargetName(step)).toBe('2 个节点'); + }); + + test('add 单节点返回名称', () => { + const step = { + opType: 'add', + diff: [{ newSchema: { id: 'n1', type: 'text' } }], + } as unknown as StepValue; + expect(detectPageTargetId(step)).toBeUndefined(); + expect(detectPageTargetName(step)).toBe('text'); + }); +}); + +describe('mergePageSteps', () => { + test('相邻同 targetId 的 update 合并', () => { + const mkUpdate = (path: string) => + ({ + opType: 'update', + diff: [ + { + newSchema: { id: 'btn_1', name: '按钮' }, + oldSchema: { id: 'btn_1', name: '按钮' }, + changeRecords: [{ propPath: path }], + }, + ], + }) as unknown as StepValue; + + const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')]; + const groups = mergePageSteps('p1', list, 2); + expect(groups).toHaveLength(1); + expect(groups[0].targetId).toBe('btn_1'); + expect(groups[0].steps).toHaveLength(2); + }); + + test('add 始终独立成组', () => { + const list = [ + { + opType: 'add', + diff: [{ newSchema: { id: 'n1', name: 'A' } }], + }, + { + opType: 'update', + diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }], + }, + ] as unknown as StepValue[]; + const groups = mergePageSteps('p1', list, 2); + expect(groups).toHaveLength(2); + expect(groups[0].opType).toBe('add'); + expect(groups[1].opType).toBe('update'); + }); + + test('重命名时 targetName 取最近一次', () => { + const list = [ + { + opType: 'update', + diff: [{ newSchema: { id: 'n1', name: '旧名' }, oldSchema: { id: 'n1', name: '旧名' } }], + }, + { + opType: 'update', + diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }], + }, + ] as unknown as StepValue[]; + const groups = mergePageSteps('p1', list, 2); + expect(groups[0].targetName).toBe('新名'); + }); +}); + +describe('serializeStacks / deserializeStacks', () => { + test('序列化后还原栈内容与游标', () => { + const stacks = { + p1: new UndoRedo<{ v: number }>(), + }; + stacks.p1.pushElement({ v: 1 }); + stacks.p1.pushElement({ v: 2 }); + + const serialized = serializeStacks(stacks); + const restored = deserializeStacks(serialized); + + expect(restored.p1.getLength()).toBe(2); + expect(restored.p1.getCursor()).toBe(2); + expect(restored.p1.getCurrentElement()).toEqual({ v: 2 }); + }); + + test('还原时游标定位到最近一条 saved 记录之后', () => { + const stacks = { + p1: new UndoRedo<{ saved?: boolean; v: number }>(), + }; + stacks.p1.pushElement({ v: 1 }); + stacks.p1.pushElement({ v: 2, saved: true }); + stacks.p1.pushElement({ v: 3 }); + + const restored = deserializeStacks(serializeStacks(stacks)); + expect(restored.p1.getCursor()).toBe(2); + expect(restored.p1.getCurrentElement()).toEqual({ v: 2, saved: true }); + }); + + test('空表与缺省参数', () => { + expect(serializeStacks({})).toEqual({}); + expect(deserializeStacks()).toEqual({}); + }); +}); + +describe('getOrCreateStack', () => { + test('不存在时创建新栈', () => { + const stacks: Record> = {}; + const stack = getOrCreateStack(stacks, 'a'); + expect(stack).toBeInstanceOf(UndoRedo); + expect(stacks.a).toBe(stack); + }); + + test('已存在时返回原栈', () => { + const existing = new UndoRedo(); + existing.pushElement(1); + const stacks = { a: existing }; + expect(getOrCreateStack(stacks, 'a')).toBe(existing); + expect(getOrCreateStack(stacks, 'a').getLength()).toBe(1); + }); +}); + +describe('undoFloor', () => { + test('空栈返回 0', () => { + expect(undoFloor(new UndoRedo())).toBe(0); + }); + + test('无 initial 基线返回 0', () => { + const undoRedo = new UndoRedo(); + undoRedo.pushElement({ opType: 'add', diff: [] } as unknown as StepValue); + expect(undoFloor(undoRedo)).toBe(0); + }); + + test('index 0 为 initial 时返回 1', () => { + const undoRedo = new UndoRedo(); + undoRedo.pushElement({ opType: 'initial', diff: [] } as unknown as StepValue); + undoRedo.pushElement({ opType: 'update', diff: [] } as unknown as StepValue); + expect(undoFloor(undoRedo)).toBe(1); + }); +}); diff --git a/playground/src/pages/Editor.vue b/playground/src/pages/Editor.vue index 052b26d7..58952b42 100644 --- a/playground/src/pages/Editor.vue +++ b/playground/src/pages/Editor.vue @@ -81,7 +81,7 @@ const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourc const { contentMenuData } = useEditorContentMenuData(); const editor = shallowRef>(); -const value = ref(dsl); +const value = ref(); const defaultSelected = ref(dsl.items[0].id); const stageRect = ref({ @@ -148,20 +148,19 @@ const persistHistory = () => { // 编辑(尤其是本次会话新增的代码块 / 数据源历史)丢失,这里改为变更即落库以保证恢复完整。 const schedulePersist = debounce(persistHistory, 500); -// 进入页面时从 IndexedDB 恢复历史记录,并对齐到当前激活页,保证 undo/redo 作用于正在编辑的页面。 +// 进入页面时从 IndexedDB 恢复历史记录。此时 root 尚未设置,需显式传入待加载 DSL 的 id 以选中 +// 按 app 隔离的库;页面对齐由编辑器挂载后 select(defaultSelected) 触发的 changePage 负责。 const restoreHistory = async () => { try { - const snapshot = await historyService.restoreFromIndexedDB(); - if (!snapshot) return; - const page = editorService.get('page'); - if (page) historyService.changePage(page); + await historyService.restoreFromIndexedDB({ appId: value.value?.id }); } catch (error) { console.error('恢复历史记录失败', error); } }; -onMounted(() => { - restoreHistory(); +onMounted(async () => { + await restoreHistory(); + value.value = dsl; historyService.on('change', schedulePersist); historyService.on('code-block-history-change', schedulePersist); historyService.on('data-source-history-change', schedulePersist); diff --git a/playground/src/pages/composables/use-editor-menu.ts b/playground/src/pages/composables/use-editor-menu.ts index 4df09303..5afa859f 100644 --- a/playground/src/pages/composables/use-editor-menu.ts +++ b/playground/src/pages/composables/use-editor-menu.ts @@ -9,7 +9,7 @@ import AdapterSelect from '../../components/AdapterSelect.vue'; import DeviceGroup from '../../components/DeviceGroup.vue'; import { uaMap } from '../../const'; -export const useEditorMenu = (value: Ref, save: () => void) => { +export const useEditorMenu = (value: Ref, save: () => void) => { const router = useRouter(); const deviceGroup = shallowRef>();