mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-06-13 18:08:29 +08:00
feat(editor): 支持页面初始基线与 root 变更历史记录
设置 root 时为各页建立 initial 基线并展示在历史列表底部;编辑期间再次 set root 按页面粒度写入历史,并抽取历史工具函数以支持撤销下限与持久化恢复。
This commit is contained in:
parent
c4ec2c5c72
commit
4f284e8d9c
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 @@
|
||||
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { BaseStepValue } from '@editor/type';
|
||||
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
|
||||
|
||||
import type { HistoryBucketConfig, HistoryBucketGroup, HistoryRowGroup } from './composables';
|
||||
import { toRowGroup } from './composables';
|
||||
import type { HistoryBucketGroup, HistoryRowGroup } from './composables';
|
||||
import { isHistoryGroupExpanded, toRowGroup } from './composables';
|
||||
import GroupRow from './GroupRow.vue';
|
||||
import InitialRow from './InitialRow.vue';
|
||||
|
||||
@ -57,7 +57,7 @@ const props = defineProps<{
|
||||
bucketId: string | number;
|
||||
/** 当前 bucket 下的所有历史分组,按时间倒序展示(最近的操作在前)。 */
|
||||
groups: HistoryBucketGroup<T>[];
|
||||
/** 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||
/** 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护以便跨 tab 复用。 */
|
||||
expanded: Record<string, boolean>;
|
||||
}>();
|
||||
|
||||
|
||||
@ -27,10 +27,10 @@
|
||||
<script lang="ts" setup generic="T extends BaseStepValue = BaseStepValue">
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { BaseStepValue } from '@editor/type';
|
||||
import type { BaseStepValue, HistoryBucketConfig } from '@editor/type';
|
||||
|
||||
import Bucket from './Bucket.vue';
|
||||
import type { HistoryBucketConfig, HistoryBucketGroup } from './composables';
|
||||
import type { HistoryBucketGroup } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListBucketTab',
|
||||
|
||||
@ -115,7 +115,7 @@ const props = withDefaults(
|
||||
* 来源 / 时间等头部信息以及子步列表。原先散落的十余个扁平 props 收敛于此单一对象。
|
||||
*/
|
||||
group: HistoryRowGroup;
|
||||
/** 当前组是否处于展开状态。仅在合并组(子步数 > 1)时生效,控制子步列表是否渲染。 */
|
||||
/** 当前组是否处于展开状态(合并组默认展开)。仅在合并组(子步数 > 1)时生效,控制子步列表是否渲染。 */
|
||||
expanded: boolean;
|
||||
/**
|
||||
* 是否支持「跳转到该记录」(goto)。默认 true。
|
||||
|
||||
@ -23,6 +23,7 @@
|
||||
<PageTab
|
||||
:list="pageGroupsDisplay"
|
||||
:expanded="expanded"
|
||||
:marker="pageMarker"
|
||||
@toggle="toggleGroup"
|
||||
@goto="onPageGoto"
|
||||
@goto-initial="onPageGotoInitial"
|
||||
@ -132,10 +133,15 @@ import type { FormState } from '@tmagic/form';
|
||||
|
||||
import MIcon from '@editor/components/Icon.vue';
|
||||
import { useServices } from '@editor/hooks/use-services';
|
||||
import type { CodeBlockStepValue, DataSourceStepValue, DiffDialogPayload, HistoryListExtraTab } from '@editor/type';
|
||||
import type {
|
||||
CodeBlockStepValue,
|
||||
DataSourceStepValue,
|
||||
DiffDialogPayload,
|
||||
HistoryBucketConfig,
|
||||
HistoryListExtraTab,
|
||||
} from '@editor/type';
|
||||
|
||||
import BucketTab from './BucketTab.vue';
|
||||
import type { HistoryBucketConfig } from './composables';
|
||||
import {
|
||||
describeCodeBlockGroup,
|
||||
describeCodeBlockStep,
|
||||
@ -206,6 +212,12 @@ const {
|
||||
codeBlockGroupsByTarget,
|
||||
} = useHistoryList();
|
||||
|
||||
/**
|
||||
* 当前活动页的「加载/初始」标记记录(设置 root 时生成),透传给 PageTab 的底部初始行展示。
|
||||
* 基于 historyService 的 reactive state 派生,活动页切换或标记写入后自动刷新。
|
||||
*/
|
||||
const pageMarker = computed(() => historyService.getPageMarker());
|
||||
|
||||
/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */
|
||||
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
|
||||
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
||||
|
||||
@ -2,11 +2,12 @@
|
||||
<li
|
||||
class="m-editor-history-list-item m-editor-history-list-initial"
|
||||
:class="{ 'is-current': isCurrent, 'is-clickable': !isCurrent }"
|
||||
:title="isCurrent ? '当前已回到未修改的初始状态' : '点击回到未修改的初始状态'"
|
||||
:title="rowTitle"
|
||||
>
|
||||
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0(未修改的初始状态)">#0</span>
|
||||
<span class="m-editor-history-list-item-op op-initial">初始</span>
|
||||
<span class="m-editor-history-list-item-desc">未修改的初始状态</span>
|
||||
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||
<span v-if="time" class="m-editor-history-list-item-time" :title="timeTitle">{{ time }}</span>
|
||||
<span
|
||||
v-if="gotoEnabled && !isCurrent"
|
||||
class="m-editor-history-list-item-goto"
|
||||
@ -22,9 +23,17 @@
|
||||
* 「初始状态」记录行:渲染于历史列表底部,作为整个栈的"零点"。
|
||||
* - 点击该行会把对应栈撤销到 cursor === 0(即没有任何已应用步骤),等同于回到所有修改之前。
|
||||
* - 当对应栈本身已处于 cursor === 0 时(isCurrent=true),用户已在初始状态,点击不再触发动作。
|
||||
* - 当上层传入 `marker`(设置 root 时为该页生成的「未修改的初始状态」标记)时,
|
||||
* 用标记的文案与时间渲染本行;标记不进入撤销/重做栈,仅作为该页基线展示。
|
||||
*
|
||||
* 该行不是真实 step,仅作为 UI 入口;上层负责把"点击"翻译为 `service.goto*(0)`。
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
import type { StepValue } from '@editor/type';
|
||||
|
||||
import { formatHistoryFullTime, formatHistoryTime } from './composables';
|
||||
|
||||
defineOptions({
|
||||
name: 'MEditorHistoryListInitialRow',
|
||||
});
|
||||
@ -34,12 +43,23 @@ const props = withDefaults(
|
||||
/** 当前对应栈是否已经处于初始状态 (cursor === 0)。true 时用蓝条高亮并禁用点击。 */
|
||||
isCurrent: boolean;
|
||||
gotoEnabled?: boolean;
|
||||
/** 该页面的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue);提供时用其文案与时间展示。 */
|
||||
marker?: StepValue;
|
||||
}>(),
|
||||
{
|
||||
gotoEnabled: true,
|
||||
marker: undefined,
|
||||
},
|
||||
);
|
||||
|
||||
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
|
||||
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
|
||||
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
|
||||
const rowTitle = computed(() => {
|
||||
const base = props.marker?.historyDescription || '未修改的初始状态';
|
||||
return props.isCurrent ? `当前已回到${base}` : `点击回到${base}`;
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击非当前的初始项时触发,由上层调用对应 service 的 goto 把 cursor 移到 0。 */
|
||||
(_e: 'goto-initial'): void;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="!list.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<div v-if="!list.length && !marker" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||
<template v-else>
|
||||
<div class="m-editor-history-list-toolbar">
|
||||
<div v-if="list.length" class="m-editor-history-list-toolbar">
|
||||
<span class="m-editor-history-list-clear" title="清空当前页面的历史记录" @click="$emit('clear')">清空</span>
|
||||
</div>
|
||||
<TMagicScrollbar max-height="360px">
|
||||
@ -10,7 +10,7 @@
|
||||
v-for="group in list"
|
||||
:key="rowKey(group)"
|
||||
:group="toRow(group)"
|
||||
:expanded="!!expanded[rowKey(group)]"
|
||||
:expanded="isHistoryGroupExpanded(expanded, rowKey(group))"
|
||||
@toggle="(key: string) => $emit('toggle', key)"
|
||||
@goto="(index: number) => $emit('goto', index)"
|
||||
@diff-step="(index: number) => $emit('diff-step', index)"
|
||||
@ -19,8 +19,9 @@
|
||||
<!--
|
||||
初始状态项:永远位于列表底部(页面 tab 倒序展示,最底部=最早),
|
||||
作为"未修改"零点。当所有 group 都未 applied 时它即为当前位置。
|
||||
设置 root 时生成的「未修改的初始状态」标记(marker)会作为该行的文案与时间来源。
|
||||
-->
|
||||
<InitialRow :is-current="isInitial" @goto-initial="$emit('goto-initial')" />
|
||||
<InitialRow :is-current="isInitial" :marker="marker" @goto-initial="$emit('goto-initial')" />
|
||||
</ul>
|
||||
</TMagicScrollbar>
|
||||
</template>
|
||||
@ -31,10 +32,16 @@ import { computed } from 'vue';
|
||||
|
||||
import { TMagicScrollbar } from '@tmagic/design';
|
||||
|
||||
import type { PageHistoryGroup, StepValue } from '@editor/type';
|
||||
import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
|
||||
|
||||
import type { HistoryRowDescriptor, HistoryRowGroup } from './composables';
|
||||
import { describePageGroup, describePageStep, isPageStepRevertable, toRowGroup } from './composables';
|
||||
import type { HistoryRowGroup } from './composables';
|
||||
import {
|
||||
describePageGroup,
|
||||
describePageStep,
|
||||
isHistoryGroupExpanded,
|
||||
isPageStepRevertable,
|
||||
toRowGroup,
|
||||
} from './composables';
|
||||
import GroupRow from './GroupRow.vue';
|
||||
import InitialRow from './InitialRow.vue';
|
||||
|
||||
@ -46,11 +53,16 @@ const props = defineProps<{
|
||||
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||
list: PageHistoryGroup[];
|
||||
/**
|
||||
* 共享的折叠状态表(key -> 是否展开),由顶层 panel 统一维护。
|
||||
* 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
|
||||
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||
* 这样历史数据更新(新增 / 撤销重做导致列表顺序变化)后,已展开的分组状态仍能正确保持。
|
||||
*/
|
||||
expanded: Record<string, boolean>;
|
||||
/**
|
||||
* 当前活动页的「加载/初始」基线记录(设置 root 时生成的 `opType: 'initial'` StepValue)。
|
||||
* 提供时即使没有任何操作记录也会展示底部初始行,并用其文案 / 时间渲染。
|
||||
*/
|
||||
marker?: StepValue;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
@ -95,8 +107,8 @@ const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, ro
|
||||
|
||||
/**
|
||||
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
|
||||
* 当 list 中所有 group 的 applied 都为 false 时即为该状态。
|
||||
* 没有任何 group 的情况由外层"暂无操作记录"分支兜底,本计算可以不考虑。
|
||||
* 当 list 中所有 group 的 applied 都为 false 时即为该状态(空列表 `every` 返回 true,
|
||||
* 即仅有 marker、无任何操作记录时也视为处于初始状态)。
|
||||
*/
|
||||
const isInitial = computed(() => props.list.length > 0 && props.list.every((g) => !g.applied));
|
||||
const isInitial = computed(() => props.list.every((g) => !g.applied));
|
||||
</script>
|
||||
|
||||
@ -11,6 +11,7 @@ import type {
|
||||
DataSourceStepValue,
|
||||
HistoryOpSource,
|
||||
HistoryOpType,
|
||||
HistoryRowDescriptor,
|
||||
PageHistoryGroup,
|
||||
StepValue,
|
||||
} from '@editor/type';
|
||||
@ -30,37 +31,6 @@ export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
|
||||
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 一组「描述 + 可操作性」的判定函数集合。页面 / 数据源 / 代码块及业务自定义历史
|
||||
* 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。
|
||||
*/
|
||||
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
|
||||
/** 组级描述文案生成器,接收一个 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<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
|
||||
/** 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<string, boolean>, 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<Record<string, boolean>>({});
|
||||
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<string, string> = {
|
||||
'component-panel': '组件面板',
|
||||
props: '配置面板',
|
||||
code: '源码',
|
||||
'root-code': 'DSL源码',
|
||||
'stage-contextmenu': '画布菜单',
|
||||
'tree-contextmenu': '树菜单',
|
||||
toolbar: '工具栏',
|
||||
@ -218,6 +193,8 @@ const HISTORY_SOURCE_LABELS: Record<string, string> = {
|
||||
rollback: '回滚',
|
||||
api: '接口',
|
||||
ai: 'AI',
|
||||
initial: '初始值',
|
||||
sync: '同步',
|
||||
unknown: '未知',
|
||||
};
|
||||
|
||||
|
||||
@ -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<K extends StoreStateKey, T extends StoreState[K]>(name: K, value: T) {
|
||||
public set<K extends StoreStateKey, T extends StoreState[K]>(
|
||||
name: K,
|
||||
value: T,
|
||||
options: { historySource?: HistoryOpSource } = {},
|
||||
) {
|
||||
const preValue = this.state[name];
|
||||
this.state[name] = value;
|
||||
|
||||
@ -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<MNode>,
|
||||
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;
|
||||
|
||||
@ -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<S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
|
||||
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<S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): void {
|
||||
if (!undoRedo) return;
|
||||
undoRedo.updateElements((element) => {
|
||||
element.saved = false;
|
||||
});
|
||||
undoRedo.updateCurrentElement((element) => {
|
||||
element.saved = true;
|
||||
});
|
||||
}
|
||||
|
||||
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
||||
private static serializeStacks<T>(stacks: Record<Id, UndoRedo<T>>) {
|
||||
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
|
||||
Object.entries(stacks).forEach(([id, undoRedo]) => {
|
||||
if (undoRedo) result[id] = undoRedo.serialize();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
||||
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
||||
*/
|
||||
private static deserializeStacks<T extends { saved?: boolean }>(
|
||||
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
||||
): Record<Id, UndoRedo<T>> {
|
||||
const result: Record<Id, UndoRedo<T>> = {};
|
||||
Object.entries(stacks).forEach(([id, serialized]) => {
|
||||
if (serialized) {
|
||||
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。
|
||||
*/
|
||||
private static getOrCreateStack<T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> {
|
||||
if (!stacks[id]) {
|
||||
stacks[id] = new UndoRedo<T>();
|
||||
}
|
||||
return stacks[id];
|
||||
}
|
||||
|
||||
public state = reactive<HistoryState>({
|
||||
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<CodeBlockContent, CodeBlockStepValue>(codeBlockId, {
|
||||
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(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<DataSourceSchema, DataSourceStepValue>(dataSourceId, {
|
||||
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(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<StepValue>();
|
||||
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<PersistedHistoryState> {
|
||||
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<PersistedHistoryState | null> {
|
||||
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<string | PersistedHistoryState>(this.resolveDbName(dbName), storeName, key);
|
||||
const raw = await idbGet<string | PersistedHistoryState>(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<T, S extends BaseStepValue<T> & { 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<T> & { 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<T = unknown> {
|
||||
* 同一栈内任意时刻最多只有一条记录为 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<T extends BaseStepValue = BaseStepValue> {
|
||||
/** 组级描述文案生成器,接收一个 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<T extends BaseStepValue = BaseStepValue> extends HistoryRowDescriptor<T> {
|
||||
/** bucket 头部标题,例如 "数据源" / "代码块"。 */
|
||||
title: string;
|
||||
/** 子项 key 的命名空间前缀(`ds` 数据源 / `cb` 代码块 / 业务自定义如 `mod`)。 */
|
||||
prefix: string;
|
||||
/** 是否展示底部「回到初始状态」入口,默认 true。无 undo cursor 语义的自定义历史可传 false。 */
|
||||
showInitial?: boolean;
|
||||
/** 是否支持「跳转到该记录」(goto),默认 true。 */
|
||||
gotoEnabled?: boolean;
|
||||
}
|
||||
|
||||
@ -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} 个节点的修改`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 = <T extends object>(
|
||||
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 = <T, S extends BaseStepValue<T> & { 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<T> & { 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 = <S extends { saved?: boolean }>(undoRedo?: UndoRedo<S>): 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 = <S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
|
||||
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<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
||||
export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
|
||||
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
|
||||
Object.entries(stacks).forEach(([id, undoRedo]) => {
|
||||
if (undoRedo) result[id] = undoRedo.serialize();
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
||||
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
||||
*/
|
||||
export const deserializeStacks = <T extends { saved?: boolean }>(
|
||||
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
||||
): Record<Id, UndoRedo<T>> => {
|
||||
const result: Record<Id, UndoRedo<T>> = {};
|
||||
Object.entries(stacks).forEach(([id, serialized]) => {
|
||||
if (serialized) {
|
||||
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 id 从「按 id 分栈」的记录表(代码块 / 数据源)中获取(或创建)对应的 UndoRedo 栈。
|
||||
*/
|
||||
export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): UndoRedo<T> => {
|
||||
if (!stacks[id]) {
|
||||
stacks[id] = new UndoRedo<T>();
|
||||
}
|
||||
return stacks[id];
|
||||
};
|
||||
|
||||
/**
|
||||
* 撤销下限:当页面栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。
|
||||
* 用于把 cursor 钉在基线之上,保证 undo / canUndo / goto 都不会越过初始基线。
|
||||
*/
|
||||
export const undoFloor = (undoRedo: UndoRedo<StepValue>): number => {
|
||||
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
|
||||
};
|
||||
|
||||
@ -139,6 +139,17 @@ export class UndoRedo<T = any> {
|
||||
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(全部已撤销)时不做任何操作。
|
||||
* 用于给「当前步骤」打标记(如标记为已保存)等元数据写入场景。
|
||||
|
||||
@ -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 设置', () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
313
packages/editor/tests/unit/utils/history.spec.ts
Normal file
313
packages/editor/tests/unit/utils/history.spec.ts
Normal file
@ -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<string, UndoRedo<number>> = {};
|
||||
const stack = getOrCreateStack(stacks, 'a');
|
||||
expect(stack).toBeInstanceOf(UndoRedo);
|
||||
expect(stacks.a).toBe(stack);
|
||||
});
|
||||
|
||||
test('已存在时返回原栈', () => {
|
||||
const existing = new UndoRedo<number>();
|
||||
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<StepValue>())).toBe(0);
|
||||
});
|
||||
|
||||
test('无 initial 基线返回 0', () => {
|
||||
const undoRedo = new UndoRedo<StepValue>();
|
||||
undoRedo.pushElement({ opType: 'add', diff: [] } as unknown as StepValue);
|
||||
expect(undoFloor(undoRedo)).toBe(0);
|
||||
});
|
||||
|
||||
test('index 0 为 initial 时返回 1', () => {
|
||||
const undoRedo = new UndoRedo<StepValue>();
|
||||
undoRedo.pushElement({ opType: 'initial', diff: [] } as unknown as StepValue);
|
||||
undoRedo.pushElement({ opType: 'update', diff: [] } as unknown as StepValue);
|
||||
expect(undoFloor(undoRedo)).toBe(1);
|
||||
});
|
||||
});
|
||||
@ -81,7 +81,7 @@ const { propsValues, propsConfigs, eventMethodList, datasourceConfigs, datasourc
|
||||
const { contentMenuData } = useEditorContentMenuData();
|
||||
|
||||
const editor = shallowRef<InstanceType<typeof TMagicEditor>>();
|
||||
const value = ref<MApp>(dsl);
|
||||
const value = ref<MApp>();
|
||||
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);
|
||||
|
||||
@ -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<MApp>, save: () => void) => {
|
||||
export const useEditorMenu = (value: Ref<MApp | undefined>, save: () => void) => {
|
||||
const router = useRouter();
|
||||
|
||||
const deviceGroup = shallowRef<InstanceType<typeof DeviceGroup>>();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user