mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-07-14 15:11:28 +08:00
Compare commits
55 Commits
v1.8.0-bet
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734e6a8ee6 | ||
|
|
f7d737a0c0 | ||
|
|
42d45bed18 | ||
|
|
de7a4f73ad | ||
|
|
605f07c8fe | ||
|
|
90492ad3ea | ||
|
|
b183e71f64 | ||
|
|
78a1259665 | ||
|
|
4a893d35df | ||
|
|
3c21f45e88 | ||
|
|
aa71fe9ddd | ||
|
|
9e8272b521 | ||
|
|
9aa251ce57 | ||
|
|
3b9fb714e5 | ||
|
|
284be0d276 | ||
|
|
c57ef89715 | ||
|
|
1ef3ea9eb9 | ||
|
|
83f4e52845 | ||
|
|
4330738b9f | ||
|
|
e59c3d2dcd | ||
|
|
55135fc063 | ||
|
|
af197363cf | ||
|
|
7bbb1f24c0 | ||
|
|
f7afed66aa | ||
|
|
fd359e493f | ||
|
|
5e661d0958 | ||
|
|
5ea992f5f7 | ||
|
|
4c81f22485 | ||
|
|
40acb5b34c | ||
|
|
ce65b18dbb | ||
|
|
1c936ff439 | ||
|
|
e32780b0fb | ||
|
|
7e789aa83a | ||
|
|
0f42989ca3 | ||
|
|
bfdaf2b244 | ||
|
|
1ade61d62e | ||
|
|
9f2fa1a9c8 | ||
|
|
38065f013f | ||
|
|
d5c1fab016 | ||
|
|
bbf79fd6df | ||
|
|
24b9b34f65 | ||
|
|
95769d6ae0 | ||
|
|
8933c46b73 | ||
|
|
c811908971 | ||
|
|
0774aa8e59 | ||
|
|
2ff7c479c2 | ||
|
|
4a8289137b | ||
|
|
ffcc734102 | ||
|
|
b35132e93e | ||
|
|
921188d2da | ||
|
|
6960bd50e1 | ||
|
|
27fac02e99 | ||
|
|
1298104732 | ||
|
|
9fe10e274c | ||
|
|
89cef4e9a9 |
10
AGENTS.md
10
AGENTS.md
@ -14,12 +14,13 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
## 架构地图
|
## 架构地图
|
||||||
|
|
||||||
关键目录:
|
关键目录:
|
||||||
- `packages/` — 核心编辑器包(202 *.vue, 194 *.ts)
|
|
||||||
|
- `packages/` — 核心编辑器包
|
||||||
- `runtime/` — Vue/React Runtime 实现
|
- `runtime/` — Vue/React Runtime 实现
|
||||||
- `vue-components/` — Vue 组件封装
|
- `vue-components/` — Vue 组件封装
|
||||||
- `react-components/` — React 组件封装
|
- `react-components/` — React 组件封装
|
||||||
- `playground/` — 演示 playground
|
- `playground/` — 演示 playground
|
||||||
- `docs/` — VitePress 文档(100 *.md)
|
- `docs/` — VitePress 文档
|
||||||
- `scripts/` — 构建和发布脚本
|
- `scripts/` — 构建和发布脚本
|
||||||
- `eslint-config/` — 共享 ESLint 配置
|
- `eslint-config/` — 共享 ESLint 配置
|
||||||
|
|
||||||
@ -27,10 +28,13 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
|
|
||||||
**分支策略:** dev=dev, test/prod=master
|
**分支策略:** dev=dev, test/prod=master
|
||||||
**提交规范:** commitlint + husky,`type: 描述`
|
**提交规范:** commitlint + husky,`type: 描述`
|
||||||
|
**测试覆盖率:** 新增或修改的代码必须补充单元测试,覆盖率不低于 85%
|
||||||
|
|
||||||
**禁止事项:**
|
**禁止事项:**
|
||||||
|
|
||||||
- 禁止在核心包中引入腾讯内部专有依赖(开源项目)
|
- 禁止在核心包中引入腾讯内部专有依赖(开源项目)
|
||||||
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
||||||
|
- 禁止提交未达 85% 测试覆盖率的新增/修改代码
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
@ -50,7 +54,7 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
## 深入阅读
|
## 深入阅读
|
||||||
|
|
||||||
| 文档 | 说明 |
|
| 文档 | 说明 |
|
||||||
|------|------|
|
| --------------- | ---------------- |
|
||||||
| docs/ | VitePress 文档站 |
|
| docs/ | VitePress 文档站 |
|
||||||
| CONTRIBUTING.md | 贡献指南 |
|
| CONTRIBUTING.md | 贡献指南 |
|
||||||
| CHANGELOG.md | 变更日志 |
|
| CHANGELOG.md | 变更日志 |
|
||||||
|
|||||||
81
CHANGELOG.md
81
CHANGELOG.md
@ -1,3 +1,84 @@
|
|||||||
|
# [1.8.0-beta.11](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.10...v1.8.0-beta.11) (2026-06-29)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** support custom width and initial diff mode for history diff dialog ([83f4e52](https://github.com/Tencent/tmagic-editor/commit/83f4e528450be3971eea25b59dab126e04ac4df4))
|
||||||
|
* **form:** add debug mode to submitForm ([4330738](https://github.com/Tencent/tmagic-editor/commit/4330738b9f3d91c728826f5a74129cf1febbcf48))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.10](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.9...v1.8.0-beta.10) (2026-06-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **vue-runtime-help:** handle remove when root is not initialized ([af19736](https://github.com/Tencent/tmagic-editor/commit/af197363cfaa5c555758c19354a0b3a5031b973e))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** add unified change events ([7bbb1f2](https://github.com/Tencent/tmagic-editor/commit/7bbb1f24c0f7180f301f573de1b916bfd0a35152))
|
||||||
|
* **editor:** refine history diff defaults ([f7afed6](https://github.com/Tencent/tmagic-editor/commit/f7afed66aaa8f57cdde02f0e25cca1dab825e7c0))
|
||||||
|
* **form:** support useFieldTextInError option ([fd359e4](https://github.com/Tencent/tmagic-editor/commit/fd359e493fdf205126957799a4a228f31267f3c2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.9](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.8...v1.8.0-beta.9) (2026-06-25)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 优化历史记录对比与回滚流程 ([ce65b18](https://github.com/Tencent/tmagic-editor/commit/ce65b18dbb2976db484ab35a98479858b4c426e4))
|
||||||
|
* 导出 componentListService 和 keybindingService ([40acb5b](https://github.com/Tencent/tmagic-editor/commit/40acb5b34cb3b492bc5234c75bde4fbf8e787be2))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.8](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.7...v1.8.0-beta.8) (2026-06-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **playground:** 调整初始化时序避免历史恢复被覆盖 ([bfdaf2b](https://github.com/Tencent/tmagic-editor/commit/bfdaf2b244dfe7387cac5b399a84ccd614201e73))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 历史记录支持展示操作人 ([1ade61d](https://github.com/Tencent/tmagic-editor/commit/1ade61d62e61c86fcff2033a3dd57c533ce4241c))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.7](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.6...v1.8.0-beta.7) (2026-06-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** sidebar 侧栏项 key 与 SideItemKey 常量对齐 ([8933c46](https://github.com/Tencent/tmagic-editor/commit/8933c46b73525aa2aeca81aeea88610539a218d9))
|
||||||
|
* **editor:** 修复历史记录面板分组与展示逻辑 ([bbf79fd](https://github.com/Tencent/tmagic-editor/commit/bbf79fd6df505351bc28d63bea1a5c0cd16a5984))
|
||||||
|
* **editor:** 恢复新增组件在当前视窗中垂直居中 ([24b9b34](https://github.com/Tencent/tmagic-editor/commit/24b9b34f6569ed671edaf4e9f14622597688d72c))
|
||||||
|
* **editor:** 数据源配置面板默认激活「数据定义」tab ([95769d6](https://github.com/Tencent/tmagic-editor/commit/95769d6ae05d6171366ad5995f6e8ce79491d5cd))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# [1.8.0-beta.6](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.5...v1.8.0-beta.6) (2026-06-15)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **editor:** 仅在使用 left/top 定位时修正节点位置 ([27fac02](https://github.com/Tencent/tmagic-editor/commit/27fac02e994b69417622d009cb69037d99eef10a))
|
||||||
|
* **editor:** 完善 fixed 与 absolute 定位切换逻辑 ([1298104](https://github.com/Tencent/tmagic-editor/commit/1298104732de1074ecc1398777459dd2fcbb74f7))
|
||||||
|
* **playground:** 修复历史恢复后加载 DSL 时缺少页面基线记录 ([4a82891](https://github.com/Tencent/tmagic-editor/commit/4a8289137b539a6597d1b978418bb9901392e5c3))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 增强 *AndGetHistoryId 与 revertById 的 historyIds 能力 ([6960bd5](https://github.com/Tencent/tmagic-editor/commit/6960bd50e16fe612e486eb78af7630642f5e6c8f))
|
||||||
|
* **editor:** 数据源与代码块历史记录不再合并相邻操作 ([89cef4e](https://github.com/Tencent/tmagic-editor/commit/89cef4e9a9b1ffb11ab72c33e8b283bf59377f47))
|
||||||
|
* **editor:** 查看/编辑时自动切换 sidebar 并定位到对应配置 ([ffcc734](https://github.com/Tencent/tmagic-editor/commit/ffcc7341021da525263819cb6559cf20c2afea6a))
|
||||||
|
* **editor:** 边框方向有配置时显示高亮状态 ([b35132e](https://github.com/Tencent/tmagic-editor/commit/b35132e93eba6136990ba82912e53887c17c0cc1))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# [1.8.0-beta.5](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.4...v1.8.0-beta.5) (2026-06-11)
|
# [1.8.0-beta.5](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.4...v1.8.0-beta.5) (2026-06-11)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -488,6 +488,15 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: '表单校验',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
text: '表单校验',
|
||||||
|
link: '/form-config/rules.md',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
text: '表单对比',
|
text: '表单对比',
|
||||||
items: [
|
items: [
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
# codeBlockService方法
|
# codeBlockService方法
|
||||||
|
|
||||||
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
|
写入历史栈的方法([setCodeDslById](#setcodedslbyid)、[setCodeDslByIdSync](#setcodedslbyidsync)、[deleteCodeDslByIds](#deletecodedslbyids) 等)的 `options` 支持
|
||||||
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.pushCodeBlock` 的 `historyDescription` / `source` 字段。
|
[historyDescription / historySource](./editorServiceMethods.md#历史记录相关-options),会透传到 `historyService.push('codeBlock', step, id)` 入栈记录的 `historyDescription` / `source` 字段。
|
||||||
|
|
||||||
## setCodeDsl
|
## setCodeDsl
|
||||||
|
|
||||||
@ -88,8 +88,8 @@
|
|||||||
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
|
同步版本的 [setCodeDslById](#setcodedslbyid),并会触发 `addOrUpdate` 事件
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.pushCodeBlock`
|
写入成功时(`force=false` 且同 id 已存在的跳过场景除外)会自动调用 `historyService.push('codeBlock', step, id)`
|
||||||
把本次变更入历史栈,参见 [historyService.pushCodeBlock](./historyServiceMethods.md#pushcodeblock)。
|
把本次变更入历史栈,参见 [historyService.push](./historyServiceMethods.md#push)。
|
||||||
传入的 `changeRecords` 会一同写进 step,撤销/重做时调用方可据此按 `propPath` 局部回放。
|
传入的 `changeRecords` 会一同写进 step,撤销/重做时调用方可据此按 `propPath` 局部回放。
|
||||||
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
||||||
:::
|
:::
|
||||||
@ -231,8 +231,8 @@
|
|||||||
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
在dsl数据源中删除指定id的代码块,每删除一个会触发一次 `remove` 事件
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
对每个实际存在并被删除的代码块,会自动调用 `historyService.pushCodeBlock` 入栈一条
|
对每个实际存在并被删除的代码块,会自动调用 `historyService.push('codeBlock', step, id)` 入栈一条
|
||||||
`newContent=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
`newSchema=null` 的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## setCodeDslByIdAndGetHistoryId
|
## setCodeDslByIdAndGetHistoryId
|
||||||
@ -240,11 +240,12 @@
|
|||||||
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
|
- **参数:** 同 [setCodeDslById](#setcodedslbyid)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`Promise<string | null>`} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true` 等)时返回 `null`
|
- {`Promise<`[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)`>`}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true` 等)时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
|
与 [setCodeDslById](#setcodedslbyid) 行为完全一致,并在返回值中额外提供 `historyIds`,可用于精确引用 / 定位该条历史记录。
|
||||||
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
@ -252,11 +253,11 @@
|
|||||||
```js
|
```js
|
||||||
import { codeBlockService } from "@tmagic/editor";
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
|
const { historyIds } = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", {
|
||||||
name: "代码块1",
|
name: "代码块1",
|
||||||
content: "() => {}",
|
content: "() => {}",
|
||||||
});
|
});
|
||||||
console.log(historyId); // 本次变更对应的历史记录 uuid,或 null
|
console.log(historyIds); // 本次变更对应的历史记录 uuid 列表,或 []
|
||||||
```
|
```
|
||||||
|
|
||||||
## setCodeDslByIdSyncAndGetHistoryId
|
## setCodeDslByIdSyncAndGetHistoryId
|
||||||
@ -264,44 +265,46 @@ console.log(historyId); // 本次变更对应的历史记录 uuid,或 null
|
|||||||
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
|
- **参数:** 同 [setCodeDslByIdSync](#setcodedslbyidsync)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`string | null`} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true`、或 `force=false` 跳过等)时返回 `null`
|
- {[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true`、或 `force=false` 跳过等)时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),仅把返回值换成本次写入历史记录的 `uuid`
|
与 [setCodeDslByIdSync](#setcodedslbyidsync) 行为完全一致(同步),并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## deleteCodeDslByIdsAndGetHistoryId
|
## deleteCodeDslByIdsAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
|
- **参数:** 同 [deleteCodeDslByIds](#deletecodedslbyids)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`Promise<string[]>`} 本次写入的全部历史记录 uuid(按删除顺序);未写入任何历史时返回空数组 `[]`
|
- {`Promise<`[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)`>`}
|
||||||
|
- `historyIds`:本次写入的全部历史记录 uuid(按删除顺序);未写入任何历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此返回的是 uuid 数组(每条删除记录一个 uuid);不存在的 id 不会入历史,也不会出现在返回数组中。
|
与 [deleteCodeDslByIds](#deletecodedslbyids) 行为完全一致。由于一次可删除多个代码块、会产生多条历史记录,因此使用 `historyIds` 数组(每条删除记录一个 uuid);不存在的 id 不会入历史,也不会出现在 `historyIds` 中。
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { codeBlockService } from "@tmagic/editor";
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
|
const { historyIds } = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(["code_1", "code_2"]);
|
||||||
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
|
console.log(historyIds); // ['xxxx', 'yyyy'],或 []
|
||||||
```
|
```
|
||||||
|
|
||||||
## revertById
|
## revertById
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{string}` uuid 目标历史记录的 uuid(通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回)
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 [setCodeDslByIdAndGetHistoryId](#setcodedslbyidandgethistoryid) 等方法返回的 `historyIds`)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`Promise<CodeBlockStepValue | null>`} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用时返回 `null`
|
- {`Promise<(`CodeBlockStepValue` | null)[]>`} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
通过历史记录 uuid「回滚」某条代码块历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
通过历史记录 uuid「回滚」代码块历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
||||||
仅无需调用方再传 `codeBlockId` 与 `index`:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。
|
仅无需调用方再传 `codeBlockId` 与 `index`。**按数组顺序依次回滚**。
|
||||||
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
@ -309,9 +312,9 @@ console.log(historyIds); // ['xxxx', 'yyyy'],或 []
|
|||||||
```js
|
```js
|
||||||
import { codeBlockService } from "@tmagic/editor";
|
import { codeBlockService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
|
const { historyIds } = await codeBlockService.setCodeDslByIdAndGetHistoryId("code_1234", { name: "代码块1" });
|
||||||
if (historyId) {
|
if (historyIds.length) {
|
||||||
await codeBlockService.revertById(historyId);
|
await codeBlockService.revertById(historyIds);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -367,7 +370,7 @@ if (codeBlockService.canUndo("code_1234")) {
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
当前指定代码块是否可撤销,等价于 `historyService.canUndoCodeBlock(id)`。
|
当前指定代码块是否可撤销,等价于 `historyService.canUndo('codeBlock', id)`。
|
||||||
|
|
||||||
## canRedo
|
## canRedo
|
||||||
|
|
||||||
@ -379,7 +382,7 @@ if (codeBlockService.canUndo("code_1234")) {
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
当前指定代码块是否可重做,等价于 `historyService.canRedoCodeBlock(id)`。
|
当前指定代码块是否可重做,等价于 `historyService.canRedo('codeBlock', id)`。
|
||||||
|
|
||||||
## setParamsColConfig
|
## setParamsColConfig
|
||||||
|
|
||||||
|
|||||||
@ -311,8 +311,8 @@ dataSourceService.setFormMethod("http", [
|
|||||||
添加一个数据源,如果配置中没有id或id已存在,会自动生成新的id
|
添加一个数据源,如果配置中没有id或id已存在,会自动生成新的id
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
添加成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema=null` 的新增记录,
|
添加成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema=null` 的新增记录,
|
||||||
参见 [historyService.pushDataSource](./historyServiceMethods.md#pushdatasource)。
|
参见 [historyService.push](./historyServiceMethods.md#push)。
|
||||||
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
传入 `doNotPushHistory: true` 可跳过写入历史栈,常用于批量导入、外部同步等非用户操作场景。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -355,7 +355,7 @@ console.log(newDs.id); // 自动生成的id
|
|||||||
更新数据源
|
更新数据源
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
更新成功会自动调用 `historyService.pushDataSource` 入栈一条 `oldSchema` / `newSchema`
|
更新成功会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `oldSchema` / `newSchema`
|
||||||
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step;撤销/重做时调用方可据此按
|
均为对应 schema 的更新记录,传入的 `changeRecords` 也会一并写进 step;撤销/重做时调用方可据此按
|
||||||
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
|
`propPath` 局部回放,缺省才退化为整 schema 替换。传入 `doNotPushHistory: true` 可跳过写入历史栈。
|
||||||
:::
|
:::
|
||||||
@ -394,7 +394,7 @@ console.log(updatedDs);
|
|||||||
删除指定id的数据源
|
删除指定id的数据源
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
对实际存在的数据源会自动调用 `historyService.pushDataSource` 入栈一条 `newSchema=null`
|
对实际存在的数据源会自动调用 `historyService.push('dataSource', step, id)` 入栈一条 `newSchema=null`
|
||||||
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
的删除记录;不存在的 id 不会入历史。传入 `doNotPushHistory: true` 也可显式跳过写入历史栈。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -411,11 +411,13 @@ dataSourceService.remove("ds_123");
|
|||||||
- **参数:** 同 [add](#add)
|
- **参数:** 同 [add](#add)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`string` | null} 本次写入历史记录的 uuid;未写入历史(`doNotPushHistory: true` 等)时返回 `null`
|
- {[`DslOpWithHistoryIdsResult<DataSourceSchema>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `result`:同 [add](#add) 的返回值(新增的数据源配置)
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史(`doNotPushHistory: true` 等)时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,可用于精确引用 / 定位该条历史记录。
|
与 [add](#add) 行为完全一致,并在返回值中额外提供 `historyIds`,可用于精确引用 / 定位该条历史记录。
|
||||||
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
@ -423,12 +425,13 @@ dataSourceService.remove("ds_123");
|
|||||||
```js
|
```js
|
||||||
import { dataSourceService } from "@tmagic/editor";
|
import { dataSourceService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyId = dataSourceService.addAndGetHistoryId({
|
const { result, historyIds } = dataSourceService.addAndGetHistoryId({
|
||||||
type: "http",
|
type: "http",
|
||||||
title: "用户信息",
|
title: "用户信息",
|
||||||
url: "/api/user",
|
url: "/api/user",
|
||||||
});
|
});
|
||||||
console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
console.log(result); // 新增的数据源配置
|
||||||
|
console.log(historyIds); // 本次新增对应的历史记录 uuid 列表,或 []
|
||||||
```
|
```
|
||||||
|
|
||||||
## updateAndGetHistoryId
|
## updateAndGetHistoryId
|
||||||
@ -436,35 +439,38 @@ console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
|||||||
- **参数:** 同 [update](#update)
|
- **参数:** 同 [update](#update)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`string` | null} 本次写入历史记录的 uuid;未写入历史时返回 `null`
|
- {[`DslOpWithHistoryIdsResult<DataSourceSchema>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `result`:同 [update](#update) 的返回值(更新后的数据源配置)
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [update](#update) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## removeAndGetHistoryId
|
## removeAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [remove](#remove)
|
- **参数:** 同 [remove](#remove)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`string` | null} 本次写入历史记录的 uuid;删除的 id 不存在或未写入历史时返回 `null`
|
- {[`DslOpWithHistoryIdsResult<void>`](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)}
|
||||||
|
- `historyIds`:本次写入历史记录的 uuid 列表;删除的 id 不存在或未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [remove](#remove) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## revertById
|
## revertById
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{string}` uuid 目标历史记录的 uuid(通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回)
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 [addAndGetHistoryId](#addandgethistoryid) 等方法返回的 `historyIds`)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {`DataSourceStepValue` | null} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用时返回 `null`
|
- {(`DataSourceStepValue` | null)[]} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
通过历史记录 uuid「回滚」某条数据源历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
通过历史记录 uuid「回滚」数据源历史步骤(类 git revert 语义),语义同按 `(id, index)` 回滚,
|
||||||
仅无需调用方再传 `dataSourceId` 与 `index`:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
|
仅无需调用方再传 `dataSourceId` 与 `index`。**按数组顺序依次回滚**。
|
||||||
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
参见 [editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
@ -472,9 +478,9 @@ console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
|||||||
```js
|
```js
|
||||||
import { dataSourceService } from "@tmagic/editor";
|
import { dataSourceService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyId = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
|
const { historyIds } = dataSourceService.addAndGetHistoryId({ type: "http", title: "用户信息" });
|
||||||
if (historyId) {
|
if (historyIds.length) {
|
||||||
dataSourceService.revertById(historyId);
|
dataSourceService.revertById(historyIds);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -573,7 +579,7 @@ if (dataSourceService.canUndo("ds_123")) {
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
当前指定数据源是否可撤销,等价于 `historyService.canUndoDataSource(id)`。
|
当前指定数据源是否可撤销,等价于 `historyService.canUndo('dataSource', id)`。
|
||||||
|
|
||||||
## canRedo
|
## canRedo
|
||||||
|
|
||||||
@ -585,7 +591,7 @@ if (dataSourceService.canUndo("ds_123")) {
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
当前指定数据源是否可重做,等价于 `historyService.canRedoDataSource(id)`。
|
当前指定数据源是否可重做,等价于 `historyService.canRedo('dataSource', id)`。
|
||||||
|
|
||||||
## copyWithRelated
|
## copyWithRelated
|
||||||
|
|
||||||
|
|||||||
@ -87,3 +87,19 @@
|
|||||||
- **详情:** 历史记录改变,[editorService.redo(),editorService.undo()](./editorServiceMethods.md#undo)后触发
|
- **详情:** 历史记录改变,[editorService.redo(),editorService.undo()](./editorServiceMethods.md#undo)后触发
|
||||||
|
|
||||||
- **事件回调函数:** `(data: MPage | MPageFragment) => void`
|
- **事件回调函数:** `(data: MPage | MPageFragment) => void`
|
||||||
|
|
||||||
|
## change
|
||||||
|
|
||||||
|
- **详情:** DSL 发生变更后统一触发,免去分别监听 `add` / `remove` / `update` / `move-layer` / `drag-to`。在 [editorService.add()](./editorServiceMethods.md#add)、[editorService.remove()](./editorServiceMethods.md#remove)、[editorService.update()](./editorServiceMethods.md#update)、[editorService.moveLayer()](./editorServiceMethods.md#movelayer)、[editorService.dragTo()](./editorServiceMethods.md#dragto) 后触发。
|
||||||
|
|
||||||
|
回调参数 `event` 通过 `type` 区分操作类型,并携带本次变更的节点列表 `data`,每项包含变更的 `node` 及其所属的 `page`(可能为 `null`)。`move-layer` 额外携带层级偏移 `offset`,`drag-to` 额外携带目标位置 `targetIndex` / `targetParent`。
|
||||||
|
|
||||||
|
::: warning 注意
|
||||||
|
撤销 / 重做(`undo` / `redo`)内部同样会经由 `add` / `remove` / `update` 触发本事件;如需区分「用户操作」与「撤销重做」,请配合 [history-change](#history-change) 事件判断。
|
||||||
|
:::
|
||||||
|
|
||||||
|
- **事件回调函数:** `(event: EditorChangeEvent) => void`
|
||||||
|
|
||||||
|
::: details 查看 EditorChangeEvent 类型定义
|
||||||
|
<<< @/../packages/editor/src/type.ts#EditorChangeEvent{ts}
|
||||||
|
:::
|
||||||
|
|||||||
@ -16,30 +16,32 @@
|
|||||||
|
|
||||||
每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。
|
每条历史记录入栈时都会自动生成一个唯一标识 `uuid`(见 [StepValue](#undo)),可用于精确引用 / 定位某一条历史记录(如埋点、回滚、跨端同步等)。
|
||||||
|
|
||||||
DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void),不会返回 `uuid`。若需要拿到本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`(`string`)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时返回 `null`。
|
DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(节点 / 节点集合 / void),不会返回 `uuid`。若需要同时拿到原操作结果与本次写入历史记录的 `uuid`,可改用对应的 `*AndGetHistoryId` 方法:它们与原方法行为完全一致,返回值类型为 [`DslOpWithHistoryIdsResult<T>`](#历史记录-uuid-与-andgethistoryid)(`result` 为原方法返回值,`historyIds` 为本次写入的 uuid 列表)。当本次操作未写入历史(`doNotPushHistory: true`、无实际变更或提前返回)时 `historyIds` 为 `[]`;单次操作通常返回含一个 uuid 的数组。
|
||||||
|
|
||||||
| 原方法 | 取 uuid 的方法 | 返回值 |
|
| 原方法 | 取 uuid 的方法 | 返回值 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<string \| null>` |
|
| [add](#add) | [addAndGetHistoryId](#addandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<string \| null>` |
|
| [remove](#remove) | [removeAndGetHistoryId](#removeandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<string \| null>` |
|
| [update](#update) | [updateAndGetHistoryId](#updateandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<string \| null>` |
|
| [moveLayer](#movelayer) | [moveLayerAndGetHistoryId](#movelayerandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<string \| null>` |
|
| [moveToContainer](#movetocontainer) | [moveToContainerAndGetHistoryId](#movetocontainerandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<MNode \| MNode[]>>` |
|
||||||
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<string \| null>` |
|
| [dragTo](#dragto) | [dragToAndGetHistoryId](#dragtoandgethistoryid) | `Promise<DslOpWithHistoryIdsResult<void>>` |
|
||||||
|
|
||||||
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法。
|
[dataSourceService](./dataSourceServiceMethods.md) / [codeBlockService](./codeBlockServiceMethods.md) 也提供了同名约定的 `*AndGetHistoryId` 方法,返回值约定相同(`result` + `historyIds`)。
|
||||||
|
|
||||||
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录(类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚,uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
|
拿到 `uuid` 后,可在需要时按 uuid「回滚」对应的历史记录(类 git revert 语义,详见[历史记录面板](../../guide/advanced/history-list.md))。相比按 index 回滚,uuid 不会随栈内步骤增删而变化,更适合业务侧持有引用后再回滚:
|
||||||
|
|
||||||
- 页面:[editorService.revertPageStepById(uuid)](#revertpagestepbyid)
|
- 页面:[editorService.revertPageStepById(uuids)](#revertpagestepbyid)
|
||||||
- 数据源:[dataSourceService.revertById(uuid)](./dataSourceServiceMethods.md#revertbyid)
|
- 数据源:[dataSourceService.revertById(uuids)](./dataSourceServiceMethods.md#revertbyid)
|
||||||
- 代码块:[codeBlockService.revertById(uuid)](./codeBlockServiceMethods.md#revertbyid)
|
- 代码块:[codeBlockService.revertById(uuids)](./codeBlockServiceMethods.md#revertbyid)
|
||||||
|
|
||||||
::: details 查看 HistoryOpOptions / DslOpOptions / HistoryOpSource 类型定义
|
::: details 查看 HistoryOpOptions / DslOpOptions / DslOpWithHistoryIdsResult / HistoryOpSource 类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpOptions{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
|
<<< @/../packages/editor/src/type.ts#DslOpOptions{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
@ -738,23 +740,26 @@ alignCenter可以支持一次水平居中多个组件,alignCenter是通过调
|
|||||||
- **参数:** 同 [add](#add)
|
- **参数:** 同 [add](#add)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [add](#add) 的返回值(新增节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [add](#add) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
|
与 [add](#add) 行为完全一致,并在返回值中额外提供 `historyIds`,见[历史记录 uuid 与 \*AndGetHistoryId](#历史记录-uuid-与-andgethistoryid)
|
||||||
|
|
||||||
- **示例:**
|
- **示例:**
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { editorService } from "@tmagic/editor";
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
const historyId = await editorService.addAndGetHistoryId(
|
const { result, historyIds } = await editorService.addAndGetHistoryId(
|
||||||
{ type: "text", text: "hello" },
|
{ type: "text", text: "hello" },
|
||||||
parent,
|
parent,
|
||||||
{ historySource: "api" },
|
{ historySource: "api" },
|
||||||
);
|
);
|
||||||
console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
console.log(result); // 新增节点
|
||||||
|
console.log(historyIds); // 本次新增对应的历史记录 uuid 列表,或 []
|
||||||
```
|
```
|
||||||
|
|
||||||
## removeAndGetHistoryId
|
## removeAndGetHistoryId
|
||||||
@ -762,67 +767,74 @@ console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
|||||||
- **参数:** 同 [remove](#remove)
|
- **参数:** 同 [remove](#remove)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [remove](#remove) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [remove](#remove) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## updateAndGetHistoryId
|
## updateAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [update](#update)
|
- **参数:** 同 [update](#update)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [update](#update) 的返回值(更新后的节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [update](#update) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [update](#update) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## moveLayerAndGetHistoryId
|
## moveLayerAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [moveLayer](#movelayer)
|
- **参数:** 同 [moveLayer](#movelayer)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [moveLayer](#movelayer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [moveLayer](#movelayer) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## moveToContainerAndGetHistoryId
|
## moveToContainerAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [moveToContainer](#movetocontainer)
|
- **参数:** 同 [moveToContainer](#movetocontainer)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<MNode | MNode[]>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `result`:同 [moveToContainer](#movetocontainer) 的返回值(移动后的节点 / 节点数组)
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [moveToContainer](#movetocontainer) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [moveToContainer](#movetocontainer) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## dragToAndGetHistoryId
|
## dragToAndGetHistoryId
|
||||||
|
|
||||||
- **参数:** 同 [dragTo](#dragto)
|
- **参数:** 同 [dragTo](#dragto)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`string` | null>} 本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid);未写入历史时返回 `null`
|
- {Promise<[`DslOpWithHistoryIdsResult<void>`](#历史记录-uuid-与-andgethistoryid)>}
|
||||||
|
- `historyIds`:本次写入历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid) 列表;未写入历史时为 `[]`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
与 [dragTo](#dragto) 行为完全一致,仅把返回值换成本次写入历史记录的 `uuid`
|
与 [dragTo](#dragto) 行为完全一致,并在返回值中额外提供 `historyIds`
|
||||||
|
|
||||||
## revertPageStepById
|
## revertPageStepById
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{string}` uuid 目标历史记录的 [uuid](#历史记录-uuid-与-andgethistoryid)(通常由 `*AndGetHistoryId` 方法返回)
|
- `{string[]}` uuids 目标历史记录的 uuid 列表(通常由 `*AndGetHistoryId` 方法返回的 `historyIds`)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`StepValue` | null>} 反向应用后产生的新 step;找不到对应 uuid / 该步未应用 / 反向失败时返回 `null`
|
- {Promise<(`StepValue` | null)[]>} 与入参同序的回滚结果列表,某项失败时为 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
通过历史记录 uuid「回滚」当前页面的某条历史步骤(类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。语义与按 index 回滚一致,仅入参从 index 改为 uuid,更适合业务侧持有引用后再回滚。
|
通过历史记录 uuid「回滚」当前页面的历史步骤(类 git revert 语义):不移动游标、不丢弃任何步骤,而是把目标 step 的修改**反向应用为一条全新的步骤**压入栈顶。**按数组顺序依次回滚**。
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
|
`opType: 'update'` 的步骤必须携带 `changeRecords` 才支持回滚(否则只能整节点替换,会冲掉后续无关变更);未应用(已被撤销)的步骤无法回滚。
|
||||||
@ -834,12 +846,10 @@ console.log(historyId); // 本次新增对应的历史记录 uuid,或 null
|
|||||||
import { editorService } from "@tmagic/editor";
|
import { editorService } from "@tmagic/editor";
|
||||||
|
|
||||||
// 执行操作时拿到本次历史记录 uuid
|
// 执行操作时拿到本次历史记录 uuid
|
||||||
const historyId = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
|
const { historyIds } = await editorService.addAndGetHistoryId({ type: "text", text: "hello" });
|
||||||
|
|
||||||
// 之后任意时机按 uuid 回滚该步骤
|
// 之后任意时机按 uuid 回滚(支持单个或整批 historyIds)
|
||||||
if (historyId) {
|
await editorService.revertPageStepById(historyIds);
|
||||||
await editorService.revertPageStepById(historyId);
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## undo
|
## undo
|
||||||
@ -852,6 +862,10 @@ if (historyId) {
|
|||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
@ -873,6 +887,10 @@ if (historyId) {
|
|||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|||||||
@ -1,24 +1,18 @@
|
|||||||
# historyService事件
|
# historyService事件
|
||||||
|
|
||||||
## page-change
|
|
||||||
|
|
||||||
- **详情:** 页面切换
|
|
||||||
|
|
||||||
- **事件回调函数:** `(undoRedo: UndoRedo) => void`
|
|
||||||
|
|
||||||
::: details 查看 UndoRedo 类定义
|
|
||||||
<<< @/../packages/editor/src/utils/undo-redo.ts#UndoRedo{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
## change
|
## change
|
||||||
|
|
||||||
- **详情:** 历史记录发生变化
|
- **详情:** 页面历史记录发生变化(`page` 类型 `push` / `undo` / `redo` 成功时触发;与 `code-block-history-change` / `data-source-history-change` 同构)
|
||||||
|
|
||||||
- **事件回调函数:** `(state: StepValue | null) => void`
|
- **事件回调函数:** `(pageId: Id, step: StepValue) => void`
|
||||||
|
|
||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
@ -29,18 +23,36 @@
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
当游标处于历史栈边界(已经无法继续撤销或重做)时,`UndoRedo.undo()` / `redo()` 返回 `null`,对应 `change` 回调收到的 `state` 为 `null`
|
回调签名已与其它历史类型统一为 `(id, step)`。当游标处于历史栈边界(已无法继续撤销 / 重做)时 `undo` / `redo` 返回 `null`,此时不会触发该事件。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## marker-change
|
||||||
|
|
||||||
|
- **详情:** 通过 [`setMarker`](./historyServiceMethods.md#setmarker) 为某个历史栈种入 `initial` 基线时触发(适用于所有类型)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(id: Id, marker: StepValue, stepType: HistoryStepType) => void`
|
||||||
|
|
||||||
|
## clear
|
||||||
|
|
||||||
|
- **详情:** 调用 [`clear`](./historyServiceMethods.md#clear) 清空历史栈时触发(适用于所有类型)
|
||||||
|
|
||||||
|
- **事件回调函数:** `(id: Id | undefined, stepType: HistoryStepType) => void`
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
`id` 缺省(清空 `stepType` 下全部栈)时回调的 `id` 为 `undefined`。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## code-block-history-change
|
## code-block-history-change
|
||||||
|
|
||||||
- **详情:** 代码块历史记录发生变化(`pushCodeBlock` / `undoCodeBlock` / `redoCodeBlock` 成功时触发)
|
- **详情:** 代码块历史记录发生变化(`push('codeBlock', step, codeBlockId)` / `undo('codeBlock', id)` / `redo('codeBlock', id)` 成功时触发)
|
||||||
|
|
||||||
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
|
- **事件回调函数:** `(codeBlockId: Id, step: CodeBlockStepValue) => void`
|
||||||
|
|
||||||
::: details 查看 CodeBlockStepValue 及关联类型定义
|
::: details 查看 CodeBlockStepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
|
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
|
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
|
||||||
@ -49,40 +61,42 @@
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
- 新增触发的 step 中 `oldContent` 为 `null`
|
- 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
|
||||||
- 删除触发的 step 中 `newContent` 为 `null`
|
- 删除触发的 step 其 diff 项 `newSchema` 为 `null`
|
||||||
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## data-source-history-change
|
## data-source-history-change
|
||||||
|
|
||||||
- **详情:** 数据源历史记录发生变化(`pushDataSource` / `undoDataSource` / `redoDataSource` 成功时触发)
|
- **详情:** 数据源历史记录发生变化(`push('dataSource', step, dataSourceId)` / `undo('dataSource', id)` / `redo('dataSource', id)` 成功时触发)
|
||||||
|
|
||||||
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
|
- **事件回调函数:** `(dataSourceId: Id, step: DataSourceStepValue) => void`
|
||||||
|
|
||||||
::: details 查看 DataSourceStepValue 及关联类型定义
|
::: details 查看 DataSourceStepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#Id{ts}
|
<<< @/../packages/schema/src/index.ts#Id{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
- 新增触发的 step 中 `oldSchema` 为 `null`
|
- 新增触发的 step 其 diff 项 `oldSchema` 为 `null`
|
||||||
- 删除触发的 step 中 `newSchema` 为 `null`
|
- 删除触发的 step 其 diff 项 `newSchema` 为 `null`
|
||||||
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
- `undo` / `redo` 返回 `null`(边界状态)时不会触发该事件
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## mark-saved
|
## mark-saved
|
||||||
|
|
||||||
- **详情:** 调用 `markSaved` / `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved` 标记「已保存」记录时触发
|
- **详情:** 调用 [`markSaved`](./historyServiceMethods.md#marksaved) 标记「已保存」记录时触发
|
||||||
|
|
||||||
- **事件回调函数:** `(payload: { kind: 'all' | 'page' | 'code-block' | 'data-source'; id?: Id }) => void`
|
- **事件回调函数:** `(payload: { kind: 'all' | HistoryStepType; id?: Id }) => void`
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
- `markSaved` 触发时 `kind` 为 `all`,无 `id`
|
- `markSaved(stepType)`(缺省 id)触发时 `kind` 为 `all`,无 `id`(此时 `stepType` 不生效)
|
||||||
- 细粒度方法触发时 `kind` 对应类别,`id` 为目标页面 / 代码块 / 数据源 id
|
- `markSaved(stepType, id)` 触发时 `kind` 为对应的 `stepType`(`page` / `codeBlock` / `dataSource` / 扩展),`id` 为目标栈 id
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## save-to-indexed-db
|
## save-to-indexed-db
|
||||||
|
|||||||
@ -4,43 +4,71 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置全部历史记录(包括页面节点栈、代码块栈、数据源栈),并重置当前页面 id / canRedo / canUndo
|
重置全部历史记录:清空 `state.steps` 下的页面 / 代码块 / 数据源 / 扩展类型全部栈(保留已注册的扩展类型键)。
|
||||||
|
|
||||||
## resetPage
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
重置当前页面的历史记录状态(清空当前页面id,重置 canRedo/canUndo)
|
|
||||||
|
|
||||||
## resetState
|
## resetState
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
重置历史记录全部内部状态(清空 pageId、pageSteps、canRedo、canUndo、codeBlockState、dataSourceState)
|
同 [`reset`](#reset),清空 `state.steps` 下全部栈。
|
||||||
|
|
||||||
## changePage
|
::: tip
|
||||||
|
历史服务不再维护「当前活动页」状态(已移除 `state.pageId` / `state.canUndo` / `state.canRedo`)。
|
||||||
|
活动页由 `editorService` 维护,撤销 / 重做 / 读取页面历史时请显式传入 pageId。
|
||||||
|
是否可撤销 / 重做请改用 [`canUndo`](#canundo) / [`canRedo`](#canredo)。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## registerStepType
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{MPage | MPageFragment} page`
|
- `{string} stepType` 自定义历史类型标识(勿与内置 `page` / `codeBlock` / `dataSource` 重名)
|
||||||
|
- `{Object} options` 可选
|
||||||
::: details 查看 MPage / MPageFragment 类型定义
|
- `{string} event` push / undo / redo 后派发的事件名;缺省为 `${stepType}-history-change`
|
||||||
<<< @/../packages/schema/src/index.ts#MPage{ts}
|
- `{string} name` 历史面板中的展示名称(tab / 分组标题等);缺省回退到 stepType 本身
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#MPageFragment{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
按页面切换历史堆栈
|
注册一个扩展历史类型,使其可与内置 `page` / `codeBlock` / `dataSource` 一样走统一的
|
||||||
|
[`push`](#push) / [`undo`](#undo) / [`redo`](#redo)(按 id 分栈、独立 undo/redo)。
|
||||||
|
注册后该类型的栈存放在 `historyService.state.steps[stepType]`,展示名称存放在 `historyService.state.stepNames[stepType]`。
|
||||||
|
|
||||||
|
## getStepName
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{string}` 该类型的展示名称(用于历史面板 tab / 分组标题等);未登记时回退到 stepType 本身
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
读取指定历史类型的展示名称。内置 `page` / `codeBlock` / `dataSource` 默认分别为「页面 / 代码块 / 数据源」。
|
||||||
|
|
||||||
|
## setStepName
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{string} name` 展示名称
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
设置指定历史类型的展示名称(写入 `historyService.state.stepNames`,历史面板会响应式刷新)。
|
||||||
|
内置 `page` / `codeBlock` / `dataSource` 也可在此覆盖默认中文名。
|
||||||
|
|
||||||
## push
|
## push
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{StepValue} state`
|
- `{HistoryStepType} stepType` 历史类型,内置 `'page'` / `'codeBlock'` / `'dataSource'`,并支持通过 [`registerStepType`](#registersteptype) 扩展
|
||||||
|
- `{StepValue | BaseStepValue} step` 已构造好的历史记录(缺省自动补全 `uuid` / `timestamp`)
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
::: details 查看 StepValue 及关联类型定义
|
::: details 查看 StepValue 及关联类型定义
|
||||||
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
<<< @/../packages/editor/src/type.ts#StepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#BaseStepValue{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/editor/src/type.ts#StepExtra{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpType{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
@ -53,278 +81,137 @@
|
|||||||
:::
|
:::
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}` 入栈失败(未传 / 无效 id)时返回 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
添加一条历史记录
|
添加一条历史记录。统一入口,所有类型(`page` / `codeBlock` / `dataSource` / 扩展)行为完全一致:按 `stepType` 选择目标栈类型、按 `id`(必填)选择具体栈,按需建栈后入栈,并派发对应的历史变更事件(`page` 为 `change`,其余如 `code-block-history-change` / `data-source-history-change`),回调签名统一为 `(id, step)`。
|
||||||
|
|
||||||
|
跨页 / 跨资源操作(如把节点搬到其它页)必须显式传入目标 id。`codeBlock` / `dataSource` 的 step 通常由 `createStackStep` 等工具按 `oldValue` / `newValue` 构造后传入。
|
||||||
|
|
||||||
::: tip
|
::: tip
|
||||||
`opType: 'update'` 的每个 `updatedItems[i]` 上可携带 `changeRecords`,用于撤销 / 重做时仅按
|
`opType: 'update'` 的每个 diff 项上可携带 `changeRecords`,用于撤销 / 重做时仅按
|
||||||
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
|
`propPath` 局部更新对应字段,避免整节点替换冲掉同节点上的其它无关变更;不带
|
||||||
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
`changeRecords` 时退化为整节点替换(如 `sort` / `moveLayer` / 拖动等纯快照场景)。
|
||||||
|
|
||||||
`StepValue` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
|
`step` 上的 `historyDescription` / `source` 仅用于历史面板展示与埋点,不影响 undo/redo 行为。
|
||||||
|
|
||||||
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
|
入栈时会为每条记录自动生成唯一标识 `uuid`(调用方未指定时),可用于精确引用 / 定位某一条历史记录。
|
||||||
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
|
若需要在执行 DSL 操作后拿到本次写入记录的 `uuid`,可使用 editorService / dataSourceService /
|
||||||
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
|
codeBlockService 提供的 `*AndGetHistoryId` 方法,参见
|
||||||
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
[editorService 历史记录 uuid 与 \*AndGetHistoryId](./editorServiceMethods.md#历史记录-uuid-与-andgethistoryid)。
|
||||||
`pushCodeBlock` / `pushDataSource` 同样会自动写入 `uuid`。
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## undo
|
## undo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
撤销当前操作。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
|
撤销指定历史栈的最近一次变更。所有类型行为一致:按 `stepType` + `id` 定位栈,不会越过 index 0 的 initial 基线(所有类型同等适用,见 [`setMarker`](#setmarker)),仅在确有可撤销 step 时派发对应的历史变更事件(`page` 为 `change`,回调签名 `(id, step)`)。
|
||||||
`propPath` 从 `oldNode` 取值做局部回滚;否则用 `oldNode` 整节点替换。
|
|
||||||
|
`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath` 从 `oldSchema` 取值做局部回滚;否则用 `oldSchema` 整节点替换。`codeBlock` / `dataSource` 拿到 step 后由调用方写回对应 service(本方法不会自动回放)。
|
||||||
|
|
||||||
## redo
|
## redo
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 必填;目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{StepValue | null}`
|
- `{StepValue | BaseStepValue | null}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
恢复到下一步。`opType: 'update'` 时,若 `updatedItems[i].changeRecords` 存在,会按
|
恢复指定历史栈到下一步,语义与 [`undo`](#undo) 对称。`page` 类型 `opType: 'update'` 时,若 diff 项的 `changeRecords` 存在,会按 `propPath` 从 `newSchema` 取值做局部重做;否则用 `newSchema` 整节点替换。
|
||||||
`propPath` 从 `newNode` 取值做局部重做;否则用 `newNode` 整节点替换。
|
|
||||||
|
|
||||||
## pushCodeBlock
|
## canUndo
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{Id} codeBlockId` 代码块 id
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
- `{Object} payload`
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `false`
|
||||||
- `{CodeBlockContent | null} oldContent` 变更前的代码块内容;新增时为 `null`
|
|
||||||
- `{CodeBlockContent | null} newContent` 变更后的代码块内容;删除时为 `null`
|
|
||||||
- `{ChangeRecord[]} changeRecords` 可选;form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整内容替换
|
|
||||||
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
|
|
||||||
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
|
|
||||||
|
|
||||||
::: details 查看 CodeBlockStepValue 及关联类型定义
|
|
||||||
<<< @/../packages/editor/src/type.ts#CodeBlockStepValue{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/schema/src/index.ts#CodeBlockContent{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{CodeBlockStepValue | null}` 入栈失败(未传 id)时返回 `null`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
推入一条代码块变更记录。与页面 / 节点完全无关,按 `codeBlockId` 维度独立一份 `UndoRedo` 栈,
|
|
||||||
栈实例存放在 `historyService.state.codeBlockState[codeBlockId]`。
|
|
||||||
|
|
||||||
入栈成功后会触发 `code-block-history-change` 事件。
|
|
||||||
|
|
||||||
::: tip
|
|
||||||
`codeBlockService.setCodeDslByIdSync` 与 `codeBlockService.deleteCodeDslByIds` 内部已经
|
|
||||||
自动调用本方法,业务代码通常无需手动调用。
|
|
||||||
:::
|
|
||||||
|
|
||||||
## undoCodeBlock
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} codeBlockId`
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{CodeBlockStepValue | null}` 栈不存在或已无可撤销记录时返回 `null`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
撤销指定代码块的最近一次变更。成功时会触发 `code-block-history-change` 事件。
|
|
||||||
拿到 step 后由调用方根据 `step.oldContent` 写回 `codeBlockService`(本方法不会自动回放)。
|
|
||||||
|
|
||||||
## redoCodeBlock
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} codeBlockId`
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{CodeBlockStepValue | null}` 栈不存在或已无可重做记录时返回 `null`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
重做指定代码块的下一次变更。成功时会触发 `code-block-history-change` 事件。
|
|
||||||
|
|
||||||
## canUndoCodeBlock
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} codeBlockId`
|
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{boolean}`
|
- `{boolean}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
指定代码块当前是否可撤销。栈不存在时返回 `false`。
|
指定历史栈当前是否可撤销(游标高于 index 0 的 initial 基线底线)。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
|
||||||
|
|
||||||
## canRedoCodeBlock
|
## canRedo
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{Id} codeBlockId`
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `false`
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{boolean}`
|
- `{boolean}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
指定代码块当前是否可重做。栈不存在时返回 `false`。
|
指定历史栈当前是否可重做。适用于所有类型(`page` / `codeBlock` / `dataSource` / 扩展)。
|
||||||
|
|
||||||
## pushDataSource
|
## setMarker
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{Id} dataSourceId` 数据源 id
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
- `{Object} payload`
|
- `{Id} id` 目标栈 id(`page` 为 pageId,其余类型为对应资源 id)
|
||||||
- `{DataSourceSchema | null} oldSchema` 变更前的数据源 schema;新增时为 `null`
|
- `{Object} options` 可选:`name` / `description` / `source`,用于基线的展示信息
|
||||||
- `{DataSourceSchema | null} newSchema` 变更后的数据源 schema;删除时为 `null`
|
|
||||||
- `{ChangeRecord[]} changeRecords` 可选;form 端 propPath/value 变更列表,撤销/重做时若有则按 propPath 局部更新;缺省(或空数组)才退化为整 schema 替换
|
|
||||||
- `{string}` historyDescription 可选;人类可读描述,用于历史面板展示;不影响 undo/redo 行为
|
|
||||||
- `{HistoryOpSource}` source 可选;操作途径,用于历史面板展示与埋点;不影响 undo/redo 行为
|
|
||||||
|
|
||||||
::: details 查看 DataSourceStepValue 及关联类型定义
|
|
||||||
<<< @/../packages/editor/src/type.ts#DataSourceStepValue{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#ChangeRecord{ts}
|
|
||||||
:::
|
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{DataSourceStepValue | null}` 入栈失败(未传 id)时返回 `null`
|
- `{StepValue | null}` 已存在基线时返回原基线;栈非空(无基线)或 id 无效时返回 `null`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
推入一条数据源变更记录。与页面 / 节点完全无关,按 `dataSourceId` 维度独立一份 `UndoRedo` 栈,
|
为指定历史栈种入一条 `opType: 'initial'` 的「初始基线」记录,作为该栈 index 0 的固定底线:它是真实入栈并随栈持久化的 step,但被钉为撤销 / 回滚的下限,`undo` / `goto` / `revert` 都不会越过它。所有类型(含扩展类型)均可设置基线,仅当目标栈为空时种入。
|
||||||
栈实例存放在 `historyService.state.dataSourceState[dataSourceId]`。
|
|
||||||
|
|
||||||
入栈成功后会触发 `data-source-history-change` 事件。
|
## getMarker
|
||||||
|
|
||||||
::: tip
|
|
||||||
`dataSourceService.add` / `update` / `remove` 内部已经自动调用本方法,业务代码通常无需手动调用。
|
|
||||||
:::
|
|
||||||
|
|
||||||
## undoDataSource
|
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{Id} dataSourceId`
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{Id} id` 可选;目标栈 id;缺省 / 无效时返回 `undefined`
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- `{DataSourceStepValue | null}`
|
- `{StepValue | undefined}`
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
撤销指定数据源的最近一次变更。成功时会触发 `data-source-history-change` 事件。
|
读取指定历史栈的初始基线 step(栈 index 0 且 `opType: 'initial'`),不存在时返回 `undefined`。
|
||||||
拿到 step 后由调用方根据 `step.oldSchema` 写回 `dataSourceService`(本方法不会自动回放)。
|
|
||||||
|
|
||||||
## redoDataSource
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} dataSourceId`
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{DataSourceStepValue | null}`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
重做指定数据源的下一次变更。成功时会触发 `data-source-history-change` 事件。
|
|
||||||
|
|
||||||
## canUndoDataSource
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} dataSourceId`
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{boolean}`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
指定数据源当前是否可撤销。栈不存在时返回 `false`。
|
|
||||||
|
|
||||||
## canRedoDataSource
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} dataSourceId`
|
|
||||||
|
|
||||||
- **返回:**
|
|
||||||
- `{boolean}`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
指定数据源当前是否可重做。栈不存在时返回 `false`。
|
|
||||||
|
|
||||||
## markSaved
|
## markSaved
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展(仅在传入 `id` 时生效)
|
||||||
|
- `{Id} id` 可选;目标栈 id。缺省表示标记全部类型、全部栈
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
标记「整份 DSL 已保存」:把页面 / 代码块 / 数据源所有栈当前游标所在的记录都标记为已保存(`saved = true`)。
|
标记历史记录为「已保存」(把对应栈当前游标所在的记录标记为 `saved = true`)。统一入口:
|
||||||
|
|
||||||
同一栈内任意时刻最多保留一条已保存记录(标记前会清除该栈内全部旧标记);某个栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,从 IndexedDB 恢复时其游标会回到 0。
|
- **缺省 `id`**:标记「整份 DSL 已保存」——把所有类型、所有栈当前游标所在的记录都标记为已保存(此时 `stepType` 不生效),触发 `mark-saved` 事件且 `{ kind: 'all' }`。通常在 DSL 整体落库成功后调用。
|
||||||
|
- **传入 `id`**:仅标记 `stepType` 下该 id 对应的栈,触发 `mark-saved` 事件且 `{ kind: stepType, id }`(如 `{ kind: 'page', id }` / `{ kind: 'codeBlock', id }` / `{ kind: 'dataSource', id }`)。
|
||||||
|
|
||||||
通常在 DSL 整体落库(保存到后端 / 本地)成功后调用,配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。仅保存了其中一类时请改用更细粒度的 `markPageSaved` / `markCodeBlockSaved` / `markDataSourceSaved`。
|
同一栈内任意时刻最多保留一条已保存记录(标记前会清除该栈内全部旧标记);某个栈处于「全部已撤销」(cursor 为 0)时不会留下已保存记录,从 IndexedDB 恢复时其游标会回到 0。配合 [`restoreFromIndexedDB`](#restorefromindexeddb) 把游标恢复到此处。
|
||||||
|
|
||||||
调用后会触发 `mark-saved` 事件(`{ kind: 'all' }`)。
|
## clear
|
||||||
|
|
||||||
## markPageSaved
|
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
- `{Id} pageId` 可选;缺省为当前活动页
|
- `{HistoryStepType} stepType` 历史类型,内置另有 `'codeBlock'` / `'dataSource'`,并支持扩展
|
||||||
|
- `{Id} id` 可选;目标栈 id。缺省表示清空 `stepType` 下的全部栈
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
标记指定页面(缺省当前活动页)历史栈的当前记录为已保存,仅影响该页面自己的栈。触发 `mark-saved` 事件(`{ kind: 'page', id }`)。
|
清空历史记录栈。统一入口,所有类型(page / codeBlock / dataSource / 扩展)行为一致:
|
||||||
|
|
||||||
## markCodeBlockSaved
|
- **传入 `id`**:仅清空 `stepType` 下该 id 对应的栈;
|
||||||
|
- **缺省 `id`**:清空 `stepType` 下的全部栈。
|
||||||
|
|
||||||
- **参数:**
|
仅删除撤销/重做记录,不会改动 DSL / 代码块 / 数据源本身。清空时会**保留各栈原有的 initial 基线**(文案 / 来源,见 [`setMarker`](#setmarker)),无基线时清空成空栈。清空后触发 `clear` 事件(签名 `(id, stepType)`)。
|
||||||
- `{Id} codeBlockId`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
标记指定代码块历史栈的当前记录为已保存,仅影响该代码块自己的栈。触发 `mark-saved` 事件(`{ kind: 'code-block', id }`)。
|
|
||||||
|
|
||||||
## markDataSourceSaved
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} dataSourceId`
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
标记指定数据源历史栈的当前记录为已保存,仅影响该数据源自己的栈。触发 `mark-saved` 事件(`{ kind: 'data-source', id }`)。
|
|
||||||
|
|
||||||
## clearPage
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} pageId` 可选;缺省为当前活动页
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
清空指定页面(缺省当前活动页)的历史记录栈。仅删除撤销/重做记录,不会改动当前 DSL;清空后该页将无法再撤销/重做之前的操作。清空当前活动页时会同步刷新 `canUndo` / `canRedo` 并触发 `change` 事件。
|
|
||||||
|
|
||||||
## clearCodeBlock
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} codeBlockId` 可选;缺省清空全部代码块
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
清空代码块历史记录栈:传入 `codeBlockId` 仅清空该代码块,缺省清空全部代码块。仅删除撤销/重做记录,不会改动代码块本身。
|
|
||||||
|
|
||||||
## clearDataSource
|
|
||||||
|
|
||||||
- **参数:**
|
|
||||||
- `{Id} dataSourceId` 可选;缺省清空全部数据源
|
|
||||||
|
|
||||||
- **详情:**
|
|
||||||
|
|
||||||
清空数据源历史记录栈:传入 `dataSourceId` 仅清空该数据源,缺省清空全部数据源。仅删除撤销/重做记录,不会改动数据源本身。
|
|
||||||
|
|
||||||
## saveToIndexedDB
|
## saveToIndexedDB
|
||||||
|
|
||||||
@ -344,7 +231,7 @@
|
|||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
把当前内存中的全部历史栈(页面 / 代码块 / 数据源)连同各自游标、容量序列化后写入本地 IndexedDB。
|
把当前内存中的全部历史栈(页面 / 代码块 / 数据源 / 扩展类型)连同各自游标、容量序列化后写入本地 IndexedDB。
|
||||||
|
|
||||||
- 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
|
- 最终库名为 `${dbName}-${当前 DSL app id}`,按应用隔离;
|
||||||
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`;
|
- `key` 用于在同一 store 下区分不同记录,缺省为 `default`;
|
||||||
@ -371,14 +258,32 @@
|
|||||||
|
|
||||||
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
|
- 每个栈都会按 `listMaxSize` 裁剪并还原游标;
|
||||||
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
|
- 若某个栈存在已保存记录(见 `markSaved`),其游标会被定位到「最近一条已保存记录」之后,使恢复后的状态与落库的 DSL 对齐;
|
||||||
- 会整体覆盖当前内存中的历史状态,并把活动页恢复为快照中的 `pageId`;
|
- 会整体覆盖当前内存中的历史状态(活动页由 `editorService` 维护,不在此恢复);
|
||||||
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
|
- 找不到对应记录时返回 `null` 且不改动当前状态;不支持 IndexedDB 的环境会 reject。
|
||||||
|
|
||||||
成功后触发 `restore-from-indexed-db` 与 `change` 事件。
|
成功后触发 `restore-from-indexed-db` 事件。
|
||||||
|
|
||||||
|
## findStepLocationByUuid
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{HistoryStepType} stepType` 历史类型
|
||||||
|
- `{string} uuid` 目标历史记录的 uuid
|
||||||
|
- `{Id} id` 可选;目标栈 id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{ { id: Id; index: number } | null }` 找到时返回所属栈 id 与步骤索引;找不到时返回 `null`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
按历史记录 uuid 在指定历史类型的栈中查找其所属 id 与索引,统一入口。
|
||||||
|
|
||||||
|
- **传入 `id`**:仅在该 id 对应的单个栈中查找(如页面历史按活动页查看,传入 pageId);
|
||||||
|
- **缺省 `id`**:遍历该类型下全部栈查找(代码块 / 数据源等按全部资源分桶的场景)。
|
||||||
|
|
||||||
|
供「按 uuid 回滚」等需要把 uuid 映射回 `(id, index)` 的场景使用,如 [editorService.revertPageStepById](./editorServiceMethods.md#revertpagestepbyid) / [codeBlockService.revertById](./codeBlockServiceMethods.md#revertbyid) / [dataSourceService.revertById](./dataSourceServiceMethods.md#revertbyid) 内部均通过本方法定位步骤。
|
||||||
|
|
||||||
## destroy
|
## destroy
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|
||||||
销毁
|
销毁
|
||||||
|
|
||||||
|
|||||||
@ -150,6 +150,14 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
## useFieldTextInError
|
||||||
|
|
||||||
|
- **详情:** 透传给内部 `Form`,控制表单校验失败时错误提示前缀是否使用字段的 `text` 文案。`false` 时直接使用字段 `name`
|
||||||
|
|
||||||
|
- **默认值:** `true`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## closeOnClickModal
|
## closeOnClickModal
|
||||||
|
|
||||||
- **详情:** 是否可以通过点击 modal 关闭 Dialog
|
- **详情:** 是否可以通过点击 modal 关闭 Dialog
|
||||||
|
|||||||
@ -40,6 +40,8 @@
|
|||||||
|
|
||||||
- **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text`
|
- **详情:** 通过 `name` 从表单 `config` 中查找对应表单项的 `text`
|
||||||
|
|
||||||
|
- **相关:** 表单校验失败时是否使用该方法生成错误提示前缀,由 [`useFieldTextInError`](./form-props.md#usefieldtextinerror) prop 控制(默认 `true`)
|
||||||
|
|
||||||
## values
|
## values
|
||||||
|
|
||||||
- **类型:** `Ref<FormValue>`
|
- **类型:** `Ref<FormValue>`
|
||||||
|
|||||||
@ -206,6 +206,19 @@
|
|||||||
|
|
||||||
- **类型:** `boolean`
|
- **类型:** `boolean`
|
||||||
|
|
||||||
|
## useFieldTextInError
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
表单校验失败时,错误提示前缀是否使用字段的 `text` 文案(通过 `getTextByName` 从 `config` 中查找)。
|
||||||
|
|
||||||
|
- `true`(默认):错误提示形如 `字段文案 -> 错误信息`,找不到 `text` 时回退为字段 `name`;
|
||||||
|
- `false`:跳过查找,直接使用字段 `name` 作为错误提示前缀(形如 `字段name -> 错误信息`)。
|
||||||
|
|
||||||
|
- **默认值:** `true`
|
||||||
|
|
||||||
|
- **类型:** `boolean`
|
||||||
|
|
||||||
## extendState
|
## extendState
|
||||||
|
|
||||||
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
- **详情:** 扩展 formState 的钩子函数,返回的对象会被合并到 formState 上
|
||||||
|
|||||||
@ -37,6 +37,7 @@ function submitForm(options: SubmitFormOptions): Promise<any>;
|
|||||||
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
|
| `keyProp` | `string` | `'__key'` | 配置项的唯一 key |
|
||||||
| `popperClass` | `string` | — | 弹层 className |
|
| `popperClass` | `string` | — | 弹层 className |
|
||||||
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
| `preventSubmitDefault` | `boolean` | — | 是否阻止表单原生 submit |
|
||||||
|
| `useFieldTextInError` | `boolean` | `true` | 校验失败时错误提示前缀是否使用字段的 `text` 文案;`false` 时直接使用字段 `name` |
|
||||||
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
| `extendState` | `(state: FormState) => Record<string, any> \| Promise<Record<string, any>>` | — | 扩展 `formState` |
|
||||||
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
| `native` | `boolean` | `false` | 透传给 `Form.submitForm`。`true` 时返回内部响应式 `values`,否则返回 `cloneDeep(toRaw(values))` |
|
||||||
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
| `returnChangeRecords` | `boolean` | `false` | `true` 时 resolve 结果为 `{ values, changeRecords }`,携带表单变更记录;否则仅 resolve `values` |
|
||||||
@ -67,7 +68,10 @@ try {
|
|||||||
type: 'text',
|
type: 'text',
|
||||||
name: 'username',
|
name: 'username',
|
||||||
text: '用户名',
|
text: '用户名',
|
||||||
rules: [{ required: true, message: '请输入用户名' }],
|
rules: [
|
||||||
|
{ required: true, message: '请输入用户名' },
|
||||||
|
{ typeMatch: true, message: '用户名类型不合法' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
initValues: { username: '' },
|
initValues: { username: '' },
|
||||||
|
|||||||
@ -24,6 +24,26 @@ type为'select'
|
|||||||
</template>
|
</template>
|
||||||
</demo-block>
|
</demo-block>
|
||||||
|
|
||||||
|
## 选项值校验
|
||||||
|
|
||||||
|
可在 `rules` 中开启 `typeMatch`,校验当前值是否落在 `options` 中(`multiple` 时校验数组元素)。详见[表单校验](/form-config/rules.md)。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: 'select',
|
||||||
|
name: 'status',
|
||||||
|
text: '状态',
|
||||||
|
options: [
|
||||||
|
{ text: '启用', value: 1 },
|
||||||
|
{ text: '禁用', value: 0 },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: '请选择状态' },
|
||||||
|
{ typeMatch: true, message: '状态值不合法' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 有禁用选项
|
## 有禁用选项
|
||||||
|
|
||||||
<demo-block type="form" :config="[{
|
<demo-block type="form" :config="[{
|
||||||
|
|||||||
@ -82,6 +82,21 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
|||||||
</template>
|
</template>
|
||||||
</demo-block>
|
</demo-block>
|
||||||
|
|
||||||
|
## 表单校验
|
||||||
|
|
||||||
|
可通过 `rules` 配置校验规则。其中 `typeMatch: true` 会按字段 type(以及 `filter` / `valueFormat` 等)校验值是否合法,详见[表单校验](/form-config/rules.md)。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'text',
|
||||||
|
text: '输入框',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: '请输入' },
|
||||||
|
{ typeMatch: true, message: '值类型不合法' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## 去掉首尾空格
|
## 去掉首尾空格
|
||||||
|
|
||||||
<demo-block type="form" :config="[{
|
<demo-block type="form" :config="[{
|
||||||
@ -124,6 +139,7 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
|||||||
| tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — |
|
| tooltip | 输入时显示内容 | string / `ToolTipConfigType` | — | — |
|
||||||
| trim | 是否去掉首尾空格 | boolean | — | false |
|
| trim | 是否去掉首尾空格 | boolean | — | false |
|
||||||
| filter | 过滤值 | string / Function | number | - |
|
| filter | 过滤值 | string / Function | number | - |
|
||||||
|
| rules | 表单验证规则 | `Rule[]` | — | — |
|
||||||
| prepend | 前置内容 | string | — | - |
|
| prepend | 前置内容 | string | — | - |
|
||||||
| append | 后置内容 | string / Object | — | - |
|
| append | 后置内容 | string / Object | — | - |
|
||||||
| onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - |
|
| onChange | 值变化时触发的函数 | `OnChangeHandler` | — | - |
|
||||||
@ -140,6 +156,8 @@ Input输入框的type为'text', 是type的默认值,所以可以不配置
|
|||||||
<<< @/../packages/form-schema/src/base.ts#FormValue{ts}
|
<<< @/../packages/form-schema/src/base.ts#FormValue{ts}
|
||||||
|
|
||||||
<<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts}
|
<<< @/../packages/form-schema/src/base.ts#ToolTipConfigType{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
183
docs/form-config/rules.md
Normal file
183
docs/form-config/rules.md
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
# 表单校验
|
||||||
|
|
||||||
|
`@tmagic/form` 字段可通过 `rules` 配置校验规则,兼容 [async-validator](https://github.com/yiminghe/async-validator)(Element Plus 内部使用)的常见能力,并额外支持按字段 `type` 做值类型 / 选项匹配校验(`typeMatch`)。
|
||||||
|
|
||||||
|
## 基础用法
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'title',
|
||||||
|
text: '名称',
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: '请输入名称' },
|
||||||
|
{ typeMatch: true, message: '名称类型不合法' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`rules` 类型见下方 [`Rule`](#类型定义)。未配置 `typeMatch` 时,现有表单行为不变。
|
||||||
|
|
||||||
|
## typeMatch
|
||||||
|
|
||||||
|
开启 `typeMatch: true` 后,表单会按字段 `config.type`(及关联配置)校验当前值是否合法。空值(`undefined` / `null` / `''`,多选类空数组 `[]`)直接通过,必填请继续使用 `required`。
|
||||||
|
|
||||||
|
### 内置映射
|
||||||
|
|
||||||
|
| 字段 type | 期望值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `text` / `textarea` / `color-picker` / `html` / 默认无 type | `string`;若 `filter: 'number'` 则为 `number`;`filter` 为自定义函数时跳过内置类型校验 |
|
||||||
|
| `display` / `hidden` | 不校验 |
|
||||||
|
| `number` | `number`(非 NaN) |
|
||||||
|
| `number-range` | 长度为 2 的数字数组 |
|
||||||
|
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
||||||
|
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
||||||
|
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
||||||
|
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
||||||
|
| `radio-group` / `radioGroup` | 值 ∈ options |
|
||||||
|
| `checkbox-group` / `checkboxGroup` | 数组且每项 ∈ options |
|
||||||
|
| `cascader` | 有 `valueSeparator` 时可为 `string` 或 `array`;默认 `emitPath` 为路径数组;`emitPath: false` 为叶子值;`multiple` 为数组;静态 options 校验路径/叶子;`remote` 只做形态校验 |
|
||||||
|
| `table` / `group-list` / `groupList` | `array` |
|
||||||
|
| 容器类(`row` / `tab` / `fieldset` / `panel` / `step` / `flex-layout` / `link` / `component` / `dynamic-field` 等) | 不校验 |
|
||||||
|
|
||||||
|
日期类默认 `valueFormat` 与字段组件一致:
|
||||||
|
|
||||||
|
- `date`:`YYYY/MM/DD`
|
||||||
|
- `datetime` / `daterange`:`YYYY/MM/DD HH:mm:ss`
|
||||||
|
- `time` / `timerange`:`HH:mm:ss`
|
||||||
|
|
||||||
|
### 与自定义 validator 共存
|
||||||
|
|
||||||
|
同一条 rule 同时配置了 `typeMatch` 与 `validator` 时,会先做类型匹配校验,通过后再执行自定义 `validator`。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'age',
|
||||||
|
type: 'number',
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
typeMatch: true,
|
||||||
|
message: '年龄必须是数字',
|
||||||
|
validator: ({ value, callback }) => {
|
||||||
|
if (value < 0) {
|
||||||
|
callback(new Error('年龄不能小于 0'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 扩展自定义 type 规则
|
||||||
|
|
||||||
|
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
|
||||||
|
|
||||||
|
### 运行时注册
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
registerTypeMatchRule,
|
||||||
|
registerTypeMatchRules,
|
||||||
|
deleteTypeMatchRule,
|
||||||
|
clearTypeMatchRules,
|
||||||
|
} from '@tmagic/form';
|
||||||
|
|
||||||
|
// 覆盖内置 text
|
||||||
|
registerTypeMatchRule('text', (value, { message }) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return message || '值类型应为字符串';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 扩展业务字段
|
||||||
|
registerTypeMatchRule('vs-code', (value, { message }) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return message || '代码字段应为字符串';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 批量注册
|
||||||
|
registerTypeMatchRules({
|
||||||
|
foo: (value) => (Array.isArray(value) ? undefined : '应为数组'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除 / 清空
|
||||||
|
deleteTypeMatchRule('foo');
|
||||||
|
clearTypeMatchRules();
|
||||||
|
```
|
||||||
|
|
||||||
|
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType`、`mForm`、`props`、`message`。
|
||||||
|
|
||||||
|
### 安装时注册
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import MagicForm from '@tmagic/form';
|
||||||
|
|
||||||
|
app.use(MagicForm, {
|
||||||
|
typeMatchRules: {
|
||||||
|
'my-field': (value, { message }) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return message || 'my-field 应为字符串';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### select 选项匹配
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'status',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ text: '启用', value: 1 },
|
||||||
|
{ text: '禁用', value: 0 },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{ required: true, message: '请选择状态' },
|
||||||
|
{ typeMatch: true, message: '状态值不合法' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### date 按 valueFormat 校验
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'birthday',
|
||||||
|
type: 'date',
|
||||||
|
valueFormat: 'YYYY-MM-DD',
|
||||||
|
rules: [{ typeMatch: true, message: '日期格式不正确' }],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### text + filter: number
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
name: 'width',
|
||||||
|
type: 'text',
|
||||||
|
filter: 'number',
|
||||||
|
rules: [{ typeMatch: true, message: '宽度应为数字' }],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 类型定义
|
||||||
|
|
||||||
|
::: details 查看 Rule 类型定义
|
||||||
|
<<< @/../packages/form-schema/src/base.ts#Rule{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: details 查看 TypeMatchValidator / TypeMatchValidateContext 类型定义
|
||||||
|
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidator{ts}
|
||||||
|
|
||||||
|
<<< @/../packages/form/src/utils/typeMatch.ts#TypeMatchValidateContext{ts}
|
||||||
|
:::
|
||||||
|
|
||||||
|
::: details 查看 FormInstallOptions 类型定义
|
||||||
|
<<< @/../packages/form/src/plugin.ts#FormInstallOptions{ts}
|
||||||
|
:::
|
||||||
@ -61,11 +61,50 @@ const menu = ref({
|
|||||||
- 数据源:`dataSourceService.revert(id, index)`
|
- 数据源:`dataSourceService.revert(id, index)`
|
||||||
- 代码块:`codeBlockService.revert(id, index)`
|
- 代码块:`codeBlockService.revert(id, index)`
|
||||||
|
|
||||||
如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 [uuid](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid),也可以直接按 uuid 回滚(无需再关心 index / id,且 uuid 不会随栈内步骤增删而变化):
|
如果业务侧在执行操作时已通过 `*AndGetHistoryId` 拿到了该条记录的 uuid(见 [uuid 与 *AndGetHistoryId](/api/editor/editorServiceMethods.md#历史记录-uuid-与-andgethistoryid) 返回的 `historyIds`),也可以直接按 uuid 回滚(无需再关心 index / id,且 uuid 不会随栈内步骤增删而变化):
|
||||||
|
|
||||||
- 页面:`editorService.revertPageStepById(uuid)`
|
- 页面:`editorService.revertPageStepById(uuids)`
|
||||||
- 数据源:`dataSourceService.revertById(uuid)`
|
- 数据源:`dataSourceService.revertById(uuids)`
|
||||||
- 代码块:`codeBlockService.revertById(uuid)`
|
- 代码块:`codeBlockService.revertById(uuids)`
|
||||||
|
|
||||||
|
#### 复用面板的「交互式回滚 / 查看差异」流程
|
||||||
|
|
||||||
|
上面的 `revert*` 方法是**静默**的:它们直接执行反向应用,不做任何校验与二次确认。如果业务方想在自己的入口(自定义按钮、右键菜单等)里复用历史面板那一套**完整交互流程**——即「目标数据已删除的前置校验 + 失败提示」「可差异步骤弹差异确认弹窗 / 其余步骤弹普通二次确认框」「用户确认后才回滚」,以及「查看差异」弹窗——可以直接 import `useHistoryRevert`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { useHistoryRevert } from '@tmagic/editor';
|
||||||
|
|
||||||
|
// editorRef 为 <m-editor> 组件实例,其 expose 出的即各 service 集合(Services)
|
||||||
|
const {
|
||||||
|
onPageRevert,
|
||||||
|
onDataSourceRevert,
|
||||||
|
onCodeBlockRevert,
|
||||||
|
onPageDiff,
|
||||||
|
onDataSourceDiff,
|
||||||
|
onCodeBlockDiff,
|
||||||
|
} = useHistoryRevert({}, editorRef.value);
|
||||||
|
|
||||||
|
// 回滚:可差异步骤弹出差异确认弹窗、其余步骤弹普通二次确认框;用户点「确定」后回滚第 index 步,
|
||||||
|
// 命中前置校验或用户取消时不执行,返回 null
|
||||||
|
await onPageRevert(index);
|
||||||
|
await onDataSourceRevert(id, index);
|
||||||
|
await onCodeBlockRevert(id, index);
|
||||||
|
|
||||||
|
// 查看差异:可差异步骤弹出只读差异弹窗展示前后值差异,无可对比内容时不弹窗
|
||||||
|
onPageDiff(index);
|
||||||
|
onDataSourceDiff(id, index);
|
||||||
|
onCodeBlockDiff(id, index);
|
||||||
|
```
|
||||||
|
|
||||||
|
回滚确认弹窗与查看差异弹窗均由 `useHistoryRevert` 内部**按需动态挂载** `HistoryDiffDialog` 实现,业务方无需自行挂载任何弹窗组件。第二个参数 `options` 可选:
|
||||||
|
|
||||||
|
| 字段 | 必填 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `appContext` | 否 | 父级应用上下文,用于让动态挂载的差异确认弹窗继承全局组件 / 指令 / provide / 插件(Element Plus、`@tmagic/form` 字段组件等)。在组件 `setup` 中调用时会自动取当前组件的 `appContext`,无需手动传;仅当在组件 setup 之外调用时才需显式传入(如 `editorApp._context`)。 |
|
||||||
|
| `extendState` | 否 | 透传给差异确认弹窗的 `extendState`(同 Editor 的 [`extendFormState`](#自定义对比判断)),使对比表单中依赖业务上下文的 `display` / `disabled` 等 `filterFunction` 正常工作。 |
|
||||||
|
| `dialogWidth` | 否 | 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 `TMagicDialog` 的 `width`),如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(`900px`)。业务自有历史可在 `viewDiff` / `confirmAndRevert` 调用时通过各自入参的 `width` 单独覆盖。 |
|
||||||
|
|
||||||
|
> 若只需要无确认、无校验的静默回滚,直接用上面的 `editorService.revertPageStep` 等即可,无需 `useHistoryRevert`。
|
||||||
|
|
||||||
### 4. 差异对比
|
### 4. 差异对比
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
tmagic-editor的表单配置,核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
|
tmagic-editor的表单配置,核心就是使用了 @tmagic/form 来作为渲染器。@tmagic/form 是一个 npm 包,可以安装它,在你想使用的地方单独使用。
|
||||||
|
|
||||||
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。
|
@tmagic/form 接受一个表单配置,详细配置可参考[表单 api](../../api/form/form-props.md)。字段校验(含 `typeMatch` 类型匹配)见[表单校验](../../form-config/rules.md)。
|
||||||
|
|
||||||
## 安装
|
## 安装
|
||||||
|
|
||||||
@ -45,6 +45,20 @@ app.use(MagicForm);
|
|||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可在安装时传入自定义 `typeMatch` 规则,详见[表单校验 - 扩展自定义 type 规则](../../form-config/rules.md#扩展自定义-type-规则):
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
app.use(MagicForm, {
|
||||||
|
typeMatchRules: {
|
||||||
|
'my-field': (value, { message }) => {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
return message || 'my-field 应为字符串';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
以上代码便完成了 @tmagic/form 的引入。需要注意的是,Element Plus 的样式文件需要单独引入。
|
以上代码便完成了 @tmagic/form 的引入。需要注意的是,Element Plus 的样式文件需要单独引入。
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@ -66,8 +66,8 @@
|
|||||||
"prettier": "^3.8.3",
|
"prettier": "^3.8.3",
|
||||||
"recast": "^0.23.11",
|
"recast": "^0.23.11",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"rolldown": "^1.0.1",
|
"rolldown": "^1.1.2",
|
||||||
"rolldown-plugin-dts": "^0.25.1",
|
"rolldown-plugin-dts": "^0.26.0",
|
||||||
"sass-embedded": "^1.99.0",
|
"sass-embedded": "^1.99.0",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"serialize-javascript": "^7.0.0",
|
"serialize-javascript": "^7.0.0",
|
||||||
@ -75,7 +75,7 @@
|
|||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "^2.0.0-alpha.17",
|
"vitepress": "^2.0.0-alpha.17",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.9",
|
||||||
"vue": "catalog:",
|
"vue": "catalog:",
|
||||||
"vue-tsc": "^3.2.9"
|
"vue-tsc": "^3.2.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
"types": "lib/index.d.ts",
|
"types": "lib/index.d.ts",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -76,10 +76,12 @@ class IteratorContainer extends Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(config.items)) {
|
||||||
config.items?.forEach((element: MNode) => {
|
config.items?.forEach((element: MNode) => {
|
||||||
this.initNode(element, node, map);
|
this.initNode(element, node, map);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public setNodes(nodes: MNode[], index: number) {
|
public setNodes(nodes: MNode[], index: number) {
|
||||||
const map = this.nodes[index] || new Map();
|
const map = this.nodes[index] || new Map();
|
||||||
|
|||||||
@ -79,10 +79,12 @@ class Page extends Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(config.items)) {
|
||||||
config.items?.forEach((element: MComponent | MContainer) => {
|
config.items?.forEach((element: MComponent | MContainer) => {
|
||||||
this.initNode(element, node);
|
this.initNode(element, node);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getNode<T extends TMagicNode = TMagicNode>(
|
public getNode<T extends TMagicNode = TMagicNode>(
|
||||||
id: Id,
|
id: Id,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -234,6 +234,9 @@ export interface PaginationProps {
|
|||||||
|
|
||||||
export interface PopconfirmProps {
|
export interface PopconfirmProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
width?: string | number;
|
||||||
|
/** 浮层是否插入到 body,默认 true。设为 false 时浮层内联渲染,便于嵌套在 hover 浮层中避免父级因 mouseleave 收起。 */
|
||||||
|
teleported?: boolean;
|
||||||
placement?:
|
placement?:
|
||||||
| 'top'
|
| 'top'
|
||||||
| 'left'
|
| 'left'
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -135,7 +135,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { provide } from 'vue';
|
import { provide, ref } from 'vue';
|
||||||
|
|
||||||
import type { MApp } from '@tmagic/core';
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
@ -227,6 +227,8 @@ const stageOptions: StageOptions = {
|
|||||||
|
|
||||||
stageOverlayService.set('stageOptions', stageOptions);
|
stageOverlayService.set('stageOptions', stageOptions);
|
||||||
|
|
||||||
|
const propsPanelRef = ref<InstanceType<typeof FormPanel> | null>(null);
|
||||||
|
|
||||||
provide('services', services);
|
provide('services', services);
|
||||||
|
|
||||||
provide('codeOptions', props.codeOptions);
|
provide('codeOptions', props.codeOptions);
|
||||||
@ -237,6 +239,11 @@ provide('stageOptions', stageOptions);
|
|||||||
* 与 PropsPanel 通过 `:extend-state` 显式传入的方式保持等价。
|
* 与 PropsPanel 通过 `:extend-state` 显式传入的方式保持等价。
|
||||||
*/
|
*/
|
||||||
provide('extendFormState', props.extendFormState);
|
provide('extendFormState', props.extendFormState);
|
||||||
|
/**
|
||||||
|
* 提供 PropsPanel 主属性表单的 formState getter,供历史差异弹窗复用,
|
||||||
|
* 让 CompareForm 与 PropsPanel 的 filterFunction 上下文保持一致。
|
||||||
|
*/
|
||||||
|
provide('getPropsPanelFormState', () => propsPanelRef.value?.configForm?.formState);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
* 把历史记录面板的自定义扩展 tab 提供给深层的 HistoryListPanel(它挂在 NavMenu 中,
|
||||||
@ -248,9 +255,11 @@ provide('historyListExtraTabs', props.historyListExtraTabs);
|
|||||||
provide<EventBus>('eventBus', new EventEmitter());
|
provide<EventBus>('eventBus', new EventEmitter());
|
||||||
|
|
||||||
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
|
||||||
|
propsPanelRef.value = e;
|
||||||
emit('props-panel-mounted', e);
|
emit('props-panel-mounted', e);
|
||||||
};
|
};
|
||||||
const propsPanelUnmountedHandler = () => {
|
const propsPanelUnmountedHandler = () => {
|
||||||
|
propsPanelRef.value = null;
|
||||||
emit('props-panel-unmounted');
|
emit('props-panel-unmounted');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,9 @@
|
|||||||
v-model:visible="boxVisible"
|
v-model:visible="boxVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="codeBlockEditorHeight"
|
v-model:height="codeBlockEditorHeight"
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
:title="content.name ? `${disabled ? '查看' : '编辑'}${content.name}` : '新增代码'"
|
:title="content.name ? `${disabled ? '查看' : '编辑'}${content.name}` : '新增代码'"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
:before-close="beforeClose"
|
:before-close="beforeClose"
|
||||||
>
|
>
|
||||||
@ -206,6 +208,7 @@ const closedHandler = () => {
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
watch(boxVisible, (visible) => {
|
watch(boxVisible, (visible) => {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
|
|||||||
@ -10,21 +10,23 @@
|
|||||||
:is-compare="true"
|
:is-compare="true"
|
||||||
:disabled="true"
|
:disabled="true"
|
||||||
:label-width="labelWidth"
|
:label-width="labelWidth"
|
||||||
:extend-state="extendState"
|
:extend-state="mergedExtendState"
|
||||||
:show-diff="showDiff"
|
:show-diff="showDiff"
|
||||||
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
|
:size="size"
|
||||||
></MForm>
|
></MForm>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
|
import { computed, inject, provide, type Ref, ref, type ShallowRef, useTemplateRef, watch, watchEffect } from 'vue';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
|
import { type CodeBlockContent, type DataSourceSchema, HookType, type MNode } from '@tmagic/core';
|
||||||
|
import { type FieldSize } from '@tmagic/design';
|
||||||
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
import { type FormConfig, type FormState, type FormValue, MForm } from '@tmagic/form';
|
||||||
|
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import type { CompareCategory, CompareFormLoadConfig, Services } from '@editor/type';
|
||||||
import type { CompareCategory, CompareFormLoadConfig } from '@editor/type';
|
|
||||||
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
import { getCodeBlockFormConfig } from '@editor/utils/code-block';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@ -61,21 +63,35 @@ const props = withDefaults(
|
|||||||
* 因此在差异对比场景下也需要透传,避免出现 `formState.xxx is undefined` 的运行时错误。
|
* 因此在差异对比场景下也需要透传,避免出现 `formState.xxx is undefined` 的运行时错误。
|
||||||
*/
|
*/
|
||||||
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
|
/**
|
||||||
|
* 外部透传的基础 formState(通常来自 PropsPanel 主属性表单)。
|
||||||
|
* CompareForm 会提取其中的扩展字段覆盖到自己的 formState,保证 filterFunction 上下文一致。
|
||||||
|
*/
|
||||||
|
baseFormState?: FormState;
|
||||||
|
/** 需要走 self diff 的字段类型(例如 mod-cond)。 */
|
||||||
|
selfDiffFieldTypes?: string[];
|
||||||
|
/**
|
||||||
|
* 表单内组件的尺寸(透传给 MForm 的 `size`),可选 'large' | 'default' | 'small'。
|
||||||
|
* 缺省时使用 MForm 内置默认尺寸。
|
||||||
|
*/
|
||||||
|
size?: FieldSize;
|
||||||
/**
|
/**
|
||||||
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
|
* 自定义 FormConfig 加载逻辑。传入后将接管内置的按 `category`(node/data-source/code-block)
|
||||||
* 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
|
* 取配置逻辑,调用方可根据业务自行返回(或异步返回)表单配置。可通过
|
||||||
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。返回的 config 直接用于对比展示。
|
* `ctx.defaultLoadConfig()` 复用默认结果再做二次加工。返回的 config 直接用于对比展示。
|
||||||
*/
|
*/
|
||||||
loadConfig?: CompareFormLoadConfig;
|
loadConfig?: CompareFormLoadConfig;
|
||||||
|
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||||
|
services?: Services;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
category: 'node',
|
category: 'node',
|
||||||
labelWidth: '120px',
|
labelWidth: '120px',
|
||||||
|
extendState: (state: FormState) => state,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const { propsService, dataSourceService, codeBlockService, editorService } = useServices();
|
provide('services', props.services);
|
||||||
const services = useServices();
|
|
||||||
|
|
||||||
const config = ref<FormConfig>([]);
|
const config = ref<FormConfig>([]);
|
||||||
|
|
||||||
@ -170,26 +186,38 @@ const removeStyleDisplayConfig = (formConfig: FormConfig): FormConfig =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mergedExtendState = (state: FormState) => {
|
||||||
|
return props.extendState(props.baseFormState || state);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
* 内置的默认 FormConfig 加载逻辑:按 `category` 从对应 service / 工具取配置。
|
||||||
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`,方便复用与二次加工。
|
* 作为 ctx.defaultLoadConfig 透传给自定义 `loadConfig`,方便复用与二次加工。
|
||||||
*/
|
*/
|
||||||
const defaultLoadConfig = async (): Promise<FormConfig> => {
|
const defaultLoadConfig = async (): Promise<FormConfig> => {
|
||||||
|
if (!props.services) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
switch (props.category) {
|
switch (props.category) {
|
||||||
case 'node': {
|
case 'node': {
|
||||||
if (!props.type) {
|
if (!props.type) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return removeStyleDisplayConfig(
|
return removeStyleDisplayConfig(
|
||||||
await propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
await props.services.propsService.getPropsConfig(props.type, { node: props.value as unknown as MNode }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'data-source': {
|
case 'data-source': {
|
||||||
return dataSourceService.getFormConfig(props.type || 'base');
|
const config = props.services.dataSourceService.getFormConfig(props.type || 'base');
|
||||||
|
// 数据源表单外层 tab 的「数据定义」项 status 为 'fields',tab-pane name 随之为 'fields'。
|
||||||
|
// 未显式设置 active 时,Tabs 默认取 '0',与 'fields' 不匹配会导致打开弹窗时无默认激活项,
|
||||||
|
// 这里与 DataSourceConfigPanel 保持一致,默认激活「数据定义」tab。
|
||||||
|
return config.map((item) => ('type' in item && item.type === 'tab' ? { ...item, active: 'fields' } : item));
|
||||||
}
|
}
|
||||||
case 'code-block': {
|
case 'code-block': {
|
||||||
return getCodeBlockFormConfig({
|
return getCodeBlockFormConfig({
|
||||||
paramColConfig: codeBlockService.getParamsColConfig(),
|
paramColConfig: props.services.codeBlockService.getParamsColConfig(),
|
||||||
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
|
// 通过传入 dataSourceType 间接表达"是数据源代码块"——在对比场景下 props.dataSourceType
|
||||||
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
|
// 由调用方按 step 上下文显式传入,未传则视为普通代码块,「执行时机」字段隐藏。
|
||||||
isDataSource: () => Boolean(props.dataSourceType),
|
isDataSource: () => Boolean(props.dataSourceType),
|
||||||
@ -237,12 +265,10 @@ const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
|
|||||||
* - services:整个 useServices() 返回的服务集合;
|
* - services:整个 useServices() 返回的服务集合;
|
||||||
* - stage:当前 editorService.get('stage') 的最新值。
|
* - stage:当前 editorService.get('stage') 的最新值。
|
||||||
*/
|
*/
|
||||||
const stage = computed(() => editorService.get('stage'));
|
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
if (formRef.value) {
|
if (formRef.value && props.services) {
|
||||||
formRef.value.formState.stage = stage.value;
|
formRef.value.formState.stage = props.services.editorService.get('stage');
|
||||||
formRef.value.formState.services = services;
|
formRef.value.formState.services = props.services;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body" v-if="visible">
|
<Teleport to="body" v-if="visible">
|
||||||
<div ref="target" class="m-editor-float-box" :style="{ ...style, zIndex: curZIndex }" @mousedown="nextZIndex">
|
<div
|
||||||
|
ref="target"
|
||||||
|
class="m-editor-float-box"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:style="{ ...style, zIndex: curZIndex }"
|
||||||
|
@mousedown="nextZIndex"
|
||||||
|
>
|
||||||
<div ref="title" class="m-editor-float-box-title">
|
<div ref="title" class="m-editor-float-box-title">
|
||||||
<slot name="title">
|
<slot name="title">
|
||||||
<span>{{ title }}</span>
|
<span>{{ title }}</span>
|
||||||
@ -9,7 +15,7 @@
|
|||||||
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
|
<TMagicButton link size="small" @click="closeHandler"><MIcon :icon="Close"></MIcon></TMagicButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px` }">
|
<div class="m-editor-float-box-body" :style="{ height: `${bodyHeight}px`, ...bodyStyle }">
|
||||||
<slot name="body"></slot>
|
<slot name="body"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -17,14 +23,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { Close } from '@element-plus/icons-vue';
|
import { Close } from '@element-plus/icons-vue';
|
||||||
import VanillaMoveable from 'moveable';
|
import VanillaMoveable from 'moveable';
|
||||||
|
|
||||||
import { TMagicButton, useZIndex } from '@tmagic/design';
|
import { TMagicButton, useZIndex } from '@tmagic/design';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
|
||||||
|
|
||||||
interface Position {
|
interface Position {
|
||||||
left: number;
|
left: number;
|
||||||
@ -39,11 +44,18 @@ const props = withDefaults(
|
|||||||
defineProps<{
|
defineProps<{
|
||||||
position?: Position;
|
position?: Position;
|
||||||
title?: string;
|
title?: string;
|
||||||
|
bodyStyle?: CSSProperties;
|
||||||
|
/** 浮窗初始样式,会与内部计算样式合并,外部传入优先 */
|
||||||
|
initialStyle?: CSSProperties;
|
||||||
|
/** 用于约束浮窗 left 的容器宽度,传入时按宽度收敛 left,避免超出右边界;默认取视窗宽度 */
|
||||||
|
frameworkWidth?: number;
|
||||||
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
|
beforeClose?: (_done: (_cancel?: boolean) => void) => void;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
position: () => ({ left: 0, top: 0 }),
|
position: () => ({ left: 0, top: 0 }),
|
||||||
|
initialStyle: () => ({}),
|
||||||
|
frameworkWidth: 0,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -66,12 +78,11 @@ const bodyHeight = computed(() => {
|
|||||||
return 'auto';
|
return 'auto';
|
||||||
});
|
});
|
||||||
|
|
||||||
const { uiService } = useServices();
|
|
||||||
const frameworkWidth = computed(() => uiService.get('frameworkRect').width || 0);
|
|
||||||
const style = computed(() => {
|
const style = computed(() => {
|
||||||
let { left } = props.position;
|
let { left } = props.position;
|
||||||
if (width.value) {
|
const frameworkWidth = props.frameworkWidth || globalThis.window?.innerWidth || 0;
|
||||||
left = left + width.value > frameworkWidth.value ? frameworkWidth.value - width.value : left;
|
if (width.value && frameworkWidth) {
|
||||||
|
left = left + width.value > frameworkWidth ? frameworkWidth - width.value : left;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -79,11 +90,60 @@ const style = computed(() => {
|
|||||||
top: `${props.position.top}px`,
|
top: `${props.position.top}px`,
|
||||||
width: width.value ? `${width.value}px` : 'auto',
|
width: width.value ? `${width.value}px` : 'auto',
|
||||||
height: height.value ? `${height.value}px` : 'auto',
|
height: height.value ? `${height.value}px` : 'auto',
|
||||||
|
...props.initialStyle,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let moveable: VanillaMoveable | null = null;
|
let moveable: VanillaMoveable | null = null;
|
||||||
|
|
||||||
|
// 拖拽/缩放时用于覆盖 iframe 的遮罩,防止鼠标进入 iframe 区域后事件被 iframe 吞掉导致拖拽丢失
|
||||||
|
let dragMask: HTMLDivElement | null = null;
|
||||||
|
let dragMaskVisible = false;
|
||||||
|
|
||||||
|
// 兜底:正常情况下遮罩由 moveable 的 dragEnd/resizeEnd 移除;但某些极端场景
|
||||||
|
// (拖拽中鼠标移出窗口后松开、alt-tab 切走窗口失焦、标签页被隐藏等)结束事件不会触发,
|
||||||
|
// 会导致全屏透明遮罩残留、整个编辑器无法点击。这里挂全局 pointerup/blur/visibilitychange 强制收尾。
|
||||||
|
const bindDragMaskSafety = () => {
|
||||||
|
globalThis.window?.addEventListener('pointerup', hideDragMask, true);
|
||||||
|
globalThis.window?.addEventListener('blur', hideDragMask);
|
||||||
|
globalThis.document?.addEventListener('visibilitychange', hideDragMask);
|
||||||
|
};
|
||||||
|
|
||||||
|
const unbindDragMaskSafety = () => {
|
||||||
|
globalThis.window?.removeEventListener('pointerup', hideDragMask, true);
|
||||||
|
globalThis.window?.removeEventListener('blur', hideDragMask);
|
||||||
|
globalThis.document?.removeEventListener('visibilitychange', hideDragMask);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDragMask = () => {
|
||||||
|
if (dragMaskVisible) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dragMask) {
|
||||||
|
dragMask = globalThis.document.createElement('div');
|
||||||
|
dragMask.className = 'm-editor-float-box-drag-mask';
|
||||||
|
}
|
||||||
|
globalThis.document.body.appendChild(dragMask);
|
||||||
|
dragMaskVisible = true;
|
||||||
|
bindDragMaskSafety();
|
||||||
|
|
||||||
|
// 拖拽标题时,root 上的 @mousedown="nextZIndex" 会把浮窗 z-index 抬高,
|
||||||
|
// 若此时才读取会拿到旧值导致遮罩被浮窗(及其内部 iframe)盖住,故用 nextTick 在 z-index 稳定后再设置到浮窗之上
|
||||||
|
const setMaskZIndex = () => {
|
||||||
|
if (dragMask) {
|
||||||
|
dragMask.style.zIndex = `${curZIndex.value + 1}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
setMaskZIndex();
|
||||||
|
nextTick(setMaskZIndex);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideDragMask = () => {
|
||||||
|
unbindDragMaskSafety();
|
||||||
|
dragMask?.parentNode?.removeChild(dragMask);
|
||||||
|
dragMaskVisible = false;
|
||||||
|
};
|
||||||
|
|
||||||
const initMoveable = () => {
|
const initMoveable = () => {
|
||||||
moveable = new VanillaMoveable(globalThis.document.body, {
|
moveable = new VanillaMoveable(globalThis.document.body, {
|
||||||
className: 'm-editor-floating-box-moveable',
|
className: 'm-editor-floating-box-moveable',
|
||||||
@ -101,6 +161,14 @@ const initMoveable = () => {
|
|||||||
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
|
bounds: { left: 0, top: 0, right: 0, bottom: 0, position: 'css' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 仅在真正发生拖拽/缩放位移时插入遮罩:moveable 的 dragStart/resizeStart 在 mousedown 时即触发,
|
||||||
|
// 若此时就盖遮罩,会盖住浮窗本身导致 mouseup 落在遮罩上、关闭按钮的 click 无法触发(点击关闭不了)。
|
||||||
|
// 改为在 drag/resize(实际位移)时才显示,纯点击不再触发遮罩。
|
||||||
|
moveable.on('drag', showDragMask);
|
||||||
|
moveable.on('resize', showDragMask);
|
||||||
|
moveable.on('dragEnd', hideDragMask);
|
||||||
|
moveable.on('resizeEnd', hideDragMask);
|
||||||
|
|
||||||
moveable.on('drag', (e) => {
|
moveable.on('drag', (e) => {
|
||||||
e.target.style.transform = e.transform;
|
e.target.style.transform = e.transform;
|
||||||
});
|
});
|
||||||
@ -115,6 +183,7 @@ const initMoveable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const destroyMoveable = () => {
|
const destroyMoveable = () => {
|
||||||
|
hideDragMask();
|
||||||
moveable?.destroy();
|
moveable?.destroy();
|
||||||
moveable = null;
|
moveable = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -107,10 +107,12 @@ const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues
|
|||||||
|
|
||||||
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
const notEditable = computed(() => filterFunction(mForm, props.config.notEditable, props));
|
||||||
|
|
||||||
const hasCodeBlockSidePanel = computed(() =>
|
const codeBlockSidePanel = computed(() =>
|
||||||
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.CODE_BLOCK),
|
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.CODE_BLOCK),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasCodeBlockSidePanel = computed(() => codeBlockSidePanel.value);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据代码块id获取代码块参数配置
|
* 根据代码块id获取代码块参数配置
|
||||||
* @param codeId 代码块ID
|
* @param codeId 代码块ID
|
||||||
@ -191,6 +193,10 @@ const onParamsChangeHandler = (value: any, eventData: ContainerChangeEventData)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editCode = (id: string) => {
|
const editCode = (id: string) => {
|
||||||
|
const sideBarItem = codeBlockSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.CODE_BLOCK);
|
||||||
|
}
|
||||||
eventBus?.emit('edit-code', id);
|
eventBus?.emit('edit-code', id);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -211,11 +211,25 @@ const onChangeHandler = (v: string[] = []) => {
|
|||||||
emit('change', v);
|
emit('change', v);
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDataSourceSidePanel = computed(() =>
|
const dataSourceSidePanel = computed(() =>
|
||||||
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
uiService.get('sideBarItems').find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasDataSourceSidePanel = computed(() => dataSourceSidePanel.value);
|
||||||
|
|
||||||
const editHandler = (id: string) => {
|
const editHandler = (id: string) => {
|
||||||
eventBus?.emit('edit-data-source', removeDataSourceFieldPrefix(id));
|
const sideBarItem = dataSourceSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.DATA_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataSourceId = removeDataSourceFieldPrefix(id);
|
||||||
|
const fieldPath = selectFieldsId.value;
|
||||||
|
|
||||||
|
if (fieldPath.length) {
|
||||||
|
eventBus?.emit('edit-data-source-field', dataSourceId, [...fieldPath]);
|
||||||
|
} else {
|
||||||
|
eventBus?.emit('edit-data-source', dataSourceId);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -8,10 +8,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addDialogVisible"
|
v-model:visible="addDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="fieldTitle"
|
:title="fieldTitle"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -28,10 +30,12 @@
|
|||||||
</FloatingBox>
|
</FloatingBox>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addFromJsonDialogVisible"
|
v-model:visible="addFromJsonDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
title="快速添加数据定义"
|
title="快速添加数据定义"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -47,7 +51,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, Ref, ref } from 'vue';
|
import { computed, type ComputedRef, inject, onMounted, provide, Ref, ref } from 'vue';
|
||||||
|
|
||||||
import type { DataSchema } from '@tmagic/core';
|
import type { DataSchema } from '@tmagic/core';
|
||||||
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
|
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
|
||||||
@ -101,6 +105,16 @@ const newHandler = () => {
|
|||||||
addDialogVisible.value = true;
|
addDialogVisible.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editField = (row: Record<string, any>, index: number) => {
|
||||||
|
fieldValues.value = {
|
||||||
|
...row,
|
||||||
|
index,
|
||||||
|
};
|
||||||
|
fieldTitle.value = `编辑${row.title}`;
|
||||||
|
calcBoxPosition();
|
||||||
|
addDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const fieldChange = ({ index, ...value }: Record<string, any>, data: ContainerChangeEventData) => {
|
const fieldChange = ({ index, ...value }: Record<string, any>, data: ContainerChangeEventData) => {
|
||||||
addDialogVisible.value = false;
|
addDialogVisible.value = false;
|
||||||
|
|
||||||
@ -158,13 +172,7 @@ const fieldColumns: ColumnConfig[] = [
|
|||||||
{
|
{
|
||||||
text: '编辑',
|
text: '编辑',
|
||||||
handler: (row: Record<string, any>, index: number) => {
|
handler: (row: Record<string, any>, index: number) => {
|
||||||
fieldValues.value = {
|
editField(row, index);
|
||||||
...row,
|
|
||||||
index,
|
|
||||||
};
|
|
||||||
fieldTitle.value = `编辑${row.title}`;
|
|
||||||
calcBoxPosition();
|
|
||||||
addDialogVisible.value = true;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -354,4 +362,30 @@ const { height: editorHeight } = useEditorContentHeight();
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由 DataSourceConfigPanel 注入:打开数据源详情后需要直接打开的字段路径(字段名数组)。
|
||||||
|
* 当前层消费 path[0],并把剩余路径下发给嵌套字段,实现逐层打开。
|
||||||
|
*/
|
||||||
|
const editingFieldPath = inject<ComputedRef<string[]>>(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => []),
|
||||||
|
);
|
||||||
|
|
||||||
|
provide(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => editingFieldPath.value.slice(1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const path = editingFieldPath.value;
|
||||||
|
if (!path.length) return;
|
||||||
|
|
||||||
|
const fields: Record<string, any>[] = props.model[props.name] || [];
|
||||||
|
const index = fields.findIndex((field) => field.name === path[0]);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
editField(fields[index], index);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -30,10 +30,10 @@
|
|||||||
<template #suffix>
|
<template #suffix>
|
||||||
<Icon :icon="Coin" />
|
<Icon :icon="Coin" />
|
||||||
</template>
|
</template>
|
||||||
<template #default="{ item }">
|
<template #default="slotProps">
|
||||||
<div style="display: flex; flex-direction: column; line-height: 1.2em">
|
<div style="display: flex; flex-direction: column; line-height: 1.2em">
|
||||||
<div>{{ item.text }}</div>
|
<div>{{ slotProps?.item?.text || '' }}</div>
|
||||||
<span style="font-size: 10px; color: rgba(0, 0, 0, 0.6)">{{ item.value }}</span>
|
<span style="font-size: 10px; color: rgba(0, 0, 0, 0.6)">{{ slotProps?.item?.value || '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
></MCascader>
|
></MCascader>
|
||||||
|
|
||||||
<TMagicTooltip
|
<TMagicTooltip
|
||||||
v-if="model[name] && isCustomMethod && hasDataSourceSidePanel && !isCompare"
|
v-if="model[name] && isCustomMethod && dataSourceSidePanel && !isCompare"
|
||||||
:content="notEditable ? '查看' : '编辑'"
|
:content="notEditable ? '查看' : '编辑'"
|
||||||
>
|
>
|
||||||
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
|
<TMagicButton class="m-fields-select-action-button" :size="size" @click="editCodeHandler">
|
||||||
@ -75,7 +75,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodSelectConfig>>
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const hasDataSourceSidePanel = computed(() =>
|
const dataSourceSidePanel = computed(() =>
|
||||||
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
(uiService.get('sideBarItems') || []).find((item) => item.$key === SideItemKey.DATA_SOURCE),
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -208,12 +208,17 @@ const onParamsChangeHandler = (value: any, eventData: ContainerChangeEventData)
|
|||||||
};
|
};
|
||||||
|
|
||||||
const editCodeHandler = () => {
|
const editCodeHandler = () => {
|
||||||
const [id] = props.model[props.name];
|
const [id, methodName] = props.model[props.name];
|
||||||
|
|
||||||
const dataSource = dataSourceService.getDataSourceById(id);
|
const dataSource = dataSourceService.getDataSourceById(id);
|
||||||
|
|
||||||
if (!dataSource) return;
|
if (!dataSource) return;
|
||||||
|
|
||||||
eventBus?.emit('edit-data-source', id);
|
const sideBarItem = dataSourceSidePanel.value;
|
||||||
|
if (sideBarItem) {
|
||||||
|
uiService.set('sideBarActiveTabName', sideBarItem.text || sideBarItem.$key || SideItemKey.DATA_SOURCE);
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBus?.emit('edit-data-source-method', id, methodName);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, nextTick, ref, useTemplateRef } from 'vue';
|
import { computed, type ComputedRef, inject, nextTick, onMounted, ref, useTemplateRef } from 'vue';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { CodeBlockContent } from '@tmagic/core';
|
import type { CodeBlockContent } from '@tmagic/core';
|
||||||
@ -52,6 +52,29 @@ const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>(
|
|||||||
|
|
||||||
let editIndex = -1;
|
let editIndex = -1;
|
||||||
|
|
||||||
|
const editMethod = (method: CodeBlockContent, index: number) => {
|
||||||
|
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
|
||||||
|
|
||||||
|
if (method.content) {
|
||||||
|
if (typeof method.content !== 'string') {
|
||||||
|
codeContent = method.content.toString();
|
||||||
|
} else {
|
||||||
|
codeContent = method.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
codeConfig.value = {
|
||||||
|
...cloneDeep(method),
|
||||||
|
content: codeContent,
|
||||||
|
};
|
||||||
|
|
||||||
|
editIndex = index;
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
codeBlockEditorRef.value?.show();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const methodColumns: ColumnConfig[] = [
|
const methodColumns: ColumnConfig[] = [
|
||||||
{
|
{
|
||||||
label: '名称',
|
label: '名称',
|
||||||
@ -77,26 +100,7 @@ const methodColumns: ColumnConfig[] = [
|
|||||||
{
|
{
|
||||||
text: '编辑',
|
text: '编辑',
|
||||||
handler: (method: CodeBlockContent, index: number) => {
|
handler: (method: CodeBlockContent, index: number) => {
|
||||||
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
|
editMethod(method, index);
|
||||||
|
|
||||||
if (method.content) {
|
|
||||||
if (typeof method.content !== 'string') {
|
|
||||||
codeContent = method.content.toString();
|
|
||||||
} else {
|
|
||||||
codeContent = method.content;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
codeConfig.value = {
|
|
||||||
...cloneDeep(method),
|
|
||||||
content: codeContent,
|
|
||||||
};
|
|
||||||
|
|
||||||
editIndex = index;
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
codeBlockEditorRef.value?.show();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -158,4 +162,21 @@ const submitCodeHandler = (value: CodeBlockContent, data: ContainerChangeEventDa
|
|||||||
|
|
||||||
codeBlockEditorRef.value?.hide();
|
codeBlockEditorRef.value?.hide();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 由 DataSourceConfigPanel 注入:打开数据源详情后需要直接打开的方法名 */
|
||||||
|
const editingMethodName = inject<ComputedRef<string | undefined>>(
|
||||||
|
'editingDataSourceMethodName',
|
||||||
|
computed(() => ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const methodName = editingMethodName.value;
|
||||||
|
if (!methodName) return;
|
||||||
|
|
||||||
|
const methods: CodeBlockContent[] = props.model[props.name] || [];
|
||||||
|
const index = methods.findIndex((method) => method.name === methodName);
|
||||||
|
if (index === -1) return;
|
||||||
|
|
||||||
|
editMethod(methods[index], index);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -7,10 +7,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="addDialogVisible"
|
v-model:visible="addDialogVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="drawerTitle"
|
:title="drawerTitle"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -254,4 +256,5 @@ const addDialogVisible = defineModel<boolean>('visible', { default: false });
|
|||||||
const { height: editorHeight } = useEditorContentHeight();
|
const { height: editorHeight } = useEditorContentHeight();
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -4,27 +4,31 @@
|
|||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-top"
|
class="border-icon border-icon-top"
|
||||||
:class="{ active: direction === 'Top' }"
|
:class="{ active: direction === 'Top', configured: isConfigured('Top') }"
|
||||||
@click="selectDirection('Top')"
|
@click="selectDirection('Top')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-left"
|
class="border-icon border-icon-left"
|
||||||
:class="{ active: direction === 'Left' }"
|
:class="{ active: direction === 'Left', configured: isConfigured('Left') }"
|
||||||
@click="selectDirection('Left')"
|
@click="selectDirection('Left')"
|
||||||
></div>
|
></div>
|
||||||
<div class="border-icon" :class="{ active: direction === '' }" @click="selectDirection()"></div>
|
<div
|
||||||
|
class="border-icon"
|
||||||
|
:class="{ active: direction === '', configured: isConfigured('') }"
|
||||||
|
@click="selectDirection()"
|
||||||
|
></div>
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-right"
|
class="border-icon border-icon-right"
|
||||||
:class="{ active: direction === 'Right' }"
|
:class="{ active: direction === 'Right', configured: isConfigured('Right') }"
|
||||||
@click="selectDirection('Right')"
|
@click="selectDirection('Right')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-icon-container-row">
|
<div class="border-icon-container-row">
|
||||||
<div
|
<div
|
||||||
class="border-icon border-icon-bottom"
|
class="border-icon border-icon-bottom"
|
||||||
:class="{ active: direction === 'Bottom' }"
|
:class="{ active: direction === 'Bottom', configured: isConfigured('Bottom') }"
|
||||||
@click="selectDirection('Bottom')"
|
@click="selectDirection('Bottom')"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
@ -98,7 +102,7 @@ const emit = defineEmits<{
|
|||||||
addDiffCount: [];
|
addDiffCount: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
model: FormValue;
|
model: FormValue;
|
||||||
lastValues?: FormValue;
|
lastValues?: FormValue;
|
||||||
@ -109,6 +113,11 @@ withDefaults(
|
|||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasValue = (value: unknown) => value !== undefined && value !== null && value !== '';
|
||||||
|
|
||||||
|
const isConfigured = (d: string) =>
|
||||||
|
['Width', 'Color', 'Style'].some((key) => hasValue(props.model?.[`border${d}${key}`]));
|
||||||
|
|
||||||
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
|
||||||
eventData.changeRecords?.forEach((record) => {
|
eventData.changeRecords?.forEach((record) => {
|
||||||
emit('change', record.value, {
|
emit('change', record.value, {
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export { default as stageOverlayService } from './services/stageOverlay';
|
|||||||
export { default as uiService } from './services/ui';
|
export { default as uiService } from './services/ui';
|
||||||
export { default as codeBlockService } from './services/codeBlock';
|
export { default as codeBlockService } from './services/codeBlock';
|
||||||
export { default as depService } from './services/dep';
|
export { default as depService } from './services/dep';
|
||||||
|
export { default as componentListService } from './services/componentList';
|
||||||
|
export { default as keybindingService } from './services/keybinding';
|
||||||
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
export { default as ComponentListPanel } from './layouts/sidebar/ComponentListPanel.vue';
|
||||||
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
export { default as LayerPanel } from './layouts/sidebar/layer/LayerPanel.vue';
|
||||||
export { default as CodeSelect } from './fields/CodeSelect.vue';
|
export { default as CodeSelect } from './fields/CodeSelect.vue';
|
||||||
@ -74,6 +76,8 @@ export { default as CompareForm } from './components/CompareForm.vue';
|
|||||||
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
|
export { default as HistoryListBucket } from './layouts/history-list/Bucket.vue';
|
||||||
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
|
export { default as HistoryListBucketTab } from './layouts/history-list/BucketTab.vue';
|
||||||
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
|
export { default as HistoryDiffDialog } from './layouts/history-list/HistoryDiffDialog.vue';
|
||||||
|
export { confirmHistoryAction } from './layouts/history-list/composables';
|
||||||
|
export { useHistoryRevert } from './layouts/history-list/useHistoryRevert';
|
||||||
export { default as FloatingBox } from './components/FloatingBox.vue';
|
export { default as FloatingBox } from './components/FloatingBox.vue';
|
||||||
export { default as Tree } from './components/Tree.vue';
|
export { default as Tree } from './components/Tree.vue';
|
||||||
export { default as TreeNode } from './components/TreeNode.vue';
|
export { default as TreeNode } from './components/TreeNode.vue';
|
||||||
|
|||||||
@ -25,9 +25,9 @@
|
|||||||
right-class="m-editor-framework-right"
|
right-class="m-editor-framework-right"
|
||||||
:left="hideSidebar ? undefined : columnWidth.left"
|
:left="hideSidebar ? undefined : columnWidth.left"
|
||||||
:right="columnWidth.right"
|
:right="columnWidth.right"
|
||||||
:min-left="hideSidebar ? 0 : MIN_LEFT_COLUMN_WIDTH"
|
:min-left="hideSidebar ? 0 : minLeftColumnWidth"
|
||||||
:min-right="MIN_RIGHT_COLUMN_WIDTH"
|
:min-right="minRightColumnWidth"
|
||||||
:min-center="MIN_CENTER_COLUMN_WIDTH"
|
:min-center="minCenterColumnWidth"
|
||||||
:width="frameworkRect.width"
|
:width="frameworkRect.width"
|
||||||
@change="columnWidthChange"
|
@change="columnWidthChange"
|
||||||
>
|
>
|
||||||
@ -78,9 +78,6 @@ import { getEditorConfig } from '@editor/utils/config';
|
|||||||
import {
|
import {
|
||||||
DEFAULT_LEFT_COLUMN_WIDTH,
|
DEFAULT_LEFT_COLUMN_WIDTH,
|
||||||
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
MIN_CENTER_COLUMN_WIDTH,
|
|
||||||
MIN_LEFT_COLUMN_WIDTH,
|
|
||||||
MIN_RIGHT_COLUMN_WIDTH,
|
|
||||||
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
} from '@editor/utils/const';
|
} from '@editor/utils/const';
|
||||||
|
|
||||||
@ -116,6 +113,10 @@ const showSrc = computed(() => uiService.get('showSrc'));
|
|||||||
|
|
||||||
const columnWidth = computed(() => uiService.get('columnWidth'));
|
const columnWidth = computed(() => uiService.get('columnWidth'));
|
||||||
|
|
||||||
|
const minLeftColumnWidth = computed(() => uiService.get('minLeftColumnWidth'));
|
||||||
|
const minCenterColumnWidth = computed(() => uiService.get('minCenterColumnWidth'));
|
||||||
|
const minRightColumnWidth = computed(() => uiService.get('minRightColumnWidth'));
|
||||||
|
|
||||||
watch(pageLength, () => {
|
watch(pageLength, () => {
|
||||||
splitViewRef.value?.updateWidth();
|
splitViewRef.value?.updateWidth();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -90,7 +90,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
className: 'undo',
|
className: 'undo',
|
||||||
icon: markRaw(Back),
|
icon: markRaw(Back),
|
||||||
tooltip: `后退(${ctrl}+z)`,
|
tooltip: `后退(${ctrl}+z)`,
|
||||||
disabled: () => !historyService.state.canUndo,
|
disabled: () => !historyService.canUndo('page', editorService.get('page')?.id),
|
||||||
handler: () => editorService.undo(),
|
handler: () => editorService.undo(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
@ -100,7 +100,7 @@ const getConfig = (item: MenuItem): (MenuButton | MenuComponent)[] => {
|
|||||||
className: 'redo',
|
className: 'redo',
|
||||||
icon: markRaw(Right),
|
icon: markRaw(Right),
|
||||||
tooltip: `前进(${ctrl}+Shift+z)`,
|
tooltip: `前进(${ctrl}+Shift+z)`,
|
||||||
disabled: () => !historyService.state.canRedo,
|
disabled: () => !historyService.canRedo('page', editorService.get('page')?.id),
|
||||||
handler: () => editorService.redo(),
|
handler: () => editorService.redo(),
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
<div v-if="!buckets.length" class="m-editor-history-list-empty">暂无操作记录</div>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="m-editor-history-list-toolbar">
|
<div v-if="config.showClear !== false" class="m-editor-history-list-toolbar">
|
||||||
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
|
<span class="m-editor-history-list-clear" :title="`清空${config.title}的历史记录`" @click="$emit('clear')"
|
||||||
>清空</span
|
>清空</span
|
||||||
>
|
>
|
||||||
@ -38,7 +38,7 @@ defineOptions({
|
|||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
/**
|
/**
|
||||||
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled),
|
* 该类历史的整体渲染配置(title / prefix / describe* / isStep* / showInitial / gotoEnabled / showClear),
|
||||||
* 由父组件按业务类型注入并整体透传给 Bucket,避免逐项透传多个 props。
|
* 由父组件按业务类型注入并整体透传给 Bucket,避免逐项透传多个 props。
|
||||||
*/
|
*/
|
||||||
config: HistoryBucketConfig<T>;
|
config: HistoryBucketConfig<T>;
|
||||||
|
|||||||
@ -49,6 +49,13 @@
|
|||||||
>{{ sourceLabel(group.source) }}</span
|
>{{ sourceLabel(group.source) }}</span
|
||||||
>
|
>
|
||||||
|
|
||||||
|
<span
|
||||||
|
v-if="!merged && group.operator"
|
||||||
|
class="m-editor-history-list-item-operator"
|
||||||
|
:title="`操作人:${group.operator}`"
|
||||||
|
>{{ group.operator }}</span
|
||||||
|
>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-if="!merged && group.time"
|
v-if="!merged && group.time"
|
||||||
class="m-editor-history-list-item-time"
|
class="m-editor-history-list-item-time"
|
||||||
@ -103,6 +110,9 @@
|
|||||||
:title="`操作途径:${sourceLabel(s.source)}`"
|
:title="`操作途径:${sourceLabel(s.source)}`"
|
||||||
>{{ sourceLabel(s.source) }}</span
|
>{{ sourceLabel(s.source) }}</span
|
||||||
>
|
>
|
||||||
|
<span v-if="s.operator" class="m-editor-history-list-item-operator" :title="`操作人:${s.operator}`">{{
|
||||||
|
s.operator
|
||||||
|
}}</span>
|
||||||
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
|
<span v-if="s.time" class="m-editor-history-list-item-time" :title="s.timeTitle || s.time">{{ s.time }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -21,7 +21,7 @@
|
|||||||
</TMagicRadioGroup>
|
</TMagicRadioGroup>
|
||||||
|
|
||||||
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
|
<TMagicRadioGroup v-model="mode" size="small" class="m-editor-history-diff-dialog-mode">
|
||||||
<TMagicRadioButton value="before">与修改前对比</TMagicRadioButton>
|
<TMagicRadioButton value="before" :disabled="!hasValue">与修改前对比</TMagicRadioButton>
|
||||||
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
|
<TMagicRadioButton value="current" :disabled="!hasCurrent">与当前对比</TMagicRadioButton>
|
||||||
</TMagicRadioGroup>
|
</TMagicRadioGroup>
|
||||||
</div>
|
</div>
|
||||||
@ -43,9 +43,12 @@
|
|||||||
:data-source-type="payload.dataSourceType"
|
:data-source-type="payload.dataSourceType"
|
||||||
:value="rightValue"
|
:value="rightValue"
|
||||||
:last-value="leftValue"
|
:last-value="leftValue"
|
||||||
|
:base-form-state="compareFormState"
|
||||||
:extend-state="extendState"
|
:extend-state="extendState"
|
||||||
:load-config="loadConfig"
|
:load-config="loadConfig"
|
||||||
:self-diff-field-types="selfDiffFieldTypes"
|
:self-diff-field-types="selfDiffFieldTypes"
|
||||||
|
:services="props.services"
|
||||||
|
:size="props.size"
|
||||||
height="70vh"
|
height="70vh"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -75,12 +78,19 @@
|
|||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { isEqual } from 'lodash-es';
|
import { isEqual } from 'lodash-es';
|
||||||
|
|
||||||
import { TMagicButton, TMagicDialog, TMagicRadioButton, TMagicRadioGroup, TMagicTag } from '@tmagic/design';
|
import {
|
||||||
|
type FieldSize,
|
||||||
|
TMagicButton,
|
||||||
|
TMagicDialog,
|
||||||
|
TMagicRadioButton,
|
||||||
|
TMagicRadioGroup,
|
||||||
|
TMagicTag,
|
||||||
|
} from '@tmagic/design';
|
||||||
import type { FormState } from '@tmagic/form';
|
import type { FormState } from '@tmagic/form';
|
||||||
|
|
||||||
import CompareForm from '@editor/components/CompareForm.vue';
|
import CompareForm from '@editor/components/CompareForm.vue';
|
||||||
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
import CodeEditor from '@editor/layouts/CodeEditor.vue';
|
||||||
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload } from '@editor/type';
|
import type { CompareCategory, CompareFormLoadConfig, DiffDialogPayload, Services } from '@editor/type';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MEditorHistoryDiffDialog',
|
name: 'MEditorHistoryDiffDialog',
|
||||||
@ -88,6 +98,8 @@ defineOptions({
|
|||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
/** 编辑器服务集合,由调用方传入(不再通过 inject('services') 获取)。 */
|
||||||
|
services?: Services;
|
||||||
/**
|
/**
|
||||||
* 来自 Editor 顶层的 `extendFormState`,用于扩展 MForm.formState。
|
* 来自 Editor 顶层的 `extendFormState`,用于扩展 MForm.formState。
|
||||||
* 透传给 CompareForm,从而让差异对比时表单 item 中依赖业务上下文的
|
* 透传给 CompareForm,从而让差异对比时表单 item 中依赖业务上下文的
|
||||||
@ -100,9 +112,12 @@ const props = withDefaults(
|
|||||||
*/
|
*/
|
||||||
loadConfig?: CompareFormLoadConfig;
|
loadConfig?: CompareFormLoadConfig;
|
||||||
width?: string;
|
width?: string;
|
||||||
|
/** 差异对比表单内组件的尺寸(透传给 CompareForm 的 `size`),可选 'large' | 'default' | 'small'。 */
|
||||||
|
size?: FieldSize;
|
||||||
isConfirm?: boolean;
|
isConfirm?: boolean;
|
||||||
onConfirm?: () => void;
|
onConfirm?: () => void;
|
||||||
selfDiffFieldTypes?: string[];
|
selfDiffFieldTypes?: string[];
|
||||||
|
compareFormState?: FormState;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
width: '900px',
|
width: '900px',
|
||||||
@ -154,6 +169,23 @@ const dialogTitle = computed(() => (props.onConfirm ? '确认回滚' : '查看
|
|||||||
|
|
||||||
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
|
const hasCurrent = computed(() => payload.value?.currentValue !== undefined && payload.value?.currentValue !== null);
|
||||||
|
|
||||||
|
/** 是否存在该步「修改后的值」:不存在(如仅删除)时「与修改前对比」无意义,置灰禁用。 */
|
||||||
|
const hasValue = computed(() => payload.value?.value !== undefined && payload.value?.value !== null);
|
||||||
|
|
||||||
|
/** 指定模式当前是否可用:before 依赖「修改后的值」,current 依赖「当前值」。 */
|
||||||
|
const isModeAvailable = (m: DiffMode): boolean => (m === 'current' ? hasCurrent.value : hasValue.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 open 时的初始对比模式:
|
||||||
|
* - 调用方通过 payload.mode 指定且该模式可用时,优先使用指定模式;
|
||||||
|
* - 否则没有「修改后的值」但有当前值时(「与修改前对比」不可用),默认进入「与当前对比」;
|
||||||
|
* - 其余情况默认「与修改前对比」。
|
||||||
|
*/
|
||||||
|
const resolveInitialMode = (specified?: DiffMode): DiffMode => {
|
||||||
|
if (specified && isModeAvailable(specified)) return specified;
|
||||||
|
return !hasValue.value && hasCurrent.value ? 'current' : 'before';
|
||||||
|
};
|
||||||
|
|
||||||
/** 左侧(旧/参照)值 */
|
/** 左侧(旧/参照)值 */
|
||||||
const leftValue = computed<Record<string, any>>(() => {
|
const leftValue = computed<Record<string, any>>(() => {
|
||||||
if (!payload.value) return {};
|
if (!payload.value) return {};
|
||||||
@ -207,8 +239,9 @@ const targetText = computed(() => {
|
|||||||
|
|
||||||
const open = (p: DiffDialogPayload) => {
|
const open = (p: DiffDialogPayload) => {
|
||||||
payload.value = p;
|
payload.value = p;
|
||||||
// 每次打开按需重置默认模式:有当前值时优先「与当前对比」更贴近"看现在差什么",否则回退到默认
|
// 每次打开按 payload 重置对比模式(hasValue / hasCurrent 依赖 payload,需先赋值):
|
||||||
mode.value = 'before';
|
// 优先使用 payload.mode 指定的模式;不可用或未指定时按数据自动推断。
|
||||||
|
mode.value = resolveInitialMode(p.mode);
|
||||||
// 默认回到表单对比形态,避免残留上一次选择的源码模式
|
// 默认回到表单对比形态,避免残留上一次选择的源码模式
|
||||||
viewMode.value = 'form';
|
viewMode.value = 'form';
|
||||||
visible.value = true;
|
visible.value = true;
|
||||||
|
|||||||
@ -18,7 +18,9 @@
|
|||||||
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
<TMagicTabs v-model="activeTab" class="m-editor-history-list-tabs">
|
||||||
<component
|
<component
|
||||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
v-bind="tabPaneComponent?.props({ name: 'page', label: `页面 (${pageGroups.length})` }) || {}"
|
v-bind="
|
||||||
|
tabPaneComponent?.props({ name: 'page', label: `${pageName} (${pageGroups.length})`, lazy: true }) || {}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<PageTab
|
<PageTab
|
||||||
:list="pageGroupsDisplay"
|
:list="pageGroupsDisplay"
|
||||||
@ -37,7 +39,13 @@
|
|||||||
<component
|
<component
|
||||||
v-if="!disabledDataSource"
|
v-if="!disabledDataSource"
|
||||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
v-bind="tabPaneComponent?.props({ name: 'data-source', label: `数据源 (${dataSourceGroups.length})` }) || {}"
|
v-bind="
|
||||||
|
tabPaneComponent?.props({
|
||||||
|
name: 'data-source',
|
||||||
|
label: `${dataSourceName} (${dataSourceGroups.length})`,
|
||||||
|
lazy: true,
|
||||||
|
}) || {}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<BucketTab
|
<BucketTab
|
||||||
:config="dataSourceConfig"
|
:config="dataSourceConfig"
|
||||||
@ -55,7 +63,13 @@
|
|||||||
<component
|
<component
|
||||||
v-if="!disabledCodeBlock"
|
v-if="!disabledCodeBlock"
|
||||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
v-bind="tabPaneComponent?.props({ name: 'code-block', label: `代码块 (${codeBlockGroups.length})` }) || {}"
|
v-bind="
|
||||||
|
tabPaneComponent?.props({
|
||||||
|
name: 'code-block',
|
||||||
|
label: `${codeBlockName} (${codeBlockGroups.length})`,
|
||||||
|
lazy: true,
|
||||||
|
}) || {}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<BucketTab
|
<BucketTab
|
||||||
:config="codeBlockConfig"
|
:config="codeBlockConfig"
|
||||||
@ -74,7 +88,7 @@
|
|||||||
v-for="tab in extraTabs"
|
v-for="tab in extraTabs"
|
||||||
:key="tab.name"
|
:key="tab.name"
|
||||||
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
:is="tabPaneComponent?.component || 'el-tab-pane'"
|
||||||
v-bind="tabPaneComponent?.props({ name: tab.name, label: resolveTabLabel(tab) }) || {}"
|
v-bind="tabPaneComponent?.props({ name: tab.name, label: resolveTabLabel(tab), lazy: true }) || {}"
|
||||||
>
|
>
|
||||||
<component :is="tab.component" v-bind="tab.props || {}" v-on="tab.listeners || {}" />
|
<component :is="tab.component" v-bind="tab.props || {}" v-on="tab.listeners || {}" />
|
||||||
</component>
|
</component>
|
||||||
@ -91,17 +105,14 @@
|
|||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
</template>
|
</template>
|
||||||
</TMagicPopover>
|
</TMagicPopover>
|
||||||
|
|
||||||
<HistoryDiffDialog ref="diffDialog" :extend-state="extendFormState" />
|
|
||||||
<HistoryDiffDialog ref="confirmDialog" :is-confirm="true" :extend-state="extendFormState" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
/**
|
||||||
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
* 历史记录面板:在顶部 NavMenu 上点击图标打开 popover,分三个 tab:
|
||||||
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
* - 页面:当前活动页面的历史栈,连续修改同一节点的多步会被合并成一组
|
||||||
* - 数据源:以 dataSource.id 分组,每组内部相邻的连续 update 自动合并
|
* - 数据源:以 dataSource.id 分桶,每条操作记录独立展示
|
||||||
* - 代码块:同上,按 codeBlock.id 分组并合并相邻 update
|
* - 代码块:同上,按 codeBlock.id 分桶,每条操作记录独立展示
|
||||||
*
|
*
|
||||||
* 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示,
|
* 数据通过 historyService 暴露的聚合 API 读取,UI 仅用于只读展示,
|
||||||
* 同时支持点击任意一条记录跳转至该状态:
|
* 同时支持点击任意一条记录跳转至该状态:
|
||||||
@ -118,42 +129,27 @@
|
|||||||
* (通过 title / prefix / describe* / isStepDiffable 注入差异)。
|
* (通过 title / prefix / describe* / isStepDiffable 注入差异)。
|
||||||
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
* 共享的描述生成与折叠状态在 composables.ts 中维护。
|
||||||
*/
|
*/
|
||||||
import { computed, inject, markRaw, ref, useTemplateRef, watch } from 'vue';
|
import { computed, inject, markRaw, ref, watch } from 'vue';
|
||||||
import { Clock, Close } from '@element-plus/icons-vue';
|
import { Clock, Close } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import {
|
import { getDesignConfig, TMagicButton, tMagicMessage, TMagicPopover, TMagicTabs, TMagicTooltip } from '@tmagic/design';
|
||||||
getDesignConfig,
|
|
||||||
TMagicButton,
|
|
||||||
tMagicMessage,
|
|
||||||
tMagicMessageBox,
|
|
||||||
TMagicPopover,
|
|
||||||
TMagicTabs,
|
|
||||||
TMagicTooltip,
|
|
||||||
} from '@tmagic/design';
|
|
||||||
import type { FormState } from '@tmagic/form';
|
import type { FormState } from '@tmagic/form';
|
||||||
|
|
||||||
import MIcon from '@editor/components/Icon.vue';
|
import MIcon from '@editor/components/Icon.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import type {
|
import type {
|
||||||
|
BaseStepValue,
|
||||||
CodeBlockStepValue,
|
CodeBlockStepValue,
|
||||||
DataSourceStepValue,
|
DataSourceStepValue,
|
||||||
DiffDialogPayload,
|
|
||||||
HistoryBucketConfig,
|
HistoryBucketConfig,
|
||||||
HistoryListExtraTab,
|
HistoryListExtraTab,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
|
|
||||||
import BucketTab from './BucketTab.vue';
|
import BucketTab from './BucketTab.vue';
|
||||||
import {
|
import { confirmHistoryAction, describeStep, isSingleDiffStepRevertable } from './composables';
|
||||||
describeCodeBlockGroup,
|
|
||||||
describeCodeBlockStep,
|
|
||||||
describeDataSourceGroup,
|
|
||||||
describeDataSourceStep,
|
|
||||||
isCodeBlockStepRevertable,
|
|
||||||
isDataSourceStepRevertable,
|
|
||||||
useHistoryList,
|
|
||||||
} from './composables';
|
|
||||||
import HistoryDiffDialog from './HistoryDiffDialog.vue';
|
|
||||||
import PageTab from './PageTab.vue';
|
import PageTab from './PageTab.vue';
|
||||||
|
import { useHistoryList } from './useHistoryList';
|
||||||
|
import { useHistoryRevert } from './useHistoryRevert';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'MEditorHistoryListPanel',
|
name: 'MEditorHistoryListPanel',
|
||||||
@ -180,8 +176,9 @@ const extraTabs = inject<HistoryListExtraTab[]>('historyListExtraTabs', []);
|
|||||||
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
|
/** label 支持字符串或函数,函数形式便于展示动态数量等内容。 */
|
||||||
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
|
const resolveTabLabel = (tab: HistoryListExtraTab) => (typeof tab.label === 'function' ? tab.label() : tab.label);
|
||||||
|
|
||||||
|
const services = useServices();
|
||||||
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
|
const { editorService, dataSourceService, codeBlockService, historyService, propsService, stageOverlayService } =
|
||||||
useServices();
|
services;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用,
|
* 数据源 / 代码块功能可被业务方通过 `disabledDataSource` / `disabledCodeBlock` 禁用,
|
||||||
@ -205,6 +202,7 @@ const extendFormState = inject<((_state: FormState) => Record<string, any> | Pro
|
|||||||
'extendFormState',
|
'extendFormState',
|
||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
|
const getPropsPanelFormState = inject<(() => FormState | undefined) | undefined>('getPropsPanelFormState', undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
expanded,
|
expanded,
|
||||||
@ -221,37 +219,42 @@ const {
|
|||||||
* 当前活动页的「加载/初始」标记记录(设置 root 时生成),透传给 PageTab 的底部初始行展示。
|
* 当前活动页的「加载/初始」标记记录(设置 root 时生成),透传给 PageTab 的底部初始行展示。
|
||||||
* 基于 historyService 的 reactive state 派生,活动页切换或标记写入后自动刷新。
|
* 基于 historyService 的 reactive state 派生,活动页切换或标记写入后自动刷新。
|
||||||
*/
|
*/
|
||||||
const pageMarker = computed(() => historyService.getPageMarker());
|
const pageMarker = computed(() => historyService.getMarker('page', editorService.get('page')?.id));
|
||||||
|
|
||||||
/** 数据源 step 仅 update(前后 schema 都存在)时可查看差异。 */
|
/**
|
||||||
const isDataSourceStepDiffable = (step: DataSourceStepValue) =>
|
* 各历史类型的展示名称(页面 / 数据源 / 代码块),由 historyService.state.stepNames 配置,
|
||||||
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
* 业务方可通过 historyService.setStepName / registerStepType 覆盖。基于 reactive state 派生,配置变更后自动刷新。
|
||||||
|
*/
|
||||||
|
const pageName = computed(() => historyService.getStepName('page'));
|
||||||
|
const dataSourceName = computed(() => historyService.getStepName('dataSource'));
|
||||||
|
const codeBlockName = computed(() => historyService.getStepName('codeBlock'));
|
||||||
|
|
||||||
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
|
/** 代码块 step 仅 update(前后 content 都存在)时可查看差异。 */
|
||||||
const isCodeBlockStepDiffable = (step: CodeBlockStepValue) =>
|
const isStepDiffable = (step: BaseStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
||||||
Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据源 / 代码块两类 bucket 历史的整体渲染配置:把 title / prefix 与各自的描述、
|
* 数据源 / 代码块两类 bucket 历史的整体渲染配置:把 title / prefix 与各自的描述、
|
||||||
* 可差异、可回滚判定收敛为单一对象整体注入 BucketTab,组件内部按需读取。
|
* 可差异、可回滚判定收敛为单一对象整体注入 BucketTab,组件内部按需读取。
|
||||||
|
* title / 描述回退名取自可配置的展示名称,故用 computed 使其随 stepNames 变更刷新。
|
||||||
*/
|
*/
|
||||||
const dataSourceConfig: HistoryBucketConfig<DataSourceStepValue> = {
|
// 数据源/代码块不做相邻合并,每组恒为单步,省略 describeGroup,由 toRowGroup 回退到 describeStep。
|
||||||
title: '数据源',
|
const dataSourceConfig = computed<HistoryBucketConfig<DataSourceStepValue>>(() => ({
|
||||||
|
title: dataSourceName.value,
|
||||||
prefix: 'ds',
|
prefix: 'ds',
|
||||||
describeGroup: describeDataSourceGroup,
|
describeStep: (step: DataSourceStepValue): string =>
|
||||||
describeStep: describeDataSourceStep,
|
describeStep(step, (schema) => schema?.title, dataSourceName.value),
|
||||||
isStepDiffable: isDataSourceStepDiffable,
|
isStepDiffable,
|
||||||
isStepRevertable: isDataSourceStepRevertable,
|
isStepRevertable: isSingleDiffStepRevertable,
|
||||||
};
|
}));
|
||||||
|
|
||||||
const codeBlockConfig: HistoryBucketConfig<CodeBlockStepValue> = {
|
const codeBlockConfig = computed<HistoryBucketConfig<CodeBlockStepValue>>(() => ({
|
||||||
title: '代码块',
|
title: codeBlockName.value,
|
||||||
prefix: 'cb',
|
prefix: 'cb',
|
||||||
describeGroup: describeCodeBlockGroup,
|
describeStep: (step: CodeBlockStepValue): string =>
|
||||||
describeStep: describeCodeBlockStep,
|
describeStep(step, (content) => content?.name, codeBlockName.value),
|
||||||
isStepDiffable: isCodeBlockStepDiffable,
|
isStepDiffable,
|
||||||
isStepRevertable: isCodeBlockStepRevertable,
|
isStepRevertable: isSingleDiffStepRevertable,
|
||||||
};
|
}));
|
||||||
|
|
||||||
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
|
/** 把"目标 step 索引"翻译成"目标 cursor"(已应用步骤数量)。 */
|
||||||
const indexToCursor = (index: number) => index + 1;
|
const indexToCursor = (index: number) => index + 1;
|
||||||
@ -267,7 +270,7 @@ const onPageGoto = (index: number) => {
|
|||||||
* - 该 step 涉及的节点都已不存在(如删除记录、被撤销的新增)时给出提示,不做选中。
|
* - 该 step 涉及的节点都已不存在(如删除记录、被撤销的新增)时给出提示,不做选中。
|
||||||
*/
|
*/
|
||||||
const onPageSelect = async (index: number) => {
|
const onPageSelect = async (index: number) => {
|
||||||
const step = historyService.getPageStepList()[index]?.step;
|
const step = historyService.getStepList('page', editorService.get('page')?.id)[index]?.step;
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
const targetId = (step.diff ?? [])
|
const targetId = (step.diff ?? [])
|
||||||
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
|
.map((item) => item.newSchema?.id ?? item.oldSchema?.id)
|
||||||
@ -307,222 +310,13 @@ const onCodeBlockGotoInitial = (id: string | number) => {
|
|||||||
codeBlockService.goto(id, 0);
|
codeBlockService.goto(id, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const diffDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('diffDialog');
|
|
||||||
const confirmDialogRef = useTemplateRef<InstanceType<typeof HistoryDiffDialog>>('confirmDialog');
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 三类历史(页面 / 数据源 / 代码块)差异弹窗入参的构造差异,收敛为一份配置:
|
* 「单步回滚」与「查看差异」的完整逻辑收敛到 useHistoryRevert,面板与业务方共用:
|
||||||
* 仅「分组来源、当前值读取、类型 / 展示名提取」不同,定位 step、校验前后值、组装 payload 的流程共用。
|
* 二者均由 useHistoryRevert 内部按需动态挂载 HistoryDiffDialog,
|
||||||
|
* 业务方亦可直接 import useHistoryRevert(options, services) 调用,无需自行挂载任何弹窗。
|
||||||
*/
|
*/
|
||||||
interface DiffPayloadSource {
|
const { onPageRevert, onDataSourceRevert, onCodeBlockRevert, onPageDiff, onDataSourceDiff, onCodeBlockDiff } =
|
||||||
/** 表单类别:节点 / 数据源 / 代码块。 */
|
useHistoryRevert({ extendState: extendFormState, getPropsPanelFormState }, services);
|
||||||
category: DiffDialogPayload['category'];
|
|
||||||
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
|
|
||||||
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
|
|
||||||
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
|
|
||||||
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
|
|
||||||
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id)。 */
|
|
||||||
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
|
|
||||||
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
|
|
||||||
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构造差异弹窗入参:仅 update(前后值都存在)可对比。
|
|
||||||
* - 页面(无 id):在全部分组中按 index 定位 step,目标 id 取自快照;
|
|
||||||
* - 数据源 / 代码块(带 id):先匹配分组 id 再按 index 定位。
|
|
||||||
* 无可对比内容(多节点 / add / remove)或定位不到时返回 null。
|
|
||||||
*/
|
|
||||||
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
|
|
||||||
for (const group of source.groups()) {
|
|
||||||
if (id !== undefined && group.id !== id) continue;
|
|
||||||
const step = group.steps.find((s) => s.index === index)?.step;
|
|
||||||
if (!step) continue;
|
|
||||||
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
|
|
||||||
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
|
||||||
const targetId = id ?? newSchema.id ?? oldSchema.id;
|
|
||||||
const type = source.resolveType?.(newSchema, oldSchema);
|
|
||||||
return {
|
|
||||||
category: source.category,
|
|
||||||
...(type !== undefined ? { type } : {}),
|
|
||||||
lastValue: oldSchema,
|
|
||||||
value: newSchema,
|
|
||||||
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
|
|
||||||
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
|
|
||||||
id: targetId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
|
|
||||||
buildDiffPayload(
|
|
||||||
{
|
|
||||||
category: 'node',
|
|
||||||
groups: () => historyService.getPageHistoryGroups(),
|
|
||||||
getCurrent: (id) => editorService.getNodeById(id) as Record<string, any> | null,
|
|
||||||
resolveType: (n, o) => n.type || o.type || '',
|
|
||||||
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
|
|
||||||
},
|
|
||||||
index,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
|
||||||
buildDiffPayload(
|
|
||||||
{
|
|
||||||
category: 'data-source',
|
|
||||||
groups: () => historyService.getDataSourceHistoryGroups(),
|
|
||||||
getCurrent: (id) => dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
|
|
||||||
resolveType: (n, o) => n.type || o.type || 'base',
|
|
||||||
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
|
|
||||||
},
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
|
||||||
buildDiffPayload(
|
|
||||||
{
|
|
||||||
category: 'code-block',
|
|
||||||
groups: () => historyService.getCodeBlockHistoryGroups(),
|
|
||||||
getCurrent: (id) => codeBlockService.getCodeContentById(id) as Record<string, any> | null,
|
|
||||||
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
|
|
||||||
},
|
|
||||||
index,
|
|
||||||
id,
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPageDiff = (index: number) => {
|
|
||||||
const payload = buildPageDiffPayload(index);
|
|
||||||
if (payload) diffDialogRef.value?.open(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDataSourceDiff = (id: string | number, index: number) => {
|
|
||||||
const payload = buildDataSourceDiffPayload(id, index);
|
|
||||||
if (payload) diffDialogRef.value?.open(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCodeBlockDiff = (id: string | number, index: number) => {
|
|
||||||
const payload = buildCodeBlockDiffPayload(id, index);
|
|
||||||
if (payload) diffDialogRef.value?.open(payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「回滚」统一入口:把目标历史步骤的修改作为一次新操作反向应用(类 git revert),
|
|
||||||
* 不破坏原有栈结构。各 service 内部完成反向 + 入栈,并自带描述用于面板展示。
|
|
||||||
*
|
|
||||||
* 交互:
|
|
||||||
* - 可差异对比的步骤(单节点 / 单实体 update):弹出差异弹窗供用户确认,点「确定回滚」再执行;
|
|
||||||
* - 无法对比的步骤(add / remove / 多节点更新,payload 为 null):弹出普通二次确认框,确认后执行。
|
|
||||||
*
|
|
||||||
* 页面 / 数据源 / 代码块三类回滚仅「差异入参构造」与「实际 revert 调用」不同,
|
|
||||||
* 由调用方分别传入 payload 与 revert,公共的弹窗 / 确认流程在此收敛。
|
|
||||||
*/
|
|
||||||
const runRevert = (payload: DiffDialogPayload | null): Promise<boolean> => {
|
|
||||||
if (payload && confirmDialogRef.value) {
|
|
||||||
return confirmDialogRef.value.confirm(payload);
|
|
||||||
}
|
|
||||||
return confirmRevert();
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 回滚前置校验:若该历史步骤回滚所依赖的目标数据已被删除,则无法回滚。
|
|
||||||
* - update(把旧值写回):被修改的目标必须仍存在;
|
|
||||||
* - 页面 remove(还原被删节点):被删节点的原父容器必须仍存在,否则无处插回;
|
|
||||||
* add(回滚即删除)即使目标已不在,也已达成「删除」目的,不视为失败。
|
|
||||||
*
|
|
||||||
* 命中时弹出「回滚失败」提示并返回 true,调用方据此中止本次回滚。
|
|
||||||
*/
|
|
||||||
const isPageRevertTargetMissing = (index: number): boolean => {
|
|
||||||
const step = historyService.getPageStepList()[index]?.step;
|
|
||||||
if (!step) return false;
|
|
||||||
if (step.opType === 'update') {
|
|
||||||
return (step.diff ?? []).some((item) => {
|
|
||||||
const id = item.newSchema?.id ?? item.oldSchema?.id;
|
|
||||||
return id !== undefined && !editorService.getNodeById(id, false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (step.opType === 'remove') {
|
|
||||||
return (step.diff ?? []).some(
|
|
||||||
(item) => item.parentId !== undefined && !editorService.getNodeById(item.parentId, false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
|
|
||||||
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
|
|
||||||
const step = historyService.getDataSourceStepList(id)[index]?.step;
|
|
||||||
return Boolean(step && step.opType === 'update' && !dataSourceService.getDataSourceById(`${id}`));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
|
|
||||||
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
|
|
||||||
const step = historyService.getCodeBlockStepList(id)[index]?.step;
|
|
||||||
return Boolean(step && step.opType === 'update' && !codeBlockService.getCodeContentById(id));
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 目标数据已被删除、无法回滚时的统一提示。 */
|
|
||||||
const showRevertTargetMissing = () => {
|
|
||||||
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPageRevert = (index: number) => {
|
|
||||||
if (isPageRevertTargetMissing(index)) {
|
|
||||||
showRevertTargetMissing();
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return runRevert(buildPageDiffPayload(index)).then((result) => (result ? editorService.revertPageStep(index) : null));
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDataSourceRevert = (id: string | number, index: number) => {
|
|
||||||
if (isDataSourceRevertTargetMissing(id, index)) {
|
|
||||||
showRevertTargetMissing();
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
|
|
||||||
result ? dataSourceService.revert(id, index) : null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCodeBlockRevert = (id: string | number, index: number) => {
|
|
||||||
if (isCodeBlockRevertTargetMissing(id, index)) {
|
|
||||||
showRevertTargetMissing();
|
|
||||||
return Promise.resolve(null);
|
|
||||||
}
|
|
||||||
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
|
|
||||||
result ? codeBlockService.revert(id, index) : null,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 「回滚」二次确认:新增 / 删除 / 多节点更新等无法做差异对比的步骤,
|
|
||||||
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
|
||||||
* 用户取消时返回 false,调用方据此中止回滚。
|
|
||||||
*/
|
|
||||||
const confirmRevert = (): Promise<boolean> =>
|
|
||||||
confirmDialog(
|
|
||||||
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 通用二次确认弹窗:清空历史 / 无法差异对比的回滚等会改变状态的操作,先弹出确认框,
|
|
||||||
* 用户点击「确定」返回 true,取消(confirm reject)时返回 false 并静默忽略。
|
|
||||||
*/
|
|
||||||
const confirmDialog = async (message: string): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
await tMagicMessageBox.confirm(message, '提示', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
|
||||||
} catch (e) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
* 把内存中(已清空对应类别后的)历史状态重新写回 IndexedDB,
|
||||||
@ -540,27 +334,33 @@ const syncIndexedDB = async () => {
|
|||||||
|
|
||||||
const onPageClear = async () => {
|
const onPageClear = async () => {
|
||||||
if (
|
if (
|
||||||
await confirmDialog('确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
|
await confirmHistoryAction(
|
||||||
|
'确定清空当前页面的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
historyService.clearPage();
|
historyService.clear('page', editorService.get('page')?.id);
|
||||||
await syncIndexedDB();
|
await syncIndexedDB();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDataSourceClear = async () => {
|
const onDataSourceClear = async () => {
|
||||||
if (
|
if (
|
||||||
await confirmDialog('确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
|
await confirmHistoryAction(
|
||||||
|
'确定清空数据源的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
historyService.clearDataSource();
|
historyService.clear('dataSource');
|
||||||
await syncIndexedDB();
|
await syncIndexedDB();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCodeBlockClear = async () => {
|
const onCodeBlockClear = async () => {
|
||||||
if (
|
if (
|
||||||
await confirmDialog('确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。')
|
await confirmHistoryAction(
|
||||||
|
'确定清空代码块的历史记录吗?清空后将无法撤销/重做之前的操作,本地保存的记录也会一并删除。',
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
historyService.clearCodeBlock();
|
historyService.clear('codeBlock');
|
||||||
await syncIndexedDB();
|
await syncIndexedDB();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0(未修改的初始状态)">#0</span>
|
<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-op op-initial">初始</span>
|
||||||
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
|
||||||
|
<span v-if="saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
|
||||||
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
|
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
|
||||||
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
|
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
|
||||||
</span>
|
</span>
|
||||||
@ -49,6 +50,8 @@ const props = withDefaults(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
|
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
|
||||||
|
/** 基线(初始状态)是否为最近一次保存点:仅页面栈的 `initial` 基线 step 会被 markSaved 标记。 */
|
||||||
|
const saved = computed(() => Boolean(props.marker?.saved));
|
||||||
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
|
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
|
||||||
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
|
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
|
||||||
const rowTitle = computed(() => {
|
const rowTitle = computed(() => {
|
||||||
|
|||||||
@ -34,7 +34,7 @@ import { computed } from 'vue';
|
|||||||
|
|
||||||
import { TMagicScrollbar } from '@tmagic/design';
|
import { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
import type { HistoryRowDescriptor, PageHistoryGroup, StepValue } from '@editor/type';
|
import type { HistoryGroup, HistoryRowDescriptor, StepValue } from '@editor/type';
|
||||||
|
|
||||||
import type { HistoryRowGroup } from './composables';
|
import type { HistoryRowGroup } from './composables';
|
||||||
import {
|
import {
|
||||||
@ -53,7 +53,7 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
/** 当前活动页面的历史分组列表,已按时间倒序排好(最新一组在最前)。空数组时显示空态。 */
|
||||||
list: PageHistoryGroup[];
|
list: HistoryGroup<StepValue>[];
|
||||||
/**
|
/**
|
||||||
* 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
|
* 共享的折叠状态表(key -> 是否展开,缺省或 true 为展开、false 为收起),由顶层 panel 统一维护。
|
||||||
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
* 本 tab 使用 `pg-${组内首步 index}` 作为 key——以稳定的 step 索引而非展示位置标识分组,
|
||||||
@ -105,9 +105,9 @@ const descriptor: HistoryRowDescriptor<StepValue> = {
|
|||||||
isStepRevertable: isPageStepRevertable,
|
isStepRevertable: isPageStepRevertable,
|
||||||
};
|
};
|
||||||
|
|
||||||
const rowKey = (group: PageHistoryGroup) => `pg-${group.steps[0]?.index}`;
|
const rowKey = (group: HistoryGroup<StepValue>) => `pg-${group.steps[0]?.index}`;
|
||||||
|
|
||||||
const toRow = (group: PageHistoryGroup): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
|
const toRow = (group: HistoryGroup<StepValue>): HistoryRowGroup => toRowGroup(group, rowKey(group), descriptor);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
|
* 是否处于"初始状态"——即对应页面历史栈 cursor===0:
|
||||||
|
|||||||
@ -1,18 +1,12 @@
|
|||||||
import { computed, reactive } from 'vue';
|
import { tMagicMessageBox } from '@tmagic/design';
|
||||||
|
|
||||||
import { datetimeFormatter } from '@tmagic/form';
|
import { datetimeFormatter } from '@tmagic/form';
|
||||||
|
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
|
||||||
import type {
|
import type {
|
||||||
BaseStepValue,
|
BaseStepValue,
|
||||||
CodeBlockHistoryGroup,
|
HistoryGroup,
|
||||||
CodeBlockStepValue,
|
|
||||||
DataSourceHistoryGroup,
|
|
||||||
DataSourceStepValue,
|
|
||||||
HistoryOpSource,
|
HistoryOpSource,
|
||||||
HistoryOpType,
|
HistoryOpType,
|
||||||
HistoryRowDescriptor,
|
HistoryRowDescriptor,
|
||||||
PageHistoryGroup,
|
|
||||||
StepValue,
|
StepValue,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
|
|
||||||
@ -31,51 +25,49 @@ export interface HistoryBucketGroup<T extends BaseStepValue = BaseStepValue> {
|
|||||||
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
|
steps: { index: number; applied: boolean; isCurrent?: boolean; step: T }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
|
/**
|
||||||
export interface HistoryRowStep {
|
* GroupRow 的组头部与子步共用的展示字段(均由 {@link toRowGroup} 预先派生)。
|
||||||
/** 该子步在所属栈中的稳定索引。 */
|
* 抽出公共部分避免 {@link HistoryRowStep} / {@link HistoryRowGroup} 重复声明,
|
||||||
index: number;
|
* 也便于消费方用本类型收窄「组头 / 子步」通用渲染逻辑的入参。
|
||||||
|
*/
|
||||||
|
export interface HistoryRowDisplay {
|
||||||
/** 是否已应用(false 表示已被 undo,UI 灰态)。 */
|
/** 是否已应用(false 表示已被 undo,UI 灰态)。 */
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
/** 是否为当前所在步骤。 */
|
/** 是否为当前所在步骤 / 分组。 */
|
||||||
isCurrent?: boolean;
|
isCurrent: boolean;
|
||||||
/** 是否为最近一次保存的记录。 */
|
/** 描述文案。 */
|
||||||
saved?: boolean;
|
|
||||||
/** 子步描述文案。 */
|
|
||||||
desc: string;
|
desc: string;
|
||||||
/** 是否可查看差异。 */
|
|
||||||
diffable?: boolean;
|
|
||||||
/** 是否可回滚。 */
|
|
||||||
revertable?: boolean;
|
|
||||||
/** 操作途径。 */
|
/** 操作途径。 */
|
||||||
source?: HistoryOpSource;
|
source?: HistoryOpSource;
|
||||||
|
/** 操作人。 */
|
||||||
|
operator?: string;
|
||||||
/** 时间文案。 */
|
/** 时间文案。 */
|
||||||
time?: string;
|
time?: string;
|
||||||
/** 时间的完整 title 提示。 */
|
/** 时间的完整 title 提示。 */
|
||||||
timeTitle?: string;
|
timeTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GroupRow 渲染所需的单个子步视图模型(已由 {@link toRowGroup} 预先派生,组件内部不再触碰原始 step)。 */
|
||||||
|
export interface HistoryRowStep extends HistoryRowDisplay {
|
||||||
|
/** 该子步在所属栈中的稳定索引。 */
|
||||||
|
index: number;
|
||||||
|
/** 是否为最近一次保存的记录。 */
|
||||||
|
saved?: boolean;
|
||||||
|
/** 是否可查看差异。 */
|
||||||
|
diffable?: boolean;
|
||||||
|
/** 是否可回滚。 */
|
||||||
|
revertable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GroupRow 渲染所需的整组视图模型(由 {@link toRowGroup} 统一派生)。
|
* GroupRow 渲染所需的整组视图模型(由 {@link toRowGroup} 统一派生)。
|
||||||
* 把原先 GroupRow 上十多个扁平 props 收敛为单一对象,header 信息与子步列表一并携带。
|
* 把原先 GroupRow 上十多个扁平 props 收敛为单一对象,header 信息与子步列表一并携带。
|
||||||
*/
|
*/
|
||||||
export interface HistoryRowGroup {
|
export interface HistoryRowGroup extends HistoryRowDisplay {
|
||||||
/** 分组的稳定 key,作为 toggle 事件 payload 与折叠状态的索引。 */
|
/** 分组的稳定 key,作为 toggle 事件 payload 与折叠状态的索引。 */
|
||||||
key: string;
|
key: string;
|
||||||
/** 组内最后一步是否已应用。 */
|
|
||||||
applied: boolean;
|
|
||||||
/** 是否为当前所在分组。 */
|
|
||||||
isCurrent: boolean;
|
|
||||||
/** 操作类型,用于徽标颜色与文案。 */
|
/** 操作类型,用于徽标颜色与文案。 */
|
||||||
opType: HistoryOpType;
|
opType: HistoryOpType;
|
||||||
/** 组整体描述文案。 */
|
|
||||||
desc: string;
|
|
||||||
/** 组的操作途径(取组内最近一步)。 */
|
|
||||||
source?: HistoryOpSource;
|
|
||||||
/** 组头部时间文案(取组内最近一步)。 */
|
|
||||||
time?: string;
|
|
||||||
/** 组头部时间的完整 title 提示。 */
|
|
||||||
timeTitle?: string;
|
|
||||||
/** 子步列表(时间正序);其长度即合并步数,length > 1 即为合并组。 */
|
/** 子步列表(时间正序);其长度即合并步数,length > 1 即为合并组。 */
|
||||||
subSteps: HistoryRowStep[];
|
subSteps: HistoryRowStep[];
|
||||||
}
|
}
|
||||||
@ -83,63 +75,6 @@ export interface HistoryRowGroup {
|
|||||||
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
|
/** 合并组默认展开;仅当 expanded[key] === false 时为收起。 */
|
||||||
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
|
export const isHistoryGroupExpanded = (expanded: Record<string, boolean>, key: string) => expanded[key] !== false;
|
||||||
|
|
||||||
/**
|
|
||||||
* 历史记录面板共享逻辑:
|
|
||||||
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
|
||||||
* - 提供折叠状态管理;
|
|
||||||
* - 提供操作描述文案生成器。
|
|
||||||
*
|
|
||||||
* 所有数据基于 historyService 的 reactive state 派生,自动跟随历史变化刷新。
|
|
||||||
*/
|
|
||||||
export const useHistoryList = () => {
|
|
||||||
const { historyService } = useServices();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 折叠状态: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] === false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const pageGroups = computed(() => historyService.getPageHistoryGroups());
|
|
||||||
const dataSourceGroups = computed(() => historyService.getDataSourceHistoryGroups());
|
|
||||||
const codeBlockGroups = computed(() => historyService.getCodeBlockHistoryGroups());
|
|
||||||
|
|
||||||
/** 页面 tab 倒序展示(最新一组在最上面)。 */
|
|
||||||
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把按时间正序的 group 列表,再按 id 聚拢成 bucket(同 id 的所有分组放一起)。
|
|
||||||
* 每个 bucket 内部仍然按时间倒序展示(最近的操作最先看到)。
|
|
||||||
*/
|
|
||||||
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
|
|
||||||
const map = new Map<string | number, G[]>();
|
|
||||||
groups.forEach((g) => {
|
|
||||||
const list = map.get(g.id) ?? [];
|
|
||||||
list.push(g);
|
|
||||||
map.set(g.id, list);
|
|
||||||
});
|
|
||||||
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
|
|
||||||
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
|
|
||||||
|
|
||||||
return {
|
|
||||||
expanded,
|
|
||||||
toggleGroup,
|
|
||||||
pageGroups,
|
|
||||||
dataSourceGroups,
|
|
||||||
codeBlockGroups,
|
|
||||||
pageGroupsDisplay,
|
|
||||||
dataSourceGroupsByTarget,
|
|
||||||
codeBlockGroupsByTarget,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 历史面板的时间展示:
|
* 历史面板的时间展示:
|
||||||
* - 当天的记录只显示 `HH:mm:ss`;
|
* - 当天的记录只显示 `HH:mm:ss`;
|
||||||
@ -207,7 +142,11 @@ export const sourceLabel = (source: HistoryOpSource = 'unknown'): string => {
|
|||||||
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
|
export const groupSource = (group: { steps: { step: { source?: HistoryOpSource } }[] }): HistoryOpSource | undefined =>
|
||||||
group.steps[group.steps.length - 1]?.step.source;
|
group.steps[group.steps.length - 1]?.step.source;
|
||||||
|
|
||||||
/** {@link toRowGroup} 接受的最小分组结构,PageHistoryGroup 与 HistoryBucketGroup 均满足。 */
|
/** 取一组历史步骤里最后一步(最近一次)的操作人,用于组头部展示。 */
|
||||||
|
export const groupOperator = (group: { steps: { step: { operator?: string } }[] }): string | undefined =>
|
||||||
|
group.steps[group.steps.length - 1]?.step.operator;
|
||||||
|
|
||||||
|
/** {@link toRowGroup} 接受的最小分组结构 */
|
||||||
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
|
interface RowGroupInput<T extends BaseStepValue = BaseStepValue> {
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
isCurrent?: boolean;
|
isCurrent?: boolean;
|
||||||
@ -227,24 +166,28 @@ export const toRowGroup = <T extends BaseStepValue = BaseStepValue>(
|
|||||||
): HistoryRowGroup => {
|
): HistoryRowGroup => {
|
||||||
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
|
const { describeGroup, describeStep, isStepDiffable, isStepRevertable } = descriptor;
|
||||||
const timestamp = groupTimestamp(group);
|
const timestamp = groupTimestamp(group);
|
||||||
|
// 无 describeGroup 时回退到组内最后一步的 describeStep:数据源/代码块不做相邻合并,每组恒为单步,二者等价。
|
||||||
|
const lastStep = group.steps[group.steps.length - 1]?.step;
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
applied: group.applied,
|
applied: group.applied,
|
||||||
isCurrent: Boolean(group.isCurrent),
|
isCurrent: Boolean(group.isCurrent),
|
||||||
opType: group.opType,
|
opType: group.opType,
|
||||||
desc: describeGroup(group),
|
desc: describeGroup ? describeGroup(group) : describeStep(lastStep),
|
||||||
source: groupSource(group),
|
source: groupSource(group),
|
||||||
|
operator: groupOperator(group),
|
||||||
time: formatHistoryTime(timestamp),
|
time: formatHistoryTime(timestamp),
|
||||||
timeTitle: formatHistoryFullTime(timestamp),
|
timeTitle: formatHistoryFullTime(timestamp),
|
||||||
subSteps: group.steps.map((s) => ({
|
subSteps: group.steps.map((s) => ({
|
||||||
index: s.index,
|
index: s.index,
|
||||||
applied: s.applied,
|
applied: s.applied,
|
||||||
isCurrent: s.isCurrent,
|
isCurrent: Boolean(s.isCurrent),
|
||||||
saved: s.step.saved,
|
saved: s.step.saved,
|
||||||
desc: describeStep(s.step),
|
desc: describeStep(s.step),
|
||||||
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
|
diffable: isStepDiffable ? isStepDiffable(s.step) : false,
|
||||||
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
|
revertable: s.applied && (isStepRevertable ? isStepRevertable(s.step) : true),
|
||||||
source: s.step.source,
|
source: s.step.source,
|
||||||
|
operator: s.step.operator,
|
||||||
time: formatHistoryTime(s.step.timestamp),
|
time: formatHistoryTime(s.step.timestamp),
|
||||||
timeTitle: formatHistoryFullTime(s.step.timestamp),
|
timeTitle: formatHistoryFullTime(s.step.timestamp),
|
||||||
})),
|
})),
|
||||||
@ -273,102 +216,55 @@ const pickLastDescription = (descs: (string | undefined)[]): string | undefined
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const describePageStep = (step: StepValue) => {
|
/**
|
||||||
|
* 页面 / 数据源 / 代码块三类历史共用的单步描述核心。
|
||||||
|
* 各类型只在「取展示名」与「实体单位名」上有差异,通过参数注入,文案模板完全一致:
|
||||||
|
* - 新增 / 删除:单实体展示「label」,多实体(仅页面可能出现)退化为「N 个X」;
|
||||||
|
* - 修改:展示「label · propPath」,无 diff 时兜底「X」,多实体退化为「N 个X」。
|
||||||
|
* 操作类型(新增 / 删除 / 修改)已由列表行的 op 徽标单独展示,故描述文案不再重复动词。
|
||||||
|
* 展示 id 统一取 schema.id;调用方显式传入的 historyDescription 永远优先。
|
||||||
|
*/
|
||||||
|
export const describeStep = <T>(
|
||||||
|
step: BaseStepValue<T>,
|
||||||
|
getLabel: (_schema?: T) => string | number | undefined,
|
||||||
|
unit: string,
|
||||||
|
): string => {
|
||||||
if (step.historyDescription) return step.historyDescription;
|
if (step.historyDescription) return step.historyDescription;
|
||||||
const { opType } = step;
|
|
||||||
const items = step.diff ?? [];
|
const items = step.diff ?? [];
|
||||||
if (opType === 'add') {
|
const label = (schema?: T) => labelWithId(getLabel(schema), (schema as { id?: string | number } | undefined)?.id);
|
||||||
const count = items.length;
|
|
||||||
|
if (step.opType === 'add') {
|
||||||
const node = items[0]?.newSchema;
|
const node = items[0]?.newSchema;
|
||||||
return `新增 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
|
return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`;
|
||||||
}
|
}
|
||||||
if (opType === 'remove') {
|
if (step.opType === 'remove') {
|
||||||
const count = items.length;
|
|
||||||
const node = items[0]?.oldSchema;
|
const node = items[0]?.oldSchema;
|
||||||
return `删除 ${count} 个节点${count === 1 && node ? `(${labelWithId(nameOf(node), node.id)})` : ''}`;
|
return items.length === 1 && node ? label(node) : `${items.length} 个${unit}`;
|
||||||
}
|
}
|
||||||
if (!items.length) return '修改节点';
|
if (!items.length) return unit;
|
||||||
if (items.length === 1) {
|
if (items.length === 1) {
|
||||||
const { newSchema, changeRecords } = items[0];
|
const { newSchema, oldSchema, changeRecords } = items[0];
|
||||||
const propPath = changeRecords?.[0]?.propPath;
|
const propPath = changeRecords?.map((changeRecord) => changeRecord.propPath).join(',');
|
||||||
const target = labelWithId(nameOf(newSchema), newSchema?.id);
|
const target = label(newSchema ?? oldSchema);
|
||||||
return `修改 ${target}${propPath ? ` · ${propPath}` : ''}`;
|
return propPath ? `${target} · ${propPath}` : target;
|
||||||
}
|
}
|
||||||
return `修改 ${items.length} 个节点`;
|
return `${items.length} 个${unit}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const describePageStep = (step: StepValue): string => describeStep(step, (node) => nameOf(node), '节点');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 合并组的展示文案:
|
* 合并组的展示文案:
|
||||||
* - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确);
|
* - 若组内任一步显式提供了 historyDescription:取最后一条非空 historyDescription(最近一次的描述更准确);
|
||||||
* - 单步组:复用 describePageStep;
|
* - 单步组:复用 describePageStep;
|
||||||
* - 多步组(连续修改同一节点):展示节点名 + 涉及的前几个 propPath。
|
* - 多步组(连续修改同一节点):展示节点名 + 涉及的前几个 propPath。
|
||||||
*/
|
*/
|
||||||
export const describePageGroup = (group: PageHistoryGroup) => {
|
export const describePageGroup = (group: HistoryGroup<StepValue>) => {
|
||||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
||||||
if (lastDesc) return lastDesc;
|
if (lastDesc) return lastDesc;
|
||||||
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
|
if (group.steps.length === 1) return describePageStep(group.steps[0].step);
|
||||||
const paths = new Set<string>();
|
|
||||||
group.steps.forEach((s) => {
|
|
||||||
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
|
||||||
});
|
|
||||||
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
|
||||||
const target = labelWithId(
|
|
||||||
group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'),
|
|
||||||
group.targetId,
|
|
||||||
);
|
|
||||||
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const describeDataSourceStep = (step: DataSourceStepValue) => {
|
return labelWithId(group.targetName ?? (group.targetId !== undefined ? `${group.targetId}` : '节点'), group.targetId);
|
||||||
if (step.historyDescription) return step.historyDescription;
|
|
||||||
const { oldSchema: oldSchema, newSchema: newSchema, changeRecords } = step.diff?.[0] ?? {};
|
|
||||||
if (!oldSchema && newSchema) return `创建 ${labelWithId(newSchema.title, newSchema.id ?? step.id)}`;
|
|
||||||
if (!newSchema && oldSchema) return `删除 ${labelWithId(oldSchema.title, oldSchema.id ?? step.id)}`;
|
|
||||||
const propPath = changeRecords?.[0]?.propPath;
|
|
||||||
const title = labelWithId(newSchema?.title || oldSchema?.title, step.id);
|
|
||||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const describeDataSourceGroup = (group: DataSourceHistoryGroup) => {
|
|
||||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
|
||||||
if (lastDesc) return lastDesc;
|
|
||||||
if (group.steps.length === 1) return describeDataSourceStep(group.steps[0].step);
|
|
||||||
const paths = new Set<string>();
|
|
||||||
group.steps.forEach((s) => {
|
|
||||||
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
|
||||||
});
|
|
||||||
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
|
||||||
const rawTitle =
|
|
||||||
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.title ||
|
|
||||||
group.steps[0].step.diff?.[0]?.oldSchema?.title;
|
|
||||||
const target = labelWithId(rawTitle, group.id);
|
|
||||||
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const describeCodeBlockStep = (step: CodeBlockStepValue) => {
|
|
||||||
if (step.historyDescription) return step.historyDescription;
|
|
||||||
const { oldSchema: oldContent, newSchema: newContent, changeRecords } = step.diff?.[0] ?? {};
|
|
||||||
if (!oldContent && newContent) return `创建 ${labelWithId(newContent.name, newContent.id ?? step.id)}`;
|
|
||||||
if (!newContent && oldContent) return `删除 ${labelWithId(oldContent.name, oldContent.id ?? step.id)}`;
|
|
||||||
const propPath = changeRecords?.[0]?.propPath;
|
|
||||||
const title = labelWithId(newContent?.name || oldContent?.name, step.id);
|
|
||||||
return propPath ? `修改 ${title} · ${propPath}` : `修改 ${title}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const describeCodeBlockGroup = (group: CodeBlockHistoryGroup) => {
|
|
||||||
const lastDesc = pickLastDescription(group.steps.map((s) => s.step.historyDescription));
|
|
||||||
if (lastDesc) return lastDesc;
|
|
||||||
if (group.steps.length === 1) return describeCodeBlockStep(group.steps[0].step);
|
|
||||||
const paths = new Set<string>();
|
|
||||||
group.steps.forEach((s) => {
|
|
||||||
s.step.diff?.[0]?.changeRecords?.forEach((r) => r.propPath && paths.add(r.propPath));
|
|
||||||
});
|
|
||||||
const pathList = Array.from(paths).slice(0, 3).join(', ');
|
|
||||||
const rawName =
|
|
||||||
group.steps[group.steps.length - 1].step.diff?.[0]?.newSchema?.name ||
|
|
||||||
group.steps[0].step.diff?.[0]?.oldSchema?.name;
|
|
||||||
const target = labelWithId(rawName, group.id);
|
|
||||||
return pathList ? `修改 ${target} · ${pathList}${paths.size > 3 ? '…' : ''}` : `修改 ${target}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -385,23 +281,29 @@ export const isPageStepRevertable = (step: StepValue): boolean => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据源 step 是否支持「回滚」:
|
* 单 diff 项历史(数据源 / 代码块)是否支持「回滚」:
|
||||||
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
|
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
|
||||||
* - 更新(前后 schema 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
* - 更新(前后内容都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
||||||
*/
|
*/
|
||||||
export const isDataSourceStepRevertable = (step: DataSourceStepValue): boolean => {
|
export const isSingleDiffStepRevertable = (step: BaseStepValue): boolean => {
|
||||||
const item = step.diff?.[0];
|
const item = step.diff?.[0];
|
||||||
if (!item?.oldSchema || !item?.newSchema) return true;
|
if (!item?.oldSchema || !item?.newSchema) return true;
|
||||||
return Boolean(item.changeRecords?.length);
|
return Boolean(item.changeRecords?.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代码块 step 是否支持「回滚」:
|
* 通用二次确认弹窗:清空历史 / 无法差异对比的回滚等会改变状态的操作,先弹出确认框,
|
||||||
* - 新增(无 oldSchema)/ 删除(无 newSchema):不依赖 changeRecords,始终可回滚;
|
* 用户点击「确定」返回 true,取消(confirm reject)时返回 false 并静默忽略。
|
||||||
* - 更新(前后 content 都存在):必须有 changeRecords 才支持局部反向 patch,否则不支持回滚。
|
|
||||||
*/
|
*/
|
||||||
export const isCodeBlockStepRevertable = (step: CodeBlockStepValue): boolean => {
|
export const confirmHistoryAction = async (message: string): Promise<boolean> => {
|
||||||
const item = step.diff?.[0];
|
try {
|
||||||
if (!item?.oldSchema || !item?.newSchema) return true;
|
await tMagicMessageBox.confirm(message, '提示', {
|
||||||
return Boolean(item.changeRecords?.length);
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal file
60
packages/editor/src/layouts/history-list/useHistoryList.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { computed, reactive } from 'vue';
|
||||||
|
|
||||||
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录面板共享逻辑:
|
||||||
|
* - 暴露三类历史的聚合数据(页面 / 数据源 / 代码块);
|
||||||
|
* - 提供折叠状态管理;
|
||||||
|
* - 提供操作描述文案生成器。
|
||||||
|
*
|
||||||
|
* 所有数据基于 historyService 的 reactive state 派生,自动跟随历史变化刷新。
|
||||||
|
*/
|
||||||
|
export const useHistoryList = () => {
|
||||||
|
const { editorService, historyService } = useServices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 折叠状态: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] === false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pageGroups = computed(() => historyService.getHistoryGroups('page', editorService.get('page')?.id));
|
||||||
|
const dataSourceGroups = computed(() => historyService.getHistoryGroups('dataSource'));
|
||||||
|
const codeBlockGroups = computed(() => historyService.getHistoryGroups('codeBlock'));
|
||||||
|
|
||||||
|
/** 页面 tab 倒序展示(最新一组在最上面)。 */
|
||||||
|
const pageGroupsDisplay = computed(() => pageGroups.value.slice().reverse());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把按时间正序的 group 列表,再按 id 聚拢成 bucket(同 id 的所有分组放一起)。
|
||||||
|
* 每个 bucket 内部仍然按时间倒序展示(最近的操作最先看到)。
|
||||||
|
*/
|
||||||
|
const groupByTarget = <G extends { id: string | number }>(groups: G[]) => {
|
||||||
|
const map = new Map<string | number, G[]>();
|
||||||
|
groups.forEach((g) => {
|
||||||
|
const list = map.get(g.id) ?? [];
|
||||||
|
list.push(g);
|
||||||
|
map.set(g.id, list);
|
||||||
|
});
|
||||||
|
return Array.from(map.entries()).map(([id, gs]) => ({ id, groups: gs.slice().reverse() }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceGroupsByTarget = computed(() => groupByTarget(dataSourceGroups.value));
|
||||||
|
const codeBlockGroupsByTarget = computed(() => groupByTarget(codeBlockGroups.value));
|
||||||
|
|
||||||
|
return {
|
||||||
|
expanded,
|
||||||
|
toggleGroup,
|
||||||
|
pageGroups,
|
||||||
|
dataSourceGroups,
|
||||||
|
codeBlockGroups,
|
||||||
|
pageGroupsDisplay,
|
||||||
|
dataSourceGroupsByTarget,
|
||||||
|
codeBlockGroupsByTarget,
|
||||||
|
};
|
||||||
|
};
|
||||||
425
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal file
425
packages/editor/src/layouts/history-list/useHistoryRevert.ts
Normal file
@ -0,0 +1,425 @@
|
|||||||
|
import { createApp, getCurrentInstance } from 'vue';
|
||||||
|
|
||||||
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
ConfirmAndRevertOptions,
|
||||||
|
CustomDiffFormOptions,
|
||||||
|
DiffDialogPayload,
|
||||||
|
Services,
|
||||||
|
UseHistoryRevertOptions,
|
||||||
|
} from '@editor/type';
|
||||||
|
|
||||||
|
import { confirmHistoryAction } from './composables';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三类历史(页面 / 数据源 / 代码块)差异弹窗入参的构造差异,收敛为一份配置:
|
||||||
|
* 仅「分组来源、当前值读取、类型 / 展示名提取」不同,定位 step、校验前后值、组装 payload 的流程共用。
|
||||||
|
*/
|
||||||
|
interface DiffPayloadSource {
|
||||||
|
/** 表单类别:节点 / 数据源 / 代码块。 */
|
||||||
|
category: DiffDialogPayload['category'];
|
||||||
|
/** 该类别按时间正序的历史分组列表(含已撤销)。 */
|
||||||
|
groups: () => { id?: string | number; steps: { index: number; step: { diff?: any[] } }[] }[];
|
||||||
|
/** 读取目标当前实际值,用于「与当前对比」;不存在时返回空即禁用对比。 */
|
||||||
|
getCurrent: (_id: string | number) => Record<string, any> | null | undefined;
|
||||||
|
/** 由新/旧快照提取展示名(含各自的兜底,如节点回退 type、数据源 / 代码块回退 id)。 */
|
||||||
|
resolveLabel: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>, _id: string | number) => string;
|
||||||
|
/** 由新/旧快照提取类型;代码块无 type 字段则不传。 */
|
||||||
|
resolveType?: (_newSchema: Record<string, any>, _oldSchema: Record<string, any>) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造差异弹窗入参:仅 update(前后值都存在)可对比。
|
||||||
|
* - 页面(无 id):在全部分组中按 index 定位 step,目标 id 取自快照;
|
||||||
|
* - 数据源 / 代码块(带 id):先匹配分组 id 再按 index 定位。
|
||||||
|
* 无可对比内容(多节点 / add / remove)或定位不到时返回 null。
|
||||||
|
*/
|
||||||
|
const buildDiffPayload = (source: DiffPayloadSource, index: number, id?: string | number): DiffDialogPayload | null => {
|
||||||
|
for (const group of source.groups()) {
|
||||||
|
if (id !== undefined && group.id !== id) continue;
|
||||||
|
const step = group.steps.find((s) => s.index === index)?.step;
|
||||||
|
if (!step) continue;
|
||||||
|
const oldSchema = step.diff?.[0]?.oldSchema as Record<string, any> | undefined;
|
||||||
|
const newSchema = step.diff?.[0]?.newSchema as Record<string, any> | undefined;
|
||||||
|
if (!oldSchema || !newSchema) return null;
|
||||||
|
const targetId = id ?? newSchema.id ?? oldSchema.id;
|
||||||
|
const type = source.resolveType?.(newSchema, oldSchema);
|
||||||
|
return {
|
||||||
|
category: source.category,
|
||||||
|
...(type !== undefined ? { type } : {}),
|
||||||
|
lastValue: oldSchema,
|
||||||
|
value: newSchema,
|
||||||
|
currentValue: (targetId !== undefined ? source.getCurrent(targetId) : null) || null,
|
||||||
|
targetLabel: source.resolveLabel(newSchema, oldSchema, targetId),
|
||||||
|
id: targetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface MountedDiffDialog {
|
||||||
|
app: ReturnType<typeof createApp>;
|
||||||
|
instance: {
|
||||||
|
open: (_payload: DiffDialogPayload) => void;
|
||||||
|
confirm: (_payload: DiffDialogPayload) => Promise<boolean>;
|
||||||
|
};
|
||||||
|
/** 卸载弹窗并清理容器(延迟以等待关闭过渡播放完成,避免动画被强行打断)。 */
|
||||||
|
destroy: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需动态 import 并挂载一个游离的 HistoryDiffDialog,返回其实例与清理函数。
|
||||||
|
* 通过继承 appContext 复用全局组件 / 指令 / provide / 插件(Element Plus、@tmagic/form 字段组件等),
|
||||||
|
* 弹窗组件动态 import,避免拖累其它消费者。供「确认回滚」与「查看差异」两种交互共用。
|
||||||
|
*/
|
||||||
|
const mountHistoryDiffDialog = async (
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
isConfirm?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
},
|
||||||
|
): Promise<MountedDiffDialog> => {
|
||||||
|
const { default: historyDiffDialog } = await import('./HistoryDiffDialog.vue');
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.style.display = 'none';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
|
||||||
|
const app = createApp(historyDiffDialog, {
|
||||||
|
services: options.services,
|
||||||
|
isConfirm: options.isConfirm,
|
||||||
|
extendState: options.extendState,
|
||||||
|
loadConfig: options.loadConfig,
|
||||||
|
selfDiffFieldTypes: options.selfDiffFieldTypes,
|
||||||
|
compareFormState: options.compareFormState,
|
||||||
|
width: options.width,
|
||||||
|
size: options.size ?? options.services?.uiService?.get('propsPanelSize'),
|
||||||
|
onClose: options.onClose,
|
||||||
|
});
|
||||||
|
if (options.appContext) {
|
||||||
|
Object.assign(app._context, options.appContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
const instance = app.mount(container) as unknown as MountedDiffDialog['instance'];
|
||||||
|
|
||||||
|
const destroy = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
app.unmount();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
container.parentNode?.removeChild(container);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, instance, destroy };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态挂载一个「确认回滚」模式的 HistoryDiffDialog,等待用户确认:
|
||||||
|
* - 用户点「确定回滚」resolve(true),取消 / 关闭 resolve(false);
|
||||||
|
* - 确认流程结束后自动卸载。
|
||||||
|
*/
|
||||||
|
const confirmRevertWithDiffDialog = async (
|
||||||
|
payload: DiffDialogPayload,
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
},
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const { instance, destroy } = await mountHistoryDiffDialog({
|
||||||
|
...options,
|
||||||
|
isConfirm: true,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
return await instance.confirm(payload);
|
||||||
|
} finally {
|
||||||
|
destroy();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 动态挂载一个「查看差异」模式(只读)的 HistoryDiffDialog 并打开:
|
||||||
|
* 弹窗保持打开直到用户关闭,关闭(close 事件)后自动卸载并清理容器。
|
||||||
|
*/
|
||||||
|
const viewHistoryDiffDialog = async (
|
||||||
|
payload: DiffDialogPayload,
|
||||||
|
options: Pick<UseHistoryRevertOptions, 'appContext' | 'extendState'> &
|
||||||
|
CustomDiffFormOptions & {
|
||||||
|
services?: Services;
|
||||||
|
},
|
||||||
|
): Promise<void> => {
|
||||||
|
// onClose 在用户关闭弹窗时才触发,此时 handle.destroy 早已赋值。
|
||||||
|
const handle: { destroy?: () => void } = {};
|
||||||
|
const { instance, destroy } = await mountHistoryDiffDialog({
|
||||||
|
...options,
|
||||||
|
isConfirm: false,
|
||||||
|
onClose: () => handle.destroy?.(),
|
||||||
|
});
|
||||||
|
handle.destroy = destroy;
|
||||||
|
instance.open(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 历史记录「单步回滚」与「查看差异」的完整交互流程,供历史面板与业务方共用。
|
||||||
|
*
|
||||||
|
* 单步回滚(类 git revert):
|
||||||
|
* 1. 前置校验:目标数据已被删除(update 写回目标 / 页面 remove 的原父容器缺失)时给出「回滚失败」提示并中止;
|
||||||
|
* 2. 二次确认:可差异对比的步骤(单实体 update)弹出差异确认弹窗,其余步骤弹普通二次确认框;
|
||||||
|
* 3. 用户确认后执行对应 service 的 revert(页面 / 数据源 / 代码块)。
|
||||||
|
*
|
||||||
|
* 查看差异:可差异对比的步骤动态挂载只读 HistoryDiffDialog 展示前后值差异。
|
||||||
|
*
|
||||||
|
* 上述弹窗均按需动态挂载 HistoryDiffDialog 实现,业务方无需自行挂载任何弹窗。
|
||||||
|
*
|
||||||
|
* 返回的能力分三组:
|
||||||
|
* - 内置三类(页面 / 数据源 / 代码块):`onPageRevert` / `onDataSourceRevert` / `onCodeBlockRevert` 单步回滚,
|
||||||
|
* `onPageDiff` / `onDataSourceDiff` / `onCodeBlockDiff` 查看差异,及配套的 `buildXxxDiffPayload` / `isXxxRevertTargetMissing`;
|
||||||
|
* - 业务自有历史(如管理台「模块」):`confirmAndRevert` / `viewDiff` 复用同一套交互流程,由业务方自行构造差异入参;
|
||||||
|
* 即上述内置三类本质上是它们各自预置好 payload 构造与 service.revert 的特例。
|
||||||
|
*
|
||||||
|
* 业务方可在拿到 Editor 实例暴露的 `services` 后直接 import 调用:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* import { useHistoryRevert } from '@tmagic/editor';
|
||||||
|
*
|
||||||
|
* const { onPageRevert, onPageDiff } = useHistoryRevert({}, editorRef.value); // editorRef.value 即 Editor 暴露的 services
|
||||||
|
* await onPageRevert(index); // 弹出差异 / 二次确认弹窗后回滚
|
||||||
|
* onPageDiff(index); // 弹出只读差异弹窗查看前后值差异
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export const useHistoryRevert = (options: UseHistoryRevertOptions = {}, services?: Services) => {
|
||||||
|
// 自动捕获调用方所在组件的 appContext(在 setup 中调用时),业务方亦可显式覆盖。
|
||||||
|
const appContext = options.appContext ?? getCurrentInstance()?.appContext ?? null;
|
||||||
|
const { extendState, getPropsPanelFormState, dialogWidth } = options;
|
||||||
|
|
||||||
|
/** 目标数据已被删除、无法回滚时的统一提示。 */
|
||||||
|
const showRevertTargetMissing = () => {
|
||||||
|
tMagicMessage.error('回滚失败:该记录对应的数据已被删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」二次确认:新增 / 删除 / 多节点更新等无法做差异对比的步骤,
|
||||||
|
* 不弹差异弹窗,改用一个普通确认框替代「确定回滚」按钮,避免点击后无任何提示直接执行。
|
||||||
|
* 用户取消时返回 false,调用方据此中止回滚。
|
||||||
|
*/
|
||||||
|
const confirmRevert = (): Promise<boolean> =>
|
||||||
|
confirmHistoryAction(
|
||||||
|
'确定回滚该步骤吗?回滚会将该操作作为一条新记录反向应用(新增将被删除、删除将被还原),不影响后续历史记录。',
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「回滚」统一确认入口:可差异对比的步骤动态挂载 HistoryDiffDialog 走差异确认弹窗,
|
||||||
|
* 无法对比的步骤(add / remove / 多节点更新)回退到普通二次确认框。
|
||||||
|
* `extra` 供业务自有历史(如模块)透传自定义表单配置加载等渲染入参。
|
||||||
|
*/
|
||||||
|
const runRevert = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<boolean> => {
|
||||||
|
if (payload) {
|
||||||
|
return confirmRevertWithDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
...extra,
|
||||||
|
width: extra?.width ?? dialogWidth,
|
||||||
|
size: extra?.size,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return confirmRevert();
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildPageDiffPayload = (index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'node',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('page', services?.editorService.get('page')?.id) ?? [],
|
||||||
|
getCurrent: (id) => services?.editorService.getNodeById(id) as Record<string, any> | null,
|
||||||
|
resolveType: (n, o) => n.type || o.type || '',
|
||||||
|
resolveLabel: (n, o) => n.name || o.name || n.type || o.type || '',
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildDataSourceDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'data-source',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('dataSource') ?? [],
|
||||||
|
getCurrent: (id) => services?.dataSourceService.getDataSourceById(`${id}`) as Record<string, any> | null,
|
||||||
|
resolveType: (n, o) => n.type || o.type || 'base',
|
||||||
|
resolveLabel: (n, o, id) => n.title || o.title || `${id}`,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildCodeBlockDiffPayload = (id: string | number, index: number): DiffDialogPayload | null =>
|
||||||
|
buildDiffPayload(
|
||||||
|
{
|
||||||
|
category: 'code-block',
|
||||||
|
groups: () => services?.historyService.getHistoryGroups('codeBlock') ?? [],
|
||||||
|
getCurrent: (id) => services?.codeBlockService.getCodeContentById(id) as Record<string, any> | null,
|
||||||
|
resolveLabel: (n, o, id) => n.name || o.name || `${id}`,
|
||||||
|
},
|
||||||
|
index,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回滚前置校验:若该历史步骤回滚所依赖的目标数据已被删除,则无法回滚。
|
||||||
|
* - update(把旧值写回):被修改的目标必须仍存在;
|
||||||
|
* - 页面 remove(还原被删节点):被删节点的原父容器必须仍存在,否则无处插回;
|
||||||
|
* add(回滚即删除)即使目标已不在,也已达成「删除」目的,不视为失败。
|
||||||
|
*/
|
||||||
|
const isPageRevertTargetMissing = (index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('page', services?.editorService.get('page')?.id)[index]?.step;
|
||||||
|
if (!step) return false;
|
||||||
|
if (step.opType === 'update') {
|
||||||
|
return (step.diff ?? []).some((item) => {
|
||||||
|
const id = item.newSchema?.id ?? item.oldSchema?.id;
|
||||||
|
return id !== undefined && !services?.editorService.getNodeById(id, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (step.opType === 'remove') {
|
||||||
|
return (step.diff ?? []).some(
|
||||||
|
(item) => item.parentId !== undefined && !services?.editorService.getNodeById(item.parentId, false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 数据源 update 步骤回滚时,对应数据源必须仍存在(已删除则无处写回旧值)。 */
|
||||||
|
const isDataSourceRevertTargetMissing = (id: string | number, index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('dataSource', id)[index]?.step;
|
||||||
|
return Boolean(step?.opType === 'update' && !services?.dataSourceService.getDataSourceById(`${id}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 代码块 update 步骤回滚时,对应代码块必须仍存在(已删除则无处写回旧值)。 */
|
||||||
|
const isCodeBlockRevertTargetMissing = (id: string | number, index: number): boolean => {
|
||||||
|
const step = services?.historyService.getStepList('codeBlock', id)[index]?.step;
|
||||||
|
return Boolean(step?.opType === 'update' && !services?.codeBlockService.getCodeContentById(id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPageRevert = (index: number) => {
|
||||||
|
if (isPageRevertTargetMissing(index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildPageDiffPayload(index), { compareFormState: getPropsPanelFormState?.() }).then((result) =>
|
||||||
|
result ? services?.editorService.revertPageStep(index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceRevert = (id: string | number, index: number) => {
|
||||||
|
if (isDataSourceRevertTargetMissing(id, index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildDataSourceDiffPayload(id, index)).then((result) =>
|
||||||
|
result ? services?.dataSourceService.revert(id, index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockRevert = (id: string | number, index: number) => {
|
||||||
|
if (isCodeBlockRevertTargetMissing(id, index)) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
return runRevert(buildCodeBlockDiffPayload(id, index)).then((result) =>
|
||||||
|
result ? services?.codeBlockService.revert(id, index) : null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 「查看差异」:可差异对比的步骤(单实体 update)动态挂载只读 HistoryDiffDialog 展示前后值差异,
|
||||||
|
* 无可对比内容(add / remove / 多节点更新)时不弹窗、静默返回。
|
||||||
|
*/
|
||||||
|
const onPageDiff = (index: number): Promise<void> | void => {
|
||||||
|
const payload = buildPageDiffPayload(index);
|
||||||
|
if (payload) {
|
||||||
|
return viewHistoryDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
width: dialogWidth,
|
||||||
|
compareFormState: getPropsPanelFormState?.(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDataSourceDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
|
const payload = buildDataSourceDiffPayload(id, index);
|
||||||
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCodeBlockDiff = (id: string | number, index: number): Promise<void> | void => {
|
||||||
|
const payload = buildCodeBlockDiffPayload(id, index);
|
||||||
|
if (payload) return viewHistoryDiffDialog(payload, { appContext, extendState, services, width: dialogWidth });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史(如管理台「模块」)的「单步回滚」统一入口:
|
||||||
|
* 复用与页面/数据源/代码块一致的「目标校验 → 差异/二次确认弹窗 → 反向回滚」流程,
|
||||||
|
* 内置三类只是它的特例(各自预置了 buildXxxDiffPayload + service.revert)。
|
||||||
|
*
|
||||||
|
* 用户取消或前置校验未过时返回 null,确认并执行回滚后返回 `revert()` 的结果。
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* await confirmAndRevert({
|
||||||
|
* diffPayload: oldSchema && newSchema ? { category: 'module', lastValue: oldSchema, value: newSchema, ... } : null,
|
||||||
|
* loadConfig: (ctx) => loadModFormConfig(modTypeId, values),
|
||||||
|
* selfDiffFieldTypes: ['mod-cond'],
|
||||||
|
* revert: () => moduleHistoryStore.revert(id, index),
|
||||||
|
* });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
const confirmAndRevert = async <T = unknown>(revertOptions: ConfirmAndRevertOptions<T>): Promise<T | null> => {
|
||||||
|
if (revertOptions.isTargetMissing?.()) {
|
||||||
|
showRevertTargetMissing();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const confirmed = await runRevert(revertOptions.diffPayload ?? null, {
|
||||||
|
loadConfig: revertOptions.loadConfig,
|
||||||
|
selfDiffFieldTypes: revertOptions.selfDiffFieldTypes,
|
||||||
|
width: revertOptions.width,
|
||||||
|
size: revertOptions.size,
|
||||||
|
});
|
||||||
|
if (!confirmed) return null;
|
||||||
|
return await revertOptions.revert();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史的「查看差异」统一入口:传入自行构造的差异入参即弹出只读差异弹窗,
|
||||||
|
* 可透传 `loadConfig` / `selfDiffFieldTypes`。payload 为 null(不可对比)时静默返回。
|
||||||
|
*/
|
||||||
|
const viewDiff = (payload: DiffDialogPayload | null, extra?: CustomDiffFormOptions): Promise<void> | void => {
|
||||||
|
if (payload)
|
||||||
|
return viewHistoryDiffDialog(payload, {
|
||||||
|
appContext,
|
||||||
|
extendState,
|
||||||
|
services,
|
||||||
|
...extra,
|
||||||
|
width: extra?.width ?? dialogWidth,
|
||||||
|
size: extra?.size,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onPageRevert,
|
||||||
|
onDataSourceRevert,
|
||||||
|
onCodeBlockRevert,
|
||||||
|
onPageDiff,
|
||||||
|
onDataSourceDiff,
|
||||||
|
onCodeBlockDiff,
|
||||||
|
buildPageDiffPayload,
|
||||||
|
buildDataSourceDiffPayload,
|
||||||
|
buildCodeBlockDiffPayload,
|
||||||
|
isPageRevertTargetMissing,
|
||||||
|
isDataSourceRevertTargetMissing,
|
||||||
|
isCodeBlockRevertTargetMissing,
|
||||||
|
confirmAndRevert,
|
||||||
|
viewDiff,
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -2,7 +2,7 @@ import { computed, type Ref, watch } from 'vue';
|
|||||||
|
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/storage';
|
||||||
import { Services } from '@editor/type';
|
import { Services } from '@editor/type';
|
||||||
import { MIN_CENTER_COLUMN_WIDTH, RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
|
|
||||||
export const useStylePanel = (
|
export const useStylePanel = (
|
||||||
{ uiService, storageService }: Pick<Services, 'uiService' | 'storageService'>,
|
{ uiService, storageService }: Pick<Services, 'uiService' | 'storageService'>,
|
||||||
@ -47,8 +47,9 @@ export const useStylePanel = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (columnWidth.center < 0) {
|
if (columnWidth.center < 0) {
|
||||||
columnWidth.right = columnWidth.right + columnWidth.center - MIN_CENTER_COLUMN_WIDTH;
|
const minCenterColumnWidth = uiService.get('minCenterColumnWidth');
|
||||||
columnWidth.center = MIN_CENTER_COLUMN_WIDTH;
|
columnWidth.right = columnWidth.right + columnWidth.center - minCenterColumnWidth;
|
||||||
|
columnWidth.center = minCenterColumnWidth;
|
||||||
|
|
||||||
propsPanelWidth.value = columnWidth.right / 2;
|
propsPanelWidth.value = columnWidth.right / 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,6 +132,7 @@
|
|||||||
v-model:height="columnLeftHeight"
|
v-model:height="columnLeftHeight"
|
||||||
:width="columnLeftWidth"
|
:width="columnLeftWidth"
|
||||||
:title="config.text"
|
:title="config.text"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="{
|
:position="{
|
||||||
left: floatBoxStates[config.$key].left,
|
left: floatBoxStates[config.$key].left,
|
||||||
top: floatBoxStates[config.$key].top,
|
top: floatBoxStates[config.$key].top,
|
||||||
@ -221,6 +222,7 @@ const taskLength = computed(() => depService.get('taskLength'));
|
|||||||
const tipsBarVisible = ref(true);
|
const tipsBarVisible = ref(true);
|
||||||
|
|
||||||
const columnLeftWidth = computed(() => uiService.get('columnWidth')[ColumnLayout.LEFT]);
|
const columnLeftWidth = computed(() => uiService.get('columnWidth')[ColumnLayout.LEFT]);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
const { height: editorContentHeight } = useEditorContentHeight();
|
const { height: editorContentHeight } = useEditorContentHeight();
|
||||||
const columnLeftHeight = ref(0);
|
const columnLeftHeight = ref(0);
|
||||||
|
|
||||||
@ -257,7 +259,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
|
|||||||
slots: {},
|
slots: {},
|
||||||
},
|
},
|
||||||
layer: {
|
layer: {
|
||||||
$key: 'layer',
|
$key: SideItemKey.LAYER,
|
||||||
type: 'component',
|
type: 'component',
|
||||||
icon: List,
|
icon: List,
|
||||||
text: '已选组件',
|
text: '已选组件',
|
||||||
@ -277,7 +279,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
|
|||||||
slots: {},
|
slots: {},
|
||||||
},
|
},
|
||||||
[SideItemKey.CODE_BLOCK]: {
|
[SideItemKey.CODE_BLOCK]: {
|
||||||
$key: 'code-block',
|
$key: SideItemKey.CODE_BLOCK,
|
||||||
type: 'component',
|
type: 'component',
|
||||||
icon: EditPen,
|
icon: EditPen,
|
||||||
text: '代码编辑',
|
text: '代码编辑',
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<FloatingBox
|
<FloatingBox
|
||||||
|
:body-style="{ padding: '0 16px' }"
|
||||||
v-model:visible="boxVisible"
|
v-model:visible="boxVisible"
|
||||||
v-model:width="width"
|
v-model:width="width"
|
||||||
v-model:height="editorHeight"
|
v-model:height="editorHeight"
|
||||||
:title="title"
|
:title="title"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="boxPosition"
|
:position="boxPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -22,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { inject, nextTick, Ref, ref, watch, watchEffect } from 'vue';
|
import { computed, inject, nextTick, provide, Ref, ref, watch, watchEffect } from 'vue';
|
||||||
|
|
||||||
import type { DataSourceSchema } from '@tmagic/core';
|
import type { DataSourceSchema } from '@tmagic/core';
|
||||||
import { tMagicMessage } from '@tmagic/design';
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
@ -41,6 +43,10 @@ const props = defineProps<{
|
|||||||
title?: string;
|
title?: string;
|
||||||
values: any;
|
values: any;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
|
/** 打开后需要直接定位并打开的方法名,传入时默认激活「方法定义」tab */
|
||||||
|
editMethodName?: string;
|
||||||
|
/** 打开后需要直接定位并打开的字段路径,传入时默认激活「数据定义」tab */
|
||||||
|
editFieldPath?: string[];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const boxVisible = defineModel<boolean>('visible', { default: false });
|
const boxVisible = defineModel<boolean>('visible', { default: false });
|
||||||
@ -61,10 +67,36 @@ const { height: editorHeight } = useEditorContentHeight();
|
|||||||
|
|
||||||
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
const parentFloating = inject<Ref<HTMLDivElement | null>>('parentFloating', ref(null));
|
||||||
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
const { boxPosition, calcBoxPosition } = useNextFloatBoxPosition(uiService, parentFloating);
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
|
/** 供「方法定义」tab 内的字段消费,用于打开数据源详情后自动打开指定方法 */
|
||||||
|
provide(
|
||||||
|
'editingDataSourceMethodName',
|
||||||
|
computed(() => props.editMethodName),
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 供「数据定义」tab 内的字段消费,用于打开数据源详情后自动打开指定字段 */
|
||||||
|
provide(
|
||||||
|
'editingDataSourceFieldPath',
|
||||||
|
computed(() => props.editFieldPath || []),
|
||||||
|
);
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
initValues.value = props.values;
|
initValues.value = props.values;
|
||||||
dataSourceConfig.value = dataSourceService.getFormConfig(initValues.value.type);
|
const config = dataSourceService.getFormConfig(initValues.value.type);
|
||||||
|
|
||||||
|
// 传入方法名/字段路径时,将外层 tab 容器默认激活到对应 tab(status: methods / fields)
|
||||||
|
// 未传入时默认激活「数据定义」tab(fields)
|
||||||
|
let activeTab = 'fields';
|
||||||
|
if (props.editMethodName) {
|
||||||
|
activeTab = 'methods';
|
||||||
|
} else if (props.editFieldPath?.length) {
|
||||||
|
activeTab = 'fields';
|
||||||
|
}
|
||||||
|
|
||||||
|
dataSourceConfig.value = config.map((item) =>
|
||||||
|
(item as { type?: string }).type === 'tab' ? { ...item, active: activeTab } : item,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const submitHandler = (values: any, data: ContainerChangeEventData) => {
|
const submitHandler = (values: any, data: ContainerChangeEventData) => {
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
:disabled="!editable"
|
:disabled="!editable"
|
||||||
:values="dataSourceValues"
|
:values="dataSourceValues"
|
||||||
:title="dialogTitle"
|
:title="dialogTitle"
|
||||||
|
:edit-method-name="editMethodName"
|
||||||
|
:edit-field-path="editFieldPath"
|
||||||
@submit="submitDataSourceHandler"
|
@submit="submitDataSourceHandler"
|
||||||
@close="editDialogCloseHandler"
|
@close="editDialogCloseHandler"
|
||||||
></DataSourceConfigPanel>
|
></DataSourceConfigPanel>
|
||||||
@ -45,7 +47,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, inject, useTemplateRef, watch } from 'vue';
|
import { computed, inject, ref, useTemplateRef, watch } from 'vue';
|
||||||
import { mergeWith } from 'lodash-es';
|
import { mergeWith } from 'lodash-es';
|
||||||
|
|
||||||
import { tMagicMessageBox, TMagicScrollbar } from '@tmagic/design';
|
import { tMagicMessageBox, TMagicScrollbar } from '@tmagic/design';
|
||||||
@ -79,7 +81,16 @@ const { dataSourceService } = useServices();
|
|||||||
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
const { editDialog, dataSourceValues, dialogTitle, editable, editHandler, submitDataSourceHandler } =
|
||||||
useDataSourceEdit(dataSourceService);
|
useDataSourceEdit(dataSourceService);
|
||||||
|
|
||||||
|
/** 打开数据源详情时需要直接定位并打开的方法名,为空则正常展示「数据定义」tab */
|
||||||
|
const editMethodName = ref('');
|
||||||
|
|
||||||
|
/** 打开数据源详情时需要直接定位并打开的字段路径,为空则不自动打开字段配置 */
|
||||||
|
const editFieldPath = ref<string[]>([]);
|
||||||
|
|
||||||
const editDialogCloseHandler = () => {
|
const editDialogCloseHandler = () => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = [];
|
||||||
|
|
||||||
if (dataSourceListRef.value) {
|
if (dataSourceListRef.value) {
|
||||||
for (const [, status] of dataSourceListRef.value.nodeStatusMap.entries()) {
|
for (const [, status] of dataSourceListRef.value.nodeStatusMap.entries()) {
|
||||||
status.selected = false;
|
status.selected = false;
|
||||||
@ -139,6 +150,20 @@ const filterTextChangeHandler = (val: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
eventBus?.on('edit-data-source', (id: string) => {
|
eventBus?.on('edit-data-source', (id: string) => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = [];
|
||||||
|
editHandler(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus?.on('edit-data-source-method', (id: string, methodName: string) => {
|
||||||
|
editMethodName.value = methodName;
|
||||||
|
editFieldPath.value = [];
|
||||||
|
editHandler(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
eventBus?.on('edit-data-source-field', (id: string, fieldPath: string[]) => {
|
||||||
|
editMethodName.value = '';
|
||||||
|
editFieldPath.value = fieldPath;
|
||||||
editHandler(id);
|
editHandler(id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
ref="box"
|
ref="box"
|
||||||
v-model:visible="visible"
|
v-model:visible="visible"
|
||||||
title="当前位置下的组件"
|
title="当前位置下的组件"
|
||||||
|
:framework-width="frameworkWidth"
|
||||||
:position="menuPosition"
|
:position="menuPosition"
|
||||||
>
|
>
|
||||||
<template #body>
|
<template #body>
|
||||||
@ -36,7 +37,9 @@ import { useNodeStatus } from '@editor/layouts/sidebar/layer/use-node-status';
|
|||||||
import type { TreeNodeData } from '@editor/type';
|
import type { TreeNodeData } from '@editor/type';
|
||||||
|
|
||||||
const services = useServices();
|
const services = useServices();
|
||||||
const { editorService } = services;
|
const { editorService, uiService } = services;
|
||||||
|
|
||||||
|
const frameworkWidth = computed(() => uiService.get('frameworkRect')?.width || 0);
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const buttonVisible = ref(false);
|
const buttonVisible = ref(false);
|
||||||
|
|||||||
@ -32,13 +32,14 @@ import type {
|
|||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
CodeBlockStepValue,
|
CodeBlockStepValue,
|
||||||
CodeState,
|
CodeState,
|
||||||
|
DslOpWithHistoryIdsResult,
|
||||||
HistoryOpOptions,
|
HistoryOpOptions,
|
||||||
HistoryOpOptionsWithChangeRecords,
|
HistoryOpOptionsWithChangeRecords,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
import { CODE_DRAFT_STORAGE_KEY } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
import { describeRevertStep } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -84,6 +85,7 @@ class CodeBlock extends BaseService {
|
|||||||
public async setCodeDsl(codeDsl: CodeBlockDSL): Promise<void> {
|
public async setCodeDsl(codeDsl: CodeBlockDSL): Promise<void> {
|
||||||
this.state.codeDsl = codeDsl;
|
this.state.codeDsl = codeDsl;
|
||||||
this.emit('code-dsl-change', this.state.codeDsl);
|
this.emit('code-dsl-change', this.state.codeDsl);
|
||||||
|
this.emit('change', this.state.codeDsl);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,17 +189,18 @@ class CodeBlock extends BaseService {
|
|||||||
const newContent = cloneDeep(codeDsl[id]);
|
const newContent = cloneDeep(codeDsl[id]);
|
||||||
|
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
this.lastPushedHistoryId =
|
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
|
||||||
historyService.pushCodeBlock(id, {
|
oldValue: oldContent,
|
||||||
oldContent,
|
newValue: newContent,
|
||||||
newContent,
|
|
||||||
changeRecords,
|
changeRecords,
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
})?.uuid ?? null;
|
});
|
||||||
|
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||||
|
this.emit('change', this.getCodeDsl());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -300,64 +303,70 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
codeIds.forEach((id) => {
|
codeIds.forEach((id) => {
|
||||||
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
|
// 历史记录:删除前快照内容;不存在的 id 直接跳过历史推入
|
||||||
const oldContent: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
|
const oldValue: CodeBlockContent | null = currentDsl[id] ? cloneDeep(currentDsl[id]) : null;
|
||||||
|
|
||||||
delete currentDsl[id];
|
delete currentDsl[id];
|
||||||
|
|
||||||
if (oldContent && !doNotPushHistory) {
|
if (oldValue && !doNotPushHistory) {
|
||||||
const uuid = historyService.pushCodeBlock(id, {
|
const step = createStackStep<CodeBlockContent, CodeBlockStepValue>(id, {
|
||||||
oldContent,
|
oldValue,
|
||||||
newContent: null,
|
newValue: null,
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
})?.uuid;
|
});
|
||||||
|
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
|
||||||
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
this.emit('remove', id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.emit('change', this.getCodeDsl());
|
||||||
}
|
}
|
||||||
|
|
||||||
// #region AndGetHistoryId
|
// #region AndGetHistoryId
|
||||||
/**
|
/**
|
||||||
* 下列 *AndGetHistoryId 方法与对应的写入方法行为完全一致,
|
* 下列 *AndGetHistoryId 方法与对应的写入方法行为完全一致,
|
||||||
* 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link CodeBlockStepValue.uuid}),
|
* 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入的历史 uuid 列表,
|
||||||
* 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
* 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
||||||
*
|
*
|
||||||
* 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时:单条写入返回 null,批量删除返回空数组。
|
* 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时 historyIds 为 `[]`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 等价于 {@link setCodeDslById},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link setCodeDslById},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async setCodeDslByIdAndGetHistoryId(
|
public async setCodeDslByIdAndGetHistoryId(
|
||||||
id: Id,
|
id: Id,
|
||||||
codeConfig: Partial<CodeBlockContent>,
|
codeConfig: Partial<CodeBlockContent>,
|
||||||
options: HistoryOpOptionsWithChangeRecords = {},
|
options: HistoryOpOptionsWithChangeRecords = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<void>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.setCodeDslById(id, codeConfig, options);
|
await this.setCodeDslById(id, codeConfig, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link setCodeDslByIdSync},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link setCodeDslByIdSync},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public setCodeDslByIdSyncAndGetHistoryId(
|
public setCodeDslByIdSyncAndGetHistoryId(
|
||||||
id: Id,
|
id: Id,
|
||||||
codeConfig: Partial<CodeBlockContent>,
|
codeConfig: Partial<CodeBlockContent>,
|
||||||
force = true,
|
force = true,
|
||||||
options: HistoryOpOptionsWithChangeRecords = {},
|
options: HistoryOpOptionsWithChangeRecords = {},
|
||||||
): string | null {
|
): DslOpWithHistoryIdsResult<void> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
this.setCodeDslByIdSync(id, codeConfig, force, options);
|
this.setCodeDslByIdSync(id, codeConfig, force, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 等价于 {@link deleteCodeDslByIds},但返回本次写入的全部历史记录 uuid(按删除顺序)。
|
* 等价于 {@link deleteCodeDslByIds},并额外返回本次写入的全部历史记录 uuid(按删除顺序)。
|
||||||
* 一次删除多个代码块会产生多条历史记录,因此返回数组;未写入任何历史时返回空数组。
|
* 一次删除多个代码块会产生多条历史记录;未写入任何历史时 historyIds 为 `[]`。
|
||||||
*/
|
*/
|
||||||
public async deleteCodeDslByIdsAndGetHistoryId(codeIds: Id[], options: HistoryOpOptions = {}): Promise<string[]> {
|
public async deleteCodeDslByIdsAndGetHistoryId(
|
||||||
|
codeIds: Id[],
|
||||||
|
options: HistoryOpOptions = {},
|
||||||
|
): Promise<DslOpWithHistoryIdsResult<void>> {
|
||||||
this.lastDeletedHistoryIds = [];
|
this.lastDeletedHistoryIds = [];
|
||||||
await this.deleteCodeDslByIds(codeIds, options);
|
await this.deleteCodeDslByIds(codeIds, options);
|
||||||
return [...this.lastDeletedHistoryIds];
|
return { result: undefined, historyIds: [...this.lastDeletedHistoryIds] };
|
||||||
}
|
}
|
||||||
// #endregion AndGetHistoryId
|
// #endregion AndGetHistoryId
|
||||||
|
|
||||||
@ -381,7 +390,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
*/
|
*/
|
||||||
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
public async undo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
const step = historyService.undoCodeBlock(id);
|
const step = historyService.undo('codeBlock', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
await this.applyHistoryStep(step, true);
|
await this.applyHistoryStep(step, true);
|
||||||
return step;
|
return step;
|
||||||
@ -393,7 +402,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
*/
|
*/
|
||||||
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
public async redo(id: Id): Promise<CodeBlockStepValue | null> {
|
||||||
const step = historyService.redoCodeBlock(id);
|
const step = historyService.redo('codeBlock', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
await this.applyHistoryStep(step, false);
|
await this.applyHistoryStep(step, false);
|
||||||
return step;
|
return step;
|
||||||
@ -401,12 +410,12 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定代码块撤销。 */
|
/** 是否可对指定代码块撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndoCodeBlock(id);
|
return historyService.canUndo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定代码块重做。 */
|
/** 是否可对指定代码块重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedoCodeBlock(id);
|
return historyService.canRedo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -417,7 +426,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public async goto(id: Id, targetCursor: number): Promise<number> {
|
public async goto(id: Id, targetCursor: number): Promise<number> {
|
||||||
let cursor = historyService.getCodeBlockCursor(id);
|
let cursor = historyService.getCursor('codeBlock', id);
|
||||||
const target = Math.max(0, targetCursor);
|
const target = Math.max(0, targetCursor);
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
const step = await this.undo(id);
|
const step = await this.undo(id);
|
||||||
@ -443,29 +452,31 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
*/
|
*/
|
||||||
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
public async revert(id: Id, index: number): Promise<CodeBlockStepValue | null> {
|
||||||
const list = historyService.getCodeBlockStepList(id);
|
const list = historyService.getStepList('codeBlock', id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
// 更新类步骤(前后 content 都存在)必须带 changeRecords 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
||||||
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
|
||||||
|
|
||||||
if (oldSchema && newSchema && !changeRecords?.length) return null;
|
if (oldSchema && newSchema && !changeRecords?.length) return null;
|
||||||
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.id, entry.step.diff?.[0], (s) => s.name)}`;
|
const description = `回滚 #${index + 1}: ${describeRevertStep<CodeBlockContent>(entry.step.data.id, entry.step.diff?.[0], (s) => s.name)}`;
|
||||||
return await this.applyRevertStep(entry.step, description);
|
return await this.applyRevertStep(entry.step, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过历史记录 uuid 回滚某条代码块历史步骤,语义同 {@link revert},
|
* 通过历史记录 uuid 回滚代码块历史步骤,语义同 {@link revert},
|
||||||
* 仅无需调用方再传 codeBlockId 与 index:内部会按 uuid({@link CodeBlockStepValue.uuid})
|
* 仅无需调用方再传 codeBlockId 与 index:内部会按 uuid 在全部代码块栈中定位对应步骤后再回滚。
|
||||||
* 在全部代码块栈中定位对应步骤后再回滚。
|
* 按数组顺序依次回滚,返回与入参同序的结果列表(某项失败时为 `null`)。
|
||||||
*
|
*
|
||||||
* @param uuid 目标历史记录的 uuid,通常由 {@link setCodeDslByIdAndGetHistoryId} 等方法返回
|
* @param uuids 目标历史记录的 uuid 列表,通常由 {@link setCodeDslByIdAndGetHistoryId} 等方法返回的 `historyIds`
|
||||||
* @returns 反向后产生的新 step;找不到对应 uuid / 未应用时返回 null
|
|
||||||
*/
|
*/
|
||||||
public async revertById(uuid: string): Promise<CodeBlockStepValue | null> {
|
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
|
||||||
const location = historyService.findCodeBlockStepLocationByUuid(uuid);
|
const results: (CodeBlockStepValue | null)[] = [];
|
||||||
if (!location) return null;
|
for (const uuid of uuids) {
|
||||||
return await this.revert(location.id, location.index);
|
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
|
||||||
|
results.push(location ? await this.revert(location.id, location.index) : null);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -558,19 +569,19 @@ class CodeBlock extends BaseService {
|
|||||||
step: CodeBlockStepValue,
|
step: CodeBlockStepValue,
|
||||||
historyDescription: string,
|
historyDescription: string,
|
||||||
): Promise<CodeBlockStepValue | null> {
|
): Promise<CodeBlockStepValue | null> {
|
||||||
const { id } = step;
|
const { id } = step.data;
|
||||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||||
|
|
||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原本是删除 → revert 即写回
|
// 原本是删除 → revert 即写回
|
||||||
if (oldSchema && !newSchema) {
|
if (oldSchema && !newSchema) {
|
||||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -594,11 +605,11 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getCodeBlockStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -616,7 +627,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @param reverse true=撤销,false=重做
|
* @param reverse true=撤销,false=重做
|
||||||
*/
|
*/
|
||||||
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
|
private async applyHistoryStep(step: CodeBlockStepValue, reverse: boolean): Promise<void> {
|
||||||
const { id } = step;
|
const { id } = step.data;
|
||||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||||
|
|
||||||
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
|
// 新增 / 删除:直接 set 或 delete,不走 patch 逻辑
|
||||||
|
|||||||
@ -13,13 +13,14 @@ import storageService, { Protocol } from '@editor/services/storage';
|
|||||||
import type {
|
import type {
|
||||||
DataSourceStepValue,
|
DataSourceStepValue,
|
||||||
DatasourceTypeOption,
|
DatasourceTypeOption,
|
||||||
|
DslOpWithHistoryIdsResult,
|
||||||
HistoryOpOptions,
|
HistoryOpOptions,
|
||||||
HistoryOpOptionsWithChangeRecords,
|
HistoryOpOptionsWithChangeRecords,
|
||||||
SyncHookPlugin,
|
SyncHookPlugin,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
import { getFormConfig, getFormValue } from '@editor/utils/data-source';
|
||||||
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_DS_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
import { describeRevertStep } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -136,16 +137,17 @@ class DataSource extends BaseService {
|
|||||||
this.get('dataSources').push(newConfig);
|
this.get('dataSources').push(newConfig);
|
||||||
|
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
this.lastPushedHistoryId =
|
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
|
||||||
historyService.pushDataSource(newConfig.id, {
|
oldValue: null,
|
||||||
oldSchema: null,
|
newValue: newConfig,
|
||||||
newSchema: newConfig,
|
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
})?.uuid ?? null;
|
});
|
||||||
|
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('add', newConfig);
|
this.emit('add', newConfig);
|
||||||
|
this.emit('change', this.get('dataSources'));
|
||||||
|
|
||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
@ -177,20 +179,21 @@ class DataSource extends BaseService {
|
|||||||
dataSources[index] = newConfig;
|
dataSources[index] = newConfig;
|
||||||
|
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
this.lastPushedHistoryId =
|
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(newConfig.id, {
|
||||||
historyService.pushDataSource(newConfig.id, {
|
oldValue: oldConfig ? cloneDeep(oldConfig) : null,
|
||||||
oldSchema: oldConfig ? cloneDeep(oldConfig) : null,
|
newValue: newConfig,
|
||||||
newSchema: newConfig,
|
|
||||||
changeRecords,
|
changeRecords,
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
})?.uuid ?? null;
|
});
|
||||||
|
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update', newConfig, {
|
this.emit('update', newConfig, {
|
||||||
oldConfig,
|
oldConfig,
|
||||||
changeRecords,
|
changeRecords,
|
||||||
});
|
});
|
||||||
|
this.emit('change', this.get('dataSources'));
|
||||||
|
|
||||||
return newConfig;
|
return newConfig;
|
||||||
}
|
}
|
||||||
@ -209,49 +212,53 @@ class DataSource extends BaseService {
|
|||||||
dataSources.splice(index, 1);
|
dataSources.splice(index, 1);
|
||||||
|
|
||||||
if (oldConfig && !doNotPushHistory) {
|
if (oldConfig && !doNotPushHistory) {
|
||||||
this.lastPushedHistoryId =
|
const step = createStackStep<DataSourceSchema, DataSourceStepValue>(id, {
|
||||||
historyService.pushDataSource(id, {
|
oldValue: cloneDeep(oldConfig),
|
||||||
oldSchema: cloneDeep(oldConfig),
|
newValue: null,
|
||||||
newSchema: null,
|
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
})?.uuid ?? null;
|
});
|
||||||
|
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
this.emit('remove', id);
|
||||||
|
this.emit('change', this.get('dataSources'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// #region AndGetHistoryId
|
// #region AndGetHistoryId
|
||||||
/**
|
/**
|
||||||
* 下列 *AndGetHistoryId 方法与对应的 add / update / remove 行为完全一致,
|
* 下列 *AndGetHistoryId 方法与对应的 add / update / remove 行为完全一致,
|
||||||
* 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link DataSourceStepValue.uuid}),
|
* 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link DataSourceStepValue.uuid}),
|
||||||
* 而非数据源配置。可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
* 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
||||||
*
|
*
|
||||||
* 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时返回 null。
|
* 当本次操作未写入历史(doNotPushHistory 为 true、或无对应记录)时 historyIds 为 `[]`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 等价于 {@link add},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public addAndGetHistoryId(config: DataSourceSchema, options: HistoryOpOptions = {}): string | null {
|
public addAndGetHistoryId(
|
||||||
|
config: DataSourceSchema,
|
||||||
|
options: HistoryOpOptions = {},
|
||||||
|
): DslOpWithHistoryIdsResult<DataSourceSchema> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
this.add(config, options);
|
const result = this.add(config, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link update},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public updateAndGetHistoryId(
|
public updateAndGetHistoryId(
|
||||||
config: DataSourceSchema,
|
config: DataSourceSchema,
|
||||||
options: HistoryOpOptionsWithChangeRecords = {},
|
options: HistoryOpOptionsWithChangeRecords = {},
|
||||||
): string | null {
|
): DslOpWithHistoryIdsResult<DataSourceSchema> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
this.update(config, options);
|
const result = this.update(config, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): string | null {
|
public removeAndGetHistoryId(id: string, options: HistoryOpOptions = {}): DslOpWithHistoryIdsResult<void> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
this.remove(id, options);
|
this.remove(id, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
// #endregion AndGetHistoryId
|
// #endregion AndGetHistoryId
|
||||||
|
|
||||||
@ -266,7 +273,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
*/
|
*/
|
||||||
public undo(id: Id) {
|
public undo(id: Id) {
|
||||||
const step = historyService.undoDataSource(id);
|
const step = historyService.undo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, true);
|
this.applyHistoryStep(step, true);
|
||||||
return step;
|
return step;
|
||||||
@ -278,7 +285,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
*/
|
*/
|
||||||
public redo(id: Id) {
|
public redo(id: Id) {
|
||||||
const step = historyService.redoDataSource(id);
|
const step = historyService.redo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, false);
|
this.applyHistoryStep(step, false);
|
||||||
return step;
|
return step;
|
||||||
@ -286,12 +293,12 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定数据源撤销。 */
|
/** 是否可对指定数据源撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndoDataSource(id);
|
return historyService.canUndo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定数据源重做。 */
|
/** 是否可对指定数据源重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedoDataSource(id);
|
return historyService.canRedo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -302,7 +309,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public goto(id: Id, targetCursor: number): number {
|
public goto(id: Id, targetCursor: number): number {
|
||||||
let cursor = historyService.getDataSourceCursor(id);
|
let cursor = historyService.getCursor('dataSource', id);
|
||||||
const target = Math.max(0, targetCursor);
|
const target = Math.max(0, targetCursor);
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
if (!this.undo(id)) break;
|
if (!this.undo(id)) break;
|
||||||
@ -326,28 +333,29 @@ class DataSource extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用时返回 null
|
||||||
*/
|
*/
|
||||||
public revert(id: Id, index: number): DataSourceStepValue | null {
|
public revert(id: Id, index: number): DataSourceStepValue | null {
|
||||||
const list = historyService.getDataSourceStepList(id);
|
const list = historyService.getStepList('dataSource', id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
// 更新类步骤(前后 schema 都存在)必须带 changeRecords 才支持回滚,否则只能整 schema 替换,会冲掉后续无关变更。
|
||||||
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = entry.step.diff?.[0] ?? {};
|
||||||
if (oldSchema && newSchema && !changeRecords?.length) return null;
|
if (oldSchema && newSchema && !changeRecords?.length) return null;
|
||||||
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.id, entry.step.diff?.[0], (s) => s.title)}`;
|
const description = `回滚 #${index + 1}: ${describeRevertStep<DataSourceSchema>(entry.step.data.id, entry.step.diff?.[0], (s) => s.title)}`;
|
||||||
return this.applyRevertStep(entry.step, description);
|
return this.applyRevertStep(entry.step, description);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过历史记录 uuid 回滚某条数据源历史步骤,语义同 {@link revert},
|
* 通过历史记录 uuid 回滚数据源历史步骤,语义同 {@link revert},
|
||||||
* 仅无需调用方再传 dataSourceId 与 index:内部会按 uuid({@link DataSourceStepValue.uuid})
|
* 仅无需调用方再传 dataSourceId 与 index:内部会按 uuid 在全部数据源栈中定位对应步骤后再回滚。
|
||||||
* 在全部数据源栈中定位对应步骤后再回滚。
|
* 按数组顺序依次回滚,返回与入参同序的结果列表(某项失败时为 `null`)。
|
||||||
*
|
*
|
||||||
* @param uuid 目标历史记录的 uuid,通常由 {@link addAndGetHistoryId} 等方法返回
|
* @param uuids 目标历史记录的 uuid 列表,通常由 {@link addAndGetHistoryId} 等方法返回的 `historyIds`
|
||||||
* @returns 反向后产生的新 step;找不到对应 uuid / 未应用时返回 null
|
|
||||||
*/
|
*/
|
||||||
public revertById(uuid: string): DataSourceStepValue | null {
|
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
||||||
const location = historyService.findDataSourceStepLocationByUuid(uuid);
|
return uuids.map((uuid) => {
|
||||||
|
const location = historyService.findStepLocationByUuid('dataSource', uuid);
|
||||||
if (!location) return null;
|
if (!location) return null;
|
||||||
return this.revert(location.id, location.index);
|
return this.revert(location.id, location.index);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public createId(): string {
|
public createId(): string {
|
||||||
@ -430,19 +438,19 @@ class DataSource extends BaseService {
|
|||||||
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
* 同构,差异仅在于走对应的公共 add / update / remove 而不是带 doNotPushHistory 的版本。
|
||||||
*/
|
*/
|
||||||
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
private applyRevertStep(step: DataSourceStepValue, historyDescription: string): DataSourceStepValue | null {
|
||||||
const { id } = step;
|
const { id } = step.data;
|
||||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||||
|
|
||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 原本是删除 → revert 即重新加回
|
// 原本是删除 → revert 即重新加回
|
||||||
if (oldSchema && !newSchema) {
|
if (oldSchema && !newSchema) {
|
||||||
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
this.add(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -466,11 +474,11 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getDataSourceStepList(id).slice(-1)[0]?.step ?? null;
|
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -488,7 +496,7 @@ class DataSource extends BaseService {
|
|||||||
* @param reverse true=撤销,false=重做
|
* @param reverse true=撤销,false=重做
|
||||||
*/
|
*/
|
||||||
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
private applyHistoryStep(step: DataSourceStepValue, reverse: boolean): void {
|
||||||
const { id } = step;
|
const { id } = step.data;
|
||||||
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
const { oldSchema, newSchema, changeRecords } = step.diff?.[0] ?? {};
|
||||||
|
|
||||||
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
// 新增 / 删除:直接 add 或 remove,不走 patch 逻辑
|
||||||
|
|||||||
@ -42,6 +42,7 @@ import type {
|
|||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
AsyncMethodName,
|
AsyncMethodName,
|
||||||
DslOpOptions,
|
DslOpOptions,
|
||||||
|
DslOpWithHistoryIdsResult,
|
||||||
EditorEvents,
|
EditorEvents,
|
||||||
EditorNodeInfo,
|
EditorNodeInfo,
|
||||||
HistoryOpSource,
|
HistoryOpSource,
|
||||||
@ -73,6 +74,7 @@ import {
|
|||||||
setLayout,
|
setLayout,
|
||||||
toggleFixedPosition,
|
toggleFixedPosition,
|
||||||
} from '@editor/utils/editor';
|
} from '@editor/utils/editor';
|
||||||
|
import { getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
||||||
|
|
||||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||||
@ -163,8 +165,8 @@ class Editor extends BaseService {
|
|||||||
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
||||||
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
||||||
app.items?.forEach((pageNode) => {
|
app.items?.forEach((pageNode) => {
|
||||||
if (pageNode?.id !== undefined && !historyService.getPageMarker(pageNode.id)) {
|
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
|
||||||
historyService.setPageMarker(pageNode.id, {
|
historyService.setMarker('page', pageNode.id, {
|
||||||
name: pageNode.name,
|
name: pageNode.name,
|
||||||
source: options.historySource,
|
source: options.historySource,
|
||||||
});
|
});
|
||||||
@ -177,7 +179,7 @@ class Editor extends BaseService {
|
|||||||
this.state.stageLoading = false;
|
this.state.stageLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']);
|
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,11 +205,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return { node: null, parent: null, page: null };
|
return { node: null, parent: null, page: null, path: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === root.id) {
|
if (id === root.id) {
|
||||||
return { node: root, parent: null, page: null };
|
return { node: root, parent: null, page: null, path: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
|
// 大多数查找的目标都在当前页面内,优先在当前页面子树中查找以避免对整棵树做全量遍历。
|
||||||
@ -295,12 +297,6 @@ class Editor extends BaseService {
|
|||||||
this.set('page', page);
|
this.set('page', page);
|
||||||
this.set('parent', parent);
|
this.set('parent', parent);
|
||||||
|
|
||||||
if (page) {
|
|
||||||
historyService.changePage(toRaw(page));
|
|
||||||
} else {
|
|
||||||
historyService.resetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node?.id) {
|
if (node?.id) {
|
||||||
this.get('stage')
|
this.get('stage')
|
||||||
?.renderer?.runtime?.getApp?.()
|
?.renderer?.runtime?.getApp?.()
|
||||||
@ -539,6 +535,10 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('add', newNodes);
|
this.emit('add', newNodes);
|
||||||
|
this.emit('change', {
|
||||||
|
type: 'add',
|
||||||
|
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
||||||
|
});
|
||||||
|
|
||||||
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
||||||
}
|
}
|
||||||
@ -584,8 +584,6 @@ class Editor extends BaseService {
|
|||||||
stage?.select(pages[0].id);
|
stage?.select(pages[0].id);
|
||||||
} else {
|
} else {
|
||||||
this.selectRoot();
|
this.selectRoot();
|
||||||
|
|
||||||
historyService.resetPage();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -641,6 +639,9 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
|
||||||
|
|
||||||
|
// 删除后节点已从树中移除,无法再反查所属 page,这里在删除前先逐个捕获用于 `change` 事件
|
||||||
|
const changeItems = nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) }));
|
||||||
|
|
||||||
const removedItems: StepDiffItem<MNode>[] = [];
|
const removedItems: StepDiffItem<MNode>[] = [];
|
||||||
let pageForOp: { name: string; id: Id } | null = null;
|
let pageForOp: { name: string; id: Id } | null = null;
|
||||||
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
|
||||||
@ -676,6 +677,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', nodes);
|
this.emit('remove', nodes);
|
||||||
|
this.emit('change', { type: 'remove', data: changeItems });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async doUpdate(
|
public async doUpdate(
|
||||||
@ -693,7 +695,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const node = toRaw(info.node);
|
const node = toRaw(info.node);
|
||||||
|
|
||||||
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
|
let newConfig = await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
||||||
|
|
||||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||||
|
|
||||||
@ -811,6 +813,10 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update', updateData);
|
this.emit('update', updateData);
|
||||||
|
this.emit('change', {
|
||||||
|
type: 'update',
|
||||||
|
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
|
||||||
|
});
|
||||||
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
|
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1052,6 +1058,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('move-layer', offset);
|
this.emit('move-layer', offset);
|
||||||
|
this.emit('change', {
|
||||||
|
type: 'move-layer',
|
||||||
|
data: [{ node, page: this.getPageOfNode(node.id) }],
|
||||||
|
offset,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1231,39 +1242,45 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.emit('drag-to', { targetIndex, configs, targetParent });
|
this.emit('drag-to', { targetIndex, configs, targetParent });
|
||||||
|
this.emit('change', {
|
||||||
|
type: 'drag-to',
|
||||||
|
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
|
||||||
|
targetIndex,
|
||||||
|
targetParent,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// #region AndGetHistoryId
|
// #region AndGetHistoryId
|
||||||
/**
|
/**
|
||||||
* 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致,
|
* 下列 *AndGetHistoryId 方法与对应的普通操作(add / remove / update ...)行为完全一致,
|
||||||
* 唯一区别是返回值为本次写入历史栈的历史记录 uuid({@link StepValue.uuid}),
|
* 返回值在 {@link DslOpWithHistoryIdsResult} 中同时包含原操作结果与本次写入历史栈的 uuid 列表({@link StepValue.uuid}),
|
||||||
* 而非节点 / 节点数组。可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
* 可用于精确引用 / 定位该条历史记录(埋点、revert、跨端同步等)。
|
||||||
*
|
*
|
||||||
* 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时返回 null。
|
* 当本次操作未写入历史(doNotPushHistory 为 true、或操作无实际变更 / 提前返回)时 historyIds 为 `[]`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 等价于 {@link add},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link add},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async addAndGetHistoryId(
|
public async addAndGetHistoryId(
|
||||||
addNode: AddMNode | MNode[],
|
addNode: AddMNode | MNode[],
|
||||||
parent?: MContainer | null,
|
parent?: MContainer | null,
|
||||||
options: DslOpOptions = {},
|
options: DslOpOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.add(addNode, parent, options);
|
const result = await this.add(addNode, parent, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link remove},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link remove},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async removeAndGetHistoryId(
|
public async removeAndGetHistoryId(
|
||||||
nodeOrNodeList: MNode | MNode[],
|
nodeOrNodeList: MNode | MNode[],
|
||||||
options: DslOpOptions = {},
|
options: DslOpOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<void>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.remove(nodeOrNodeList, options);
|
await this.remove(nodeOrNodeList, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link update},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link update},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async updateAndGetHistoryId(
|
public async updateAndGetHistoryId(
|
||||||
config: MNode | MNode[],
|
config: MNode | MNode[],
|
||||||
data: {
|
data: {
|
||||||
@ -1273,43 +1290,43 @@ class Editor extends BaseService {
|
|||||||
historyDescription?: string;
|
historyDescription?: string;
|
||||||
historySource?: HistoryOpSource;
|
historySource?: HistoryOpSource;
|
||||||
} = {},
|
} = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.update(config, data);
|
const result = await this.update(config, data);
|
||||||
return this.lastPushedHistoryId;
|
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link moveLayer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link moveLayer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async moveLayerAndGetHistoryId(
|
public async moveLayerAndGetHistoryId(
|
||||||
offset: number | LayerOffset,
|
offset: number | LayerOffset,
|
||||||
options: DslOpOptions = {},
|
options: DslOpOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<void>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.moveLayer(offset, options);
|
await this.moveLayer(offset, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link moveToContainer},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link moveToContainer},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async moveToContainerAndGetHistoryId(
|
public async moveToContainerAndGetHistoryId(
|
||||||
config: MNode | MNode[],
|
config: MNode | MNode[],
|
||||||
targetId: Id,
|
targetId: Id,
|
||||||
options: DslOpOptions = {},
|
options: DslOpOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.moveToContainer(config, targetId, options);
|
const result = await this.moveToContainer(config, targetId, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 等价于 {@link dragTo},但返回本次写入历史记录的 uuid(未入栈时返回 null)。 */
|
/** 等价于 {@link dragTo},并额外返回本次写入历史记录的 uuid 列表(未入栈时 historyIds 为 `[]`)。 */
|
||||||
public async dragToAndGetHistoryId(
|
public async dragToAndGetHistoryId(
|
||||||
config: MNode | MNode[],
|
config: MNode | MNode[],
|
||||||
targetParent: MContainer,
|
targetParent: MContainer,
|
||||||
targetIndex: number,
|
targetIndex: number,
|
||||||
options: DslOpOptions = {},
|
options: DslOpOptions = {},
|
||||||
): Promise<string | null> {
|
): Promise<DslOpWithHistoryIdsResult<void>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
await this.dragTo(config, targetParent, targetIndex, options);
|
await this.dragTo(config, targetParent, targetIndex, options);
|
||||||
return this.lastPushedHistoryId;
|
return { result: undefined, historyIds: getLastPushedHistoryIds(this.lastPushedHistoryId) };
|
||||||
}
|
}
|
||||||
// #endregion AndGetHistoryId
|
// #endregion AndGetHistoryId
|
||||||
|
|
||||||
@ -1318,7 +1335,9 @@ class Editor extends BaseService {
|
|||||||
* @returns 被撤销的操作
|
* @returns 被撤销的操作
|
||||||
*/
|
*/
|
||||||
public async undo(): Promise<StepValue | null> {
|
public async undo(): Promise<StepValue | null> {
|
||||||
const value = historyService.undo();
|
const pageId = this.get('page')?.id;
|
||||||
|
if (pageId === undefined) return null;
|
||||||
|
const value = historyService.undo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, true);
|
await this.applyHistoryOp(value, true);
|
||||||
}
|
}
|
||||||
@ -1330,7 +1349,9 @@ class Editor extends BaseService {
|
|||||||
* @returns 被恢复的操作
|
* @returns 被恢复的操作
|
||||||
*/
|
*/
|
||||||
public async redo(): Promise<StepValue | null> {
|
public async redo(): Promise<StepValue | null> {
|
||||||
const value = historyService.redo();
|
const pageId = this.get('page')?.id;
|
||||||
|
if (pageId === undefined) return null;
|
||||||
|
const value = historyService.redo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, false);
|
await this.applyHistoryOp(value, false);
|
||||||
}
|
}
|
||||||
@ -1354,7 +1375,7 @@ class Editor extends BaseService {
|
|||||||
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
|
* @returns 反向后产生的新 step;目标不存在 / 未应用 / 反向失败时返回 null
|
||||||
*/
|
*/
|
||||||
public async revertPageStep(index: number): Promise<StepValue | null> {
|
public async revertPageStep(index: number): Promise<StepValue | null> {
|
||||||
const list = historyService.getPageStepList();
|
const list = historyService.getStepList('page', this.get('page')?.id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
|
|
||||||
@ -1372,7 +1393,8 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
|
// 反向应用产生的新 step 由内部 pushOpHistory 触发 history `change` 事件,监听一次以拿到引用。
|
||||||
let revertedStep: StepValue | null = null;
|
let revertedStep: StepValue | null = null;
|
||||||
const captureRevert = (s: StepValue) => {
|
// page 的 `change` 事件回调签名为 `(pageId, step)`,这里只关心被回滚产生的新 step。
|
||||||
|
const captureRevert = (_pageId: Id, s: StepValue) => {
|
||||||
revertedStep = s;
|
revertedStep = s;
|
||||||
};
|
};
|
||||||
historyService.once('change', captureRevert);
|
historyService.once('change', captureRevert);
|
||||||
@ -1451,17 +1473,20 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 通过历史记录 uuid 回滚当前页面的某条历史步骤,语义与 {@link revertPageStep} 完全一致,
|
* 通过历史记录 uuid 回滚当前页面的历史步骤,语义与 {@link revertPageStep} 完全一致,
|
||||||
* 仅入参从 index 改为 uuid({@link StepValue.uuid})。uuid 不随栈内步骤增删而变化,
|
* 仅入参从 index 改为 uuid 列表({@link StepValue.uuid})。按数组顺序依次回滚,
|
||||||
* 更适合业务侧持有引用后再回滚(埋点、跨端同步等场景)。
|
* 返回与入参同序的结果列表(某项失败时为 `null`)。
|
||||||
*
|
*
|
||||||
* @param uuid 目标历史记录的 uuid,通常由 *AndGetHistoryId 方法返回
|
* @param uuids 目标历史记录的 uuid 列表,通常由 *AndGetHistoryId 方法返回的 `historyIds`
|
||||||
* @returns 反向后产生的新 step;找不到对应 uuid / 未应用 / 反向失败时返回 null
|
|
||||||
*/
|
*/
|
||||||
public async revertPageStepById(uuid: string): Promise<StepValue | null> {
|
public async revertPageStepById(uuids: string[]): Promise<(StepValue | null)[]> {
|
||||||
const index = historyService.getPageStepIndexByUuid(uuid);
|
const results: (StepValue | null)[] = [];
|
||||||
if (index < 0) return null;
|
const pageId = this.get('page')?.id;
|
||||||
return this.revertPageStep(index);
|
for (const uuid of uuids) {
|
||||||
|
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
|
||||||
|
results.push(!location ? null : await this.revertPageStep(location.index));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1474,8 +1499,9 @@ class Editor extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public async gotoPageStep(targetCursor: number): Promise<number> {
|
public async gotoPageStep(targetCursor: number): Promise<number> {
|
||||||
let cursor = historyService.getPageCursor();
|
const pageId = this.get('page')?.id;
|
||||||
const { length } = historyService.getPageStepList();
|
let cursor = historyService.getCursor('page', pageId);
|
||||||
|
const { length } = historyService.getStepList('page', pageId);
|
||||||
const target = Math.max(0, Math.min(targetCursor, length));
|
const target = Math.max(0, Math.min(targetCursor, length));
|
||||||
while (cursor > target) {
|
while (cursor > target) {
|
||||||
const step = await this.undo();
|
const step = await this.undo();
|
||||||
@ -1555,6 +1581,20 @@ class Editor extends BaseService {
|
|||||||
this.get('modifiedNodeIds').set(id, id);
|
this.get('modifiedNodeIds').set(id, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定节点所属的页面 / 页面片:
|
||||||
|
* - 普通节点返回其所在的 page;
|
||||||
|
* - 节点本身就是 page / pageFragment 时返回它自己;
|
||||||
|
* - 找不到时返回 null。
|
||||||
|
* 供 `change` 事件携带「变更节点对应的 page」(而非编辑器当前选中页)。
|
||||||
|
*/
|
||||||
|
private getPageOfNode(id: Id): MPage | MPageFragment | null {
|
||||||
|
const { node, page } = this.getNodeInfo(id, false);
|
||||||
|
if (page) return page;
|
||||||
|
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private captureSelectionBeforeOp() {
|
private captureSelectionBeforeOp() {
|
||||||
if (this.selectionBeforeOp) return;
|
if (this.selectionBeforeOp) return;
|
||||||
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
|
||||||
@ -1624,9 +1664,11 @@ class Editor extends BaseService {
|
|||||||
uuid: guid(),
|
uuid: guid(),
|
||||||
data: { name: page.name || '', id: page.id },
|
data: { name: page.name || '', id: page.id },
|
||||||
opType,
|
opType,
|
||||||
|
extra: {
|
||||||
selectedBefore: [],
|
selectedBefore: [],
|
||||||
selectedAfter: [],
|
selectedAfter: [],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
|
},
|
||||||
diff: [diffItem],
|
diff: [diffItem],
|
||||||
rootStep: true,
|
rootStep: true,
|
||||||
};
|
};
|
||||||
@ -1634,9 +1676,9 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const top = historyService.getCurrentPageStep(page.id);
|
const top = historyService.getCurrentPageStep(page.id);
|
||||||
if (top?.rootStep && top.source === source) {
|
if (top?.rootStep && top.source === source) {
|
||||||
historyService.replaceCurrentPageStep(step, page.id);
|
historyService.replaceCurrentStep('page', step, page.id);
|
||||||
} else {
|
} else {
|
||||||
historyService.push(step, page.id);
|
historyService.push('page', step, page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1658,16 +1700,18 @@ class Editor extends BaseService {
|
|||||||
uuid: guid(),
|
uuid: guid(),
|
||||||
data: pageData,
|
data: pageData,
|
||||||
opType,
|
opType,
|
||||||
|
extra: {
|
||||||
selectedBefore: this.selectionBeforeOp ?? [],
|
selectedBefore: this.selectionBeforeOp ?? [],
|
||||||
selectedAfter: this.get('nodes').map((n) => n.id),
|
selectedAfter: this.get('nodes').map((n) => n.id),
|
||||||
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
|
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
|
||||||
|
},
|
||||||
diff,
|
diff,
|
||||||
};
|
};
|
||||||
if (historyDescription) step.historyDescription = historyDescription;
|
if (historyDescription) step.historyDescription = historyDescription;
|
||||||
if (source) step.source = source;
|
if (source) step.source = source;
|
||||||
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
||||||
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
||||||
const pushed = historyService.push(step, pageData.id);
|
const pushed = historyService.push('page', step, pageData.id);
|
||||||
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
|
// push 返回 null 表示当前没有可写入的页面栈(未真正入栈),此时不应返回 uuid。
|
||||||
const historyId = pushed ? step.uuid : null;
|
const historyId = pushed ? step.uuid : null;
|
||||||
this.lastPushedHistoryId = historyId;
|
this.lastPushedHistoryId = historyId;
|
||||||
@ -1795,11 +1839,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.set('modifiedNodeIds', step.modifiedNodeIds);
|
this.set('modifiedNodeIds', step.extra?.modifiedNodeIds ?? new Map());
|
||||||
|
|
||||||
const page = toRaw(this.get('page'));
|
const page = toRaw(this.get('page'));
|
||||||
if (page) {
|
if (page) {
|
||||||
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
|
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!selectIds.length) return;
|
if (!selectIds.length) return;
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -27,6 +27,9 @@ import {
|
|||||||
DEFAULT_LEFT_COLUMN_WIDTH,
|
DEFAULT_LEFT_COLUMN_WIDTH,
|
||||||
DEFAULT_RIGHT_COLUMN_WIDTH,
|
DEFAULT_RIGHT_COLUMN_WIDTH,
|
||||||
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
LEFT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
|
MIN_CENTER_COLUMN_WIDTH,
|
||||||
|
MIN_LEFT_COLUMN_WIDTH,
|
||||||
|
MIN_RIGHT_COLUMN_WIDTH,
|
||||||
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
RIGHT_COLUMN_WIDTH_STORAGE_KEY,
|
||||||
} from '@editor/utils/const';
|
} from '@editor/utils/const';
|
||||||
|
|
||||||
@ -54,6 +57,9 @@ const state = shallowReactive<UiState>({
|
|||||||
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) ||
|
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) ||
|
||||||
DEFAULT_RIGHT_COLUMN_WIDTH,
|
DEFAULT_RIGHT_COLUMN_WIDTH,
|
||||||
},
|
},
|
||||||
|
minLeftColumnWidth: MIN_LEFT_COLUMN_WIDTH,
|
||||||
|
minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH,
|
||||||
|
minRightColumnWidth: MIN_RIGHT_COLUMN_WIDTH,
|
||||||
showGuides: true,
|
showGuides: true,
|
||||||
hasGuides: false,
|
hasGuides: false,
|
||||||
showRule: true,
|
showRule: true,
|
||||||
|
|||||||
@ -25,10 +25,18 @@
|
|||||||
.m-editor-float-box-body {
|
.m-editor-float-box-body {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0 16px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.m-editor-floating-box-moveable {
|
.m-editor-floating-box-moveable {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.m-editor-float-box-drag-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
|||||||
@ -330,6 +330,20 @@
|
|||||||
font-weight: 400; // 防止被合并组头部的粗体继承
|
font-weight: 400; // 防止被合并组头部的粗体继承
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 「操作人」徽标:浅蓝描边胶囊,弱化展示操作人,仅在 step.operator 有值时渲染。
|
||||||
|
.m-editor-history-list-item-operator {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0 6px;
|
||||||
|
border: 1px solid #c6e2ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 14px;
|
||||||
|
color: #409eff;
|
||||||
|
background-color: #ecf5ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 400; // 防止被合并组头部的粗体继承
|
||||||
|
}
|
||||||
|
|
||||||
// 「已保存」徽标:绿色实心胶囊,标记最近一次保存对应的历史记录(与 historyService.markSaved 对应)。
|
// 「已保存」徽标:绿色实心胶囊,标记最近一次保存对应的历史记录(与 historyService.markSaved 对应)。
|
||||||
.m-editor-history-list-item-saved {
|
.m-editor-history-list-item-saved {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
|||||||
@ -24,9 +24,12 @@
|
|||||||
& + .border-icon {
|
& + .border-icon {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
&.configured {
|
||||||
|
border-color: var(--el-color-success, var(--td-success-color, #2ba471));
|
||||||
|
}
|
||||||
&.active {
|
&.active {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
border-color: var(--el-color-primary);
|
border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
|
||||||
}
|
}
|
||||||
&.border-icon-top {
|
&.border-icon-top {
|
||||||
border-top-width: 2px;
|
border-top-width: 2px;
|
||||||
|
|||||||
@ -16,7 +16,7 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Component } from 'vue';
|
import type { AppContext, Component } from 'vue';
|
||||||
import type EventEmitter from 'events';
|
import type EventEmitter from 'events';
|
||||||
import type * as Monaco from 'monaco-editor';
|
import type * as Monaco from 'monaco-editor';
|
||||||
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
|
||||||
@ -33,7 +33,8 @@ import type {
|
|||||||
MPage,
|
MPage,
|
||||||
MPageFragment,
|
MPageFragment,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
import type { FieldSize } from '@tmagic/design';
|
||||||
|
import type { ChangeRecord, FormConfig, FormState, TableColumnConfig, TypeFunction } from '@tmagic/form';
|
||||||
import type StageCore from '@tmagic/stage';
|
import type StageCore from '@tmagic/stage';
|
||||||
import type {
|
import type {
|
||||||
CanDropIn,
|
CanDropIn,
|
||||||
@ -296,6 +297,12 @@ export interface UiState {
|
|||||||
stageRect: StageRect;
|
stageRect: StageRect;
|
||||||
/** 编辑器列布局每一列的宽度,分为左中右三列 */
|
/** 编辑器列布局每一列的宽度,分为左中右三列 */
|
||||||
columnWidth: GetColumnWidth;
|
columnWidth: GetColumnWidth;
|
||||||
|
/** 编辑器列布局左侧列最小宽度 */
|
||||||
|
minLeftColumnWidth: number;
|
||||||
|
/** 编辑器列布局中间列最小宽度 */
|
||||||
|
minCenterColumnWidth: number;
|
||||||
|
/** 编辑器列布局右侧列最小宽度 */
|
||||||
|
minRightColumnWidth: number;
|
||||||
/** 是否显示画布参考线,true: 显示,false: 不显示,默认为true */
|
/** 是否显示画布参考线,true: 显示,false: 不显示,默认为true */
|
||||||
showGuides: boolean;
|
showGuides: boolean;
|
||||||
/** 画布上是否存在参考线 */
|
/** 画布上是否存在参考线 */
|
||||||
@ -335,6 +342,7 @@ export interface EditorNodeInfo {
|
|||||||
node: MNode | null;
|
node: MNode | null;
|
||||||
parent: MContainer | null;
|
parent: MContainer | null;
|
||||||
page: MPage | MPageFragment | null;
|
page: MPage | MPageFragment | null;
|
||||||
|
path: MNode[];
|
||||||
}
|
}
|
||||||
// #endregion EditorNodeInfo
|
// #endregion EditorNodeInfo
|
||||||
|
|
||||||
@ -736,6 +744,14 @@ export type HistoryOpSource =
|
|||||||
| (string & {});
|
| (string & {});
|
||||||
// #endregion HistoryOpSource
|
// #endregion HistoryOpSource
|
||||||
|
|
||||||
|
// #region DslOpWithHistoryIdsResult
|
||||||
|
/** *AndGetHistoryId 系列方法返回值:原操作结果 + 本次写入历史记录的 uuid 列表(未入栈时为 `[]`)。 */
|
||||||
|
export type DslOpWithHistoryIdsResult<T> = {
|
||||||
|
result: T;
|
||||||
|
historyIds: string[];
|
||||||
|
};
|
||||||
|
// #endregion DslOpWithHistoryIdsResult
|
||||||
|
|
||||||
// #region StepDiffItem
|
// #region StepDiffItem
|
||||||
/**
|
/**
|
||||||
* 单条变更的 diff 描述,统一表达「页面节点 / 代码块 / 数据源」的变化内容,
|
* 单条变更的 diff 描述,统一表达「页面节点 / 代码块 / 数据源」的变化内容,
|
||||||
@ -771,13 +787,18 @@ export interface StepDiffItem<T = unknown> {
|
|||||||
*
|
*
|
||||||
* 泛型 `T` 为 `diff` 中变化内容的快照类型(页面节点 `MNode` / 代码块 `CodeBlockContent` / 数据源 `DataSourceSchema`)。
|
* 泛型 `T` 为 `diff` 中变化内容的快照类型(页面节点 `MNode` / 代码块 `CodeBlockContent` / 数据源 `DataSourceSchema`)。
|
||||||
*/
|
*/
|
||||||
export interface BaseStepValue<T = unknown> {
|
export interface BaseStepValue<T = unknown, U extends Record<string, any> = {}> {
|
||||||
/**
|
/**
|
||||||
* 历史记录唯一标识(uuid)。入栈时自动写入(若调用方未指定),
|
* 历史记录唯一标识(uuid)。入栈时自动写入(若调用方未指定),
|
||||||
* 用于精确定位 / 引用某一条历史记录(如 revert、埋点、跨端同步等)。
|
* 用于精确定位 / 引用某一条历史记录(如 revert、埋点、跨端同步等)。
|
||||||
* 注意与各自的 `id`(关联的页面 / 代码块 / 数据源 id)区分。
|
* 注意与 `data.id`(关联的页面 / 代码块 / 数据源 id)区分。
|
||||||
*/
|
*/
|
||||||
uuid: string;
|
uuid: string;
|
||||||
|
/**
|
||||||
|
* 关联目标信息:`id` 为关联的页面 / 代码块 / 数据源等资源 id(也是历史栈的分组 key),
|
||||||
|
* `name` 为展示名。所有历史类型统一携带。
|
||||||
|
*/
|
||||||
|
data: { name: string; id: Id };
|
||||||
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
|
/** 操作类型:新增 / 删除 / 更新(三类历史记录统一携带)。 */
|
||||||
opType: HistoryOpType;
|
opType: HistoryOpType;
|
||||||
/**
|
/**
|
||||||
@ -811,64 +832,96 @@ export interface BaseStepValue<T = unknown> {
|
|||||||
* 避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录。
|
* 避免源码反复保存 / 外部重设 DSL 时堆积多条 root 记录。
|
||||||
*/
|
*/
|
||||||
rootStep?: boolean;
|
rootStep?: boolean;
|
||||||
|
/** 操作人 */
|
||||||
|
operator?: string;
|
||||||
|
/** 扩展信息 */
|
||||||
|
extra?: U;
|
||||||
}
|
}
|
||||||
// #endregion BaseStepValue
|
// #endregion BaseStepValue
|
||||||
|
|
||||||
// #region StepValue
|
// #region StepExtra
|
||||||
export interface StepValue extends BaseStepValue<MNode> {
|
/**
|
||||||
/** 页面信息 */
|
* 历史记录的扩展上下文({@link BaseStepValue.extra})。
|
||||||
data: { name: string; id: Id };
|
* 内置字段供 `page` 类型在撤销 / 重做时恢复选区与受影响节点;扩展类型可自由附加其它键。
|
||||||
/** 操作前选中的节点 ID,用于撤销后恢复选择状态 */
|
*/
|
||||||
selectedBefore: Id[];
|
export interface StepExtra {
|
||||||
/** 操作后选中的节点 ID,用于重做后恢复选择状态 */
|
/** 操作前选中的节点 ID,用于撤销后恢复选择状态(page 类型) */
|
||||||
selectedAfter: Id[];
|
selectedBefore?: Id[];
|
||||||
modifiedNodeIds: Map<Id, Id>;
|
/** 操作后选中的节点 ID,用于重做后恢复选择状态(page 类型) */
|
||||||
|
selectedAfter?: Id[];
|
||||||
|
/** 本次操作涉及的节点 id 集合(page 类型) */
|
||||||
|
modifiedNodeIds?: Map<Id, Id>;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
// #endregion StepExtra
|
||||||
|
|
||||||
|
// #region StepValue
|
||||||
|
/**
|
||||||
|
* 页面节点历史记录条目(`diff` 内容为 {@link MNode})。结构已与代码块 / 数据源统一收敛到
|
||||||
|
* {@link BaseStepValue}:关联 id 见 `data.id`,选区等上下文见 `extra`。
|
||||||
|
*/
|
||||||
|
export type StepValue = BaseStepValue<MNode, StepExtra>;
|
||||||
// #endregion StepValue
|
// #endregion StepValue
|
||||||
|
|
||||||
// #region CodeBlockStepValue
|
// #region CodeBlockStepValue
|
||||||
/**
|
/**
|
||||||
* 代码块历史记录条目。按 codeBlock.id 分组保存到 historyState.codeBlockState。
|
* 代码块历史记录条目(`diff` 内容为 {@link CodeBlockContent}),按 `data.id`(codeBlock.id)
|
||||||
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
|
* 分组保存到 historyState.steps.codeBlock。结构与 {@link StepValue} / {@link DataSourceStepValue}
|
||||||
* - 新增(opType 'add'):仅 `newSchema`(新内容);
|
* 一致,仅 `diff` 快照类型不同。
|
||||||
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
|
|
||||||
* - 删除(opType 'remove'):仅 `oldSchema`(删除前内容)。
|
|
||||||
*/
|
*/
|
||||||
export interface CodeBlockStepValue extends BaseStepValue<CodeBlockContent> {
|
export type CodeBlockStepValue = BaseStepValue<CodeBlockContent>;
|
||||||
/** 关联的代码块 id */
|
|
||||||
id: Id;
|
|
||||||
}
|
|
||||||
// #endregion CodeBlockStepValue
|
// #endregion CodeBlockStepValue
|
||||||
|
|
||||||
// #region DataSourceStepValue
|
// #region DataSourceStepValue
|
||||||
/**
|
/**
|
||||||
* 数据源历史记录条目。按 dataSource.id 分组保存到 historyState.dataSourceState。
|
* 数据源历史记录条目(`diff` 内容为 {@link DataSourceSchema}),按 `data.id`(dataSource.id)
|
||||||
* 变更内容统一由 `diff`(单项)表达,每项见 {@link StepDiffItem}:
|
* 分组保存到 historyState.steps.dataSource。结构与 {@link StepValue} / {@link CodeBlockStepValue}
|
||||||
* - 新增(opType 'add'):仅 `newSchema`(新 schema);
|
* 一致,仅 `diff` 快照类型不同。
|
||||||
* - 更新(opType 'update'):`oldSchema` + `newSchema`,并可带 `changeRecords` 做局部更新;
|
|
||||||
* - 删除(opType 'remove'):仅 `oldSchema`(删除前 schema)。
|
|
||||||
*/
|
*/
|
||||||
export interface DataSourceStepValue extends BaseStepValue<DataSourceSchema> {
|
export type DataSourceStepValue = BaseStepValue<DataSourceSchema>;
|
||||||
/** 关联的数据源 id */
|
|
||||||
id: Id;
|
|
||||||
}
|
|
||||||
// #endregion DataSourceStepValue
|
// #endregion DataSourceStepValue
|
||||||
|
|
||||||
|
// #region HistorySteps
|
||||||
|
/**
|
||||||
|
* 历史记录类型标识:内置 `page` / `codeBlock` / `dataSource`,并允许业务扩展自定义类型。
|
||||||
|
* `(string & {})` 保留对内置字面量的智能提示,同时不限制扩展取值。
|
||||||
|
*/
|
||||||
|
export type HistoryStepType = 'page' | 'codeBlock' | 'dataSource' | (string & {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全部历史栈的统一容器,按「类型 -> id -> UndoRedo 栈」两级分组。
|
||||||
|
*
|
||||||
|
* - `page`:页面历史栈,按 page.id 分组(每页一份 UndoRedo);
|
||||||
|
* - `codeBlock`:代码块历史栈,按 codeBlock.id 分组;
|
||||||
|
* - `dataSource`:数据源历史栈,按 dataSource.id 分组;
|
||||||
|
* - 其余键:业务通过 {@link HistoryService.registerStepType} 注册的自定义历史类型。
|
||||||
|
*
|
||||||
|
* 所有类型(含扩展类型)一视同仁:均按 id 独立分栈、独立 undo/redo,且都可通过
|
||||||
|
* {@link HistoryService.setMarker} 在 index 0 种入 `initial` 基线(撤销 / 回滚不会越过该基线)。
|
||||||
|
*/
|
||||||
|
export interface HistorySteps {
|
||||||
|
page: Record<Id, UndoRedo<StepValue>>;
|
||||||
|
codeBlock: Record<Id, UndoRedo<CodeBlockStepValue>>;
|
||||||
|
dataSource: Record<Id, UndoRedo<DataSourceStepValue>>;
|
||||||
|
/** 扩展历史类型:按 id 分组的 UndoRedo 栈。 */
|
||||||
|
[stepType: string]: Record<Id, UndoRedo<any>>;
|
||||||
|
}
|
||||||
|
// #endregion HistorySteps
|
||||||
|
|
||||||
export interface HistoryState {
|
export interface HistoryState {
|
||||||
pageId?: Id;
|
|
||||||
pageSteps: Record<Id, UndoRedo<StepValue>>;
|
|
||||||
canRedo: boolean;
|
|
||||||
canUndo: boolean;
|
|
||||||
/**
|
/**
|
||||||
* 代码块历史栈,按 codeBlock.id 分组(每个代码块独立一份 UndoRedo)。
|
* 全部历史栈的统一容器(页面 / 代码块 / 数据源 / 扩展类型),见 {@link HistorySteps}。
|
||||||
* 与页面/节点无关,支持独立 undo/redo。
|
* 各类型互不影响,支持按 id 独立 undo/redo;是否可撤销 / 重做改用 {@link HistoryService.canUndo} /
|
||||||
|
* {@link HistoryService.canRedo}(按 stepType + id 查询)替代旧的全局 canUndo / canRedo 字段。
|
||||||
*/
|
*/
|
||||||
codeBlockState: Record<Id, UndoRedo<CodeBlockStepValue>>;
|
steps: HistorySteps;
|
||||||
/**
|
/**
|
||||||
* 数据源历史栈,按 dataSource.id 分组(每个数据源独立一份 UndoRedo)。
|
* 各历史类型的展示名称,用于历史面板({@link HistorySteps} 的 tab / 分组标题等)。
|
||||||
* 与页面/节点无关,支持独立 undo/redo。
|
* 内置 `page` / `codeBlock` / `dataSource` 有默认中文名(页面 / 代码块 / 数据源),
|
||||||
|
* 扩展类型可通过 {@link HistoryService.registerStepType} 的 `name` 选项或
|
||||||
|
* {@link HistoryService.setStepName} 登记;读取请用 {@link HistoryService.getStepName}。
|
||||||
*/
|
*/
|
||||||
dataSourceState: Record<Id, UndoRedo<DataSourceStepValue>>;
|
stepNames: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #region PersistedHistoryState
|
// #region PersistedHistoryState
|
||||||
@ -879,14 +932,16 @@ export interface HistoryState {
|
|||||||
export interface PersistedHistoryState {
|
export interface PersistedHistoryState {
|
||||||
/** 快照结构版本号,便于后续兼容升级。 */
|
/** 快照结构版本号,便于后续兼容升级。 */
|
||||||
version: number;
|
version: number;
|
||||||
/** 保存时的活动页 id。 */
|
/**
|
||||||
pageId?: Id;
|
* 全部历史栈的序列化快照,按「类型 -> id」两级分组,与 {@link HistorySteps} 对应。
|
||||||
/** 各页面历史栈的序列化快照,按 pageId 分组。 */
|
* 内置 `page` / `codeBlock` / `dataSource`,并包含业务注册的扩展类型。
|
||||||
pageSteps: Record<Id, SerializedUndoRedo<StepValue>>;
|
*/
|
||||||
/** 各代码块历史栈的序列化快照,按 codeBlockId 分组。 */
|
steps: {
|
||||||
codeBlockState: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
|
page: Record<Id, SerializedUndoRedo<StepValue>>;
|
||||||
/** 各数据源历史栈的序列化快照,按 dataSourceId 分组。 */
|
codeBlock: Record<Id, SerializedUndoRedo<CodeBlockStepValue>>;
|
||||||
dataSourceState: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
|
dataSource: Record<Id, SerializedUndoRedo<DataSourceStepValue>>;
|
||||||
|
[stepType: string]: Record<Id, SerializedUndoRedo<any>>;
|
||||||
|
};
|
||||||
/** 保存时间戳(毫秒)。 */
|
/** 保存时间戳(毫秒)。 */
|
||||||
savedAt: number;
|
savedAt: number;
|
||||||
}
|
}
|
||||||
@ -914,9 +969,9 @@ export interface HistoryPersistOptions {
|
|||||||
/**
|
/**
|
||||||
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
* 历史面板用:当前页面的一条历史步骤(包含位置和是否已应用)。
|
||||||
*/
|
*/
|
||||||
export interface PageHistoryStepEntry {
|
export interface HistoryStepEntry<T> {
|
||||||
/** 步骤内容 */
|
/** 步骤内容 */
|
||||||
step: StepValue;
|
step: T;
|
||||||
/** 在所属栈中的索引(0 为最早) */
|
/** 在所属栈中的索引(0 为最早) */
|
||||||
index: number;
|
index: number;
|
||||||
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
|
/** 是否处于"已应用"段(即位于栈游标之前)。撤销后变为 false。 */
|
||||||
@ -926,63 +981,39 @@ export interface PageHistoryStepEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 页面历史面板分组。
|
* 历史面板分组(页面 / 数据源 / 代码块 / 扩展类型统一结构)。
|
||||||
* - 连续修改同一目标节点(updatedItems[0].oldNode.id 一致)的 'update' 步骤合并成一组;
|
*
|
||||||
* - 多节点更新 / add / remove 始终独立成组(无法明确归属单一目标)。
|
* 把指定历史栈的步骤列表按"目标"做相邻合并:
|
||||||
* - targetId 为 undefined 表示"无明确目标"(如 add/remove/多节点 update),不参与合并。
|
* - 连续修改同一目标(单实体 update,targetId 一致)的多步合并成一组,组内可展开查看每步;
|
||||||
|
* - add / remove / 多实体 update 始终独立成组(无法明确归属单一目标);
|
||||||
|
* - targetId 为 undefined 表示"无明确目标",不参与合并。
|
||||||
|
*
|
||||||
|
* 各类型仅 `kind` 与 step 快照类型不同,统一由泛型描述:
|
||||||
|
* - 页面:`HistoryGroup<StepValue>`,`kind: 'page'`,`id` 为 pageId,`targetId` 为被改节点 id;
|
||||||
|
* - 数据源:`HistoryGroup<DataSourceStepValue>`,`kind: 'data-source'`,`id` 为 dataSource.id;
|
||||||
|
* - 代码块:`HistoryGroup<CodeBlockStepValue>`,`kind: 'code-block'`,`id` 为 codeBlock.id。
|
||||||
*/
|
*/
|
||||||
export interface PageHistoryGroup {
|
export interface HistoryGroup<T extends BaseStepValue = BaseStepValue> {
|
||||||
kind: 'page';
|
/** 历史类型标识:page / code-block / data-source(扩展类型同理)。 */
|
||||||
/** 所属页面 id */
|
kind: string;
|
||||||
pageId: Id;
|
/** 所属栈 id(page 为 pageId,代码块 / 数据源为对应资源 id)。 */
|
||||||
/** 该分组的操作类型 */
|
id: Id;
|
||||||
|
/** 该分组的操作类型。 */
|
||||||
opType: HistoryOpType;
|
opType: HistoryOpType;
|
||||||
/**
|
/**
|
||||||
* 合并的目标节点 id;只有"单节点 update"才有值,并按此 id 与相邻同 id 的 update 合并。
|
* 合并的目标 id:仅"单实体 update"有值,并按此与相邻同 id 的 update 合并。
|
||||||
* undefined 表示该分组不可被合并(add / remove / 多节点 update)。
|
* undefined 表示该分组不可被合并(add / remove / 多实体 update)。
|
||||||
*/
|
*/
|
||||||
targetId?: Id;
|
targetId?: Id;
|
||||||
/** 目标节点的可读名(取最后一步的 newNode.name/type/id) */
|
/** 目标可读名(取最后一步快照的 name/type/id)。 */
|
||||||
targetName?: string;
|
targetName?: string;
|
||||||
/** 组内所有步骤,按时间正序 */
|
/** 组内所有步骤,按时间正序。 */
|
||||||
steps: PageHistoryStepEntry[];
|
steps: { step: T; index: number; applied: boolean; isCurrent?: boolean }[];
|
||||||
/** 组内最后一步是否已应用 */
|
/** 组内最后一步是否已应用。 */
|
||||||
applied: boolean;
|
applied: boolean;
|
||||||
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
|
/** 是否为当前所在的分组(包含栈中最近一次已应用步骤的那一组)。 */
|
||||||
isCurrent?: boolean;
|
isCurrent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 代码块历史面板分组。
|
|
||||||
* - 同一 codeBlockId 的栈内,相邻的 'update' 操作会合并成一个 group;
|
|
||||||
* - 'add' / 'remove' 始终独立成组(语义上是一次性事件)。
|
|
||||||
*/
|
|
||||||
export interface CodeBlockHistoryGroup {
|
|
||||||
kind: 'code-block';
|
|
||||||
/** 关联的 codeBlock id */
|
|
||||||
id: Id;
|
|
||||||
/** 该分组的操作类型 */
|
|
||||||
opType: HistoryOpType;
|
|
||||||
/** 组内所有步骤,按时间正序 */
|
|
||||||
steps: { step: CodeBlockStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
|
||||||
/** 组内最后一步是否已应用,用于整组的状态展示 */
|
|
||||||
applied: boolean;
|
|
||||||
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
|
||||||
isCurrent?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 数据源历史面板分组,结构同 CodeBlockHistoryGroup。
|
|
||||||
*/
|
|
||||||
export interface DataSourceHistoryGroup {
|
|
||||||
kind: 'data-source';
|
|
||||||
id: Id;
|
|
||||||
opType: HistoryOpType;
|
|
||||||
steps: { step: DataSourceStepValue; index: number; applied: boolean; isCurrent?: boolean }[];
|
|
||||||
applied: boolean;
|
|
||||||
/** 是否为当前所在的分组(包含该栈最近一次已应用步骤的那一组)。 */
|
|
||||||
isCurrent?: boolean;
|
|
||||||
}
|
|
||||||
// #endregion HistoryListEntry
|
// #endregion HistoryListEntry
|
||||||
|
|
||||||
export enum KeyBindingCommand {
|
export enum KeyBindingCommand {
|
||||||
@ -1147,6 +1178,8 @@ export type SyncHookPlugin<
|
|||||||
|
|
||||||
export interface EventBusEvent {
|
export interface EventBusEvent {
|
||||||
'edit-data-source': [id: string];
|
'edit-data-source': [id: string];
|
||||||
|
'edit-data-source-method': [id: string, methodName: string];
|
||||||
|
'edit-data-source-field': [id: string, fieldPath: string[]];
|
||||||
'remove-data-source': [id: string];
|
'remove-data-source': [id: string];
|
||||||
'edit-code': [id: string];
|
'edit-code': [id: string];
|
||||||
}
|
}
|
||||||
@ -1180,7 +1213,11 @@ export type CustomContentMenuFunction = (
|
|||||||
) => (MenuButton | MenuComponent)[];
|
) => (MenuButton | MenuComponent)[];
|
||||||
|
|
||||||
export interface EditorEvents {
|
export interface EditorEvents {
|
||||||
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
|
'root-change': [
|
||||||
|
value: StoreState['root'],
|
||||||
|
preValue?: StoreState['root'],
|
||||||
|
options?: { historySource?: HistoryOpSource },
|
||||||
|
];
|
||||||
select: [node: MNode | null];
|
select: [node: MNode | null];
|
||||||
add: [nodes: MNode[]];
|
add: [nodes: MNode[]];
|
||||||
remove: [nodes: MNode[]];
|
remove: [nodes: MNode[]];
|
||||||
@ -1188,6 +1225,54 @@ export interface EditorEvents {
|
|||||||
'move-layer': [offset: number | LayerOffset];
|
'move-layer': [offset: number | LayerOffset];
|
||||||
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
|
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
|
||||||
'history-change': [data: MPage | MPageFragment];
|
'history-change': [data: MPage | MPageFragment];
|
||||||
|
/**
|
||||||
|
* DSL 发生变更后统一触发,免去分别监听 add / remove / update / move-layer / drag-to。
|
||||||
|
* 回调参数为 {@link EditorChangeEvent},按 `type` 区分操作类型并携带各自的操作内容(payload)
|
||||||
|
* 以及变更所在的当前 page(可能为 null)。撤销 / 重做内部同样会经由
|
||||||
|
* add / remove / update 触发本事件;如需区分「用户操作」与「撤销重做」请配合 `history-change`。
|
||||||
|
*/
|
||||||
|
change: [event: EditorChangeEvent];
|
||||||
|
}
|
||||||
|
|
||||||
|
// #region EditorChangeEvent
|
||||||
|
/** `change` 事件中单个变更项:变更的 node 及其所属的 page(可能为 null)。 */
|
||||||
|
export interface EditorChangeItem {
|
||||||
|
node: MNode;
|
||||||
|
page: StoreState['page'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `update` 类型变更项:node 为前后快照及 form 端变更记录,page 为其所属页面。 */
|
||||||
|
export interface EditorUpdateChangeItem {
|
||||||
|
node: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] };
|
||||||
|
page: StoreState['page'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link EditorEvents.change} 的回调参数:以 `type` 区分操作类型,并携带对应的操作内容。
|
||||||
|
* `data` 为本次变更的节点列表,每项包含 node 及其所属的 page(可能为 null);
|
||||||
|
* `move-layer` 额外带层级偏移 `offset`,`drag-to` 额外带目标位置 `targetIndex` / `targetParent`。
|
||||||
|
*/
|
||||||
|
export type EditorChangeEvent =
|
||||||
|
| { type: 'add'; data: EditorChangeItem[] }
|
||||||
|
| { type: 'remove'; data: EditorChangeItem[] }
|
||||||
|
| { type: 'update'; data: EditorUpdateChangeItem[] }
|
||||||
|
| { type: 'move-layer'; data: EditorChangeItem[]; offset: number | LayerOffset }
|
||||||
|
| { type: 'drag-to'; data: EditorChangeItem[]; targetIndex: number; targetParent: MContainer };
|
||||||
|
// #endregion EditorChangeEvent
|
||||||
|
|
||||||
|
export interface HistoryEvents {
|
||||||
|
change: [
|
||||||
|
state: BaseStepValue | StepValue | CodeBlockStepValue | DataSourceStepValue,
|
||||||
|
stepType: HistoryStepType,
|
||||||
|
id: Id,
|
||||||
|
];
|
||||||
|
'code-block-history-change': [id: Id, state: CodeBlockStepValue];
|
||||||
|
'data-source-history-change': [id: Id, state: DataSourceStepValue];
|
||||||
|
'restore-from-indexed-db': [snapshot: PersistedHistoryState | null];
|
||||||
|
'save-to-indexed-db': [snapshot: PersistedHistoryState];
|
||||||
|
'mark-saved': [{ kind: HistoryStepType; id?: Id }];
|
||||||
|
clear: [{ id: Id; stepType: HistoryStepType }];
|
||||||
|
'marker-change': [{ id: Id; marker: StepValue; stepType: HistoryStepType }];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const canUsePluginMethods = {
|
export const canUsePluginMethods = {
|
||||||
@ -1274,6 +1359,14 @@ export interface DiffDialogPayload {
|
|||||||
targetLabel?: string;
|
targetLabel?: string;
|
||||||
/** 用于标题展示的目标 id */
|
/** 用于标题展示的目标 id */
|
||||||
id?: string | number;
|
id?: string | number;
|
||||||
|
/**
|
||||||
|
* 指定打开时的初始对比模式:
|
||||||
|
* - before:该步骤修改前 vs 修改后
|
||||||
|
* - current:该步骤修改后 vs 当前最新值
|
||||||
|
* 不传时按是否存在「修改后的值」/「当前值」自动推断。
|
||||||
|
* 若指定的模式当前不可用(对应数据缺失),将回退到自动推断结果。
|
||||||
|
*/
|
||||||
|
mode?: 'before' | 'current';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1281,8 +1374,11 @@ export interface DiffDialogPayload {
|
|||||||
* 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。
|
* 各自实现一份,作为整体注入,避免把 describe* / isStep* 拆成多个独立 props 反复透传。
|
||||||
*/
|
*/
|
||||||
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
|
export interface HistoryRowDescriptor<T extends BaseStepValue = BaseStepValue> {
|
||||||
/** 组级描述文案生成器,接收一个 group,返回展示文本。 */
|
/**
|
||||||
describeGroup: (_group: any) => string;
|
* 组级描述文案生成器,接收一个 group,返回展示文本。
|
||||||
|
* 不传时回退到对组内最后一步调用 {@link describeStep}(适用于不做相邻合并、每组恒为单步的历史,如数据源/代码块)。
|
||||||
|
*/
|
||||||
|
describeGroup?: (_group: any) => string;
|
||||||
/** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */
|
/** 单步描述文案生成器,接收一个 step,返回展示文本(合并组展开后的子步列表用)。 */
|
||||||
describeStep: (_step: T) => string;
|
describeStep: (_step: T) => string;
|
||||||
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
|
/** 判断某个 step 是否可查看差异(前后值都存在)。不传则一律不展示差异入口。 */
|
||||||
@ -1305,4 +1401,79 @@ export interface HistoryBucketConfig<T extends BaseStepValue = BaseStepValue> ex
|
|||||||
showInitial?: boolean;
|
showInitial?: boolean;
|
||||||
/** 是否支持「跳转到该记录」(goto),默认 true。 */
|
/** 是否支持「跳转到该记录」(goto),默认 true。 */
|
||||||
gotoEnabled?: boolean;
|
gotoEnabled?: boolean;
|
||||||
|
/** 是否展示顶部「清空」按钮,默认 true。无需提供清空能力的自定义历史可传 false。 */
|
||||||
|
showClear?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseHistoryRevertOptions {
|
||||||
|
/**
|
||||||
|
* 父级应用上下文,用于让动态挂载的「差异确认弹窗」继承全局组件 / 指令 / provide / 插件
|
||||||
|
* (Element Plus、@tmagic/form 字段组件等)。未显式传入时,会自动取调用方所在组件的 appContext
|
||||||
|
* (`getCurrentInstance()?.appContext`)。业务方若在组件 setup 之外调用,需手动传入(如 `editorApp._context`)。
|
||||||
|
*/
|
||||||
|
appContext?: AppContext | null;
|
||||||
|
/**
|
||||||
|
* 透传给差异确认弹窗的 `extendState`(即 Editor 的 `extendFormState`),
|
||||||
|
* 使对比表单中依赖业务上下文的 `display` / `disabled` 等 filterFunction 正常工作。
|
||||||
|
*/
|
||||||
|
extendState?: (_state: FormState) => Record<string, any> | Promise<Record<string, any>>;
|
||||||
|
/**
|
||||||
|
* 返回 PropsPanel 主属性表单(FormPanel -> MForm)的 formState。
|
||||||
|
* 仅页面历史「查看差异 / 回滚确认」场景会使用该 formState 覆盖 CompareForm 中同名扩展字段,
|
||||||
|
* 以保证两处 filterFunction 读取到一致的运行态上下文。
|
||||||
|
*/
|
||||||
|
getPropsPanelFormState?: () => FormState | undefined;
|
||||||
|
/**
|
||||||
|
* 内置页面 / 数据源 / 代码块的差异 / 回滚确认弹窗默认宽度(透传给 TMagicDialog 的 `width`),
|
||||||
|
* 如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(900px)。
|
||||||
|
* 业务自有历史(`viewDiff` / `confirmAndRevert`)可在调用时通过各自入参的 `width` 单独覆盖。
|
||||||
|
*/
|
||||||
|
dialogWidth?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史(如管理台「模块」)做差异对比时所需的额外渲染入参。
|
||||||
|
* 内置的页面 / 数据源 / 代码块按 `category` 自动取表单配置,无需传这些;
|
||||||
|
* 业务自有类别可通过 `loadConfig` 注入自定义表单配置加载逻辑。
|
||||||
|
*/
|
||||||
|
export interface CustomDiffFormOptions {
|
||||||
|
/**
|
||||||
|
* 自定义差异表单配置加载逻辑(如「模块」按 c_type 重建表单配置),
|
||||||
|
* 透传给弹窗内部的 CompareForm;缺省时按 `category` 走内置加载。
|
||||||
|
*/
|
||||||
|
loadConfig?: CompareFormLoadConfig;
|
||||||
|
/** 需要走 self diff 的字段类型(如模块的 mod-cond)。 */
|
||||||
|
selfDiffFieldTypes?: string[];
|
||||||
|
/**
|
||||||
|
* 可选:外部提供的 formState(通常来自 PropsPanel 主表单),
|
||||||
|
* 对比弹窗会用它覆盖 CompareForm 中同名扩展字段,避免上下文不一致。
|
||||||
|
*/
|
||||||
|
compareFormState?: FormState;
|
||||||
|
/**
|
||||||
|
* 差异 / 确认回滚弹窗宽度(透传给 HistoryDiffDialog 的 TMagicDialog `width`),
|
||||||
|
* 如 `'1200px'` / `'80%'`。缺省时使用弹窗内置默认宽度(900px)。
|
||||||
|
*/
|
||||||
|
width?: string;
|
||||||
|
/**
|
||||||
|
* 差异 / 确认回滚弹窗内 form 表单的尺寸(透传给 CompareForm 的 `size`),
|
||||||
|
* 可选 `'large' | 'default' | 'small'`,缺省时使用表单内置默认尺寸。
|
||||||
|
*/
|
||||||
|
size?: FieldSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务自有历史复用「单步回滚」交互({@link useHistoryRevert} 的 `confirmAndRevert`)的入参。
|
||||||
|
* 与内置页面 / 数据源 / 代码块回滚共用「目标校验 → 差异/二次确认弹窗 → 反向回滚」流程,
|
||||||
|
* 业务方只需提供:差异弹窗入参(可选)、表单配置加载(可选)、实际回滚执行函数。
|
||||||
|
*/
|
||||||
|
export interface ConfirmAndRevertOptions<T = unknown> extends CustomDiffFormOptions {
|
||||||
|
/**
|
||||||
|
* 差异确认弹窗入参;可对比的步骤(单实体 update)传入后弹差异确认弹窗,
|
||||||
|
* 传 null / 省略则退化为普通二次确认框(add / remove / 不可对比)。
|
||||||
|
*/
|
||||||
|
diffPayload?: DiffDialogPayload | null;
|
||||||
|
/** 回滚前置校验:返回 true 表示目标数据已删除等不可回滚,给出统一提示并中止。 */
|
||||||
|
isTargetMissing?: () => boolean;
|
||||||
|
/** 用户确认后执行的实际回滚逻辑。 */
|
||||||
|
revert: () => T | Promise<T>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const dataSourceFormConfig: TabConfig = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: '数据定义',
|
title: '数据定义',
|
||||||
|
status: 'fields',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'fields',
|
name: 'fields',
|
||||||
@ -20,6 +21,7 @@ const dataSourceFormConfig: TabConfig = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '方法定义',
|
title: '方法定义',
|
||||||
|
status: 'methods',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
name: 'methods',
|
name: 'methods',
|
||||||
|
|||||||
@ -132,6 +132,34 @@ const getMiddleTop = (node: MNode, parentNode: MNode, stage: StageCore | null) =
|
|||||||
return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2;
|
return (Math.min(parentHeight, wrapperHeightDeal) - height) / 2;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 同时存在一对相反的定位属性(如 left/right)时只保留一个,避免元素被拉伸
|
||||||
|
const removeConflictPosition = (style: Record<string, any>, primary: string, secondary: string) => {
|
||||||
|
// '' / 0 / undefined / null 视为无效值
|
||||||
|
const isInvalid = (value: any) => value === '' || value === 0 || value === undefined || value === null;
|
||||||
|
|
||||||
|
if (!(primary in style) || !(secondary in style)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const primaryValue = style[primary];
|
||||||
|
const secondaryValue = style[secondary];
|
||||||
|
const primaryInvalid = isInvalid(primaryValue);
|
||||||
|
const secondaryInvalid = isInvalid(secondaryValue);
|
||||||
|
|
||||||
|
if (primaryInvalid && !secondaryInvalid) {
|
||||||
|
delete style[primary];
|
||||||
|
} else if (secondaryInvalid && !primaryInvalid) {
|
||||||
|
delete style[secondary];
|
||||||
|
} else if (primaryInvalid && secondaryInvalid) {
|
||||||
|
// 都是无效值时优先保留 0,否则保留 primary(left/top)
|
||||||
|
if (secondaryValue === 0 && primaryValue !== 0) {
|
||||||
|
delete style[primary];
|
||||||
|
} else {
|
||||||
|
delete style[secondary];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const getInitPositionStyle = (style: Record<string, any> = {}, layout: Layout) => {
|
export const getInitPositionStyle = (style: Record<string, any> = {}, layout: Layout) => {
|
||||||
if (layout === Layout.ABSOLUTE) {
|
if (layout === Layout.ABSOLUTE) {
|
||||||
const newStyle: Record<string, any> = {
|
const newStyle: Record<string, any> = {
|
||||||
@ -139,6 +167,9 @@ export const getInitPositionStyle = (style: Record<string, any> = {}, layout: La
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
removeConflictPosition(newStyle, 'left', 'right');
|
||||||
|
removeConflictPosition(newStyle, 'top', 'bottom');
|
||||||
|
|
||||||
if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') {
|
if (typeof newStyle.left === 'undefined' && typeof newStyle.right === 'undefined') {
|
||||||
newStyle.left = 0;
|
newStyle.left = 0;
|
||||||
}
|
}
|
||||||
@ -178,78 +209,98 @@ export const setLayout = (node: MNode, layout: Layout) => {
|
|||||||
return node;
|
return node;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const change2Fixed = (node: MNode, root: MApp) => {
|
type PositionKey = 'left' | 'top' | 'right' | 'bottom';
|
||||||
|
|
||||||
|
// 判断 style 是否显式设置了某个定位属性,使用 typeof 判断以避免将 0(如 right: 0)误判为未设置
|
||||||
|
const hasPositionValue = (style: Record<string, any> | undefined, key: PositionKey): boolean =>
|
||||||
|
typeof style?.[key] !== 'undefined' && style?.[key] !== '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 沿节点路径累加(或抵消)某一方向上的定位偏移量
|
||||||
|
* 当路径上的祖先使用了反方向定位(例如计算 left 时祖先用了 right)或非数值定位时,
|
||||||
|
* 无法简单地通过求和换算坐标,此时返回 0 表示放弃补偿、保持原值
|
||||||
|
* @param path 从根到目标节点的路径(含节点自身)
|
||||||
|
* @param dir 需要累加的定位方向
|
||||||
|
* @param opposite 反方向定位属性
|
||||||
|
* @param sign change2Fixed 取 1(累加祖先偏移),Fixed2Other 取 -1(抵消祖先偏移)
|
||||||
|
* @param init 偏移量初始值
|
||||||
|
*/
|
||||||
|
const accumulatePositionOffset = (
|
||||||
|
path: MNode[],
|
||||||
|
dir: PositionKey,
|
||||||
|
opposite: PositionKey,
|
||||||
|
sign: 1 | -1,
|
||||||
|
init = 0,
|
||||||
|
): number => {
|
||||||
|
let offset = init;
|
||||||
|
for (const value of path) {
|
||||||
|
if (hasPositionValue(value.style, opposite) || !isNumber(value.style?.[dir] || 0)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
offset = offset + sign * Number(value.style?.[dir] || 0);
|
||||||
|
}
|
||||||
|
return offset;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const change2Fixed = (node: MNode, path: MNode[]) => {
|
||||||
const style = {
|
const style = {
|
||||||
...(node.style || {}),
|
...(node.style || {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const path = getNodePath(node.id, root.items);
|
// 水平方向:以 left 锚定则累加祖先 left,以 right 锚定则累加祖先 right
|
||||||
const offset = {
|
if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
|
||||||
left: 0,
|
const left = accumulatePositionOffset(path, 'left', 'right', 1);
|
||||||
top: 0,
|
if (left) {
|
||||||
};
|
style.left = left;
|
||||||
|
|
||||||
if (!node.style?.right && isNumber(node.style?.left || 0)) {
|
|
||||||
for (const value of path) {
|
|
||||||
if (value.style?.right || !isNumber(value.style?.left || 0)) {
|
|
||||||
offset.left = 0;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
offset.left = offset.left + Number(value.style?.left || 0);
|
} else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
|
||||||
|
const right = accumulatePositionOffset(path, 'right', 'left', 1);
|
||||||
|
if (right) {
|
||||||
|
style.right = right;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.style?.bottom && isNumber(node.style?.top || 0)) {
|
// 垂直方向:以 top 锚定则累加祖先 top,以 bottom 锚定则累加祖先 bottom
|
||||||
for (const value of path) {
|
if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
|
||||||
if (value.style?.bottom || !isNumber(value.style?.top || 0)) {
|
const top = accumulatePositionOffset(path, 'top', 'bottom', 1);
|
||||||
offset.top = 0;
|
if (top) {
|
||||||
break;
|
style.top = top;
|
||||||
}
|
}
|
||||||
offset.top = offset.top + Number(value.style?.top || 0);
|
} else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
|
||||||
|
const bottom = accumulatePositionOffset(path, 'bottom', 'top', 1);
|
||||||
|
if (bottom) {
|
||||||
|
style.bottom = bottom;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (offset.left) {
|
|
||||||
style.left = offset.left;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (offset.top) {
|
|
||||||
style.top = offset.top;
|
|
||||||
}
|
|
||||||
|
|
||||||
return style;
|
return style;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Fixed2Other = async (
|
export const Fixed2Other = async (
|
||||||
node: MNode,
|
node: MNode,
|
||||||
root: MApp,
|
path: MNode[],
|
||||||
getLayout: (parent: MNode, node?: MNode) => Promise<Layout>,
|
getLayout: (parent: MNode, node?: MNode) => Promise<Layout>,
|
||||||
) => {
|
) => {
|
||||||
const path = getNodePath(node.id, root.items);
|
|
||||||
const cur = path.pop();
|
const cur = path.pop();
|
||||||
const offset = {
|
const offset = {
|
||||||
left: cur?.style?.left || 0,
|
left: 0,
|
||||||
top: cur?.style?.top || 0,
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!node.style?.right && isNumber(node.style?.left || 0)) {
|
// 水平方向:抵消祖先在对应锚定方向上的偏移量,初始值为节点自身的偏移
|
||||||
for (const value of path) {
|
if (!hasPositionValue(node.style, 'right') && isNumber(node.style?.left || 0)) {
|
||||||
if (value.style?.right || !isNumber(value.style?.left || 0)) {
|
offset.left = accumulatePositionOffset(path, 'left', 'right', -1, Number(cur?.style?.left || 0));
|
||||||
offset.left = 0;
|
} else if (hasPositionValue(node.style, 'right') && isNumber(node.style?.right || 0)) {
|
||||||
break;
|
offset.right = accumulatePositionOffset(path, 'right', 'left', -1, Number(cur?.style?.right || 0));
|
||||||
}
|
|
||||||
offset.left = offset.left - Number(value.style?.left || 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.style?.bottom && isNumber(node.style?.top || 0)) {
|
// 垂直方向:同上
|
||||||
for (const value of path) {
|
if (!hasPositionValue(node.style, 'bottom') && isNumber(node.style?.top || 0)) {
|
||||||
if (value.style?.bottom || !isNumber(value.style?.top || 0)) {
|
offset.top = accumulatePositionOffset(path, 'top', 'bottom', -1, Number(cur?.style?.top || 0));
|
||||||
offset.top = 0;
|
} else if (hasPositionValue(node.style, 'bottom') && isNumber(node.style?.bottom || 0)) {
|
||||||
break;
|
offset.bottom = accumulatePositionOffset(path, 'bottom', 'top', -1, Number(cur?.style?.bottom || 0));
|
||||||
}
|
|
||||||
offset.top = offset.top - Number(value.style?.top || 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = node.style || {};
|
const style = node.style || {};
|
||||||
@ -269,6 +320,14 @@ export const Fixed2Other = async (
|
|||||||
style.top = offset.top;
|
style.top = offset.top;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (offset.right) {
|
||||||
|
style.right = offset.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (offset.bottom) {
|
||||||
|
style.bottom = offset.bottom;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...style,
|
...style,
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@ -316,11 +375,18 @@ export const fixNodePosition = (config: MNode, parent: MContainer, stage: StageC
|
|||||||
return config.style;
|
return config.style;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const style = { ...(config.style || {}) };
|
||||||
...(config.style || {}),
|
const baseStyle = config.style || {};
|
||||||
top: getMiddleTop(config, parent, stage),
|
|
||||||
left: fixNodeLeft(config, parent, stage?.renderer?.contentWindow?.document),
|
if (!('right' in baseStyle)) {
|
||||||
};
|
style.left = fixNodeLeft(config, parent, stage?.renderer?.contentWindow?.document);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('top' in baseStyle) && !('bottom' in baseStyle)) {
|
||||||
|
style.top = getMiddleTop(config, parent, stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
return style;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 序列化配置
|
// 序列化配置
|
||||||
@ -460,11 +526,11 @@ export const resolveSelectedNode = (
|
|||||||
throw new Error('没有ID,无法选中');
|
throw new Error('没有ID,无法选中');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { node, parent, page } = getNodeInfoFn(id);
|
const { node, parent, page, path } = getNodeInfoFn(id);
|
||||||
if (!node) throw new Error('获取不到组件信息');
|
if (!node) throw new Error('获取不到组件信息');
|
||||||
if (node.id === rootId) throw new Error('不能选根节点');
|
if (node.id === rootId) throw new Error('不能选根节点');
|
||||||
|
|
||||||
return { node, parent, page };
|
return { node, parent, page, path };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -479,16 +545,16 @@ export const resolveSelectedNode = (
|
|||||||
export const toggleFixedPosition = async (
|
export const toggleFixedPosition = async (
|
||||||
dist: MNode,
|
dist: MNode,
|
||||||
src: MNode,
|
src: MNode,
|
||||||
root: MApp,
|
path: MNode[],
|
||||||
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
|
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
|
||||||
): Promise<MNode> => {
|
): Promise<MNode> => {
|
||||||
const newConfig = cloneDeep(dist);
|
const newConfig = cloneDeep(dist);
|
||||||
|
|
||||||
if (!isPop(src) && newConfig.style?.position) {
|
if (!isPop(src) && newConfig.style?.position) {
|
||||||
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
|
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
|
||||||
newConfig.style = change2Fixed(newConfig, root);
|
newConfig.style = change2Fixed(newConfig, path);
|
||||||
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
|
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
|
||||||
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn);
|
newConfig.style = await Fixed2Other(newConfig, path, getLayoutFn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,20 +17,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
import serialize from 'serialize-javascript';
|
||||||
|
|
||||||
import type { Id } from '@tmagic/core';
|
import type { Id } from '@tmagic/core';
|
||||||
import type { ChangeRecord } from '@tmagic/form';
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
import { guid } from '@tmagic/utils';
|
import { guid } from '@tmagic/utils';
|
||||||
|
|
||||||
import type {
|
import type { BaseStepValue, HistoryGroup, StepDiffItem } from '@editor/type';
|
||||||
BaseStepValue,
|
|
||||||
HistoryOpSource,
|
|
||||||
HistoryOpType,
|
|
||||||
PageHistoryGroup,
|
|
||||||
PageHistoryStepEntry,
|
|
||||||
StepDiffItem,
|
|
||||||
StepValue,
|
|
||||||
} from '@editor/type';
|
|
||||||
|
|
||||||
import { UndoRedo } from './undo-redo';
|
import { UndoRedo } from './undo-redo';
|
||||||
|
|
||||||
@ -70,17 +63,20 @@ export const detectStackOpType = (oldVal: unknown, newVal: unknown): 'add' | 're
|
|||||||
*
|
*
|
||||||
* - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。
|
* - `add`:oldValue = null;`remove`:newValue = null;`update`:两者都有,可带 changeRecords 做局部更新。
|
||||||
* - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。
|
* - 内容会做 cloneDeep 防止后续被外部引用篡改;opType 依据 old/new 是否为 null 推断。
|
||||||
* - 仅负责构造 step 并返回,入栈与事件 emit 由各公共方法(pushCodeBlock / pushDataSource)自行处理。
|
* - 仅负责构造 step 并返回,入栈与事件 emit 由统一的 history.push(stepType, step, id) 处理。
|
||||||
* - 不直接驱动业务 service,调用方负责实际写回。
|
* - 不直接驱动业务 service,调用方负责实际写回。
|
||||||
*/
|
*/
|
||||||
export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
|
export const createStackStep = <T, S extends BaseStepValue<T>>(
|
||||||
id: Id,
|
id: Id,
|
||||||
payload: {
|
// payload 以 {@link BaseStepValue} 为基础:透传字段(historyDescription / source / operator / rootStep / extra)
|
||||||
|
// 随 BaseStepValue 演进自动同步,原样写入 step;自动生成字段(uuid / data / opType / diff / timestamp / saved)
|
||||||
|
// 从 payload 中排除,由本方法内部构造。oldValue / newValue / changeRecords / name 为构造 diff 与 data 用的输入。
|
||||||
|
payload: Omit<BaseStepValue<T>, 'uuid' | 'data' | 'opType' | 'diff' | 'timestamp' | 'saved'> & {
|
||||||
oldValue: T | null;
|
oldValue: T | null;
|
||||||
newValue: T | null;
|
newValue: T | null;
|
||||||
changeRecords?: ChangeRecord[];
|
changeRecords?: ChangeRecord[];
|
||||||
historyDescription?: string;
|
/** 展示名(缺省时从快照 name / title 推断)。 */
|
||||||
source?: HistoryOpSource;
|
name?: string;
|
||||||
},
|
},
|
||||||
): S | null => {
|
): S | null => {
|
||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
@ -89,10 +85,13 @@ export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
|
|||||||
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
|
const newSchema = payload.newValue ? cloneDeep(payload.newValue) : null;
|
||||||
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
|
const changeRecords = payload.changeRecords?.length ? cloneDeep(payload.changeRecords) : undefined;
|
||||||
const opType = detectStackOpType(payload.oldValue, payload.newValue);
|
const opType = detectStackOpType(payload.oldValue, payload.newValue);
|
||||||
|
// 展示名:代码块取 name,数据源取 title,取不到则留空(不影响 undo/redo,仅用于展示)。
|
||||||
|
const schema = (payload.newValue ?? payload.oldValue) as { name?: string; title?: string } | null;
|
||||||
|
const name = payload.name ?? schema?.name ?? schema?.title ?? '';
|
||||||
|
|
||||||
const step: BaseStepValue<T> & { id: Id } = {
|
const step: BaseStepValue<T> = {
|
||||||
uuid: guid(),
|
uuid: guid(),
|
||||||
id,
|
data: { name, id },
|
||||||
opType,
|
opType,
|
||||||
diff: [
|
diff: [
|
||||||
{
|
{
|
||||||
@ -103,7 +102,10 @@ export const createStackStep = <T, S extends BaseStepValue<T> & { id: Id }>(
|
|||||||
],
|
],
|
||||||
historyDescription: payload.historyDescription,
|
historyDescription: payload.historyDescription,
|
||||||
source: payload.source,
|
source: payload.source,
|
||||||
|
operator: payload.operator,
|
||||||
|
rootStep: payload.rootStep,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
|
extra: payload.extra,
|
||||||
};
|
};
|
||||||
|
|
||||||
return step as S;
|
return step as S;
|
||||||
@ -120,88 +122,41 @@ export const markStackSaved = <S extends { saved?: boolean }>(undoRedo?: UndoRed
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把单个「按 id 分栈」的历史栈(代码块 / 数据源)拆成若干 group:
|
* 把单个历史栈(页面 / 代码块 / 数据源 / 扩展类型)的步骤列表按"目标"做相邻合并:
|
||||||
* - 把"新增/删除"独立成组(语义上属于一次性事件,不应与 update 合并);
|
* - 单实体的 'update' 按 targetId 与相邻同 targetId 的 update 合并到一个 group,组内可展开查看每步;
|
||||||
* - 连续 'update' 合并到同一组,组内 steps 顺序就是发生顺序。
|
* - 'add' / 'remove' 始终独立成组(语义上是结构变更,不应被收纳进单实体修改组);
|
||||||
|
* - 多实体 'update'(如页面批量改属性)也独立成组(无明确单一目标,避免误合并)。
|
||||||
*
|
*
|
||||||
* 代码块与数据源除 `kind` 外结构完全一致,统一由本方法处理;`kind` 决定返回的具体分组类型。
|
* 各类型行为完全一致,仅 `kind` 与 step 快照类型不同,统一由本方法处理。
|
||||||
*/
|
*/
|
||||||
export const mergeStackSteps = <S extends BaseStepValue, K extends 'code-block' | 'data-source'>(
|
export const mergeSteps = <T extends BaseStepValue>(
|
||||||
kind: K,
|
kind: string,
|
||||||
id: Id,
|
id: Id,
|
||||||
list: S[],
|
list: T[],
|
||||||
cursor: number,
|
cursor: number,
|
||||||
): {
|
): HistoryGroup<T>[] => {
|
||||||
kind: K;
|
const groups: HistoryGroup<T>[] = [];
|
||||||
id: Id;
|
let current: HistoryGroup<T> | null = null;
|
||||||
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;
|
const currentIndex = cursor - 1;
|
||||||
list.forEach((step, index) => {
|
list.forEach((step, index) => {
|
||||||
const applied = index < cursor;
|
const applied = index < cursor;
|
||||||
const isCurrent = index === currentIndex;
|
const isCurrent = index === currentIndex;
|
||||||
const targetId = detectPageTargetId(step);
|
const targetId = detectTargetId(step);
|
||||||
const targetName = detectPageTargetName(step);
|
const targetName = detectTargetName(step);
|
||||||
const entry: PageHistoryStepEntry = { step, index, applied, isCurrent };
|
const entry = { step, index, applied, isCurrent };
|
||||||
|
|
||||||
// 仅"单节点 update"参与合并;其它情形(add/remove/多节点 update)始终独立成组。
|
// 仅"单实体 update"参与合并;其它情形(add/remove/多实体 update)始终独立成组。
|
||||||
const mergeable = step.opType === 'update' && targetId !== undefined;
|
const mergeable = step.opType === 'update' && targetId !== undefined;
|
||||||
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
|
if (mergeable && current?.opType === 'update' && current.targetId === targetId) {
|
||||||
current.steps.push(entry);
|
current.steps.push(entry);
|
||||||
current.applied = applied;
|
current.applied = applied;
|
||||||
if (isCurrent) current.isCurrent = true;
|
if (isCurrent) current.isCurrent = true;
|
||||||
// 保持目标名为最近一次的(节点重命名时也能反映)
|
// 保持目标名为最近一次的(重命名时也能反映)
|
||||||
if (targetName) current.targetName = targetName;
|
if (targetName) current.targetName = targetName;
|
||||||
} else {
|
} else {
|
||||||
current = {
|
current = {
|
||||||
kind: 'page',
|
kind,
|
||||||
pageId,
|
id,
|
||||||
opType: step.opType,
|
opType: step.opType,
|
||||||
targetId: mergeable ? targetId : undefined,
|
targetId: mergeable ? targetId : undefined,
|
||||||
targetName,
|
targetName,
|
||||||
@ -216,37 +171,40 @@ export const mergePageSteps = (pageId: Id, list: StepValue[], cursor: number): P
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 StepValue 中的"目标节点 id"用于合并:
|
* 解析 step 中的"目标 id"用于合并:
|
||||||
* - 单节点 update:取唯一一项 updatedItems 的节点 id;
|
* - 单实体 update:取唯一一项 diff 的快照 id;快照无 id 时(如 CodeBlockContent)回退到 `step.data.id`
|
||||||
* - 其它情形(多节点 update / add / remove):返回 undefined,表示不参与合并。
|
* (即资源 id),使代码块 / 数据源同样能按资源合并相邻 update;
|
||||||
|
* - 其它情形(多实体 update / add / remove):返回 undefined,表示不参与合并。
|
||||||
*/
|
*/
|
||||||
export const detectPageTargetId = (step: StepValue): Id | undefined => {
|
export const detectTargetId = (step: BaseStepValue): Id | undefined => {
|
||||||
if (step.opType !== 'update') return undefined;
|
if (step.opType !== 'update') return undefined;
|
||||||
const items = step.diff;
|
const items = step.diff;
|
||||||
if (items?.length !== 1) return undefined;
|
if (items?.length !== 1) return undefined;
|
||||||
return items[0].newSchema?.id ?? items[0].oldSchema?.id;
|
const newSchema = items[0].newSchema as { id?: Id } | undefined;
|
||||||
|
const oldSchema = items[0].oldSchema as { id?: Id } | undefined;
|
||||||
|
return newSchema?.id ?? oldSchema?.id ?? step.data?.id;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 解析 StepValue 中的目标节点可读名(用于 UI 展示)。 */
|
/** 解析 step 中的目标可读名(用于 UI 展示)。 */
|
||||||
export const detectPageTargetName = (step: StepValue): string | undefined => {
|
export const detectTargetName = (step: BaseStepValue): string | undefined => {
|
||||||
const items = step.diff;
|
const items = step.diff;
|
||||||
if (step.opType === 'update') {
|
if (step.opType === 'update') {
|
||||||
if (items?.length === 1) {
|
if (items?.length === 1) {
|
||||||
const node = items[0].newSchema || items[0].oldSchema;
|
const node = (items[0].newSchema || items[0].oldSchema) as { name?: string; type?: string; id?: Id } | undefined;
|
||||||
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
|
return (node?.name as string) || (node?.type as string) || (node?.id !== undefined ? `${node.id}` : undefined);
|
||||||
}
|
}
|
||||||
return items?.length ? `${items.length} 个节点` : undefined;
|
return items?.length ? `${items.length} 个节点` : undefined;
|
||||||
}
|
}
|
||||||
if (step.opType === 'add') {
|
if (step.opType === 'add') {
|
||||||
if (items?.length === 1) {
|
if (items?.length === 1) {
|
||||||
const n = items[0].newSchema;
|
const n = items[0].newSchema as { name?: string; type?: string; id?: Id } | undefined;
|
||||||
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
||||||
}
|
}
|
||||||
return items?.length ? `${items.length} 个节点` : undefined;
|
return items?.length ? `${items.length} 个节点` : undefined;
|
||||||
}
|
}
|
||||||
if (step.opType === 'remove') {
|
if (step.opType === 'remove') {
|
||||||
if (items?.length === 1) {
|
if (items?.length === 1) {
|
||||||
const n = items[0].oldSchema;
|
const n = items[0].oldSchema as { name?: string; type?: string; id?: Id } | undefined;
|
||||||
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
return (n?.name as string) || (n?.type as string) || `${n?.id}`;
|
||||||
}
|
}
|
||||||
return items?.length ? `${items.length} 个节点` : undefined;
|
return items?.length ? `${items.length} 个节点` : undefined;
|
||||||
@ -254,11 +212,25 @@ export const detectPageTargetName = (step: StepValue): string | undefined => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
|
/**
|
||||||
export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
|
* 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。
|
||||||
|
*
|
||||||
|
* 序列化(深克隆)的同一趟里,只把每条 step 中可能含函数的 `diff` 用 serialize-javascript 序列化成字符串,
|
||||||
|
* 其余字段(uuid / opType / timestamp / `modifiedNodeIds` Map 等)原样保留,交给 IndexedDB 结构化克隆。
|
||||||
|
* 这样既能写入函数,又避免序列化整份快照的开销;读取时再由 {@link parseStacksStepDiff} 还原 diff。
|
||||||
|
* 不含 `diff` 的元素(如通用栈)原样透传。
|
||||||
|
*/
|
||||||
|
export const serializeStacks = <T extends { diff?: unknown }>(stacks: Record<Id, UndoRedo<T>>) => {
|
||||||
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
|
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
|
||||||
Object.entries(stacks).forEach(([id, undoRedo]) => {
|
Object.entries(stacks).forEach(([id, undoRedo]) => {
|
||||||
if (undoRedo) result[id] = undoRedo.serialize();
|
if (!undoRedo) return;
|
||||||
|
const serialized = undoRedo.serialize();
|
||||||
|
result[id] = {
|
||||||
|
...serialized,
|
||||||
|
elementList: serialized.elementList.map((step) =>
|
||||||
|
step.diff === undefined ? step : Object.assign({}, step, { diff: serialize(step.diff) }),
|
||||||
|
),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@ -266,15 +238,27 @@ export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
|
|||||||
/**
|
/**
|
||||||
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
* 把 `Record<Id, SerializedUndoRedo>` 整体还原为 `Record<Id, UndoRedo>`。
|
||||||
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
* 还原时把每个栈的游标定位到最近一条已保存(`saved === true`)记录之后。
|
||||||
|
*
|
||||||
|
* 与 {@link serializeStacks} 相反:当传入 `parse`(parseDSL)时,把每条 step 中以字符串形式存储的 `diff`
|
||||||
|
* 解析回真实对象(含函数);不含 `diff` 的元素(如通用栈)原样透传。
|
||||||
*/
|
*/
|
||||||
export const deserializeStacks = <T extends { saved?: boolean }>(
|
export const deserializeStacks = <T extends { saved?: boolean }>(
|
||||||
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
|
||||||
|
parse?: (serialized: string) => unknown,
|
||||||
): Record<Id, UndoRedo<T>> => {
|
): Record<Id, UndoRedo<T>> => {
|
||||||
const result: Record<Id, UndoRedo<T>> = {};
|
const result: Record<Id, UndoRedo<T>> = {};
|
||||||
Object.entries(stacks).forEach(([id, serialized]) => {
|
Object.entries(stacks).forEach(([id, serialized]) => {
|
||||||
if (serialized) {
|
if (!serialized) return;
|
||||||
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
|
const elementList = parse
|
||||||
}
|
? serialized.elementList.map((step) => {
|
||||||
|
const { diff } = step as { diff?: unknown };
|
||||||
|
return typeof diff === 'string' ? Object.assign({}, step, { diff: parse(`(${diff})`) }) : step;
|
||||||
|
})
|
||||||
|
: serialized.elementList;
|
||||||
|
result[id] = UndoRedo.fromSerialized<T>(
|
||||||
|
{ ...serialized, elementList },
|
||||||
|
{ isSavedStep: (element) => element.saved === true },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
@ -290,9 +274,13 @@ export const getOrCreateStack = <T>(stacks: Record<Id, UndoRedo<T>>, id: Id): Un
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤销下限:当页面栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。
|
* 撤销下限:当栈 index 0 是 `opType: 'initial'` 的基线 step 时为 1(基线不可被撤销),否则为 0。
|
||||||
* 用于把 cursor 钉在基线之上,保证 undo / canUndo / goto 都不会越过初始基线。
|
* 适用于所有历史类型(page / codeBlock / dataSource / 扩展),把 cursor 钉在基线之上,
|
||||||
|
* 保证 undo / canUndo / goto 都不会越过初始基线。
|
||||||
*/
|
*/
|
||||||
export const undoFloor = (undoRedo: UndoRedo<StepValue>): number => {
|
export const undoFloor = (undoRedo: UndoRedo<any>): number => {
|
||||||
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
|
return undoRedo.getElementList()[0]?.opType === 'initial' ? 1 : 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 将单次 push 产生的 history uuid(或 null)转为 *AndGetHistoryId 返回用的 uuid 列表。 */
|
||||||
|
export const getLastPushedHistoryIds = (historyId: string | null): string[] => (historyId ? [historyId] : []);
|
||||||
|
|||||||
237
packages/editor/tests/unit/components/CompareForm.spec.ts
Normal file
237
packages/editor/tests/unit/components/CompareForm.spec.ts
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { defineComponent, h, nextTick, ref } from 'vue';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import { HookType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import CompareForm from '@editor/components/CompareForm.vue';
|
||||||
|
|
||||||
|
const propsService = {
|
||||||
|
getPropsConfig: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
type: 'tab',
|
||||||
|
items: [{ title: '样式', items: [{ type: 'text', name: 'color', display: false }] }],
|
||||||
|
},
|
||||||
|
{ type: 'text', name: 'name' },
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
const dataSourceService = {
|
||||||
|
getFormConfig: vi.fn(() => [{ type: 'text', name: 'title' }]),
|
||||||
|
};
|
||||||
|
const codeBlockService = {
|
||||||
|
getParamsColConfig: vi.fn(() => null),
|
||||||
|
};
|
||||||
|
const editorService = {
|
||||||
|
get: vi.fn(() => ({ select: vi.fn() })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const services = {
|
||||||
|
propsService,
|
||||||
|
dataSourceService,
|
||||||
|
codeBlockService,
|
||||||
|
editorService,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
let capturedShowDiff: ((args: any) => boolean) | undefined;
|
||||||
|
let capturedFormProps: Record<string, any> = {};
|
||||||
|
|
||||||
|
vi.mock('@editor/utils/code-block', () => ({
|
||||||
|
getCodeBlockFormConfig: vi.fn(() => [{ type: 'text', name: 'content' }]),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@tmagic/form', () => ({
|
||||||
|
MForm: defineComponent({
|
||||||
|
name: 'MForm',
|
||||||
|
props: ['config', 'initValues', 'lastValues', 'isCompare', 'disabled', 'labelWidth', 'extendState', 'showDiff'],
|
||||||
|
setup(props, { expose }) {
|
||||||
|
capturedShowDiff = props.showDiff as (args: any) => boolean;
|
||||||
|
capturedFormProps = props as Record<string, any>;
|
||||||
|
expose({ formState: {} });
|
||||||
|
return () => h('div', { class: 'fake-mform' });
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedShowDiff = undefined;
|
||||||
|
capturedFormProps = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('CompareForm.vue', () => {
|
||||||
|
test('node 类别按 type 加载 props 配置并展示 MForm', async () => {
|
||||||
|
const wrapper = mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
type: 'text',
|
||||||
|
value: { id: 'n1', name: 'new' },
|
||||||
|
lastValue: { id: 'n1', name: 'old' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
provide: {
|
||||||
|
codeOptions: { theme: 'vs-dark' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(propsService.getPropsConfig).toHaveBeenCalledWith('text', { node: { id: 'n1', name: 'new' } });
|
||||||
|
expect(wrapper.find('.fake-mform').exists()).toBe(true);
|
||||||
|
expect(capturedFormProps.initValues).toEqual({ id: 'n1', name: 'new' });
|
||||||
|
expect(capturedFormProps.lastValues).toEqual({ id: 'n1', name: 'old' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('node 类别缺少 type 时不渲染 MForm', async () => {
|
||||||
|
const wrapper = mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
value: { id: 'n1' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
expect(wrapper.find('.fake-mform').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('data-source 类别加载数据源表单配置', async () => {
|
||||||
|
mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'data-source',
|
||||||
|
type: 'http',
|
||||||
|
value: { id: 'ds_1', title: 'A' },
|
||||||
|
lastValue: { id: 'ds_1', title: 'B' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
expect(dataSourceService.getFormConfig).toHaveBeenCalledWith('http');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('code-block 类别会把 content 非字符串值 normalize 成字符串', async () => {
|
||||||
|
mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'code-block',
|
||||||
|
value: { id: 'cb_1', content: { toString: () => 'fn-body' } },
|
||||||
|
lastValue: { id: 'cb_1', content: '' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
expect(capturedFormProps.initValues.content).toBe('fn-body');
|
||||||
|
expect(capturedFormProps.lastValues.content).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入 height 时外层容器启用内部滚动样式', () => {
|
||||||
|
const wrapper = mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
type: 'text',
|
||||||
|
value: { id: 'n1' },
|
||||||
|
height: '400px',
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const style = wrapper.find('.m-editor-compare-form-wrapper').attributes('style') || '';
|
||||||
|
expect(style).toContain('height: 400px');
|
||||||
|
expect(style).toContain('overflow: auto');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 loadConfig 可接管配置加载', async () => {
|
||||||
|
const loadConfig = vi.fn(async ({ defaultLoadConfig }) => {
|
||||||
|
await defaultLoadConfig();
|
||||||
|
return [{ type: 'text', name: 'custom' }];
|
||||||
|
});
|
||||||
|
const wrapper = mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
type: 'text',
|
||||||
|
value: { id: 'n1' },
|
||||||
|
loadConfig,
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
expect(loadConfig).toHaveBeenCalled();
|
||||||
|
expect((wrapper.vm as any).config).toEqual([{ type: 'text', name: 'custom' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showDiff 对 code-select 的空形态视为相等', async () => {
|
||||||
|
mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
type: 'text',
|
||||||
|
value: { id: 'n1' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
expect(capturedShowDiff).toBeTypeOf('function');
|
||||||
|
expect(
|
||||||
|
capturedShowDiff!({
|
||||||
|
curValue: '',
|
||||||
|
lastValue: { hookType: HookType.CODE, hookData: [] },
|
||||||
|
config: { type: 'code-select' },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
capturedShowDiff!({
|
||||||
|
curValue: { hookType: HookType.CODE, hookData: [] },
|
||||||
|
lastValue: '',
|
||||||
|
config: { type: 'code-select' },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
capturedShowDiff!({
|
||||||
|
curValue: 'a',
|
||||||
|
lastValue: 'b',
|
||||||
|
config: { type: 'code-select' },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reload 暴露方法会重新加载配置', async () => {
|
||||||
|
const wrapper = mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'data-source',
|
||||||
|
type: 'base',
|
||||||
|
value: { id: 'ds_1' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
dataSourceService.getFormConfig.mockClear();
|
||||||
|
await (wrapper.vm as any).reload();
|
||||||
|
expect(dataSourceService.getFormConfig).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('watchEffect 会把 stage / services 写入 MForm.formState', async () => {
|
||||||
|
const stage = ref({ select: vi.fn() });
|
||||||
|
editorService.get.mockReturnValue(stage.value);
|
||||||
|
mount(CompareForm, {
|
||||||
|
props: {
|
||||||
|
category: 'node',
|
||||||
|
type: 'text',
|
||||||
|
value: { id: 'n1' },
|
||||||
|
services,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await nextTick();
|
||||||
|
await nextTick();
|
||||||
|
stage.value = { select: vi.fn() };
|
||||||
|
await nextTick();
|
||||||
|
expect(editorService.get).toHaveBeenCalledWith('stage');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -9,10 +9,13 @@ import { mount } from '@vue/test-utils';
|
|||||||
|
|
||||||
import FloatingBox from '@editor/components/FloatingBox.vue';
|
import FloatingBox from '@editor/components/FloatingBox.vue';
|
||||||
|
|
||||||
const moveableHandlers = new Map<string, (...args: any[]) => void>();
|
const moveableHandlers = new Map<string, ((...args: any[]) => void)[]>();
|
||||||
const destroyMock = vi.fn();
|
const destroyMock = vi.fn();
|
||||||
let lastInstance: any;
|
let lastInstance: any;
|
||||||
|
|
||||||
|
const emitMoveable = (event: string, ...args: any[]) =>
|
||||||
|
(moveableHandlers.get(event) || []).forEach((fn) => fn(...args));
|
||||||
|
|
||||||
vi.mock('moveable', () => {
|
vi.mock('moveable', () => {
|
||||||
class FakeMoveable {
|
class FakeMoveable {
|
||||||
public target: any;
|
public target: any;
|
||||||
@ -26,7 +29,9 @@ vi.mock('moveable', () => {
|
|||||||
moveableHandlers.clear();
|
moveableHandlers.clear();
|
||||||
}
|
}
|
||||||
public on(event: string, fn: (...args: any[]) => void) {
|
public on(event: string, fn: (...args: any[]) => void) {
|
||||||
moveableHandlers.set(event, fn);
|
const list = moveableHandlers.get(event) || [];
|
||||||
|
list.push(fn);
|
||||||
|
moveableHandlers.set(event, list);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
public destroy() {
|
public destroy() {
|
||||||
@ -64,6 +69,18 @@ const services = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const rect200: DOMRect = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 200,
|
||||||
|
bottom: 100,
|
||||||
|
toJSON: () => '',
|
||||||
|
};
|
||||||
|
|
||||||
describe('FloatingBox.vue', () => {
|
describe('FloatingBox.vue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
moveableHandlers.clear();
|
moveableHandlers.clear();
|
||||||
@ -132,7 +149,7 @@ describe('FloatingBox.vue', () => {
|
|||||||
});
|
});
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
const target = document.createElement('div');
|
const target = document.createElement('div');
|
||||||
moveableHandlers.get('resize')?.({
|
emitMoveable('resize', {
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 300,
|
height: 300,
|
||||||
target,
|
target,
|
||||||
@ -151,20 +168,67 @@ describe('FloatingBox.vue', () => {
|
|||||||
});
|
});
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
const target = document.createElement('div');
|
const target = document.createElement('div');
|
||||||
moveableHandlers.get('drag')?.({ target, transform: 'translate(10px,20px)' });
|
emitMoveable('drag', { target, transform: 'translate(10px,20px)' });
|
||||||
expect(target.style.transform.replace(/\s+/g, '')).toBe('translate(10px,20px)');
|
expect(target.style.transform.replace(/\s+/g, '')).toBe('translate(10px,20px)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('left + width 超过 frameworkWidth 时 left 被收敛', async () => {
|
test('left + width 超过 frameworkWidth 时 left 被收敛', async () => {
|
||||||
const wrapper = mount(FloatingBox as any, {
|
const wrapper = mount(FloatingBox as any, {
|
||||||
...services,
|
props: { visible: true, position: { left: 950, top: 0 }, width: 200, frameworkWidth: 1000 },
|
||||||
props: { visible: true, position: { left: 950, top: 0 }, width: 200 },
|
|
||||||
attachTo: document.body,
|
attachTo: document.body,
|
||||||
});
|
});
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
await wrapper.vm.$nextTick();
|
await wrapper.vm.$nextTick();
|
||||||
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
||||||
expect(box).not.toBeNull();
|
expect(box).not.toBeNull();
|
||||||
|
// jsdom 中 getBoundingClientRect 返回 0,width 会被重置为 0,故不触发收敛,left 保持原值
|
||||||
|
expect(box.style.left).toBe('950px');
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('当实际宽度使 left + width 超过 frameworkWidth 时 left 收敛到右边界内', async () => {
|
||||||
|
const getBoundingClientRectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect200);
|
||||||
|
const wrapper = mount(FloatingBox as any, {
|
||||||
|
props: { visible: true, position: { left: 950, top: 0 }, frameworkWidth: 1000 },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box.style.left).toBe('800px');
|
||||||
|
getBoundingClientRectSpy.mockRestore();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未传入 frameworkWidth 时默认按视窗宽度收敛 left', async () => {
|
||||||
|
const getBoundingClientRectSpy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect200);
|
||||||
|
const innerWidthSpy = vi.spyOn(globalThis, 'window', 'get').mockReturnValue({ innerWidth: 300 } as any);
|
||||||
|
const wrapper = mount(FloatingBox as any, {
|
||||||
|
props: { visible: true, position: { left: 250, top: 0 } },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
// 250 + 200 = 450 > 视窗宽度 300,left 收敛为 300 - 200 = 100
|
||||||
|
expect(box.style.left).toBe('100px');
|
||||||
|
getBoundingClientRectSpy.mockRestore();
|
||||||
|
innerWidthSpy.mockRestore();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('传入 initialStyle 时合并到浮窗样式', async () => {
|
||||||
|
const wrapper = mount(FloatingBox as any, {
|
||||||
|
props: { visible: true, initialStyle: { backgroundColor: 'rgb(255, 0, 0)' } },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
const box = document.querySelector('.m-editor-float-box') as HTMLElement;
|
||||||
|
expect(box).not.toBeNull();
|
||||||
|
expect(box.style.backgroundColor).toBe('rgb(255, 0, 0)');
|
||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -178,4 +242,26 @@ describe('FloatingBox.vue', () => {
|
|||||||
wrapper.unmount();
|
wrapper.unmount();
|
||||||
expect(destroyMock).toHaveBeenCalled();
|
expect(destroyMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dragStart 时不显示拖拽遮罩,仅在真正 drag 时显示,dragEnd 时移除', async () => {
|
||||||
|
const wrapper = mount(FloatingBox as any, {
|
||||||
|
props: { visible: true },
|
||||||
|
attachTo: document.body,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
|
||||||
|
// mousedown(dragStart)不应立即盖遮罩,否则会盖住关闭按钮导致点击失效
|
||||||
|
emitMoveable('dragStart');
|
||||||
|
expect(document.querySelector('.m-editor-float-box-drag-mask')).toBeNull();
|
||||||
|
|
||||||
|
// 真正发生位移时才显示遮罩
|
||||||
|
emitMoveable('drag', { target: document.body, transform: 'translate(0,0)' });
|
||||||
|
expect(document.querySelector('.m-editor-float-box-drag-mask')).not.toBeNull();
|
||||||
|
|
||||||
|
// 拖拽结束移除遮罩
|
||||||
|
emitMoveable('dragEnd');
|
||||||
|
expect(document.querySelector('.m-editor-float-box-drag-mask')).toBeNull();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -17,6 +17,7 @@ const codeBlockService = {
|
|||||||
};
|
};
|
||||||
const uiService = {
|
const uiService = {
|
||||||
get: vi.fn(() => [{ $key: 'code-block' }]),
|
get: vi.fn(() => [{ $key: 'code-block' }]),
|
||||||
|
set: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock('@editor/hooks/use-services', () => ({
|
vi.mock('@editor/hooks/use-services', () => ({
|
||||||
@ -138,13 +139,14 @@ describe('CodeSelectCol', () => {
|
|||||||
expect(((evts?.[0]?.[1] as any).changeRecords[0] as any).propPath).toContain('p1');
|
expect(((evts?.[0]?.[1] as any).changeRecords[0] as any).propPath).toContain('p1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('编辑按钮 emit edit-code', async () => {
|
test('编辑按钮 emit edit-code 并切换到代码块 tab', async () => {
|
||||||
const eventBus = { emit: vi.fn() };
|
const eventBus = { emit: vi.fn() };
|
||||||
const wrapper = mount(CodeSelectCol, {
|
const wrapper = mount(CodeSelectCol, {
|
||||||
props: baseProps() as any,
|
props: baseProps() as any,
|
||||||
global: { provide: { eventBus } },
|
global: { provide: { eventBus } },
|
||||||
});
|
});
|
||||||
await wrapper.find('button').trigger('click');
|
await wrapper.find('button').trigger('click');
|
||||||
|
expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'code-block');
|
||||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1');
|
expect(eventBus.emit).toHaveBeenCalledWith('edit-code', 'c1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -14,7 +14,7 @@ const { messageError } = vi.hoisted(() => ({ messageError: vi.fn() }));
|
|||||||
|
|
||||||
const dataSourceService = { get: vi.fn() };
|
const dataSourceService = { get: vi.fn() };
|
||||||
const propsService = { getDisabledDataSource: vi.fn() };
|
const propsService = { getDisabledDataSource: vi.fn() };
|
||||||
const uiService = { get: vi.fn() };
|
const uiService = { get: vi.fn(), set: vi.fn() };
|
||||||
|
|
||||||
vi.mock('@editor/hooks/use-services', () => ({
|
vi.mock('@editor/hooks/use-services', () => ({
|
||||||
useServices: () => ({ dataSourceService, propsService, uiService }),
|
useServices: () => ({ dataSourceService, propsService, uiService }),
|
||||||
@ -158,13 +158,25 @@ describe('FieldSelect', () => {
|
|||||||
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']);
|
expect(wrapper.emitted('change')?.[0]?.[0]).toEqual(['ds1', 'a']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('editHandler emit edit-data-source 到 eventBus', () => {
|
test('editHandler 无字段时 emit edit-data-source 并切换数据源 tab', async () => {
|
||||||
const eventBusEmit = vi.fn();
|
const eventBusEmit = vi.fn();
|
||||||
const wrapper = mount(FieldSelect, {
|
const wrapper = mount(FieldSelect, {
|
||||||
props: { dataSourceId: 'ds1' } as any,
|
props: { dataSourceId: 'ds1' } as any,
|
||||||
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
|
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
|
||||||
});
|
});
|
||||||
expect(wrapper).toBeTruthy();
|
await wrapper.find('.m-fields-select-action-button').trigger('click');
|
||||||
|
expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'data-source');
|
||||||
|
expect(eventBusEmit).toHaveBeenCalledWith('edit-data-source', 'ds1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editHandler 有字段时 emit edit-data-source-field 带字段路径', async () => {
|
||||||
|
const eventBusEmit = vi.fn();
|
||||||
|
const wrapper = mount(FieldSelect, {
|
||||||
|
props: { dataSourceId: 'ds1', modelValue: ['a'] } as any,
|
||||||
|
global: { provide: { eventBus: { emit: eventBusEmit, on: vi.fn() } } },
|
||||||
|
});
|
||||||
|
await wrapper.find('.m-fields-select-action-button').trigger('click');
|
||||||
|
expect(eventBusEmit).toHaveBeenCalledWith('edit-data-source-field', 'ds1', ['a']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -69,6 +69,8 @@ vi.mock('@tmagic/design', async () => {
|
|||||||
class: 'select-field-btn',
|
class: 'select-field-btn',
|
||||||
onClick: () => emit('select', { value: 'a', type: 'field' }),
|
onClick: () => emit('select', { value: 'a', type: 'field' }),
|
||||||
}),
|
}),
|
||||||
|
// 模拟部分实现会以 undefined 触发默认插槽参数
|
||||||
|
slots.default?.(),
|
||||||
slots.suffix?.(),
|
slots.suffix?.(),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
@ -164,6 +166,10 @@ describe('DataSourceInput', () => {
|
|||||||
expect(wrapper.find('.fake-autocomplete').exists()).toBe(true);
|
expect(wrapper.find('.fake-autocomplete').exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('autocomplete 默认插槽参数为 undefined 时不应报错', () => {
|
||||||
|
expect(() => mountIt('text-value', true)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test('未 focus 时显示文本视图', () => {
|
test('未 focus 时显示文本视图', () => {
|
||||||
const wrapper = mountIt('hello');
|
const wrapper = mountIt('hello');
|
||||||
expect(wrapper.find('.tmagic-data-source-input-text').exists()).toBe(true);
|
expect(wrapper.find('.tmagic-data-source-input-text').exists()).toBe(true);
|
||||||
|
|||||||
@ -22,7 +22,7 @@ const dataSourceService = {
|
|||||||
getFormMethod: vi.fn(() => []),
|
getFormMethod: vi.fn(() => []),
|
||||||
};
|
};
|
||||||
|
|
||||||
const uiService = { get: vi.fn(() => [{ $key: 'data-source' }]) };
|
const uiService = { get: vi.fn(() => [{ $key: 'data-source' }]), set: vi.fn() };
|
||||||
|
|
||||||
vi.mock('@editor/hooks/use-services', () => ({
|
vi.mock('@editor/hooks/use-services', () => ({
|
||||||
useServices: () => ({ dataSourceService, uiService }),
|
useServices: () => ({ dataSourceService, uiService }),
|
||||||
@ -158,7 +158,7 @@ describe('DataSourceMethodSelect', () => {
|
|||||||
expect(evts).toBeTruthy();
|
expect(evts).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('编辑按钮 emit edit-data-source', async () => {
|
test('编辑按钮 emit edit-data-source-method 并切换到数据源 tab', async () => {
|
||||||
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
dataSourceService.getDataSourceById.mockReturnValue({ id: 'ds1', methods: [{ name: 'doFetch' }] });
|
||||||
const eventBus = { emit: vi.fn() };
|
const eventBus = { emit: vi.fn() };
|
||||||
const wrapper = mount(DataSourceMethodSelect, {
|
const wrapper = mount(DataSourceMethodSelect, {
|
||||||
@ -166,7 +166,8 @@ describe('DataSourceMethodSelect', () => {
|
|||||||
global: { provide: { eventBus } },
|
global: { provide: { eventBus } },
|
||||||
});
|
});
|
||||||
await wrapper.find('button').trigger('click');
|
await wrapper.find('button').trigger('click');
|
||||||
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source', 'ds1');
|
expect(uiService.set).toHaveBeenCalledWith('sideBarActiveTabName', 'data-source');
|
||||||
|
expect(eventBus.emit).toHaveBeenCalledWith('edit-data-source-method', 'ds1', 'doFetch');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('编辑按钮: 找不到 dataSource 时不触发', async () => {
|
test('编辑按钮: 找不到 dataSource 时不触发', async () => {
|
||||||
|
|||||||
@ -15,7 +15,7 @@ const editorService = {
|
|||||||
undo: vi.fn(),
|
undo: vi.fn(),
|
||||||
redo: vi.fn(),
|
redo: vi.fn(),
|
||||||
};
|
};
|
||||||
const historyService = { state: { canUndo: true, canRedo: true } };
|
const historyService = { state: { steps: {} }, canUndo: vi.fn(() => true), canRedo: vi.fn(() => true) };
|
||||||
const uiService = {
|
const uiService = {
|
||||||
get: vi.fn(),
|
get: vi.fn(),
|
||||||
set: vi.fn(),
|
set: vi.fn(),
|
||||||
|
|||||||
@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { describe, expect, test } from 'vitest';
|
||||||
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
|
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||||
|
import type { HistoryBucketConfig } from '@editor/type';
|
||||||
|
|
||||||
|
const buildConfig = (): HistoryBucketConfig<any> => ({
|
||||||
|
title: '数据源',
|
||||||
|
prefix: 'ds',
|
||||||
|
describeGroup: () => 'desc',
|
||||||
|
describeStep: () => 'sub-desc',
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildGroup = (): any => ({
|
||||||
|
applied: true,
|
||||||
|
opType: 'update' as const,
|
||||||
|
steps: [{ index: 0, applied: true, step: { mark: 's-0' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BucketTab.vue', () => {
|
||||||
|
test('buckets 为空时显示空态', () => {
|
||||||
|
const wrapper = mount(BucketTab, {
|
||||||
|
props: {
|
||||||
|
config: buildConfig(),
|
||||||
|
buckets: [],
|
||||||
|
expanded: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.m-editor-history-list-empty').text()).toBe('暂无操作记录');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buckets 非空时渲染 toolbar 与 Bucket 列表', () => {
|
||||||
|
const wrapper = mount(BucketTab, {
|
||||||
|
props: {
|
||||||
|
config: buildConfig(),
|
||||||
|
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||||
|
expanded: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.m-editor-history-list-bucket-title').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点击清空按钮触发 clear 事件', async () => {
|
||||||
|
const wrapper = mount(BucketTab, {
|
||||||
|
props: {
|
||||||
|
config: buildConfig(),
|
||||||
|
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||||
|
expanded: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await wrapper.find('.m-editor-history-list-clear').trigger('click');
|
||||||
|
expect(wrapper.emitted('clear')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('config.showClear 为 false 时不渲染清空按钮', () => {
|
||||||
|
const wrapper = mount(BucketTab, {
|
||||||
|
props: {
|
||||||
|
config: { ...buildConfig(), showClear: false },
|
||||||
|
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||||
|
expanded: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(wrapper.find('.m-editor-history-list-toolbar').exists()).toBe(false);
|
||||||
|
expect(wrapper.find('.m-editor-history-list-clear').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('透传 Bucket 子组件事件', async () => {
|
||||||
|
const wrapper = mount(BucketTab, {
|
||||||
|
props: {
|
||||||
|
config: buildConfig(),
|
||||||
|
buckets: [{ id: 'ds_1', groups: [buildGroup()] }],
|
||||||
|
expanded: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await wrapper.find('.m-editor-history-list-item-goto').trigger('click');
|
||||||
|
expect(wrapper.emitted('goto')?.[0]).toEqual(['ds_1', 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||||
import { describeCodeBlockGroup, describeCodeBlockStep } from '@editor/layouts/history-list/composables';
|
import { describeStep } from '@editor/layouts/history-list/composables';
|
||||||
import type { CodeBlockHistoryGroup, CodeBlockStepValue } from '@editor/type';
|
import type { CodeBlockStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
|
||||||
|
|
||||||
vi.mock('@tmagic/design', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
TMagicScrollbar: defineComponent({
|
TMagicScrollbar: defineComponent({
|
||||||
@ -27,8 +27,8 @@ const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
|
|||||||
opType,
|
opType,
|
||||||
diff: [
|
diff: [
|
||||||
{
|
{
|
||||||
...(s.newContent != null ? { newSchema: s.newContent } : {}),
|
...(s.newContent !== null && s.newContent !== undefined ? { newSchema: s.newContent } : {}),
|
||||||
...(s.oldContent != null ? { oldSchema: s.oldContent } : {}),
|
...(s.oldContent !== null && s.oldContent !== undefined ? { oldSchema: s.oldContent } : {}),
|
||||||
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -40,7 +40,7 @@ const buildGroup = (
|
|||||||
steps: any[],
|
steps: any[],
|
||||||
applied = true,
|
applied = true,
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
): CodeBlockHistoryGroup => ({
|
): StackHistoryGroup<CodeBlockStepValue, 'code-block'> => ({
|
||||||
kind: 'code-block',
|
kind: 'code-block',
|
||||||
id,
|
id,
|
||||||
opType,
|
opType,
|
||||||
@ -49,16 +49,17 @@ const buildGroup = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 代码块 tab 复用通用 BucketTab,固定注入代码块的 config(title/prefix/describe/isStepDiffable)。 */
|
/** 代码块 tab 复用通用 BucketTab,固定注入代码块的 config(title/prefix/describe/isStepDiffable)。 */
|
||||||
|
const codeBlockConfig: HistoryBucketConfig<any> = {
|
||||||
|
title: '代码块',
|
||||||
|
prefix: 'cb',
|
||||||
|
describeStep: (step: CodeBlockStepValue): string => describeStep(step, (content) => content?.name, '代码块'),
|
||||||
|
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
||||||
|
};
|
||||||
|
|
||||||
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
const mountCodeBlockTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
||||||
mount(BucketTab, {
|
mount(BucketTab, {
|
||||||
props: {
|
props: {
|
||||||
config: {
|
config: codeBlockConfig,
|
||||||
title: '代码块',
|
|
||||||
prefix: 'cb',
|
|
||||||
describeGroup: describeCodeBlockGroup,
|
|
||||||
describeStep: describeCodeBlockStep,
|
|
||||||
isStepDiffable: (step: CodeBlockStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
|
||||||
},
|
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -83,7 +84,7 @@ describe('CodeBlockTab.vue', () => {
|
|||||||
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
|
expect(wrapper.find('.m-editor-history-list-bucket-title code').text()).toBe('code_1');
|
||||||
|
|
||||||
const desc = wrapper.find('.m-editor-history-list-item-desc').text();
|
const desc = wrapper.find('.m-editor-history-list-item-desc').text();
|
||||||
expect(desc).toBe('创建 fn (id: code_1)');
|
expect(desc).toBe('fn (id: code_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggle 透传:key 形如 cb-${id}-${idx}', async () => {
|
test('toggle 透传:key 形如 cb-${id}-${idx}', async () => {
|
||||||
@ -178,7 +179,7 @@ describe('CodeBlockTab.vue', () => {
|
|||||||
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
const items = wrapper.findAll('.m-editor-history-list-substeps li');
|
||||||
expect(items).toHaveLength(2);
|
expect(items).toHaveLength(2);
|
||||||
// 子步倒序渲染(最新在上):params 在前,content 在后
|
// 子步倒序渲染(最新在上):params 在前,content 在后
|
||||||
expect(items[0].text()).toContain('修改 fn (id: code_1) · params');
|
expect(items[0].text()).toContain('fn (id: code_1) · params');
|
||||||
expect(items[1].text()).toContain('修改 fn (id: code_1) · content');
|
expect(items[1].text()).toContain('fn (id: code_1) · content');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,8 +8,8 @@ import { defineComponent, h } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
import BucketTab from '@editor/layouts/history-list/BucketTab.vue';
|
||||||
import { describeDataSourceGroup, describeDataSourceStep } from '@editor/layouts/history-list/composables';
|
import { describeStep } from '@editor/layouts/history-list/composables';
|
||||||
import type { DataSourceHistoryGroup, DataSourceStepValue } from '@editor/type';
|
import type { DataSourceStepValue, HistoryBucketConfig, StackHistoryGroup } from '@editor/type';
|
||||||
|
|
||||||
vi.mock('@tmagic/design', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
TMagicScrollbar: defineComponent({
|
TMagicScrollbar: defineComponent({
|
||||||
@ -27,8 +27,8 @@ const toDiffStep = (s: any, opType: 'add' | 'remove' | 'update') => ({
|
|||||||
opType,
|
opType,
|
||||||
diff: [
|
diff: [
|
||||||
{
|
{
|
||||||
...(s.newSchema != null ? { newSchema: s.newSchema } : {}),
|
...(s.newSchema !== null && s.newSchema !== undefined ? { newSchema: s.newSchema } : {}),
|
||||||
...(s.oldSchema != null ? { oldSchema: s.oldSchema } : {}),
|
...(s.oldSchema !== null && s.oldSchema !== undefined ? { oldSchema: s.oldSchema } : {}),
|
||||||
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
...(s.changeRecords ? { changeRecords: s.changeRecords } : {}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@ -40,7 +40,7 @@ const buildGroup = (
|
|||||||
steps: any[],
|
steps: any[],
|
||||||
applied = true,
|
applied = true,
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
): DataSourceHistoryGroup => ({
|
): StackHistoryGroup<DataSourceStepValue, 'data-source'> => ({
|
||||||
kind: 'data-source',
|
kind: 'data-source',
|
||||||
id,
|
id,
|
||||||
opType,
|
opType,
|
||||||
@ -49,16 +49,17 @@ const buildGroup = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 数据源 tab 复用通用 BucketTab,固定注入数据源的 config(title/prefix/describe/isStepDiffable)。 */
|
/** 数据源 tab 复用通用 BucketTab,固定注入数据源的 config(title/prefix/describe/isStepDiffable)。 */
|
||||||
|
const dataSourceConfig: HistoryBucketConfig<any> = {
|
||||||
|
title: '数据源',
|
||||||
|
prefix: 'ds',
|
||||||
|
describeStep: (step: DataSourceStepValue): string => describeStep(step, (schema) => schema?.title, '数据源'),
|
||||||
|
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
||||||
|
};
|
||||||
|
|
||||||
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
const mountDataSourceTab = (props: { buckets: any[]; expanded: Record<string, boolean> }) =>
|
||||||
mount(BucketTab, {
|
mount(BucketTab, {
|
||||||
props: {
|
props: {
|
||||||
config: {
|
config: dataSourceConfig,
|
||||||
title: '数据源',
|
|
||||||
prefix: 'ds',
|
|
||||||
describeGroup: describeDataSourceGroup,
|
|
||||||
describeStep: describeDataSourceStep,
|
|
||||||
isStepDiffable: (step: DataSourceStepValue) => Boolean(step.diff?.[0]?.oldSchema && step.diff?.[0]?.newSchema),
|
|
||||||
},
|
|
||||||
...props,
|
...props,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -92,9 +93,9 @@ describe('DataSourceTab.vue', () => {
|
|||||||
const rows = wrapper.findAll('.m-editor-history-list-group');
|
const rows = wrapper.findAll('.m-editor-history-list-group');
|
||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
||||||
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('创建 A (id: ds_1)');
|
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: ds_1)');
|
||||||
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除');
|
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('删除');
|
||||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('删除 B (id: ds_2)');
|
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('B (id: ds_2)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toggle 透传:key 形如 ds-${id}-${idx}', async () => {
|
test('toggle 透传:key 形如 ds-${id}-${idx}', async () => {
|
||||||
|
|||||||
@ -23,6 +23,7 @@ const makeGroup = (overrides: Partial<HistoryRowGroup> = {}): HistoryRowGroup =>
|
|||||||
/** 构造单个子步,缺省值贴近真实派生结果。 */
|
/** 构造单个子步,缺省值贴近真实派生结果。 */
|
||||||
const makeStep = (overrides: Partial<HistoryRowStep> & Pick<HistoryRowStep, 'index'>): HistoryRowStep => ({
|
const makeStep = (overrides: Partial<HistoryRowStep> & Pick<HistoryRowStep, 'index'>): HistoryRowStep => ({
|
||||||
applied: true,
|
applied: true,
|
||||||
|
isCurrent: false,
|
||||||
desc: '',
|
desc: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -82,6 +82,8 @@ vi.mock('@editor/layouts/CodeEditor.vue', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const services = {} as any;
|
||||||
|
|
||||||
const factory = () =>
|
const factory = () =>
|
||||||
mount(HistoryDiffDialog, {
|
mount(HistoryDiffDialog, {
|
||||||
// 让 Teleport 内容内联渲染,便于通过 wrapper 查询
|
// 让 Teleport 内容内联渲染,便于通过 wrapper 查询
|
||||||
@ -164,6 +166,43 @@ describe('HistoryDiffDialog.vue', () => {
|
|||||||
expect(currentRadio!.attributes('data-disabled')).toBe('true');
|
expect(currentRadio!.attributes('data-disabled')).toBe('true');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('无「修改后的值」时「与修改前对比」按钮置灰', async () => {
|
||||||
|
const wrapper = factory();
|
||||||
|
(wrapper.vm as any).open(basePayload({ value: null }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const beforeRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'before');
|
||||||
|
expect(beforeRadio!.attributes('data-disabled')).toBe('true');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无「修改后的值」但有当前值时默认进入「与当前对比」', async () => {
|
||||||
|
const wrapper = factory();
|
||||||
|
(wrapper.vm as any).open(basePayload({ value: null }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const form = wrapper.findComponent({ name: 'CompareForm' });
|
||||||
|
// current 模式:左=该步修改后 value(此处为空),右=当前 currentValue
|
||||||
|
expect(form.props('value')).toEqual({ text: 'current' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('无「修改后的值」且无当前值时仍默认「与修改前对比」', async () => {
|
||||||
|
const wrapper = factory();
|
||||||
|
(wrapper.vm as any).open(basePayload({ value: null, currentValue: null }));
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const form = wrapper.findComponent({ name: 'CompareForm' });
|
||||||
|
expect(form.props('lastValue')).toEqual({ text: 'old' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('有「修改后的值」时「与修改前对比」按钮可用', async () => {
|
||||||
|
const wrapper = factory();
|
||||||
|
(wrapper.vm as any).open(basePayload());
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const beforeRadio = wrapper.findAll('.fake-radio-btn').find((b) => b.attributes('data-value') === 'before');
|
||||||
|
expect(beforeRadio!.attributes('data-disabled')).toBe('false');
|
||||||
|
});
|
||||||
|
|
||||||
test('targetText 按 category 生成前缀', async () => {
|
test('targetText 按 category 生成前缀', async () => {
|
||||||
const wrapper = factory();
|
const wrapper = factory();
|
||||||
(wrapper.vm as any).open(basePayload({ category: 'data-source', targetLabel: '接口A' }));
|
(wrapper.vm as any).open(basePayload({ category: 'data-source', targetLabel: '接口A' }));
|
||||||
@ -217,7 +256,7 @@ describe('HistoryDiffDialog.vue', () => {
|
|||||||
|
|
||||||
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
|
test('有 onConfirm 时标题为「确认回滚」并展示回滚说明', async () => {
|
||||||
const wrapper = mount(HistoryDiffDialog, {
|
const wrapper = mount(HistoryDiffDialog, {
|
||||||
global: { stubs: { teleport: true } },
|
global: { stubs: { teleport: true }, provide: { services } },
|
||||||
props: { onConfirm: vi.fn() },
|
props: { onConfirm: vi.fn() },
|
||||||
});
|
});
|
||||||
(wrapper.vm as any).open(basePayload());
|
(wrapper.vm as any).open(basePayload());
|
||||||
|
|||||||
@ -8,18 +8,59 @@ import { defineComponent, h, nextTick } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import historyService from '@editor/services/history';
|
import historyService from '@editor/services/history';
|
||||||
|
import { createStackStep } from '@editor/utils/history';
|
||||||
|
|
||||||
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
|
||||||
|
const pushCodeBlock = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldContent,
|
||||||
|
newValue: payload.newContent,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? historyService.push('codeBlock', step as any, id) : null;
|
||||||
|
};
|
||||||
|
const pushDataSource = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldSchema,
|
||||||
|
newValue: payload.newSchema,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? historyService.push('dataSource', step as any, id) : null;
|
||||||
|
};
|
||||||
|
// 页面 push 现在必须显式传 pageId;默认推到 'p1'(与 editorService.get('page') 的 mock 对齐)。
|
||||||
|
const pushPage = (step: any, id: any = 'p1') => historyService.push('page', step, id);
|
||||||
|
|
||||||
|
const { onPageDiff, onDataSourceDiff, onCodeBlockDiff, onPageRevert, onDataSourceRevert, onCodeBlockRevert } =
|
||||||
|
vi.hoisted(() => ({
|
||||||
|
onPageDiff: vi.fn(),
|
||||||
|
onDataSourceDiff: vi.fn(),
|
||||||
|
onCodeBlockDiff: vi.fn(),
|
||||||
|
onPageRevert: vi.fn(),
|
||||||
|
onDataSourceRevert: vi.fn(),
|
||||||
|
onCodeBlockRevert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const stageSelect = vi.fn();
|
const stageSelect = vi.fn();
|
||||||
const overlayStageSelect = vi.fn();
|
const overlayStageSelect = vi.fn();
|
||||||
const editorService = {
|
const editorService = {
|
||||||
gotoPageStep: vi.fn(async () => 0),
|
gotoPageStep: vi.fn(async () => 0),
|
||||||
|
revertPageStep: vi.fn(async () => null),
|
||||||
getNodeById: vi.fn((id: string | number) => ({ id })),
|
getNodeById: vi.fn((id: string | number) => ({ id })),
|
||||||
select: vi.fn(async () => {}),
|
select: vi.fn(async () => {}),
|
||||||
get: vi.fn(() => ({ select: stageSelect })),
|
// 历史面板已改为显式按当前页 id 取页面历史,'page' 返回固定 id 'p1'。
|
||||||
|
get: vi.fn((key?: string) => (key === 'page' ? { id: 'p1' } : { select: stageSelect })),
|
||||||
};
|
};
|
||||||
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
|
const stageOverlayService = { get: vi.fn(() => ({ select: overlayStageSelect })) };
|
||||||
const dataSourceService = { goto: vi.fn(() => 0) };
|
const dataSourceService = {
|
||||||
const codeBlockService = { goto: vi.fn(async () => 0) };
|
goto: vi.fn(() => 0),
|
||||||
|
revert: vi.fn(async () => null),
|
||||||
|
getDataSourceById: vi.fn((id: string) => ({ id, title: 'DS' })),
|
||||||
|
};
|
||||||
|
const codeBlockService = {
|
||||||
|
goto: vi.fn(async () => 0),
|
||||||
|
revert: vi.fn(async () => null),
|
||||||
|
getCodeContentById: vi.fn((id: string | number) => ({ id, name: 'CB' })),
|
||||||
|
};
|
||||||
const propsService = {
|
const propsService = {
|
||||||
getDisabledDataSource: vi.fn(() => false),
|
getDisabledDataSource: vi.fn(() => false),
|
||||||
getDisabledCodeBlock: vi.fn(() => false),
|
getDisabledCodeBlock: vi.fn(() => false),
|
||||||
@ -36,6 +77,17 @@ vi.mock('@editor/hooks/use-services', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@editor/layouts/history-list/useHistoryRevert', () => ({
|
||||||
|
useHistoryRevert: vi.fn(() => ({
|
||||||
|
onPageRevert,
|
||||||
|
onDataSourceRevert,
|
||||||
|
onCodeBlockRevert,
|
||||||
|
onPageDiff,
|
||||||
|
onDataSourceDiff,
|
||||||
|
onCodeBlockDiff,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@tmagic/design', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
getDesignConfig: vi.fn(() => undefined),
|
getDesignConfig: vi.fn(() => undefined),
|
||||||
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
tMagicMessage: { warning: vi.fn(), error: vi.fn(), success: vi.fn() },
|
||||||
@ -85,21 +137,11 @@ vi.mock('@editor/components/Icon.vue', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 差异对话框有独立的单测(HistoryDiffDialog.spec.ts),这里 stub 掉以隔离面板自身逻辑,
|
|
||||||
// 同时避免其内部依赖(monaco CodeEditor / CompareForm / 设计层弹窗组件)在本用例下未被 mock 而报错。
|
|
||||||
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
|
|
||||||
default: defineComponent({
|
|
||||||
name: 'FakeHistoryDiffDialog',
|
|
||||||
setup(_p, { expose }) {
|
|
||||||
expose({ open: vi.fn(), close: vi.fn() });
|
|
||||||
return () => h('div', { class: 'fake-history-diff-dialog' });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
propsService.getDisabledDataSource.mockReturnValue(false);
|
||||||
|
propsService.getDisabledCodeBlock.mockReturnValue(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const factory = async () => {
|
const factory = async () => {
|
||||||
@ -119,17 +161,16 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => {
|
test('页面/数据源/代码块 数据齐全时各 tab 渲染对应内容', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
pushPage({
|
||||||
historyService.push({
|
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any);
|
||||||
historyService.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||||
});
|
});
|
||||||
historyService.pushCodeBlock('code_1', {
|
pushCodeBlock('code_1', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { id: 'code_1', name: 'CB' } as any,
|
newContent: { id: 'code_1', name: 'CB' } as any,
|
||||||
});
|
});
|
||||||
@ -142,13 +183,12 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
expect(rows.length).toBe(3);
|
expect(rows.length).toBe(3);
|
||||||
|
|
||||||
const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text());
|
const descs = rows.map((r) => r.find('.m-editor-history-list-item-desc').text());
|
||||||
expect(descs.some((t) => t.includes('新增 1 个节点'))).toBe(true);
|
expect(descs.some((t) => t === 'A (id: n1)')).toBe(true);
|
||||||
expect(descs.some((t) => t === '创建 DS (id: ds_1)')).toBe(true);
|
expect(descs.some((t) => t === 'DS (id: ds_1)')).toBe(true);
|
||||||
expect(descs.some((t) => t === '创建 CB (id: code_1)')).toBe(true);
|
expect(descs.some((t) => t === 'CB (id: code_1)')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('点击合并组头部能切换 expanded 状态(不触发 goto)', async () => {
|
test('点击合并组头部能切换 expanded 状态(不触发 goto)', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
|
||||||
// 推两个修改同一节点的步骤,会合并为一个 group
|
// 推两个修改同一节点的步骤,会合并为一个 group
|
||||||
const mkUpdate = (path: string) => ({
|
const mkUpdate = (path: string) => ({
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
@ -161,8 +201,8 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
historyService.push(mkUpdate('a') as any);
|
pushPage(mkUpdate('a') as any);
|
||||||
historyService.push(mkUpdate('b') as any);
|
pushPage(mkUpdate('b') as any);
|
||||||
|
|
||||||
const wrapper = await factory();
|
const wrapper = await factory();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
@ -183,13 +223,12 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {
|
test('点击页面 group 头部调用 editorService.gotoPageStep', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
pushPage({
|
||||||
historyService.push({
|
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any);
|
||||||
historyService.push({
|
pushPage({
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
|
diff: [{ newSchema: { id: 'n2', name: 'B' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
@ -214,8 +253,7 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('点击页面 group 头部选中对应节点(editorService.select + 画布 select 联动)', async () => {
|
test('点击页面 group 头部选中对应节点(editorService.select + 画布 select 联动)', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
pushPage({
|
||||||
historyService.push({
|
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
@ -237,8 +275,7 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('点击页面记录时节点已不存在则提示且不选中', async () => {
|
test('点击页面记录时节点已不存在则提示且不选中', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
pushPage({
|
||||||
historyService.push({
|
|
||||||
opType: 'remove',
|
opType: 'remove',
|
||||||
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
|
diff: [{ oldSchema: { id: 'gone', name: 'G' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
@ -258,7 +295,7 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
|
test('点击数据源组头部调用 dataSourceService.goto(id, cursor)', async () => {
|
||||||
historyService.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||||
});
|
});
|
||||||
@ -267,19 +304,19 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
// 当前 ds 组(isCurrent)点击不触发 goto;为了能触发,先撤销该步使其变为非当前
|
// 当前 ds 组(isCurrent)点击不触发 goto;为了能触发,先撤销该步使其变为非当前
|
||||||
historyService.undoDataSource('ds_1');
|
historyService.undo('dataSource', 'ds_1');
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||||
// 找到数据源 tab 那一组
|
// 找到数据源 tab 那一组
|
||||||
const dsHead = heads.find((h) => h.text().includes('创建 DS'));
|
const dsHead = heads.find((h) => h.text().includes('DS (id: ds_1)'));
|
||||||
expect(dsHead).toBeTruthy();
|
expect(dsHead).toBeTruthy();
|
||||||
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
await dsHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
||||||
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
|
expect(dataSourceService.goto).toHaveBeenCalledWith('ds_1', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => {
|
test('点击代码块组头部调用 codeBlockService.goto(id, cursor)', async () => {
|
||||||
historyService.pushCodeBlock('code_1', {
|
pushCodeBlock('code_1', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { id: 'code_1', name: 'CB' } as any,
|
newContent: { id: 'code_1', name: 'CB' } as any,
|
||||||
});
|
});
|
||||||
@ -287,19 +324,18 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
const wrapper = await factory();
|
const wrapper = await factory();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
historyService.undoCodeBlock('code_1');
|
historyService.undo('codeBlock', 'code_1');
|
||||||
await nextTick();
|
await nextTick();
|
||||||
|
|
||||||
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
const heads = wrapper.findAll('.m-editor-history-list-group-head');
|
||||||
const cbHead = heads.find((h) => h.text().includes('创建 CB'));
|
const cbHead = heads.find((h) => h.text().includes('CB (id: code_1)'));
|
||||||
expect(cbHead).toBeTruthy();
|
expect(cbHead).toBeTruthy();
|
||||||
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
await cbHead!.find('.m-editor-history-list-item-goto').trigger('click');
|
||||||
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);
|
expect(codeBlockService.goto).toHaveBeenCalledWith('code_1', 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => {
|
test('点击页面初始项调用 editorService.gotoPageStep(0)', async () => {
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
pushPage({
|
||||||
historyService.push({
|
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
@ -348,12 +384,103 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
expect(custom.text()).toBe('hello-custom');
|
expect(custom.text()).toBe('hello-custom');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('disabledDataSource / disabledCodeBlock 为 true 时不渲染对应 tab', async () => {
|
||||||
|
propsService.getDisabledDataSource.mockReturnValue(true);
|
||||||
|
propsService.getDisabledCodeBlock.mockReturnValue(true);
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const empties = wrapper.findAll('.m-editor-history-list-empty');
|
||||||
|
expect(empties).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点击页面 update 记录的「查看差异」打开 diff 弹窗', async () => {
|
||||||
|
pushPage({
|
||||||
|
opType: 'update',
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
diff: [
|
||||||
|
{
|
||||||
|
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
|
||||||
|
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
|
||||||
|
changeRecords: [{ propPath: 'name' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await wrapper.find('.m-editor-history-list-item-diff').trigger('click');
|
||||||
|
expect(onPageDiff).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('点击页面 update 记录的「回滚」透传到 onPageRevert', async () => {
|
||||||
|
pushPage({
|
||||||
|
opType: 'update',
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
diff: [
|
||||||
|
{
|
||||||
|
newSchema: { id: 'btn', name: '新按钮', type: 'button' },
|
||||||
|
oldSchema: { id: 'btn', name: '旧按钮', type: 'button' },
|
||||||
|
changeRecords: [{ propPath: 'name' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||||
|
expect(onPageRevert).toHaveBeenCalledWith(0);
|
||||||
|
expect(editorService.revertPageStep).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('回滚目标校验由 useHistoryRevert 处理,面板仅透传 index', async () => {
|
||||||
|
pushPage({
|
||||||
|
opType: 'update',
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
diff: [
|
||||||
|
{
|
||||||
|
newSchema: { id: 'gone', name: '按钮', type: 'button' },
|
||||||
|
oldSchema: { id: 'gone', name: '按钮', type: 'button' },
|
||||||
|
changeRecords: [{ propPath: 'name' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||||
|
expect(onPageRevert).toHaveBeenCalledWith(0);
|
||||||
|
expect(editorService.revertPageStep).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('确认清空页面历史后调用 historyService.clear', async () => {
|
||||||
|
pushPage({
|
||||||
|
opType: 'add',
|
||||||
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
} as any);
|
||||||
|
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
await wrapper.find('.m-editor-history-list-clear').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(historyService.getHistoryGroups('page', 'p1')).toHaveLength(0);
|
||||||
|
expect(saveSpy).toHaveBeenCalled();
|
||||||
|
saveSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
|
test('点击数据源/代码块初始项调用对应 service.goto(id, 0)', async () => {
|
||||||
historyService.pushDataSource('ds_x', {
|
pushDataSource('ds_x', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_x', title: 'DS' } as any,
|
newSchema: { id: 'ds_x', title: 'DS' } as any,
|
||||||
});
|
});
|
||||||
historyService.pushCodeBlock('code_x', {
|
pushCodeBlock('code_x', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { id: 'code_x', name: 'CB' } as any,
|
newContent: { id: 'code_x', name: 'CB' } as any,
|
||||||
});
|
});
|
||||||
@ -374,4 +501,54 @@ describe('HistoryListPanel.vue', () => {
|
|||||||
await initials[1].find('.m-editor-history-list-item-goto').trigger('click');
|
await initials[1].find('.m-editor-history-list-item-goto').trigger('click');
|
||||||
expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0);
|
expect(codeBlockService.goto).toHaveBeenCalledWith('code_x', 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('点击数据源 update 记录的「查看差异」与「回滚」', async () => {
|
||||||
|
pushDataSource('ds_1', {
|
||||||
|
oldSchema: null,
|
||||||
|
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||||
|
});
|
||||||
|
pushDataSource('ds_1', {
|
||||||
|
oldSchema: { id: 'ds_1', title: '旧 DS' } as any,
|
||||||
|
newSchema: { id: 'ds_1', title: '新 DS' } as any,
|
||||||
|
changeRecords: [{ propPath: 'title', value: '新 DS' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const diffBtn = wrapper.find('.m-editor-history-list-item-diff');
|
||||||
|
await diffBtn.trigger('click');
|
||||||
|
expect(onDataSourceDiff).toHaveBeenCalledWith('ds_1', 1);
|
||||||
|
|
||||||
|
await wrapper.find('.m-editor-history-list-item-revert').trigger('click');
|
||||||
|
expect(onDataSourceRevert).toHaveBeenCalledWith('ds_1', 1);
|
||||||
|
expect(dataSourceService.revert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('确认清空数据源/代码块历史后调用 clear(undefined, stepType)', async () => {
|
||||||
|
pushDataSource('ds_1', {
|
||||||
|
oldSchema: null,
|
||||||
|
newSchema: { id: 'ds_1', title: 'DS' } as any,
|
||||||
|
});
|
||||||
|
pushCodeBlock('code_1', {
|
||||||
|
oldContent: null,
|
||||||
|
newContent: { id: 'code_1', name: 'CB' } as any,
|
||||||
|
});
|
||||||
|
const saveSpy = vi.spyOn(historyService, 'saveToIndexedDB').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const wrapper = await factory();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
const clears = wrapper.findAll('.m-editor-history-list-clear');
|
||||||
|
expect(clears.length).toBeGreaterThanOrEqual(2);
|
||||||
|
await clears[0].trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
await clears[1].trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(historyService.getHistoryGroups('dataSource')).toHaveLength(0);
|
||||||
|
expect(historyService.getHistoryGroups('codeBlock')).toHaveLength(0);
|
||||||
|
expect(saveSpy).toHaveBeenCalled();
|
||||||
|
saveSpy.mockRestore();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { defineComponent, h } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import PageTab from '@editor/layouts/history-list/PageTab.vue';
|
import PageTab from '@editor/layouts/history-list/PageTab.vue';
|
||||||
import type { PageHistoryGroup } from '@editor/type';
|
import type { HistoryGroup, StepValue } from '@editor/type';
|
||||||
|
|
||||||
vi.mock('@tmagic/design', () => ({
|
vi.mock('@tmagic/design', () => ({
|
||||||
TMagicScrollbar: defineComponent({
|
TMagicScrollbar: defineComponent({
|
||||||
@ -27,9 +27,9 @@ const buildPageGroup = (
|
|||||||
targetName?: string,
|
targetName?: string,
|
||||||
targetId?: string,
|
targetId?: string,
|
||||||
startIndex = 0,
|
startIndex = 0,
|
||||||
): PageHistoryGroup => ({
|
): HistoryGroup<StepValue> => ({
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType,
|
opType,
|
||||||
applied,
|
applied,
|
||||||
targetId,
|
targetId,
|
||||||
@ -71,10 +71,10 @@ describe('PageTab.vue', () => {
|
|||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
// 第一组 add
|
// 第一组 add
|
||||||
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
expect(rows[0].find('.m-editor-history-list-item-op').text()).toBe('新增');
|
||||||
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toContain('新增 1 个节点');
|
expect(rows[0].find('.m-editor-history-list-item-desc').text()).toBe('A (id: n1)');
|
||||||
// 第二组 update
|
// 第二组 update
|
||||||
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改');
|
expect(rows[1].find('.m-editor-history-list-item-op').text()).toBe('修改');
|
||||||
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('修改 按钮 (id: btn) · style.color');
|
expect(rows[1].find('.m-editor-history-list-item-desc').text()).toBe('按钮 (id: btn) · style.color');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('step 含 timestamp 时渲染时间元素', () => {
|
test('step 含 timestamp 时渲染时间元素', () => {
|
||||||
|
|||||||
@ -8,37 +8,43 @@ import { defineComponent, h } from 'vue';
|
|||||||
import { mount } from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
describeCodeBlockGroup,
|
|
||||||
describeCodeBlockStep,
|
|
||||||
describeDataSourceGroup,
|
|
||||||
describeDataSourceStep,
|
|
||||||
describePageGroup,
|
describePageGroup,
|
||||||
describePageStep,
|
describePageStep,
|
||||||
formatHistoryFullTime,
|
formatHistoryFullTime,
|
||||||
formatHistoryTime,
|
formatHistoryTime,
|
||||||
groupTimestamp,
|
groupTimestamp,
|
||||||
isCodeBlockStepRevertable,
|
|
||||||
isDataSourceStepRevertable,
|
|
||||||
isPageStepRevertable,
|
isPageStepRevertable,
|
||||||
|
isSingleDiffStepRevertable,
|
||||||
opLabel,
|
opLabel,
|
||||||
useHistoryList,
|
|
||||||
} from '@editor/layouts/history-list/composables';
|
} from '@editor/layouts/history-list/composables';
|
||||||
|
import { useHistoryList } from '@editor/layouts/history-list/useHistoryList';
|
||||||
import historyService from '@editor/services/history';
|
import historyService from '@editor/services/history';
|
||||||
import type {
|
import type { HistoryGroup, HistoryStepEntry, StepValue } from '@editor/type';
|
||||||
CodeBlockHistoryGroup,
|
import { createStackStep } from '@editor/utils/history';
|
||||||
CodeBlockStepValue,
|
|
||||||
DataSourceHistoryGroup,
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
|
||||||
DataSourceStepValue,
|
const pushCodeBlock = (id: any, payload: any) => {
|
||||||
PageHistoryGroup,
|
const step = createStackStep(id, {
|
||||||
PageHistoryStepEntry,
|
oldValue: payload.oldContent,
|
||||||
StepValue,
|
newValue: payload.newContent,
|
||||||
} from '@editor/type';
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? historyService.push('codeBlock', step as any, id) : null;
|
||||||
|
};
|
||||||
|
const pushDataSource = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldSchema,
|
||||||
|
newValue: payload.newSchema,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? historyService.push('dataSource', step as any, id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
const buildPageEntry = (step: StepValue, index = 0, applied = true): PageHistoryStepEntry => ({
|
const buildPageEntry = (step: StepValue, index = 0, applied = true): HistoryStepEntry<StepValue> => ({
|
||||||
step,
|
step,
|
||||||
index,
|
index,
|
||||||
applied,
|
applied,
|
||||||
@ -111,7 +117,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
|
diff: [{ newSchema: { id: 'btn_1', type: 'button', name: '主按钮' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('新增 1 个节点(主按钮 (id: btn_1))');
|
expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add 节点无 name 但有 type:使用 type 作为名称', () => {
|
test('add 节点无 name 但有 type:使用 type 作为名称', () => {
|
||||||
@ -119,7 +125,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
|
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('新增 1 个节点(text (id: n1))');
|
expect(describePageStep(step)).toBe('text (id: n1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add 节点 name 与 id 相同:仅显示 id', () => {
|
test('add 节点 name 与 id 相同:仅显示 id', () => {
|
||||||
@ -127,7 +133,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'n1' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('新增 1 个节点(n1)');
|
expect(describePageStep(step)).toBe('n1');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add 多个节点:仅给出数量', () => {
|
test('add 多个节点:仅给出数量', () => {
|
||||||
@ -135,12 +141,12 @@ describe('describePageStep', () => {
|
|||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
|
diff: [{ newSchema: { id: 'a' } }, { newSchema: { id: 'b' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('新增 2 个节点');
|
expect(describePageStep(step)).toBe('2 个节点');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add 无 nodes:count 为 0 且不附名称', () => {
|
test('add 无 nodes:count 为 0 且不附名称', () => {
|
||||||
const step = { opType: 'add' } as unknown as StepValue;
|
const step = { opType: 'add' } as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('新增 0 个节点');
|
expect(describePageStep(step)).toBe('0 个节点');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove 单个节点:含名称与 id', () => {
|
test('remove 单个节点:含名称与 id', () => {
|
||||||
@ -148,7 +154,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'remove',
|
opType: 'remove',
|
||||||
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
|
diff: [{ oldSchema: { id: 'btn_1', name: '主按钮' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('删除 1 个节点(主按钮 (id: btn_1))');
|
expect(describePageStep(step)).toBe('主按钮 (id: btn_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove 多个节点', () => {
|
test('remove 多个节点', () => {
|
||||||
@ -156,7 +162,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'remove',
|
opType: 'remove',
|
||||||
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
|
diff: [{ oldSchema: { id: 'a' } }, { oldSchema: { id: 'b' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('删除 2 个节点');
|
expect(describePageStep(step)).toBe('2 个节点');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update 单节点:附 propPath 与 id', () => {
|
test('update 单节点:附 propPath 与 id', () => {
|
||||||
@ -170,7 +176,7 @@ describe('describePageStep', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1) · style.color');
|
expect(describePageStep(step)).toBe('按钮 (id: btn_1) · style.color');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update 单节点无 propPath:仅展示节点', () => {
|
test('update 单节点无 propPath:仅展示节点', () => {
|
||||||
@ -178,7 +184,7 @@ describe('describePageStep', () => {
|
|||||||
opType: 'update',
|
opType: 'update',
|
||||||
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('修改 按钮 (id: btn_1)');
|
expect(describePageStep(step)).toBe('按钮 (id: btn_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update 多节点:返回数量', () => {
|
test('update 多节点:返回数量', () => {
|
||||||
@ -189,20 +195,20 @@ describe('describePageStep', () => {
|
|||||||
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
|
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
|
||||||
],
|
],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('修改 2 个节点');
|
expect(describePageStep(step)).toBe('2 个节点');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update diff 缺省:兜底为「修改节点」', () => {
|
test('update diff 缺省:兜底为「节点」', () => {
|
||||||
const step = { opType: 'update' } as unknown as StepValue;
|
const step = { opType: 'update' } as unknown as StepValue;
|
||||||
expect(describePageStep(step)).toBe('修改节点');
|
expect(describePageStep(step)).toBe('节点');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('describePageGroup', () => {
|
describe('describePageGroup', () => {
|
||||||
test('historyDescription 取最后一条非空的描述', () => {
|
test('historyDescription 取最后一条非空的描述', () => {
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'btn_1',
|
targetId: 'btn_1',
|
||||||
targetName: '按钮',
|
targetName: '按钮',
|
||||||
@ -221,19 +227,19 @@ describe('describePageGroup', () => {
|
|||||||
opType: 'update',
|
opType: 'update',
|
||||||
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
|
diff: [{ newSchema: { id: 'a', name: 'A' }, oldSchema: { id: 'a' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'a',
|
targetId: 'a',
|
||||||
targetName: 'A',
|
targetName: 'A',
|
||||||
applied: true,
|
applied: true,
|
||||||
steps: [buildPageEntry(step)],
|
steps: [buildPageEntry(step)],
|
||||||
};
|
};
|
||||||
expect(describePageGroup(group)).toBe('修改 A (id: a)');
|
expect(describePageGroup(group)).toBe('A (id: a)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多步合并组:聚合 propPath 列表', () => {
|
test('多步合并组:展示目标名称与 id', () => {
|
||||||
const mkStep = (path: string) =>
|
const mkStep = (path: string) =>
|
||||||
({
|
({
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
@ -246,19 +252,19 @@ describe('describePageGroup', () => {
|
|||||||
],
|
],
|
||||||
}) as unknown as StepValue;
|
}) as unknown as StepValue;
|
||||||
|
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'btn_1',
|
targetId: 'btn_1',
|
||||||
targetName: '按钮',
|
targetName: '按钮',
|
||||||
applied: true,
|
applied: true,
|
||||||
steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)],
|
steps: [buildPageEntry(mkStep('style.color'), 0), buildPageEntry(mkStep('style.fontSize'), 1)],
|
||||||
};
|
};
|
||||||
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1) · style.color, style.fontSize');
|
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多步合并组:超过 3 个 propPath 时截断并加省略号', () => {
|
test('多步合并组:多步时仍仅展示目标', () => {
|
||||||
const mkStep = (path: string) =>
|
const mkStep = (path: string) =>
|
||||||
({
|
({
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
@ -271,9 +277,9 @@ describe('describePageGroup', () => {
|
|||||||
],
|
],
|
||||||
}) as unknown as StepValue;
|
}) as unknown as StepValue;
|
||||||
|
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'btn_1',
|
targetId: 'btn_1',
|
||||||
targetName: '按钮',
|
targetName: '按钮',
|
||||||
@ -285,9 +291,7 @@ describe('describePageGroup', () => {
|
|||||||
buildPageEntry(mkStep('d'), 3),
|
buildPageEntry(mkStep('d'), 3),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const desc = describePageGroup(group);
|
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
|
||||||
expect(desc).toContain('修改 按钮 (id: btn_1) · a, b, c');
|
|
||||||
expect(desc.endsWith('…')).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多步合并组无 propPath 时仅展示目标', () => {
|
test('多步合并组无 propPath 时仅展示目标', () => {
|
||||||
@ -297,22 +301,22 @@ describe('describePageGroup', () => {
|
|||||||
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
||||||
}) as unknown as StepValue;
|
}) as unknown as StepValue;
|
||||||
|
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'btn_1',
|
targetId: 'btn_1',
|
||||||
targetName: '按钮',
|
targetName: '按钮',
|
||||||
applied: true,
|
applied: true,
|
||||||
steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)],
|
steps: [buildPageEntry(mkStep(), 0), buildPageEntry(mkStep(), 1)],
|
||||||
};
|
};
|
||||||
expect(describePageGroup(group)).toBe('修改 按钮 (id: btn_1)');
|
expect(describePageGroup(group)).toBe('按钮 (id: btn_1)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多步组 targetName 缺省时使用 targetId 兜底', () => {
|
test('多步组 targetName 缺省时使用 targetId 兜底', () => {
|
||||||
const group: PageHistoryGroup = {
|
const group: HistoryGroup<StepValue> = {
|
||||||
kind: 'page',
|
kind: 'page',
|
||||||
pageId: 'p1',
|
id: 'p1',
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
targetId: 'btn_1',
|
targetId: 'btn_1',
|
||||||
applied: true,
|
applied: true,
|
||||||
@ -322,226 +326,7 @@ describe('describePageGroup', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
// targetName 为 undefined,labelWithId 看 label === id 时只展示 id
|
// targetName 为 undefined,labelWithId 看 label === id 时只展示 id
|
||||||
expect(describePageGroup(group)).toBe('修改 btn_1');
|
expect(describePageGroup(group)).toBe('btn_1');
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describeDataSourceStep', () => {
|
|
||||||
test('historyDescription 优先', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [{}],
|
|
||||||
historyDescription: '自定义',
|
|
||||||
} as unknown as DataSourceStepValue;
|
|
||||||
expect(describeDataSourceStep(step)).toBe('自定义');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('新增(oldSchema=null):展示 title 与 id', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'add',
|
|
||||||
diff: [{ newSchema: { id: 'ds_1', title: '用户列表' } }],
|
|
||||||
} as unknown as DataSourceStepValue;
|
|
||||||
expect(describeDataSourceStep(step)).toBe('创建 用户列表 (id: ds_1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('删除(newSchema=null):展示 title 与 id', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'remove',
|
|
||||||
diff: [{ oldSchema: { id: 'ds_1', title: '用户列表' } }],
|
|
||||||
} as unknown as DataSourceStepValue;
|
|
||||||
expect(describeDataSourceStep(step)).toBe('删除 用户列表 (id: ds_1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('修改:展示 propPath', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [
|
|
||||||
{
|
|
||||||
oldSchema: { id: 'ds_1', title: '用户列表' },
|
|
||||||
newSchema: { id: 'ds_1', title: '用户列表' },
|
|
||||||
changeRecords: [{ propPath: 'fields.0.name' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as DataSourceStepValue;
|
|
||||||
expect(describeDataSourceStep(step)).toBe('修改 用户列表 (id: ds_1) · fields.0.name');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('修改无 title 时仅展示 id', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }],
|
|
||||||
} as unknown as DataSourceStepValue;
|
|
||||||
expect(describeDataSourceStep(step)).toBe('修改 ds_1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describeDataSourceGroup', () => {
|
|
||||||
test('多步组:聚合 propPath 与目标 id', () => {
|
|
||||||
const mkStep = (path: string) =>
|
|
||||||
({
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [
|
|
||||||
{
|
|
||||||
oldSchema: { id: 'ds_1', title: 'T' },
|
|
||||||
newSchema: { id: 'ds_1', title: 'T' },
|
|
||||||
changeRecords: [{ propPath: path }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as unknown as DataSourceStepValue;
|
|
||||||
const group: DataSourceHistoryGroup = {
|
|
||||||
kind: 'data-source',
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
applied: true,
|
|
||||||
steps: [
|
|
||||||
{ step: mkStep('a'), index: 0, applied: true },
|
|
||||||
{ step: mkStep('b'), index: 1, applied: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(describeDataSourceGroup(group)).toBe('修改 T (id: ds_1) · a, b');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('单步组:复用 describeDataSourceStep', () => {
|
|
||||||
const group: DataSourceHistoryGroup = {
|
|
||||||
kind: 'data-source',
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'add',
|
|
||||||
applied: true,
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
step: {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'add',
|
|
||||||
diff: [{ newSchema: { id: 'ds_1', title: 'T' } }],
|
|
||||||
} as unknown as DataSourceStepValue,
|
|
||||||
index: 0,
|
|
||||||
applied: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(describeDataSourceGroup(group)).toBe('创建 T (id: ds_1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historyDescription 优先', () => {
|
|
||||||
const group: DataSourceHistoryGroup = {
|
|
||||||
kind: 'data-source',
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
applied: true,
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
step: {
|
|
||||||
id: 'ds_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [{}],
|
|
||||||
historyDescription: '我的描述',
|
|
||||||
} as unknown as DataSourceStepValue,
|
|
||||||
index: 0,
|
|
||||||
applied: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(describeDataSourceGroup(group)).toBe('我的描述');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describeCodeBlockStep', () => {
|
|
||||||
test('新增', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'add',
|
|
||||||
diff: [{ newSchema: { id: 'code_1', name: 'onClick' } }],
|
|
||||||
} as unknown as CodeBlockStepValue;
|
|
||||||
expect(describeCodeBlockStep(step)).toBe('创建 onClick (id: code_1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('删除', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'remove',
|
|
||||||
diff: [{ oldSchema: { id: 'code_1', name: 'onClick' } }],
|
|
||||||
} as unknown as CodeBlockStepValue;
|
|
||||||
expect(describeCodeBlockStep(step)).toBe('删除 onClick (id: code_1)');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('修改 + propPath', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [
|
|
||||||
{
|
|
||||||
oldSchema: { id: 'code_1', name: 'onClick' },
|
|
||||||
newSchema: { id: 'code_1', name: 'onClick' },
|
|
||||||
changeRecords: [{ propPath: 'content' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as CodeBlockStepValue;
|
|
||||||
expect(describeCodeBlockStep(step)).toBe('修改 onClick (id: code_1) · content');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('historyDescription 优先', () => {
|
|
||||||
const step = {
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [{}],
|
|
||||||
historyDescription: '自定义说明',
|
|
||||||
} as unknown as CodeBlockStepValue;
|
|
||||||
expect(describeCodeBlockStep(step)).toBe('自定义说明');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('describeCodeBlockGroup', () => {
|
|
||||||
test('多步组:聚合 propPath', () => {
|
|
||||||
const mkStep = (path: string) =>
|
|
||||||
({
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'update',
|
|
||||||
diff: [
|
|
||||||
{
|
|
||||||
oldSchema: { id: 'code_1', name: 'fn' },
|
|
||||||
newSchema: { id: 'code_1', name: 'fn' },
|
|
||||||
changeRecords: [{ propPath: path }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as unknown as CodeBlockStepValue;
|
|
||||||
const group: CodeBlockHistoryGroup = {
|
|
||||||
kind: 'code-block',
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'update',
|
|
||||||
applied: true,
|
|
||||||
steps: [
|
|
||||||
{ step: mkStep('content'), index: 0, applied: true },
|
|
||||||
{ step: mkStep('params'), index: 1, applied: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(describeCodeBlockGroup(group)).toBe('修改 fn (id: code_1) · content, params');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('单步组:复用 step 描述', () => {
|
|
||||||
const group: CodeBlockHistoryGroup = {
|
|
||||||
kind: 'code-block',
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'remove',
|
|
||||||
applied: false,
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
step: {
|
|
||||||
id: 'code_1',
|
|
||||||
opType: 'remove',
|
|
||||||
diff: [{ oldSchema: { id: 'code_1', name: 'fn' } }],
|
|
||||||
} as unknown as CodeBlockStepValue,
|
|
||||||
index: 0,
|
|
||||||
applied: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(describeCodeBlockGroup(group)).toBe('删除 fn (id: code_1)');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -558,7 +343,11 @@ describe('useHistoryList', () => {
|
|||||||
const wrapper = mount(host, {
|
const wrapper = mount(host, {
|
||||||
global: {
|
global: {
|
||||||
provide: {
|
provide: {
|
||||||
services: { historyService },
|
// useHistoryList 现在按当前页 id 取页面历史,提供 editorService.get('page') 返回固定 id 'p1'。
|
||||||
|
services: {
|
||||||
|
historyService,
|
||||||
|
editorService: { get: (k?: string) => (k === 'page' ? { id: 'p1' } : undefined) },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -579,17 +368,24 @@ describe('useHistoryList', () => {
|
|||||||
test('pageGroupsDisplay:按时间倒序', () => {
|
test('pageGroupsDisplay:按时间倒序', () => {
|
||||||
const { api } = mountWithHost();
|
const { api } = mountWithHost();
|
||||||
|
|
||||||
historyService.changePage({ id: 'p1' } as any);
|
historyService.push(
|
||||||
historyService.push({
|
'page',
|
||||||
|
{
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any,
|
||||||
historyService.push({
|
'p1',
|
||||||
|
);
|
||||||
|
historyService.push(
|
||||||
|
'page',
|
||||||
|
{
|
||||||
opType: 'remove',
|
opType: 'remove',
|
||||||
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
|
diff: [{ oldSchema: { id: 'n2', name: 'B' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any,
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
|
||||||
expect(api.pageGroups.value).toHaveLength(2);
|
expect(api.pageGroups.value).toHaveLength(2);
|
||||||
// 正序:最早的 add 在前;倒序展示:最新的 remove 在前
|
// 正序:最早的 add 在前;倒序展示:最新的 remove 在前
|
||||||
@ -600,15 +396,15 @@ describe('useHistoryList', () => {
|
|||||||
test('dataSourceGroupsByTarget:按 id 聚拢,每 bucket 内倒序', () => {
|
test('dataSourceGroupsByTarget:按 id 聚拢,每 bucket 内倒序', () => {
|
||||||
const { api } = mountWithHost();
|
const { api } = mountWithHost();
|
||||||
|
|
||||||
historyService.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_1', title: 'A' } as any,
|
newSchema: { id: 'ds_1', title: 'A' } as any,
|
||||||
});
|
});
|
||||||
historyService.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: { id: 'ds_1', title: 'A' } as any,
|
oldSchema: { id: 'ds_1', title: 'A' } as any,
|
||||||
newSchema: { id: 'ds_1', title: 'A2' } as any,
|
newSchema: { id: 'ds_1', title: 'A2' } as any,
|
||||||
});
|
});
|
||||||
historyService.pushDataSource('ds_2', {
|
pushDataSource('ds_2', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_2', title: 'B' } as any,
|
newSchema: { id: 'ds_2', title: 'B' } as any,
|
||||||
});
|
});
|
||||||
@ -628,11 +424,11 @@ describe('useHistoryList', () => {
|
|||||||
test('codeBlockGroupsByTarget:按 id 聚拢', () => {
|
test('codeBlockGroupsByTarget:按 id 聚拢', () => {
|
||||||
const { api } = mountWithHost();
|
const { api } = mountWithHost();
|
||||||
|
|
||||||
historyService.pushCodeBlock('code_1', {
|
pushCodeBlock('code_1', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { id: 'code_1', name: 'fn' } as any,
|
newContent: { id: 'code_1', name: 'fn' } as any,
|
||||||
});
|
});
|
||||||
historyService.pushCodeBlock('code_2', {
|
pushCodeBlock('code_2', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { id: 'code_2', name: 'fn2' } as any,
|
newContent: { id: 'code_2', name: 'fn2' } as any,
|
||||||
});
|
});
|
||||||
@ -684,38 +480,20 @@ describe('isPageStepRevertable', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isDataSourceStepRevertable', () => {
|
describe('isSingleDiffStepRevertable', () => {
|
||||||
test('新增 / 删除 始终可回滚', () => {
|
test('新增 / 删除 始终可回滚', () => {
|
||||||
expect(isDataSourceStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
|
expect(isSingleDiffStepRevertable({ diff: [{ newSchema: { id: 'ds_1' } }] } as any)).toBe(true);
|
||||||
expect(isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
|
expect(isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' } }] } as any)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('更新有 changeRecords 才可回滚', () => {
|
test('更新有 changeRecords 才可回滚', () => {
|
||||||
expect(
|
expect(
|
||||||
isDataSourceStepRevertable({
|
isSingleDiffStepRevertable({
|
||||||
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
|
diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' }, changeRecords: [{ propPath: 'title' }] }],
|
||||||
} as any),
|
} as any),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
isDataSourceStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
|
isSingleDiffStepRevertable({ diff: [{ oldSchema: { id: 'ds_1' }, newSchema: { id: 'ds_1' } }] } as any),
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('isCodeBlockStepRevertable', () => {
|
|
||||||
test('新增 / 删除 始终可回滚', () => {
|
|
||||||
expect(isCodeBlockStepRevertable({ diff: [{ newSchema: { id: 'code_1' } }] } as any)).toBe(true);
|
|
||||||
expect(isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' } }] } as any)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('更新有 changeRecords 才可回滚', () => {
|
|
||||||
expect(
|
|
||||||
isCodeBlockStepRevertable({
|
|
||||||
diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' }, changeRecords: [{ propPath: 'content' }] }],
|
|
||||||
} as any),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
isCodeBlockStepRevertable({ diff: [{ oldSchema: { id: 'code_1' }, newSchema: { id: 'code_1' } }] } as any),
|
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -0,0 +1,229 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { tMagicMessage } from '@tmagic/design';
|
||||||
|
|
||||||
|
import { confirmHistoryAction } from '@editor/layouts/history-list/composables';
|
||||||
|
import { useHistoryRevert } from '@editor/layouts/history-list/useHistoryRevert';
|
||||||
|
|
||||||
|
vi.mock('@tmagic/design', () => ({
|
||||||
|
tMagicMessage: {
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@editor/layouts/history-list/composables', async () => {
|
||||||
|
const actual = await vi.importActual<object>('@editor/layouts/history-list/composables');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
confirmHistoryAction: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 捕获动态挂载 HistoryDiffDialog 时传入 createApp 的 props(用于断言 width 透传),
|
||||||
|
// 并桩掉弹窗实例的 open / confirm,避免真正渲染组件。
|
||||||
|
const dialogInstance = {
|
||||||
|
open: vi.fn(),
|
||||||
|
confirm: vi.fn(async () => true),
|
||||||
|
};
|
||||||
|
const appMock = {
|
||||||
|
_context: {},
|
||||||
|
mount: vi.fn(() => dialogInstance),
|
||||||
|
unmount: vi.fn(),
|
||||||
|
};
|
||||||
|
const createAppMock = vi.fn((..._args: any[]) => appMock);
|
||||||
|
|
||||||
|
vi.mock('vue', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('vue')>('vue');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
createApp: (...args: any[]) => createAppMock(...args),
|
||||||
|
// setup 之外调用,强制返回 null,避免依赖宿主组件上下文
|
||||||
|
getCurrentInstance: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@editor/layouts/history-list/HistoryDiffDialog.vue', () => ({
|
||||||
|
default: { name: 'MEditorHistoryDiffDialog' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 读取最近一次挂载弹窗时传入的 props(createApp 第二个参数)。 */
|
||||||
|
const lastDialogProps = () => createAppMock.mock.calls.at(-1)?.[1] as unknown as Record<string, any>;
|
||||||
|
|
||||||
|
const createServices = () =>
|
||||||
|
({
|
||||||
|
editorService: {
|
||||||
|
get: vi.fn(() => ({ id: 'p1' })),
|
||||||
|
getNodeById: vi.fn(),
|
||||||
|
revertPageStep: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
dataSourceService: {
|
||||||
|
getDataSourceById: vi.fn(),
|
||||||
|
revert: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
codeBlockService: {
|
||||||
|
getCodeContentById: vi.fn(),
|
||||||
|
revert: vi.fn(async () => null),
|
||||||
|
},
|
||||||
|
historyService: {
|
||||||
|
getStepList: vi.fn(() => []),
|
||||||
|
getHistoryGroups: vi.fn(() => []),
|
||||||
|
},
|
||||||
|
}) as any;
|
||||||
|
|
||||||
|
/** 构造一个可差异对比(单实体 update,前后值都在)的历史分组,用于触发差异弹窗。 */
|
||||||
|
const diffableGroups = (id: string | number = 'p1') => [
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
step: { diff: [{ oldSchema: { id: 'n1', name: 'A' }, newSchema: { id: 'n1', name: 'B' } }] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useHistoryRevert', () => {
|
||||||
|
test('页面 update 记录的目标节点已删除时,提示错误且不执行回滚', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getStepList.mockReturnValue([
|
||||||
|
{
|
||||||
|
step: {
|
||||||
|
opType: 'update',
|
||||||
|
diff: [{ newSchema: { id: 'gone' }, oldSchema: { id: 'gone' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
services.editorService.getNodeById.mockReturnValue(null);
|
||||||
|
|
||||||
|
const { onPageRevert } = useHistoryRevert({}, services);
|
||||||
|
await onPageRevert(0);
|
||||||
|
|
||||||
|
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
|
||||||
|
expect(services.editorService.revertPageStep).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('页面 add 记录回滚时走普通二次确认,并执行 revertPageStep', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getStepList.mockReturnValue([
|
||||||
|
{
|
||||||
|
step: {
|
||||||
|
opType: 'add',
|
||||||
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { onPageRevert } = useHistoryRevert({}, services);
|
||||||
|
await onPageRevert(0);
|
||||||
|
|
||||||
|
expect(confirmHistoryAction).toHaveBeenCalled();
|
||||||
|
expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源 update 记录对应目标已删除时,提示错误且不执行回滚', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getStepList.mockReturnValue([
|
||||||
|
{
|
||||||
|
step: {
|
||||||
|
opType: 'update',
|
||||||
|
diff: [{ newSchema: { id: 'ds_1' }, oldSchema: { id: 'ds_1' } }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
services.dataSourceService.getDataSourceById.mockReturnValue(null);
|
||||||
|
|
||||||
|
const { onDataSourceRevert } = useHistoryRevert({}, services);
|
||||||
|
await onDataSourceRevert('ds_1', 0);
|
||||||
|
|
||||||
|
expect(tMagicMessage.error).toHaveBeenCalledWith('回滚失败:该记录对应的数据已被删除');
|
||||||
|
expect(services.dataSourceService.revert).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('弹窗宽度透传', () => {
|
||||||
|
test('onPageDiff 使用 options.dialogWidth 作为弹窗宽度并打开弹窗', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||||
|
|
||||||
|
const { onPageDiff } = useHistoryRevert({ dialogWidth: '1200px' }, services);
|
||||||
|
await onPageDiff(0);
|
||||||
|
|
||||||
|
expect(createAppMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lastDialogProps().width).toBe('1200px');
|
||||||
|
expect(lastDialogProps().isConfirm).toBe(false);
|
||||||
|
expect(dialogInstance.open).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('未配置 dialogWidth 时不强制 width(由弹窗内置默认值兜底)', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||||
|
|
||||||
|
const { onPageDiff } = useHistoryRevert({}, services);
|
||||||
|
await onPageDiff(0);
|
||||||
|
|
||||||
|
expect(createAppMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(lastDialogProps().width).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('onPageRevert 在可差异步骤上走差异确认弹窗并透传 dialogWidth', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
services.historyService.getStepList.mockReturnValue([
|
||||||
|
{ step: { opType: 'update', diff: [{ newSchema: { id: 'n1' }, oldSchema: { id: 'n1' } }] } },
|
||||||
|
]);
|
||||||
|
services.editorService.getNodeById.mockReturnValue({ id: 'n1' });
|
||||||
|
services.historyService.getHistoryGroups.mockReturnValue(diffableGroups());
|
||||||
|
|
||||||
|
const { onPageRevert } = useHistoryRevert({ dialogWidth: '80%' }, services);
|
||||||
|
await onPageRevert(0);
|
||||||
|
|
||||||
|
expect(lastDialogProps().width).toBe('80%');
|
||||||
|
expect(lastDialogProps().isConfirm).toBe(true);
|
||||||
|
expect(dialogInstance.confirm).toHaveBeenCalled();
|
||||||
|
expect(services.editorService.revertPageStep).toHaveBeenCalledWith(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewDiff 的逐次 width 入参可覆盖 dialogWidth 默认值', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services);
|
||||||
|
|
||||||
|
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any, { width: '600px' });
|
||||||
|
|
||||||
|
expect(lastDialogProps().width).toBe('600px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewDiff 未传 width 时回退到 dialogWidth 默认值', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
const { viewDiff } = useHistoryRevert({ dialogWidth: '1000px' }, services);
|
||||||
|
|
||||||
|
await viewDiff({ category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any);
|
||||||
|
|
||||||
|
expect(lastDialogProps().width).toBe('1000px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('confirmAndRevert 透传 width 至确认弹窗并在确认后执行 revert', async () => {
|
||||||
|
const services = createServices();
|
||||||
|
const revert = vi.fn(async () => 'done');
|
||||||
|
const { confirmAndRevert } = useHistoryRevert({ dialogWidth: '1000px' }, services);
|
||||||
|
|
||||||
|
const result = await confirmAndRevert({
|
||||||
|
diffPayload: { category: 'module', lastValue: { a: 1 }, value: { a: 2 } } as any,
|
||||||
|
width: '720px',
|
||||||
|
revert,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastDialogProps().width).toBe('720px');
|
||||||
|
expect(lastDialogProps().isConfirm).toBe(true);
|
||||||
|
expect(revert).toHaveBeenCalled();
|
||||||
|
expect(result).toBe('done');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -7,12 +7,14 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|||||||
import { nextTick, reactive, ref } from 'vue';
|
import { nextTick, reactive, ref } from 'vue';
|
||||||
|
|
||||||
import { useStylePanel } from '@editor/layouts/props-panel/use-style-panel';
|
import { useStylePanel } from '@editor/layouts/props-panel/use-style-panel';
|
||||||
|
import { MIN_CENTER_COLUMN_WIDTH } from '@editor/utils/const';
|
||||||
|
|
||||||
const mkServices = (storageInit?: any) => {
|
const mkServices = (storageInit?: any) => {
|
||||||
const uiState: Record<string, any> = reactive({
|
const uiState: Record<string, any> = reactive({
|
||||||
frameworkRect: { width: 1280 },
|
frameworkRect: { width: 1280 },
|
||||||
showStylePanel: true,
|
showStylePanel: true,
|
||||||
columnWidth: { right: 400, center: 800, left: 200 },
|
columnWidth: { right: 400, center: 800, left: 200 },
|
||||||
|
minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH,
|
||||||
});
|
});
|
||||||
const uiService = {
|
const uiService = {
|
||||||
get: vi.fn((k: string) => uiState[k]),
|
get: vi.fn((k: string) => uiState[k]),
|
||||||
|
|||||||
@ -9,7 +9,13 @@ import { mount } from '@vue/test-utils';
|
|||||||
|
|
||||||
import Sidebar from '@editor/layouts/sidebar/Sidebar.vue';
|
import Sidebar from '@editor/layouts/sidebar/Sidebar.vue';
|
||||||
|
|
||||||
const depService = { get: vi.fn(() => false) };
|
const depService = {
|
||||||
|
get: vi.fn((name: string) => {
|
||||||
|
if (name === 'collecting') return false;
|
||||||
|
if (name === 'taskLength') return 0;
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
|
};
|
||||||
const uiState: Record<string, any> = reactive({});
|
const uiState: Record<string, any> = reactive({});
|
||||||
const uiService = {
|
const uiService = {
|
||||||
get: vi.fn((name: string) => (name === 'sideBarActiveTabName' ? uiState.sideBarActiveTabName : { left: 200 })),
|
get: vi.fn((name: string) => (name === 'sideBarActiveTabName' ? uiState.sideBarActiveTabName : { left: 200 })),
|
||||||
@ -177,4 +183,44 @@ describe('Sidebar', () => {
|
|||||||
await items[0].trigger('dragstart');
|
await items[0].trigger('dragstart');
|
||||||
expect(dragstartHandler).toHaveBeenCalled();
|
expect(dragstartHandler).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('dragendHandler 触发', async () => {
|
||||||
|
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||||
|
const items = wrapper.findAll('.m-editor-sidebar-header-item');
|
||||||
|
await items[0].trigger('dragend');
|
||||||
|
expect(dragendHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自定义 sidebar item 可渲染 slots 组件', async () => {
|
||||||
|
const customPanel = stub('CustomPanel');
|
||||||
|
const wrapper = mount(Sidebar, {
|
||||||
|
props: baseProps({
|
||||||
|
data: {
|
||||||
|
type: 'tabs',
|
||||||
|
status: '自定义',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
$key: 'custom',
|
||||||
|
text: '自定义',
|
||||||
|
component: customPanel,
|
||||||
|
slots: { componentList: stub('SlotComponentList') },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) as any,
|
||||||
|
});
|
||||||
|
await wrapper.find('.m-editor-sidebar-header-item').trigger('click');
|
||||||
|
expect(wrapper.find('.CustomPanel').exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dep collecting 时展示 tips 区域', () => {
|
||||||
|
depService.get.mockImplementation((name: string) => {
|
||||||
|
if (name === 'collecting') return true;
|
||||||
|
if (name === 'taskLength') return 3;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const wrapper = mount(Sidebar, { props: baseProps() as any });
|
||||||
|
expect(wrapper.find('.m-editor-sidebar-tips').exists()).toBe(true);
|
||||||
|
expect(wrapper.find('.m-editor-sidebar-tips').text()).toContain('剩余任务:3');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -38,10 +38,14 @@ const editorService = {
|
|||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const uiService = {
|
||||||
|
get: vi.fn((k: string) => (k === 'frameworkRect' ? { width: 1000 } : undefined)),
|
||||||
|
};
|
||||||
|
|
||||||
const nodeStatusMap = ref(new Map<string, any>([['p1', { selected: false }]]));
|
const nodeStatusMap = ref(new Map<string, any>([['p1', { selected: false }]]));
|
||||||
|
|
||||||
vi.mock('@editor/hooks/use-services', () => ({
|
vi.mock('@editor/hooks/use-services', () => ({
|
||||||
useServices: () => ({ editorService }),
|
useServices: () => ({ editorService, uiService }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@editor/layouts/sidebar/layer/use-node-status', () => ({
|
vi.mock('@editor/layouts/sidebar/layer/use-node-status', () => ({
|
||||||
|
|||||||
@ -83,5 +83,24 @@ describe('plugin install', () => {
|
|||||||
editorPlugin.install(app);
|
editorPlugin.install(app);
|
||||||
expect(app.config.globalProperties.$TMAGIC_EDITOR).toBeDefined();
|
expect(app.config.globalProperties.$TMAGIC_EDITOR).toBeDefined();
|
||||||
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.parseDSL).toBe('function');
|
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.parseDSL).toBe('function');
|
||||||
|
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoEditor).toBe('function');
|
||||||
|
expect(typeof app.config.globalProperties.$TMAGIC_EDITOR.customCreateMonacoDiffEditor).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('customCreateMonacoEditor / customCreateMonacoDiffEditor 会调用 monaco API', () => {
|
||||||
|
const { app } = buildApp();
|
||||||
|
editorPlugin.install(app);
|
||||||
|
const { customCreateMonacoEditor, customCreateMonacoDiffEditor } = app.config.globalProperties.$TMAGIC_EDITOR;
|
||||||
|
const monaco = {
|
||||||
|
editor: {
|
||||||
|
create: vi.fn(() => 'editor'),
|
||||||
|
createDiffEditor: vi.fn(() => 'diff-editor'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const el = document.createElement('div');
|
||||||
|
expect(customCreateMonacoEditor(monaco, el, { theme: 'vs' })).toBe('editor');
|
||||||
|
expect(customCreateMonacoDiffEditor(monaco, el, { readOnly: true })).toBe('diff-editor');
|
||||||
|
expect(monaco.editor.create).toHaveBeenCalledWith(el, { theme: 'vs' });
|
||||||
|
expect(monaco.editor.createDiffEditor).toHaveBeenCalledWith(el, { readOnly: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -174,8 +174,8 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any);
|
codeBlockService.setCodeDslByIdSync('new_code', { name: 'A' } as any);
|
||||||
|
|
||||||
expect(historyService.canUndoCodeBlock('new_code')).toBe(true);
|
expect(historyService.canUndo('codeBlock', 'new_code')).toBe(true);
|
||||||
const step = historyService.undoCodeBlock('new_code');
|
const step = historyService.undo('codeBlock', 'new_code');
|
||||||
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
||||||
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
|
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A' }));
|
||||||
});
|
});
|
||||||
@ -184,7 +184,7 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||||
|
|
||||||
const step = historyService.undoCodeBlock('a');
|
const step = historyService.undo('codeBlock', 'a');
|
||||||
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
||||||
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
|
expect(step?.diff?.[0]?.newSchema).toEqual(expect.objectContaining({ name: 'A2' }));
|
||||||
});
|
});
|
||||||
@ -192,14 +192,14 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
|
test('setCodeDslByIdSync - force=false 已存在时不入历史', async () => {
|
||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false);
|
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any, false);
|
||||||
expect(historyService.canUndoCodeBlock('a')).toBe(false);
|
expect(historyService.canUndo('codeBlock', 'a')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteCodeDslByIds - 删除已存在的代码块入历史(newContent=null)', async () => {
|
test('deleteCodeDslByIds - 删除已存在的代码块入历史(newContent=null)', async () => {
|
||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
|
||||||
await codeBlockService.deleteCodeDslByIds(['a']);
|
await codeBlockService.deleteCodeDslByIds(['a']);
|
||||||
|
|
||||||
const step = historyService.undoCodeBlock('a');
|
const step = historyService.undo('codeBlock', 'a');
|
||||||
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
expect(step?.diff?.[0]?.oldSchema).toEqual({ name: 'A' });
|
||||||
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
|
||||||
});
|
});
|
||||||
@ -207,7 +207,7 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
|
test('deleteCodeDslByIds - 删除不存在的 id 不入历史', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
await codeBlockService.deleteCodeDslByIds(['ghost']);
|
await codeBlockService.deleteCodeDslByIds(['ghost']);
|
||||||
expect(historyService.canUndoCodeBlock('ghost')).toBe(false);
|
expect(historyService.canUndo('codeBlock', 'ghost')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setCodeDslByIdSync - 携带 changeRecords 时写入历史 step', async () => {
|
test('setCodeDslByIdSync - 携带 changeRecords 时写入历史 step', async () => {
|
||||||
@ -217,7 +217,7 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
changeRecords: [{ propPath: 'name', value: 'A2' }],
|
changeRecords: [{ propPath: 'name', value: 'A2' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const step = historyService.undoCodeBlock('a');
|
const step = historyService.undo('codeBlock', 'a');
|
||||||
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
|
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'name', value: 'A2' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -226,53 +226,53 @@ describe('CodeBlockService - 历史记录接入', () => {
|
|||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||||
|
|
||||||
const step = historyService.undoCodeBlock('a');
|
const step = historyService.undo('codeBlock', 'a');
|
||||||
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
|
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CodeBlockService - *AndGetHistoryId', () => {
|
describe('CodeBlockService - *AndGetHistoryId', () => {
|
||||||
const lastStepUuid = (id: string) => {
|
const lastStepUuid = (id: string) => {
|
||||||
const list = historyService.getCodeBlockStepList(id);
|
const list = historyService.getStepList('codeBlock', id);
|
||||||
return list[list.length - 1]?.step.uuid;
|
return list[list.length - 1]?.step.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
test('setCodeDslByIdSyncAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
test('setCodeDslByIdSyncAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
|
|
||||||
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid('a'));
|
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
||||||
// 与默认行为一致:内容仍被写入
|
// 与默认行为一致:内容仍被写入
|
||||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setCodeDslByIdSyncAndGetHistoryId - force=false 已存在时返回 null', async () => {
|
test('setCodeDslByIdSyncAndGetHistoryId - force=false 已存在时返回空数组', async () => {
|
||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||||
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'NEW' } as any, false);
|
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'NEW' } as any, false);
|
||||||
expect(historyId).toBeNull();
|
expect(historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setCodeDslByIdSyncAndGetHistoryId - doNotPushHistory 时返回 null', async () => {
|
test('setCodeDslByIdSyncAndGetHistoryId - doNotPushHistory 时返回空数组', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
const historyId = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any, true, {
|
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any, true, {
|
||||||
doNotPushHistory: true,
|
doNotPushHistory: true,
|
||||||
});
|
});
|
||||||
expect(historyId).toBeNull();
|
expect(historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setCodeDslByIdAndGetHistoryId(async)返回本次写入历史记录的 uuid', async () => {
|
test('setCodeDslByIdAndGetHistoryId(async)返回本次写入历史记录的 uuid', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
|
|
||||||
const historyId = await codeBlockService.setCodeDslByIdAndGetHistoryId('a', { name: 'A' } as any);
|
const { historyIds } = await codeBlockService.setCodeDslByIdAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid('a'));
|
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteCodeDslByIdsAndGetHistoryId 返回每条删除记录的 uuid 数组', async () => {
|
test('deleteCodeDslByIdsAndGetHistoryId 返回每条删除记录的 uuid 数组', async () => {
|
||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' }, b: { name: 'B' } } as any);
|
||||||
|
|
||||||
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'b']);
|
const { historyIds } = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'b']);
|
||||||
expect(Array.isArray(historyIds)).toBe(true);
|
expect(Array.isArray(historyIds)).toBe(true);
|
||||||
expect(historyIds).toHaveLength(2);
|
expect(historyIds).toHaveLength(2);
|
||||||
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
||||||
@ -282,14 +282,14 @@ describe('CodeBlockService - *AndGetHistoryId', () => {
|
|||||||
test('deleteCodeDslByIdsAndGetHistoryId - 不存在的 id 不计入返回数组', async () => {
|
test('deleteCodeDslByIdsAndGetHistoryId - 不存在的 id 不计入返回数组', async () => {
|
||||||
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
await codeBlockService.setCodeDsl({ a: { name: 'A' } } as any);
|
||||||
|
|
||||||
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'ghost']);
|
const { historyIds } = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['a', 'ghost']);
|
||||||
expect(historyIds).toHaveLength(1);
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
expect(historyIds[0]).toBe(lastStepUuid('a'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteCodeDslByIdsAndGetHistoryId - 全部不存在时返回空数组', async () => {
|
test('deleteCodeDslByIdsAndGetHistoryId - 全部不存在时返回空数组', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
const historyIds = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['ghost']);
|
const { historyIds } = await codeBlockService.deleteCodeDslByIdsAndGetHistoryId(['ghost']);
|
||||||
expect(historyIds).toEqual([]);
|
expect(historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -297,20 +297,21 @@ describe('CodeBlockService - *AndGetHistoryId', () => {
|
|||||||
describe('CodeBlockService - revertById', () => {
|
describe('CodeBlockService - revertById', () => {
|
||||||
test('通过 uuid 回滚新增(删除代码块内容)', async () => {
|
test('通过 uuid 回滚新增(删除代码块内容)', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
|
const uuid = historyIds[0];
|
||||||
expect(typeof uuid).toBe('string');
|
expect(typeof uuid).toBe('string');
|
||||||
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
expect(codeBlockService.getCodeContentById('a')?.name).toBe('A');
|
||||||
|
|
||||||
const reverted = await codeBlockService.revertById(uuid!);
|
const reverted = await codeBlockService.revertById([uuid!]);
|
||||||
expect(reverted).not.toBeNull();
|
expect(reverted[0]).not.toBeNull();
|
||||||
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('按 uuid 能定位到对应 (id, index)', async () => {
|
test('按 uuid 能定位到对应 (id, index)', async () => {
|
||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
const uuid = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
const { historyIds } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
|
|
||||||
const location = historyService.findCodeBlockStepLocationByUuid(uuid!);
|
const location = historyService.findStepLocationByUuid('codeBlock', historyIds[0]!);
|
||||||
expect(location).toEqual({ id: 'a', index: 0 });
|
expect(location).toEqual({ id: 'a', index: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -318,8 +319,21 @@ describe('CodeBlockService - revertById', () => {
|
|||||||
await codeBlockService.setCodeDsl({} as any);
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
|
|
||||||
await expect(codeBlockService.revertById('not-exist')).resolves.toBeNull();
|
await expect(codeBlockService.revertById(['not-exist'])).resolves.toEqual([null]);
|
||||||
await expect(codeBlockService.revertById('')).resolves.toBeNull();
|
await expect(codeBlockService.revertById([''])).resolves.toEqual([null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('支持传入 uuid 数组并按顺序回滚', async () => {
|
||||||
|
await codeBlockService.setCodeDsl({} as any);
|
||||||
|
const { historyIds: ids1 } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('a', { name: 'A' } as any);
|
||||||
|
const { historyIds: ids2 } = codeBlockService.setCodeDslByIdSyncAndGetHistoryId('b', { name: 'B' } as any);
|
||||||
|
|
||||||
|
const reverted = await codeBlockService.revertById([ids1[0]!, ids2[0]!]);
|
||||||
|
expect(reverted).toHaveLength(2);
|
||||||
|
expect(reverted[0]).not.toBeNull();
|
||||||
|
expect(reverted[1]).not.toBeNull();
|
||||||
|
expect(codeBlockService.getCodeContentById('a')).toBeNull();
|
||||||
|
expect(codeBlockService.getCodeContentById('b')).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -419,14 +433,14 @@ describe('CodeBlockService - undo / redo', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
codeBlockService.setCodeDslByIdSync('a', { name: 'A2' } as any);
|
||||||
expect(historyService.canUndoCodeBlock('a')).toBe(true);
|
expect(historyService.canUndo('codeBlock', 'a')).toBe(true);
|
||||||
|
|
||||||
await codeBlockService.undo('a');
|
await codeBlockService.undo('a');
|
||||||
expect(historyService.canRedoCodeBlock('a')).toBe(true);
|
expect(historyService.canRedo('codeBlock', 'a')).toBe(true);
|
||||||
|
|
||||||
await codeBlockService.redo('a');
|
await codeBlockService.redo('a');
|
||||||
expect(historyService.canRedoCodeBlock('a')).toBe(false);
|
expect(historyService.canRedo('codeBlock', 'a')).toBe(false);
|
||||||
expect(historyService.canUndoCodeBlock('a')).toBe(true);
|
expect(historyService.canUndo('codeBlock', 'a')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('canUndo / canRedo 委托给 historyService', async () => {
|
test('canUndo / canRedo 委托给 historyService', async () => {
|
||||||
|
|||||||
@ -133,8 +133,8 @@ describe('DataSource service', () => {
|
|||||||
describe('DataSource service - 历史记录接入', () => {
|
describe('DataSource service - 历史记录接入', () => {
|
||||||
test('add - 入历史(oldSchema=null)', () => {
|
test('add - 入历史(oldSchema=null)', () => {
|
||||||
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
|
const ds = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
expect(historyService.canUndoDataSource(ds.id!)).toBe(true);
|
expect(historyService.canUndo('dataSource', ds.id!)).toBe(true);
|
||||||
const step = historyService.undoDataSource(ds.id!);
|
const step = historyService.undo('dataSource', ds.id!);
|
||||||
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
||||||
expect(step?.diff?.[0]?.newSchema?.title).toBe('a');
|
expect(step?.diff?.[0]?.newSchema?.title).toBe('a');
|
||||||
});
|
});
|
||||||
@ -145,7 +145,7 @@ describe('DataSource service - 历史记录接入', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
dataSource.update({ ...created, title: 'b' } as any);
|
dataSource.update({ ...created, title: 'b' } as any);
|
||||||
const step = historyService.undoDataSource(created.id!);
|
const step = historyService.undo('dataSource', created.id!);
|
||||||
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
|
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
|
||||||
expect(step?.diff?.[0]?.newSchema?.title).toBe('b');
|
expect(step?.diff?.[0]?.newSchema?.title).toBe('b');
|
||||||
});
|
});
|
||||||
@ -155,14 +155,14 @@ describe('DataSource service - 历史记录接入', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
dataSource.remove(created.id!);
|
dataSource.remove(created.id!);
|
||||||
const step = historyService.undoDataSource(created.id!);
|
const step = historyService.undo('dataSource', created.id!);
|
||||||
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
|
expect(step?.diff?.[0]?.oldSchema?.title).toBe('a');
|
||||||
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.newSchema).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('remove - 不存在的 id 不入历史', () => {
|
test('remove - 不存在的 id 不入历史', () => {
|
||||||
dataSource.remove('ghost');
|
dataSource.remove('ghost');
|
||||||
expect(historyService.canUndoDataSource('ghost')).toBe(false);
|
expect(historyService.canUndo('dataSource', 'ghost')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('update - 携带 changeRecords 时写入历史 step', () => {
|
test('update - 携带 changeRecords 时写入历史 step', () => {
|
||||||
@ -173,7 +173,7 @@ describe('DataSource service - 历史记录接入', () => {
|
|||||||
changeRecords: [{ propPath: 'title', value: 'b' }],
|
changeRecords: [{ propPath: 'title', value: 'b' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const step = historyService.undoDataSource(created.id!);
|
const step = historyService.undo('dataSource', created.id!);
|
||||||
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
|
expect(step?.diff?.[0]?.changeRecords).toEqual([{ propPath: 'title', value: 'b' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -182,14 +182,14 @@ describe('DataSource service - 历史记录接入', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
dataSource.update({ ...created, title: 'b' } as any);
|
dataSource.update({ ...created, title: 'b' } as any);
|
||||||
const step = historyService.undoDataSource(created.id!);
|
const step = historyService.undo('dataSource', created.id!);
|
||||||
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
|
expect(step?.diff?.[0]?.changeRecords).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DataSource service - *AndGetHistoryId', () => {
|
describe('DataSource service - *AndGetHistoryId', () => {
|
||||||
const lastStepUuid = (id: string) => {
|
const lastStepUuid = (id: string) => {
|
||||||
const list = historyService.getDataSourceStepList(id);
|
const list = historyService.getStepList('dataSource', id);
|
||||||
return list[list.length - 1]?.step.uuid;
|
return list[list.length - 1]?.step.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -197,66 +197,82 @@ describe('DataSource service - *AndGetHistoryId', () => {
|
|||||||
const ds = dataSource.add({ id: 'temp', title: 'a', type: 'base' } as any);
|
const ds = dataSource.add({ id: 'temp', title: 'a', type: 'base' } as any);
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_new', title: 'a', type: 'base' } as any);
|
const { result, historyIds } = dataSource.addAndGetHistoryId({ id: 'ds_new', title: 'a', type: 'base' } as any);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid('ds_new'));
|
expect(historyIds[0]).toBe(lastStepUuid('ds_new'));
|
||||||
|
expect(result.id).toBe('ds_new');
|
||||||
// 与默认 add 行为一致:仍会写入数据源
|
// 与默认 add 行为一致:仍会写入数据源
|
||||||
expect(dataSource.getDataSourceById('ds_new')).toBeDefined();
|
expect(dataSource.getDataSourceById('ds_new')).toBeDefined();
|
||||||
expect(ds).toBeDefined();
|
expect(ds).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', () => {
|
test('addAndGetHistoryId 传 doNotPushHistory 时返回空数组', () => {
|
||||||
const historyId = dataSource.addAndGetHistoryId({ id: 'ds_x', title: 'a', type: 'base' } as any, {
|
const { historyIds } = dataSource.addAndGetHistoryId({ id: 'ds_x', title: 'a', type: 'base' } as any, {
|
||||||
doNotPushHistory: true,
|
doNotPushHistory: true,
|
||||||
});
|
});
|
||||||
expect(historyId).toBeNull();
|
expect(historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
|
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', () => {
|
||||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
const historyId = dataSource.updateAndGetHistoryId({ ...created, title: 'b' } as any);
|
const { result, historyIds } = dataSource.updateAndGetHistoryId({ ...created, title: 'b' } as any);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid(created.id!));
|
expect(historyIds[0]).toBe(lastStepUuid(created.id!));
|
||||||
|
expect(result.title).toBe('b');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid;不存在的 id 返回 null', () => {
|
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid;不存在的 id 返回空数组', () => {
|
||||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
|
|
||||||
const historyId = dataSource.removeAndGetHistoryId(created.id!);
|
const { historyIds } = dataSource.removeAndGetHistoryId(created.id!);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid(created.id!));
|
expect(historyIds[0]).toBe(lastStepUuid(created.id!));
|
||||||
|
|
||||||
expect(dataSource.removeAndGetHistoryId('ghost')).toBeNull();
|
expect(dataSource.removeAndGetHistoryId('ghost').historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DataSource service - revertById', () => {
|
describe('DataSource service - revertById', () => {
|
||||||
test('通过 uuid 回滚 add(移除数据源)', () => {
|
test('通过 uuid 回滚 add(移除数据源)', () => {
|
||||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
|
const uuid = historyService.getStepList('dataSource', created.id!).slice(-1)[0]?.step.uuid;
|
||||||
expect(typeof uuid).toBe('string');
|
expect(typeof uuid).toBe('string');
|
||||||
expect(dataSource.getDataSourceById(created.id!)).toBeDefined();
|
expect(dataSource.getDataSourceById(created.id!)).toBeDefined();
|
||||||
|
|
||||||
const reverted = dataSource.revertById(uuid!);
|
const reverted = dataSource.revertById([uuid!]);
|
||||||
expect(reverted).not.toBeNull();
|
expect(reverted[0]).not.toBeNull();
|
||||||
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
|
expect(dataSource.getDataSourceById(created.id!)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('通过 uuid 回滚等价于按 (id, index) 回滚', () => {
|
test('通过 uuid 回滚等价于按 (id, index) 回滚', () => {
|
||||||
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
const created = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
const uuid = historyService.getDataSourceStepList(created.id!).slice(-1)[0]?.step.uuid;
|
const uuid = historyService.getStepList('dataSource', created.id!).slice(-1)[0]?.step.uuid;
|
||||||
|
|
||||||
const location = historyService.findDataSourceStepLocationByUuid(uuid!);
|
const location = historyService.findStepLocationByUuid('dataSource', uuid!);
|
||||||
expect(location).toEqual({ id: created.id, index: 0 });
|
expect(location).toEqual({ id: created.id, index: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('找不到 uuid 时返回 null', () => {
|
test('找不到 uuid 时返回 null', () => {
|
||||||
dataSource.add({ title: 'a', type: 'base' } as any);
|
dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
expect(dataSource.revertById('not-exist')).toBeNull();
|
expect(dataSource.revertById(['not-exist'])).toEqual([null]);
|
||||||
expect(dataSource.revertById('')).toBeNull();
|
expect(dataSource.revertById([''])).toEqual([null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('支持传入 uuid 数组并按顺序回滚', () => {
|
||||||
|
const ds1 = dataSource.add({ title: 'a', type: 'base' } as any);
|
||||||
|
const ds2 = dataSource.add({ title: 'b', type: 'base' } as any);
|
||||||
|
const uuid1 = historyService.getStepList('dataSource', ds1.id!).slice(-1)[0]?.step.uuid;
|
||||||
|
const uuid2 = historyService.getStepList('dataSource', ds2.id!).slice(-1)[0]?.step.uuid;
|
||||||
|
|
||||||
|
const reverted = dataSource.revertById([uuid1!, uuid2!]);
|
||||||
|
expect(reverted).toHaveLength(2);
|
||||||
|
expect(reverted[0]).not.toBeNull();
|
||||||
|
expect(reverted[1]).not.toBeNull();
|
||||||
|
expect(dataSource.getDataSourceById(ds1.id!)).toBeUndefined();
|
||||||
|
expect(dataSource.getDataSourceById(ds2.id!)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -367,15 +383,15 @@ describe('DataSource service - undo / redo', () => {
|
|||||||
|
|
||||||
dataSource.update({ ...created, title: 'b' } as any);
|
dataSource.update({ ...created, title: 'b' } as any);
|
||||||
// 此时栈里只有一条 update
|
// 此时栈里只有一条 update
|
||||||
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
|
expect(historyService.canUndo('dataSource', created.id!)).toBe(true);
|
||||||
|
|
||||||
dataSource.undo(created.id!);
|
dataSource.undo(created.id!);
|
||||||
// undo 后栈应可 redo,并且 undo 不应再生新栈记录
|
// undo 后栈应可 redo,并且 undo 不应再生新栈记录
|
||||||
expect(historyService.canRedoDataSource(created.id!)).toBe(true);
|
expect(historyService.canRedo('dataSource', created.id!)).toBe(true);
|
||||||
|
|
||||||
dataSource.redo(created.id!);
|
dataSource.redo(created.id!);
|
||||||
expect(historyService.canRedoDataSource(created.id!)).toBe(false);
|
expect(historyService.canRedo('dataSource', created.id!)).toBe(false);
|
||||||
expect(historyService.canUndoDataSource(created.id!)).toBe(true);
|
expect(historyService.canUndo('dataSource', created.id!)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('canUndo / canRedo 委托给 historyService', () => {
|
test('canUndo / canRedo 委托给 historyService', () => {
|
||||||
|
|||||||
@ -11,11 +11,17 @@ import depService from '@editor/services/dep';
|
|||||||
|
|
||||||
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
vi.mock('@editor/utils/dep/worker.ts?worker&inline', () => ({
|
||||||
default: class FakeWorker {
|
default: class FakeWorker {
|
||||||
|
public static nextData: Record<string, any> = {};
|
||||||
|
public static nextError = false;
|
||||||
public onmessage: ((e: any) => void) | null = null;
|
public onmessage: ((e: any) => void) | null = null;
|
||||||
public onerror: (() => void) | null = null;
|
public onerror: (() => void) | null = null;
|
||||||
public postMessage() {
|
public postMessage() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.onmessage?.({ data: {} });
|
if (FakeWorker.nextError) {
|
||||||
|
this.onerror?.(new Event('error'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.onmessage?.({ data: FakeWorker.nextData });
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -148,4 +154,74 @@ describe('Dep service', () => {
|
|||||||
expect(depService.get('collecting')).toBe(false);
|
expect(depService.get('collecting')).toBe(false);
|
||||||
expect(depService.hasTarget('rs')).toBe(false);
|
expect(depService.hasTarget('rs')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collect 在有 collectable target 时会收集依赖并触发 collected / ds-collected', () => {
|
||||||
|
const collected = vi.fn();
|
||||||
|
const dsCollected = vi.fn();
|
||||||
|
depService.on('collected', collected);
|
||||||
|
depService.on('ds-collected', dsCollected);
|
||||||
|
depService.addTarget(makeTarget('t-collect'));
|
||||||
|
depService.collect([{ id: 'n1', type: 'text' }] as any);
|
||||||
|
expect(collected).toHaveBeenCalledWith([{ id: 'n1', type: 'text' }], false);
|
||||||
|
expect(dsCollected).toHaveBeenCalled();
|
||||||
|
depService.off('collected', collected);
|
||||||
|
depService.off('ds-collected', dsCollected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect 对 page 节点会清理 page 级旧依赖', () => {
|
||||||
|
depService.addTarget(makeTarget('page-target'));
|
||||||
|
expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectNode 支持 page 与普通节点两条路径', () => {
|
||||||
|
const target = makeTarget('node-target');
|
||||||
|
depService.addTarget(target);
|
||||||
|
depService.collectNode({ id: 'n1', type: 'text' } as any, target);
|
||||||
|
depService.collectNode({ id: 'p1', type: 'page', items: [] } as any, target, { pageId: 'p1' });
|
||||||
|
expect(depService.get('collecting')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectByWorker worker 报错时返回空对象并完成 collected', async () => {
|
||||||
|
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
|
||||||
|
fakeWorker.nextError = true;
|
||||||
|
fakeWorker.nextData = {};
|
||||||
|
const collected = vi.fn();
|
||||||
|
depService.on('collected', collected);
|
||||||
|
const result = await depService.collectByWorker({ items: [], id: 'app', type: 'app' } as any);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(collected).toHaveBeenCalled();
|
||||||
|
fakeWorker.nextError = false;
|
||||||
|
depService.off('collected', collected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collectByWorker 会把 worker 返回的 deps 写回 target 与 dsl', async () => {
|
||||||
|
const fakeWorker = (await import('@editor/utils/dep/worker.ts?worker&inline')).default as any;
|
||||||
|
depService.addTarget(makeTarget('ds1', DepTargetType.DATA_SOURCE));
|
||||||
|
depService.addTarget(makeTarget('cond1', DepTargetType.DATA_SOURCE_COND));
|
||||||
|
depService.addTarget(makeTarget('method1', DepTargetType.DATA_SOURCE_METHOD));
|
||||||
|
fakeWorker.nextData = {
|
||||||
|
[DepTargetType.DATA_SOURCE]: { ds1: { fieldA: { data: {} } } },
|
||||||
|
[DepTargetType.DATA_SOURCE_COND]: { cond1: { condA: { data: {} } } },
|
||||||
|
[DepTargetType.DATA_SOURCE_METHOD]: { method1: { methodA: { data: {} } } },
|
||||||
|
};
|
||||||
|
const dsl: any = {
|
||||||
|
items: [{ id: 'n1', type: 'text' }],
|
||||||
|
id: 'app',
|
||||||
|
type: 'app',
|
||||||
|
dataSourceDeps: {},
|
||||||
|
dataSourceCondDeps: {},
|
||||||
|
dataSourceMethodDeps: {},
|
||||||
|
};
|
||||||
|
await depService.collectByWorker(dsl);
|
||||||
|
expect(dsl.dataSourceDeps.ds1).toBeDefined();
|
||||||
|
expect(dsl.dataSourceCondDeps.cond1).toBeDefined();
|
||||||
|
expect(dsl.dataSourceMethodDeps.method1).toBeDefined();
|
||||||
|
fakeWorker.nextData = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 会 reset 并移除监听', () => {
|
||||||
|
depService.addTarget(makeTarget('destroy-me'));
|
||||||
|
expect(() => depService.destroy()).not.toThrow();
|
||||||
|
expect(depService.hasTarget('destroy-me')).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -16,15 +16,17 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { beforeAll, describe, expect, test } from 'vitest';
|
import { beforeAll, describe, expect, test, vi } from 'vitest';
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
|
|
||||||
import type { MApp } from '@tmagic/core';
|
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import { getNodePath } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import editorService from '@editor/services/editor';
|
||||||
import historyService from '@editor/services/history';
|
import historyService from '@editor/services/history';
|
||||||
import storageService from '@editor/services/storage';
|
import storageService from '@editor/services/storage';
|
||||||
|
import type { EditorChangeEvent } from '@editor/type';
|
||||||
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
|
import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
|
||||||
|
|
||||||
setEditorConfig({
|
setEditorConfig({
|
||||||
@ -553,7 +555,30 @@ describe('remove', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
beforeAll(() => editorService.set('root', cloneDeep(root)));
|
beforeAll(() => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
|
||||||
|
// dist 版 getNodeInfo 尚未返回 path,测试中手动补齐以匹配 toggleFixedPosition 入参
|
||||||
|
const originalGetNodeInfo = editorService.getNodeInfo.bind(editorService);
|
||||||
|
editorService.getNodeInfo = (id, raw = true) => {
|
||||||
|
const info = originalGetNodeInfo(id, raw);
|
||||||
|
if (!Array.isArray(info.path) && info.node) {
|
||||||
|
const appRoot = editorService.get('root');
|
||||||
|
if (appRoot && `${id}` !== `${appRoot.id}`) {
|
||||||
|
const path = getNodePath(id, appRoot.items) as MNode[];
|
||||||
|
if (path.length) {
|
||||||
|
path.unshift(appRoot as unknown as MNode);
|
||||||
|
info.path = path;
|
||||||
|
} else {
|
||||||
|
info.path = [];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
info.path = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
test('正常', async () => {
|
test('正常', async () => {
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
@ -735,6 +760,105 @@ describe('moveLayer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('change 事件', () => {
|
||||||
|
// 取出最后一次 change 事件的回调参数
|
||||||
|
const lastChangeEvent = (fn: ReturnType<typeof vi.fn>): EditorChangeEvent =>
|
||||||
|
fn.mock.calls[fn.mock.calls.length - 1][0];
|
||||||
|
|
||||||
|
test('add 触发 change:type 为 add,data 携带新增 node 及其所属 page', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
editorService.on('change', fn);
|
||||||
|
const newNode = await editorService.add({ type: 'text' });
|
||||||
|
editorService.off('change', fn);
|
||||||
|
|
||||||
|
expect(fn).toHaveBeenCalledTimes(1);
|
||||||
|
const event = lastChangeEvent(fn);
|
||||||
|
expect(event.type).toBe('add');
|
||||||
|
expect(event.data).toHaveLength(1);
|
||||||
|
const addedId = Array.isArray(newNode) ? newNode[0].id : newNode.id;
|
||||||
|
expect(event.data[0].node.id).toBe(addedId);
|
||||||
|
// 新增节点归属于当前页面
|
||||||
|
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove 触发 change:type 为 remove,data 携带被删 node 及删除前所属 page', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
editorService.on('change', fn);
|
||||||
|
await editorService.remove({ id: NodeId.NODE_ID, type: 'text' });
|
||||||
|
editorService.off('change', fn);
|
||||||
|
|
||||||
|
const event = lastChangeEvent(fn);
|
||||||
|
expect(event.type).toBe('remove');
|
||||||
|
expect(event.data).toHaveLength(1);
|
||||||
|
expect(event.data[0].node.id).toBe(NodeId.NODE_ID);
|
||||||
|
// 删除前捕获,page 仍能正确反查到
|
||||||
|
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update 触发 change:type 为 update,data.node 携带 newNode / oldNode', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
editorService.on('change', fn);
|
||||||
|
await editorService.update({ id: NodeId.NODE_ID, type: 'text', text: 'changed' });
|
||||||
|
editorService.off('change', fn);
|
||||||
|
|
||||||
|
const event = lastChangeEvent(fn);
|
||||||
|
expect(event.type).toBe('update');
|
||||||
|
if (event.type === 'update') {
|
||||||
|
expect(event.data[0].node.newNode.id).toBe(NodeId.NODE_ID);
|
||||||
|
expect(event.data[0].node.newNode.text).toBe('changed');
|
||||||
|
expect(event.data[0].node.oldNode.id).toBe(NodeId.NODE_ID);
|
||||||
|
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('moveLayer 触发 change:type 为 move-layer,并携带 offset', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
await editorService.select(NodeId.NODE_ID);
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
editorService.on('change', fn);
|
||||||
|
await editorService.moveLayer(1);
|
||||||
|
editorService.off('change', fn);
|
||||||
|
|
||||||
|
const event = lastChangeEvent(fn);
|
||||||
|
expect(event.type).toBe('move-layer');
|
||||||
|
if (event.type === 'move-layer') {
|
||||||
|
expect(event.offset).toBe(1);
|
||||||
|
expect(event.data[0].node.id).toBe(NodeId.NODE_ID);
|
||||||
|
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dragTo 触发 change:type 为 drag-to,并携带 targetIndex / targetParent', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
const page = editorService.get('page') as unknown as MContainer;
|
||||||
|
|
||||||
|
const fn = vi.fn();
|
||||||
|
editorService.on('change', fn);
|
||||||
|
await editorService.dragTo({ id: NodeId.NODE_ID2, type: 'text' }, page, 0);
|
||||||
|
editorService.off('change', fn);
|
||||||
|
|
||||||
|
const event = lastChangeEvent(fn);
|
||||||
|
expect(event.type).toBe('drag-to');
|
||||||
|
if (event.type === 'drag-to') {
|
||||||
|
expect(event.targetIndex).toBe(0);
|
||||||
|
expect(event.targetParent.id).toBe(NodeId.PAGE_ID);
|
||||||
|
expect(event.data[0].node.id).toBe(NodeId.NODE_ID2);
|
||||||
|
expect(event.data[0].page?.id).toBe(NodeId.PAGE_ID);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('undo redo', () => {
|
describe('undo redo', () => {
|
||||||
beforeAll(() => editorService.set('root', cloneDeep(root)));
|
beforeAll(() => editorService.set('root', cloneDeep(root)));
|
||||||
|
|
||||||
@ -801,7 +925,7 @@ describe('undo redo', () => {
|
|||||||
|
|
||||||
describe('*AndGetHistoryId', () => {
|
describe('*AndGetHistoryId', () => {
|
||||||
const lastStepUuid = () => {
|
const lastStepUuid = () => {
|
||||||
const list = historyService.getPageStepList();
|
const list = historyService.getStepList('page', editorService.get('page')?.id);
|
||||||
return list[list.length - 1]?.step.uuid;
|
return list[list.length - 1]?.step.uuid;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -810,19 +934,20 @@ describe('*AndGetHistoryId', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const historyId = await editorService.addAndGetHistoryId({ type: 'text' });
|
const { result, historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
|
||||||
expect(typeof historyId).toBe('string');
|
expect(result).toBeTruthy();
|
||||||
expect(historyId).toBeTruthy();
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid());
|
expect(historyIds[0]).toBeTruthy();
|
||||||
|
expect(historyIds[0]).toBe(lastStepUuid());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('addAndGetHistoryId 传 doNotPushHistory 时返回 null', async () => {
|
test('addAndGetHistoryId 传 doNotPushHistory 时返回空数组', async () => {
|
||||||
editorService.set('root', cloneDeep(root));
|
editorService.set('root', cloneDeep(root));
|
||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const historyId = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true });
|
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' }, null, { doNotPushHistory: true });
|
||||||
expect(historyId).toBeNull();
|
expect(historyIds).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
test('updateAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
||||||
@ -830,9 +955,14 @@ describe('*AndGetHistoryId', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const historyId = await editorService.updateAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text', text: 'x' });
|
const { result, historyIds } = await editorService.updateAndGetHistoryId({
|
||||||
expect(typeof historyId).toBe('string');
|
id: NodeId.NODE_ID,
|
||||||
expect(historyId).toBe(lastStepUuid());
|
type: 'text',
|
||||||
|
text: 'x',
|
||||||
|
});
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(historyIds).toHaveLength(1);
|
||||||
|
expect(historyIds[0]).toBe(lastStepUuid());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
test('removeAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
||||||
@ -840,9 +970,9 @@ describe('*AndGetHistoryId', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const historyId = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' });
|
const { historyIds } = await editorService.removeAndGetHistoryId({ id: NodeId.NODE_ID, type: 'text' });
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid());
|
expect(historyIds[0]).toBe(lastStepUuid());
|
||||||
});
|
});
|
||||||
|
|
||||||
test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
test('moveLayerAndGetHistoryId 返回本次写入历史记录的 uuid', async () => {
|
||||||
@ -850,9 +980,9 @@ describe('*AndGetHistoryId', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.NODE_ID);
|
await editorService.select(NodeId.NODE_ID);
|
||||||
|
|
||||||
const historyId = await editorService.moveLayerAndGetHistoryId(1);
|
const { historyIds } = await editorService.moveLayerAndGetHistoryId(1);
|
||||||
expect(typeof historyId).toBe('string');
|
expect(historyIds).toHaveLength(1);
|
||||||
expect(historyId).toBe(lastStepUuid());
|
expect(historyIds[0]).toBe(lastStepUuid());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -862,15 +992,18 @@ describe('revertPageStepById', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
|
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
|
||||||
|
const uuid = historyIds[0];
|
||||||
expect(typeof uuid).toBe('string');
|
expect(typeof uuid).toBe('string');
|
||||||
|
|
||||||
const addedStep = historyService.getPageStepList().find((e) => e.step.uuid === uuid)!.step;
|
const addedStep = historyService
|
||||||
|
.getStepList('page', editorService.get('page')?.id)
|
||||||
|
.find((e) => e.step.uuid === uuid)!.step;
|
||||||
const addedId = addedStep.diff[0].newSchema!.id;
|
const addedId = addedStep.diff[0].newSchema!.id;
|
||||||
expect(editorService.getNodeById(addedId)).toBeTruthy();
|
expect(editorService.getNodeById(addedId)).toBeTruthy();
|
||||||
|
|
||||||
const reverted = await editorService.revertPageStepById(uuid!);
|
const reverted = await editorService.revertPageStepById([uuid!]);
|
||||||
expect(reverted).not.toBeNull();
|
expect(reverted[0]).not.toBeNull();
|
||||||
// 回滚(git revert 语义)会把被新增的节点删掉
|
// 回滚(git revert 语义)会把被新增的节点删掉
|
||||||
expect(editorService.getNodeById(addedId)).toBeNull();
|
expect(editorService.getNodeById(addedId)).toBeNull();
|
||||||
});
|
});
|
||||||
@ -880,9 +1013,10 @@ describe('revertPageStepById', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
const uuid = await editorService.addAndGetHistoryId({ type: 'text' });
|
const { historyIds } = await editorService.addAndGetHistoryId({ type: 'text' });
|
||||||
const index = historyService.getPageStepIndexByUuid(uuid!);
|
const uuid = historyIds[0];
|
||||||
expect(index).toBeGreaterThanOrEqual(0);
|
const location = historyService.findStepLocationByUuid('page', uuid!, editorService.get('page')?.id);
|
||||||
|
expect(location?.index).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('找不到 uuid 时返回 null', async () => {
|
test('找不到 uuid 时返回 null', async () => {
|
||||||
@ -890,7 +1024,30 @@ describe('revertPageStepById', () => {
|
|||||||
historyService.reset();
|
historyService.reset();
|
||||||
await editorService.select(NodeId.PAGE_ID);
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
expect(await editorService.revertPageStepById('not-exist')).toBeNull();
|
expect(await editorService.revertPageStepById(['not-exist'])).toEqual([null]);
|
||||||
expect(await editorService.revertPageStepById('')).toBeNull();
|
expect(await editorService.revertPageStepById([''])).toEqual([null]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('支持传入 uuid 数组并按顺序回滚', async () => {
|
||||||
|
editorService.set('root', cloneDeep(root));
|
||||||
|
historyService.reset();
|
||||||
|
await editorService.select(NodeId.PAGE_ID);
|
||||||
|
|
||||||
|
const { historyIds: ids1 } = await editorService.addAndGetHistoryId({ type: 'text' });
|
||||||
|
const { historyIds: ids2 } = await editorService.addAndGetHistoryId({ type: 'text' });
|
||||||
|
const uuids = [ids1[0]!, ids2[0]!];
|
||||||
|
|
||||||
|
const pageId = editorService.get('page')?.id;
|
||||||
|
const addedId1 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[0])!.step.diff[0]
|
||||||
|
.newSchema!.id;
|
||||||
|
const addedId2 = historyService.getStepList('page', pageId).find((e) => e.step.uuid === uuids[1])!.step.diff[0]
|
||||||
|
.newSchema!.id;
|
||||||
|
|
||||||
|
const reverted = await editorService.revertPageStepById(uuids);
|
||||||
|
expect(reverted).toHaveLength(2);
|
||||||
|
expect(reverted[0]).not.toBeNull();
|
||||||
|
expect(reverted[1]).not.toBeNull();
|
||||||
|
expect(editorService.getNodeById(addedId1)).toBeNull();
|
||||||
|
expect(editorService.getNodeById(addedId2)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,8 +19,27 @@ import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vi
|
|||||||
|
|
||||||
import history from '@editor/services/history';
|
import history from '@editor/services/history';
|
||||||
import { setEditorConfig } from '@editor/utils/config';
|
import { setEditorConfig } from '@editor/utils/config';
|
||||||
|
import { createStackStep } from '@editor/utils/history';
|
||||||
import * as indexedDb from '@editor/utils/indexed-db';
|
import * as indexedDb from '@editor/utils/indexed-db';
|
||||||
|
|
||||||
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id);用等价小工具沿用既有用例。
|
||||||
|
const pushCodeBlock = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldContent,
|
||||||
|
newValue: payload.newContent,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? history.push('codeBlock', step as any, id) : null;
|
||||||
|
};
|
||||||
|
const pushDataSource = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldSchema,
|
||||||
|
newValue: payload.newSchema,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
});
|
||||||
|
return step ? history.push('dataSource', step as any, id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDB(happy-dom 不提供)。
|
// 用内存实现 mock 掉 IndexedDB 读写工具,避免依赖真实 IndexedDB(happy-dom 不提供)。
|
||||||
vi.mock('@editor/utils/indexed-db', () => {
|
vi.mock('@editor/utils/indexed-db', () => {
|
||||||
const store = new Map<string, any>();
|
const store = new Map<string, any>();
|
||||||
@ -59,120 +78,131 @@ describe('history service - markSaved', () => {
|
|||||||
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
|
test('markSaved 派发 mark-saved 事件并带 kind=all', () => {
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
history.on('mark-saved', fn);
|
history.on('mark-saved', fn);
|
||||||
history.markSaved();
|
history.markSaved('page');
|
||||||
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
|
expect(fn).toHaveBeenCalledWith({ kind: 'all' });
|
||||||
history.off('mark-saved', fn);
|
history.off('mark-saved', fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('markPageSaved / markCodeBlockSaved / markDataSourceSaved 派发对应 kind 事件', () => {
|
test('markSaved(stepType, id) 派发对应 kind 事件', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
|
||||||
|
|
||||||
const pageFn = vi.fn();
|
const pageFn = vi.fn();
|
||||||
const codeFn = vi.fn();
|
const codeFn = vi.fn();
|
||||||
history.on('mark-saved', (payload) => {
|
history.on('mark-saved', (payload) => {
|
||||||
if (payload.kind === 'page') pageFn(payload);
|
if (payload.kind === 'page') pageFn(payload);
|
||||||
if (payload.kind === 'code-block') codeFn(payload);
|
if (payload.kind === 'codeBlock') codeFn(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
history.markPageSaved();
|
history.markSaved('page', 'p1');
|
||||||
history.markCodeBlockSaved('code_1');
|
history.markSaved('codeBlock', 'code_1');
|
||||||
|
|
||||||
expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' });
|
expect(pageFn).toHaveBeenCalledWith({ kind: 'page', id: 'p1' });
|
||||||
expect(codeFn).toHaveBeenCalledWith({ kind: 'code-block', id: 'code_1' });
|
expect(codeFn).toHaveBeenCalledWith({ kind: 'codeBlock', id: 'code_1' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('history service - clear', () => {
|
describe('history service - clear', () => {
|
||||||
test('clearPage 清空当前页面历史并复位 canUndo/canRedo', () => {
|
test('clear 清空指定页面历史并复位 canUndo/canRedo', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
expect(history.canUndo('page', 'p1')).toBe(true);
|
||||||
expect(history.state.canUndo).toBe(true);
|
|
||||||
|
|
||||||
history.clearPage();
|
history.clear('page', 'p1');
|
||||||
expect((history.state.pageSteps as any).p1.getLength()).toBe(0);
|
expect((history.state.steps.page as any).p1.getLength()).toBe(0);
|
||||||
expect(history.state.canUndo).toBe(false);
|
expect(history.canUndo('page', 'p1')).toBe(false);
|
||||||
expect(history.state.canRedo).toBe(false);
|
expect(history.canRedo('page', 'p1')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearCodeBlock 传 id 清单个,缺省清全部', () => {
|
test('clear 保留被清空栈的 initial 基线', () => {
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
history.setMarker('page', 'p1', { name: 'P1', description: '初始' });
|
||||||
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
|
history.push('page', pageStep(), 'p1');
|
||||||
|
expect((history.state.steps.page as any).p1.getLength()).toBe(2);
|
||||||
|
|
||||||
history.clearCodeBlock('code_1');
|
history.clear('page', 'p1');
|
||||||
expect((history.state.codeBlockState as any).code_1).toBeUndefined();
|
// 真实操作记录被清空,仅保留 index 0 的 initial 基线
|
||||||
expect((history.state.codeBlockState as any).code_2).toBeDefined();
|
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
|
||||||
|
expect(history.getMarker('page', 'p1')?.opType).toBe('initial');
|
||||||
history.clearCodeBlock();
|
expect(history.canUndo('page', 'p1')).toBe(false);
|
||||||
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clearDataSource 传 id 清单个,缺省清全部', () => {
|
test('clear 传 id 清单个,缺省清全部(codeBlock)', () => {
|
||||||
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
|
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
|
||||||
|
|
||||||
history.clearDataSource('ds_1');
|
history.clear('codeBlock', 'code_1');
|
||||||
expect((history.state.dataSourceState as any).ds_1).toBeUndefined();
|
expect((history.state.steps.codeBlock as any).code_1.getLength()).toBe(0);
|
||||||
expect((history.state.dataSourceState as any).ds_2).toBeDefined();
|
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(1);
|
||||||
|
|
||||||
history.clearDataSource();
|
history.clear('codeBlock');
|
||||||
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
|
expect((history.state.steps.codeBlock as any).code_2.getLength()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clear 传 id 清单个,缺省清全部(dataSource)', () => {
|
||||||
|
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
|
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
|
||||||
|
|
||||||
|
history.clear('dataSource', 'ds_1');
|
||||||
|
expect((history.state.steps.dataSource as any).ds_1.getLength()).toBe(0);
|
||||||
|
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(1);
|
||||||
|
|
||||||
|
history.clear('dataSource');
|
||||||
|
expect((history.state.steps.dataSource as any).ds_2.getLength()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('history service - IndexedDB 持久化', () => {
|
describe('history service - IndexedDB 持久化', () => {
|
||||||
test('saveToIndexedDB 以序列化字符串写入并返回快照对象', async () => {
|
test('saveToIndexedDB 以对象写入(仅 step.diff 序列化成字符串)并返回快照对象', async () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', { ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any, 'p1');
|
||||||
history.push(pageStep());
|
|
||||||
|
|
||||||
const snapshot = await history.saveToIndexedDB();
|
const snapshot = await history.saveToIndexedDB();
|
||||||
expect(snapshot.version).toBe(1);
|
expect(snapshot.version).toBe(3);
|
||||||
expect(snapshot.pageId).toBe('p1');
|
// 实际写入 IndexedDB 的是对象(交给结构化克隆),仅每条 step 的 diff 被序列化成字符串
|
||||||
// 实际写入 IndexedDB 的是字符串(serialize-javascript 结果)
|
|
||||||
expect(indexedDb.idbSet).toHaveBeenCalled();
|
expect(indexedDb.idbSet).toHaveBeenCalled();
|
||||||
const written = (indexedDb.idbSet as any).mock.calls[0][3];
|
const written = (indexedDb.idbSet as any).mock.calls[0][3];
|
||||||
expect(typeof written).toBe('string');
|
expect(typeof written).toBe('object');
|
||||||
|
expect(typeof written.steps.page.p1.elementList[0].diff).toBe('string');
|
||||||
|
// diff 之外的字段(如 modifiedNodeIds Map)原样交给结构化克隆,不被字符串化
|
||||||
|
expect(written.steps.page.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
|
||||||
|
// 返回的快照即写入 IndexedDB 的持久化形态:diff 已是序列化字符串
|
||||||
|
expect(written).toBe(snapshot);
|
||||||
|
expect(typeof snapshot.steps.page.p1.elementList[0].diff).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {
|
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
history.undo('page', 'p1'); // page cursor = 1
|
||||||
history.undo(); // page cursor = 1
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
|
||||||
|
|
||||||
await history.saveToIndexedDB();
|
await history.saveToIndexedDB();
|
||||||
history.reset();
|
history.reset();
|
||||||
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
|
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
|
||||||
|
|
||||||
const restored = await history.restoreFromIndexedDB();
|
const restored = await history.restoreFromIndexedDB();
|
||||||
expect(restored).not.toBeNull();
|
expect(restored).not.toBeNull();
|
||||||
expect(history.state.pageId).toBe('p1');
|
expect(history.getCursor('page', 'p1')).toBe(1);
|
||||||
expect(history.getPageCursor('p1')).toBe(1);
|
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
|
||||||
expect((history.state.codeBlockState as any).code_1).toBeDefined();
|
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
|
||||||
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
|
test('restoreFromIndexedDB 把游标恢复到最近一个已保存记录', async () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
history.markSaved('page', 'p1'); // 标记 index 1(cursor=2)
|
||||||
history.markPageSaved(); // 标记 index 1(cursor=2)
|
history.push('page', pageStep(), 'p1'); // cursor=3,saved 仍在 index 1
|
||||||
history.push(pageStep()); // cursor=3,saved 仍在 index 1
|
|
||||||
|
|
||||||
await history.saveToIndexedDB();
|
await history.saveToIndexedDB();
|
||||||
history.reset();
|
history.reset();
|
||||||
|
|
||||||
await history.restoreFromIndexedDB();
|
await history.restoreFromIndexedDB();
|
||||||
// 恢复后游标定位到已保存记录之后:index 1 -> cursor 2
|
// 恢复后游标定位到已保存记录之后:index 1 -> cursor 2
|
||||||
expect(history.getPageCursor('p1')).toBe(2);
|
expect(history.getCursor('page', 'p1')).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreFromIndexedDB 能还原内容中的函数(serialize + parseDSL 往返)', async () => {
|
test('restoreFromIndexedDB 能还原内容中的函数(serialize + parseDSL 往返)', async () => {
|
||||||
history.pushCodeBlock('code_1', {
|
pushCodeBlock('code_1', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: {
|
newContent: {
|
||||||
name: 'A',
|
name: 'A',
|
||||||
@ -186,19 +216,18 @@ describe('history service - IndexedDB 持久化', () => {
|
|||||||
history.reset();
|
history.reset();
|
||||||
await history.restoreFromIndexedDB();
|
await history.restoreFromIndexedDB();
|
||||||
|
|
||||||
const current = (history.state.codeBlockState as any).code_1.getCurrentElement();
|
const current = (history.state.steps.codeBlock as any).code_1.getCurrentElement();
|
||||||
expect(typeof current.diff[0].newSchema.code).toBe('function');
|
expect(typeof current.diff[0].newSchema.code).toBe('function');
|
||||||
expect(current.diff[0].newSchema.code()).toBe(42);
|
expect(current.diff[0].newSchema.code()).toBe(42);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
|
test('restoreFromIndexedDB 找不到记录时返回 null 且不改动当前状态', async () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
|
||||||
|
|
||||||
const restored = await history.restoreFromIndexedDB();
|
const restored = await history.restoreFromIndexedDB();
|
||||||
expect(restored).toBeNull();
|
expect(restored).toBeNull();
|
||||||
// 当前状态保持不变
|
// 当前状态保持不变
|
||||||
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
|
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
|
test('saveToIndexedDB 派发 save-to-indexed-db、restoreFromIndexedDB 派发 restore-from-indexed-db', async () => {
|
||||||
@ -207,8 +236,7 @@ describe('history service - IndexedDB 持久化', () => {
|
|||||||
history.on('save-to-indexed-db', saveFn);
|
history.on('save-to-indexed-db', saveFn);
|
||||||
history.on('restore-from-indexed-db', restoreFn);
|
history.on('restore-from-indexed-db', restoreFn);
|
||||||
|
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', pageStep(), 'p1');
|
||||||
history.push(pageStep());
|
|
||||||
await history.saveToIndexedDB();
|
await history.saveToIndexedDB();
|
||||||
await history.restoreFromIndexedDB();
|
await history.restoreFromIndexedDB();
|
||||||
|
|
||||||
|
|||||||
@ -6,330 +6,344 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import history from '@editor/services/history';
|
import history from '@editor/services/history';
|
||||||
|
import { createStackStep } from '@editor/utils/history';
|
||||||
|
|
||||||
|
// pushCodeBlock / pushDataSource 已合入统一的 push(stepType, step, id)。
|
||||||
|
// 这里用与旧便捷方法等价的小工具,按 payload 构造 step 后走新的 push,便于沿用既有用例。
|
||||||
|
const pushCodeBlock = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldContent,
|
||||||
|
newValue: payload.newContent,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
historyDescription: payload.historyDescription,
|
||||||
|
source: payload.source,
|
||||||
|
});
|
||||||
|
return step ? history.push('codeBlock', step as any, id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushDataSource = (id: any, payload: any) => {
|
||||||
|
const step = createStackStep(id, {
|
||||||
|
oldValue: payload.oldSchema,
|
||||||
|
newValue: payload.newSchema,
|
||||||
|
changeRecords: payload.changeRecords,
|
||||||
|
historyDescription: payload.historyDescription,
|
||||||
|
source: payload.source,
|
||||||
|
});
|
||||||
|
return step ? history.push('dataSource', step as any, id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
history.reset();
|
history.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('history service', () => {
|
describe('history service', () => {
|
||||||
test('changePage 切换页面会创建对应的 UndoRedo', () => {
|
|
||||||
history.changePage({ id: 'p1' } as any);
|
|
||||||
expect((history.state.pageSteps as any).p1).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('push / undo / redo 链路', () => {
|
test('push / undo / redo 链路', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
|
||||||
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
|
const v1 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'a' } as any;
|
||||||
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
|
const v2 = { data: { items: [] }, modifiedNodeIds: new Map(), nodeId: 'b' } as any;
|
||||||
history.push(v1);
|
history.push('page', v1, 'p1');
|
||||||
history.push(v2);
|
history.push('page', v2, 'p1');
|
||||||
|
|
||||||
const undone = history.undo();
|
const undone = history.undo('page', 'p1');
|
||||||
expect(undone).toBeDefined();
|
expect(undone).toBeDefined();
|
||||||
const redone = history.redo();
|
const redone = history.redo('page', 'p1');
|
||||||
expect(redone).toBeDefined();
|
expect(redone).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('未指定 pageId 时 push/undo/redo 返回 null', () => {
|
test('未传 / 无效 id 时 push/undo/redo 返回 null', () => {
|
||||||
history.resetPage();
|
expect(history.push('page', {} as any, '')).toBeNull();
|
||||||
expect(history.push({} as any)).toBeNull();
|
expect(history.undo('page', '')).toBeNull();
|
||||||
expect(history.undo()).toBeNull();
|
expect(history.redo('page', '')).toBeNull();
|
||||||
expect(history.redo()).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reset / resetPage / resetState', () => {
|
test('reset / resetState 清空页面栈', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', { data: {} } as any, 'p1');
|
||||||
history.push({ data: {} } as any);
|
|
||||||
history.reset();
|
history.reset();
|
||||||
expect(history.state.pageId).toBeUndefined();
|
expect(Object.keys(history.state.steps.page)).toHaveLength(0);
|
||||||
expect(Object.keys(history.state.pageSteps)).toHaveLength(0);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('canUndo / canRedo 在 push 后更新', () => {
|
test('canUndo / canRedo 在 push 后更新', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.push('page', { data: {} } as any, 'p1');
|
||||||
history.push({ data: {} } as any);
|
history.push('page', { data: {} } as any, 'p1');
|
||||||
history.push({ data: {} } as any);
|
expect(history.canUndo('page', 'p1')).toBe(true);
|
||||||
expect(history.state.canUndo).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('changePage 接到 undefined/null 时不变更', () => {
|
test('push 指定 pageId 落到目标页栈,不影响其它页', () => {
|
||||||
history.changePage(null as any);
|
|
||||||
expect(history.state.pageId).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('push 指定 pageId 落到目标页栈,不影响当前页', () => {
|
|
||||||
// 当前激活在 p1
|
|
||||||
history.changePage({ id: 'p1' } as any);
|
|
||||||
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
|
const step = { data: { id: 'p2', name: '' }, modifiedNodeIds: new Map() } as any;
|
||||||
|
|
||||||
// 跨页 push:把记录推到 p2(目标页),p1 栈应保持为空
|
// 跨页 push:把记录推到 p2(目标页),p1 栈应保持为空
|
||||||
history.push(step, 'p2');
|
history.push('page', step, 'p2');
|
||||||
expect((history.state.pageSteps as any).p2).toBeDefined();
|
expect((history.state.steps.page as any).p2).toBeDefined();
|
||||||
expect((history.state.pageSteps as any).p2.canUndo()).toBe(true);
|
expect(history.canUndo('page', 'p2')).toBe(true);
|
||||||
// p1 栈虽然激活但没有 push 进来,仍不可撤销
|
// p1 栈没有 push 进来,仍不可撤销
|
||||||
expect((history.state.pageSteps as any).p1.canUndo()).toBe(false);
|
expect(history.canUndo('page', 'p1')).toBe(false);
|
||||||
|
|
||||||
// 跨页 push 不应触发当前页(p1)的 canUndo 改变
|
// p2 能正常 undo 该步骤
|
||||||
expect(history.state.canUndo).toBe(false);
|
expect(history.undo('page', 'p2')).toBeDefined();
|
||||||
|
|
||||||
// 切到 p2 后能正常 undo 该跨页步骤
|
|
||||||
history.changePage({ id: 'p2' } as any);
|
|
||||||
expect(history.state.canUndo).toBe(true);
|
|
||||||
expect(history.undo()).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('push 不传 pageId 时落到当前活动页栈(向后兼容)', () => {
|
|
||||||
history.changePage({ id: 'p1' } as any);
|
|
||||||
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
|
||||||
expect((history.state.pageSteps as any).p1.canUndo()).toBe(true);
|
|
||||||
expect(history.state.canUndo).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('push 未带 timestamp 时自动写入入栈时间', () => {
|
test('push 未带 timestamp 时自动写入入栈时间', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
||||||
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('push 已带 timestamp 时保留调用方指定的值', () => {
|
test('push 已带 timestamp 时保留调用方指定的值', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
const step = history.push(
|
||||||
const step = history.push({
|
'page',
|
||||||
|
{
|
||||||
data: { id: 'p1', name: '' },
|
data: { id: 'p1', name: '' },
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
timestamp: 123456,
|
timestamp: 123456,
|
||||||
} as any);
|
} as any,
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
expect(step?.timestamp).toBe(123456);
|
expect(step?.timestamp).toBe(123456);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('push 未带 uuid 时自动生成 uuid', () => {
|
test('push 未带 uuid 时自动生成 uuid', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
const step = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
|
||||||
const step = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
|
||||||
expect(typeof step?.uuid).toBe('string');
|
expect(typeof step?.uuid).toBe('string');
|
||||||
expect(step?.uuid).toBeTruthy();
|
expect(step?.uuid).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('push 已带 uuid 时保留调用方指定的值', () => {
|
test('push 已带 uuid 时保留调用方指定的值', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
const step = history.push(
|
||||||
const step = history.push({
|
'page',
|
||||||
|
{
|
||||||
uuid: 'my-uuid',
|
uuid: 'my-uuid',
|
||||||
data: { id: 'p1', name: '' },
|
data: { id: 'p1', name: '' },
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any,
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
expect(step?.uuid).toBe('my-uuid');
|
expect(step?.uuid).toBe('my-uuid');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('push 为每条记录生成不同的 uuid', () => {
|
test('push 为每条记录生成不同的 uuid', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
const s1 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
|
||||||
const s1 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
const s2 = history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
|
||||||
const s2 = history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
|
||||||
expect(s1?.uuid).toBeTruthy();
|
expect(s1?.uuid).toBeTruthy();
|
||||||
expect(s2?.uuid).toBeTruthy();
|
expect(s2?.uuid).toBeTruthy();
|
||||||
expect(s1?.uuid).not.toBe(s2?.uuid);
|
expect(s1?.uuid).not.toBe(s2?.uuid);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('setPageMarker 在空栈时种入 initial 基线', () => {
|
test('setMarker 在空栈时种入 initial 基线', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
const marker = history.setMarker('page', 'p1', { name: '首页', description: '初始' });
|
||||||
const marker = history.setPageMarker('p1', { name: '首页', description: '初始' });
|
|
||||||
expect(marker?.opType).toBe('initial');
|
expect(marker?.opType).toBe('initial');
|
||||||
expect(history.getPageMarker('p1')?.uuid).toBe(marker?.uuid);
|
expect(history.getMarker('page', 'p1')?.uuid).toBe(marker?.uuid);
|
||||||
expect((history.state.pageSteps as any).p1.getLength()).toBe(1);
|
expect((history.state.steps.page as any).p1.getLength()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('有 initial 基线时不可撤销越过基线', () => {
|
test('有 initial 基线时不可撤销越过基线', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.setMarker('page', 'p1');
|
||||||
history.setPageMarker('p1');
|
history.push('page', { data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any, 'p1');
|
||||||
history.push({ data: { id: 'p1', name: '' }, modifiedNodeIds: new Map() } as any);
|
|
||||||
|
|
||||||
expect(history.state.canUndo).toBe(true);
|
expect(history.canUndo('page', 'p1')).toBe(true);
|
||||||
history.undo();
|
history.undo('page', 'p1');
|
||||||
expect(history.state.canUndo).toBe(false);
|
expect(history.canUndo('page', 'p1')).toBe(false);
|
||||||
expect(history.undo()).toBeNull();
|
expect(history.undo('page', 'p1')).toBeNull();
|
||||||
expect(history.getPageCursor('p1')).toBe(1);
|
expect(history.getCursor('page', 'p1')).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPageHistoryGroups 过滤 initial 基线', () => {
|
test('扩展类型同样支持 initial 基线(撤销不越过基线)', () => {
|
||||||
history.changePage({ id: 'p1' } as any);
|
history.setMarker('custom', 'ext_1', {});
|
||||||
history.setPageMarker('p1');
|
history.push('custom', { opType: 'update', diff: [] } as any, 'ext_1');
|
||||||
history.push({
|
|
||||||
|
expect(history.canUndo('custom', 'ext_1')).toBe(true);
|
||||||
|
history.undo('custom', 'ext_1');
|
||||||
|
expect(history.canUndo('custom', 'ext_1')).toBe(false);
|
||||||
|
expect(history.undo('custom', 'ext_1')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getHistoryGroups 过滤 initial 基线', () => {
|
||||||
|
history.setMarker('page', 'p1');
|
||||||
|
history.push(
|
||||||
|
'page',
|
||||||
|
{
|
||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' } }],
|
||||||
modifiedNodeIds: new Map(),
|
modifiedNodeIds: new Map(),
|
||||||
} as any);
|
} as any,
|
||||||
|
'p1',
|
||||||
|
);
|
||||||
|
|
||||||
const groups = history.getPageHistoryGroups('p1');
|
const groups = history.getHistoryGroups('page', 'p1');
|
||||||
expect(groups).toHaveLength(1);
|
expect(groups).toHaveLength(1);
|
||||||
expect(groups[0].opType).toBe('add');
|
expect(groups[0].opType).toBe('add');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('history service - codeBlock', () => {
|
describe('history service - codeBlock', () => {
|
||||||
test('pushCodeBlock 入栈并触发 code-block-history-change 事件', () => {
|
test('pushCodeBlock 入栈并触发 change 事件', () => {
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
history.on('code-block-history-change', fn);
|
history.on('change', fn);
|
||||||
|
|
||||||
const step = history.pushCodeBlock('code_1', {
|
const step = pushCodeBlock('code_1', {
|
||||||
oldContent: null,
|
oldContent: null,
|
||||||
newContent: { name: 'A', content: 'x' } as any,
|
newContent: { name: 'A', content: 'x' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(step).not.toBeNull();
|
expect(step).not.toBeNull();
|
||||||
expect(step?.id).toBe('code_1');
|
expect(step?.data?.id).toBe('code_1');
|
||||||
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
||||||
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
|
expect(step?.diff?.[0]?.newSchema).toEqual({ name: 'A', content: 'x' });
|
||||||
expect((history.state.codeBlockState as any).code_1).toBeDefined();
|
expect((history.state.steps.codeBlock as any).code_1).toBeDefined();
|
||||||
expect(history.canUndoCodeBlock('code_1')).toBe(true);
|
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
|
||||||
expect(fn).toHaveBeenCalledWith('code_1', expect.objectContaining({ id: 'code_1' }));
|
expect(fn).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: { name: 'A', id: 'code_1' } }),
|
||||||
|
'codeBlock',
|
||||||
|
'code_1',
|
||||||
|
);
|
||||||
|
|
||||||
history.off('code-block-history-change', fn);
|
history.off('change', fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushCodeBlock 不传 id 返回 null', () => {
|
test('pushCodeBlock 不传 id 返回 null', () => {
|
||||||
expect(history.pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
|
expect(pushCodeBlock('', { oldContent: null, newContent: null })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushCodeBlock 自动写入入栈时间戳', () => {
|
test('pushCodeBlock 自动写入入栈时间戳', () => {
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
const step = pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
||||||
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushCodeBlock 自动生成 uuid', () => {
|
test('pushCodeBlock 自动生成 uuid', () => {
|
||||||
const step = history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
const step = pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
expect(typeof step?.uuid).toBe('string');
|
expect(typeof step?.uuid).toBe('string');
|
||||||
expect(step?.uuid).toBeTruthy();
|
expect(step?.uuid).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
|
test('undoCodeBlock / redoCodeBlock 走对应 id 的 UndoRedo 栈', () => {
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.pushCodeBlock('code_1', {
|
pushCodeBlock('code_1', {
|
||||||
oldContent: { name: 'A' } as any,
|
oldContent: { name: 'A' } as any,
|
||||||
newContent: { name: 'B' } as any,
|
newContent: { name: 'B' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(history.canUndoCodeBlock('code_1')).toBe(true);
|
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
|
||||||
const undone = history.undoCodeBlock('code_1');
|
const undone = history.undo('codeBlock', 'code_1');
|
||||||
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
|
expect(undone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
|
||||||
expect(history.canRedoCodeBlock('code_1')).toBe(true);
|
expect(history.canRedo('codeBlock', 'code_1')).toBe(true);
|
||||||
|
|
||||||
const redone = history.redoCodeBlock('code_1');
|
const redone = history.redo('codeBlock', 'code_1');
|
||||||
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
|
expect(redone?.diff?.[0]?.newSchema).toEqual({ name: 'B' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('undoCodeBlock 对不存在 id 返回 null', () => {
|
test('undoCodeBlock 对不存在 id 返回 null', () => {
|
||||||
expect(history.undoCodeBlock('not-exist')).toBeNull();
|
expect(history.undo('codeBlock', 'not-exist')).toBeNull();
|
||||||
expect(history.redoCodeBlock('not-exist')).toBeNull();
|
expect(history.redo('codeBlock', 'not-exist')).toBeNull();
|
||||||
expect(history.canUndoCodeBlock('not-exist')).toBe(false);
|
expect(history.canUndo('codeBlock', 'not-exist')).toBe(false);
|
||||||
expect(history.canRedoCodeBlock('not-exist')).toBe(false);
|
expect(history.canRedo('codeBlock', 'not-exist')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('不同代码块 id 的栈相互隔离', () => {
|
test('不同代码块 id 的栈相互隔离', () => {
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
|
pushCodeBlock('code_2', { oldContent: null, newContent: { name: 'B' } as any });
|
||||||
|
|
||||||
expect(history.canUndoCodeBlock('code_1')).toBe(true);
|
expect(history.canUndo('codeBlock', 'code_1')).toBe(true);
|
||||||
expect(history.canUndoCodeBlock('code_2')).toBe(true);
|
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
|
||||||
|
|
||||||
history.undoCodeBlock('code_1');
|
history.undo('codeBlock', 'code_1');
|
||||||
expect(history.canUndoCodeBlock('code_1')).toBe(false);
|
expect(history.canUndo('codeBlock', 'code_1')).toBe(false);
|
||||||
// code_2 的栈不受影响
|
// code_2 的栈不受影响
|
||||||
expect(history.canUndoCodeBlock('code_2')).toBe(true);
|
expect(history.canUndo('codeBlock', 'code_2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reset / resetState 清空 codeBlockState', () => {
|
test('reset / resetState 清空 codeBlockState', () => {
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.reset();
|
history.reset();
|
||||||
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
|
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
|
||||||
|
|
||||||
history.pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
pushCodeBlock('code_1', { oldContent: null, newContent: { name: 'A' } as any });
|
||||||
history.resetState();
|
history.resetState();
|
||||||
expect(Object.keys(history.state.codeBlockState)).toHaveLength(0);
|
expect(Object.keys(history.state.steps.codeBlock)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('history service - dataSource', () => {
|
describe('history service - dataSource', () => {
|
||||||
test('pushDataSource 入栈并触发 data-source-history-change 事件', () => {
|
test('pushDataSource 入栈并触发 change 事件', () => {
|
||||||
const fn = vi.fn();
|
const fn = vi.fn();
|
||||||
history.on('data-source-history-change', fn);
|
history.on('change', fn);
|
||||||
|
|
||||||
const step = history.pushDataSource('ds_1', {
|
const step = pushDataSource('ds_1', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(step).not.toBeNull();
|
expect(step).not.toBeNull();
|
||||||
expect(step?.id).toBe('ds_1');
|
expect(step?.data?.id).toBe('ds_1');
|
||||||
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
expect(step?.diff?.[0]?.oldSchema).toBeUndefined();
|
||||||
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
|
expect(step?.diff?.[0]?.newSchema?.title).toBe('A');
|
||||||
expect((history.state.dataSourceState as any).ds_1).toBeDefined();
|
expect((history.state.steps.dataSource as any).ds_1).toBeDefined();
|
||||||
expect(history.canUndoDataSource('ds_1')).toBe(true);
|
expect(history.canUndo('dataSource', 'ds_1')).toBe(true);
|
||||||
expect(fn).toHaveBeenCalledWith('ds_1', expect.objectContaining({ id: 'ds_1' }));
|
expect(fn).toHaveBeenCalledWith(expect.objectContaining({ data: { name: 'A', id: 'ds_1' } }), 'dataSource', 'ds_1');
|
||||||
|
|
||||||
history.off('data-source-history-change', fn);
|
history.off('change', fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushDataSource 不传 id 返回 null', () => {
|
test('pushDataSource 不传 id 返回 null', () => {
|
||||||
expect(history.pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
|
expect(pushDataSource('', { oldSchema: null, newSchema: null })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushDataSource 自动写入入栈时间戳', () => {
|
test('pushDataSource 自动写入入栈时间戳', () => {
|
||||||
const before = Date.now();
|
const before = Date.now();
|
||||||
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
const step = pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
const after = Date.now();
|
const after = Date.now();
|
||||||
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
expect(step?.timestamp).toBeGreaterThanOrEqual(before);
|
||||||
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
expect(step?.timestamp).toBeLessThanOrEqual(after);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('pushDataSource 自动生成 uuid', () => {
|
test('pushDataSource 自动生成 uuid', () => {
|
||||||
const step = history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
const step = pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
expect(typeof step?.uuid).toBe('string');
|
expect(typeof step?.uuid).toBe('string');
|
||||||
expect(step?.uuid).toBeTruthy();
|
expect(step?.uuid).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
|
test('undoDataSource / redoDataSource 走对应 id 的 UndoRedo 栈', () => {
|
||||||
history.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: null,
|
oldSchema: null,
|
||||||
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
newSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
||||||
});
|
});
|
||||||
history.pushDataSource('ds_1', {
|
pushDataSource('ds_1', {
|
||||||
oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
oldSchema: { id: 'ds_1', type: 'base', title: 'A' } as any,
|
||||||
newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any,
|
newSchema: { id: 'ds_1', type: 'base', title: 'B' } as any,
|
||||||
});
|
});
|
||||||
|
|
||||||
const undone = history.undoDataSource('ds_1');
|
const undone = history.undo('dataSource', 'ds_1');
|
||||||
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
|
expect(undone?.diff?.[0]?.newSchema?.title).toBe('B');
|
||||||
|
|
||||||
const redone = history.redoDataSource('ds_1');
|
const redone = history.redo('dataSource', 'ds_1');
|
||||||
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
|
expect(redone?.diff?.[0]?.newSchema?.title).toBe('B');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('undoDataSource 对不存在 id 返回 null', () => {
|
test('undoDataSource 对不存在 id 返回 null', () => {
|
||||||
expect(history.undoDataSource('not-exist')).toBeNull();
|
expect(history.undo('dataSource', 'not-exist')).toBeNull();
|
||||||
expect(history.redoDataSource('not-exist')).toBeNull();
|
expect(history.redo('dataSource', 'not-exist')).toBeNull();
|
||||||
expect(history.canUndoDataSource('not-exist')).toBe(false);
|
expect(history.canUndo('dataSource', 'not-exist')).toBe(false);
|
||||||
expect(history.canRedoDataSource('not-exist')).toBe(false);
|
expect(history.canRedo('dataSource', 'not-exist')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('不同数据源 id 的栈相互隔离', () => {
|
test('不同数据源 id 的栈相互隔离', () => {
|
||||||
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
history.pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
|
pushDataSource('ds_2', { oldSchema: null, newSchema: { id: 'ds_2' } as any });
|
||||||
|
|
||||||
history.undoDataSource('ds_1');
|
history.undo('dataSource', 'ds_1');
|
||||||
expect(history.canUndoDataSource('ds_1')).toBe(false);
|
expect(history.canUndo('dataSource', 'ds_1')).toBe(false);
|
||||||
expect(history.canUndoDataSource('ds_2')).toBe(true);
|
expect(history.canUndo('dataSource', 'ds_2')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('reset / resetState 清空 dataSourceState', () => {
|
test('reset / resetState 清空 dataSourceState', () => {
|
||||||
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
history.reset();
|
history.reset();
|
||||||
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
|
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
|
||||||
|
|
||||||
history.pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
pushDataSource('ds_1', { oldSchema: null, newSchema: { id: 'ds_1' } as any });
|
||||||
history.resetState();
|
history.resetState();
|
||||||
expect(Object.keys(history.state.dataSourceState)).toHaveLength(0);
|
expect(Object.keys(history.state.steps.dataSource)).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,9 +6,32 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import keybinding from '@editor/services/keybinding';
|
import keybinding from '@editor/services/keybinding';
|
||||||
|
import { KeyBindingCommand } from '@editor/type';
|
||||||
|
|
||||||
|
const editorService = vi.hoisted(() => ({
|
||||||
|
get: vi.fn(() => [{ id: 'n1', type: 'text' }]),
|
||||||
|
remove: vi.fn(),
|
||||||
|
copy: vi.fn(),
|
||||||
|
paste: vi.fn(),
|
||||||
|
undo: vi.fn(),
|
||||||
|
redo: vi.fn(),
|
||||||
|
move: vi.fn(),
|
||||||
|
selectNextNode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const uiService = vi.hoisted(() => ({
|
||||||
|
zoom: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
calcZoom: vi.fn(async () => 1.2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@editor/services/editor', () => ({ default: editorService }));
|
||||||
|
vi.mock('@editor/services/ui', () => ({ default: uiService }));
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
keybinding.reset();
|
keybinding.reset();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
editorService.get.mockReturnValue([{ id: 'n1', type: 'text' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('keybinding service', () => {
|
describe('keybinding service', () => {
|
||||||
@ -36,6 +59,10 @@ describe('keybinding service', () => {
|
|||||||
expect(() => keybinding.registerEl('global')).not.toThrow();
|
expect(() => keybinding.registerEl('global')).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('registeEl 兼容别名', () => {
|
||||||
|
expect(() => keybinding.registeEl('global')).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
test('register 同一条目去重', () => {
|
test('register 同一条目去重', () => {
|
||||||
keybinding.register([
|
keybinding.register([
|
||||||
{
|
{
|
||||||
@ -78,6 +105,12 @@ describe('keybinding service', () => {
|
|||||||
expect((keybinding as any).bindingList.length).toBe(0);
|
expect((keybinding as any).bindingList.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('destroy 调用 reset', () => {
|
||||||
|
keybinding.registerEl('global');
|
||||||
|
keybinding.destroy();
|
||||||
|
expect((keybinding as any).controllers.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test('getKeyconKeys ctrl 在 mac 下被替换为 meta', () => {
|
test('getKeyconKeys ctrl 在 mac 下被替换为 meta', () => {
|
||||||
const original = (keybinding as any).ctrlKey;
|
const original = (keybinding as any).ctrlKey;
|
||||||
(keybinding as any).ctrlKey = 'meta';
|
(keybinding as any).ctrlKey = 'meta';
|
||||||
@ -90,4 +123,53 @@ describe('keybinding service', () => {
|
|||||||
const result = (keybinding as any).getKeyconKeys(['a', 'b+c']);
|
const result = (keybinding as any).getKeyconKeys(['a', 'b+c']);
|
||||||
expect(result).toHaveLength(2);
|
expect(result).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('内置 DELETE/CUT 命令在 page 节点时不执行 remove', () => {
|
||||||
|
editorService.get.mockReturnValue([{ id: 'p1', type: 'page' }]);
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.DELETE_NODE]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.CUT_NODE]();
|
||||||
|
expect(editorService.remove).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内置 COPY/CUT/PASTE/UNDO/REDO 命令', () => {
|
||||||
|
const nodes = [{ id: 'n1', type: 'text' }];
|
||||||
|
editorService.get.mockReturnValue(nodes);
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.COPY_NODE]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.CUT_NODE]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.PASTE_NODE]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.UNDO]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.REDO]();
|
||||||
|
expect(editorService.copy).toHaveBeenCalledWith(nodes);
|
||||||
|
expect(editorService.remove).toHaveBeenCalledWith(nodes, { historySource: 'shortcut' });
|
||||||
|
expect(editorService.paste).toHaveBeenCalled();
|
||||||
|
expect(editorService.undo).toHaveBeenCalled();
|
||||||
|
expect(editorService.redo).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('内置缩放与移动命令', async () => {
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.ZOOM_IN]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.ZOOM_OUT]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.ZOOM_RESET]();
|
||||||
|
await (keybinding as any).commands[KeyBindingCommand.ZOOM_FIT]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.MOVE_UP_1]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.MOVE_DOWN_10]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.MOVE_LEFT_1]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.MOVE_RIGHT_10]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.SWITCH_NODE]();
|
||||||
|
expect(uiService.zoom).toHaveBeenCalledWith(0.1);
|
||||||
|
expect(uiService.zoom).toHaveBeenCalledWith(-0.1);
|
||||||
|
expect(uiService.set).toHaveBeenCalledWith('zoom', 1);
|
||||||
|
expect(uiService.set).toHaveBeenCalledWith('zoom', 1.2);
|
||||||
|
expect(editorService.move).toHaveBeenCalledWith(0, -1);
|
||||||
|
expect(editorService.move).toHaveBeenCalledWith(0, 10);
|
||||||
|
expect(editorService.selectNextNode).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nodes 为空时 COPY/PASTE 不执行', () => {
|
||||||
|
editorService.get.mockReturnValue(null);
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.COPY_NODE]();
|
||||||
|
(keybinding as any).commands[KeyBindingCommand.PASTE_NODE]();
|
||||||
|
expect(editorService.copy).not.toHaveBeenCalled();
|
||||||
|
expect(editorService.paste).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { describe, expect, test, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
import type { MApp, MContainer, MNode } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import { getNodePath } from '@tmagic/utils';
|
||||||
|
|
||||||
import type { EditorNodeInfo } from '@editor/type';
|
import type { EditorNodeInfo } from '@editor/type';
|
||||||
import { LayerOffset, Layout } from '@editor/type';
|
import { LayerOffset, Layout } from '@editor/type';
|
||||||
@ -342,20 +343,31 @@ const mockRoot: MApp = {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 构建从 root 到目标节点的路径(含 root 与目标节点),与 getNodeInfo 返回的 path 一致 */
|
||||||
|
const buildNodePath = (root: MApp, nodeId: string | number): MNode[] => {
|
||||||
|
const path = getNodePath(nodeId, root.items) as MNode[];
|
||||||
|
if (path.length) {
|
||||||
|
path.unshift(root as unknown as MNode);
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockNodePath = buildNodePath(mockRoot, 'node_1');
|
||||||
|
|
||||||
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
|
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
|
||||||
const page = mockRoot.items[0];
|
const page = mockRoot.items[0];
|
||||||
if (`${id}` === `${mockRoot.id}`) {
|
if (`${id}` === `${mockRoot.id}`) {
|
||||||
return { node: mockRoot as unknown as MNode, parent: null, page: null };
|
return { node: mockRoot as unknown as MNode, parent: null, page: null, path: [] };
|
||||||
}
|
}
|
||||||
if (`${id}` === `${page.id}`) {
|
if (`${id}` === `${page.id}`) {
|
||||||
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
|
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any, path: [] };
|
||||||
}
|
}
|
||||||
const items = (page as MContainer).items || [];
|
const items = (page as MContainer).items || [];
|
||||||
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
|
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
|
||||||
if (node) {
|
if (node) {
|
||||||
return { node, parent: page as MContainer, page: page as any };
|
return { node, parent: page as MContainer, page: page as any, path: buildNodePath(mockRoot, id) };
|
||||||
}
|
}
|
||||||
return { node: null, parent: null, page: null };
|
return { node: null, parent: null, page: null, path: [] };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('resolveSelectedNode', () => {
|
describe('resolveSelectedNode', () => {
|
||||||
@ -402,7 +414,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
|
||||||
expect(result.style?.position).toBe('fixed');
|
expect(result.style?.position).toBe('fixed');
|
||||||
expect(result).not.toBe(dist);
|
expect(result).not.toBe(dist);
|
||||||
});
|
});
|
||||||
@ -411,7 +423,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
|
||||||
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, [...mockNodePath], getLayoutFn);
|
||||||
expect(result.style?.position).toBe('absolute');
|
expect(result.style?.position).toBe('absolute');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -419,7 +431,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
|
||||||
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
|
||||||
expect(result.style?.top).toBe(30);
|
expect(result.style?.top).toBe(30);
|
||||||
expect(result.style?.left).toBe(40);
|
expect(result.style?.left).toBe(40);
|
||||||
});
|
});
|
||||||
@ -433,7 +445,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
};
|
};
|
||||||
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
|
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
|
||||||
expect(result.style?.position).toBe('fixed');
|
expect(result.style?.position).toBe('fixed');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -441,7 +453,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
|
||||||
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
|
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
|
||||||
expect(result.style?.position).toBeUndefined();
|
expect(result.style?.position).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -449,7 +461,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
|
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
|
||||||
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
|
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
|
||||||
|
|
||||||
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
|
const result = await editor.toggleFixedPosition(dist, src, mockNodePath, getLayoutFn);
|
||||||
expect(result).not.toBe(dist);
|
expect(result).not.toBe(dist);
|
||||||
expect(dist.style?.top).toBe(20);
|
expect(dist.style?.top).toBe(20);
|
||||||
});
|
});
|
||||||
@ -680,18 +692,19 @@ describe('classifyDragSources', () => {
|
|||||||
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
|
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
|
||||||
parent: container1,
|
parent: container1,
|
||||||
page,
|
page,
|
||||||
|
path: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (`${id}` === 'c3') {
|
if (`${id}` === 'c3') {
|
||||||
return { node: child3, parent: container2, page };
|
return { node: child3, parent: container2, page, path: [] };
|
||||||
}
|
}
|
||||||
if (`${id}` === 'cont1') {
|
if (`${id}` === 'cont1') {
|
||||||
return { node: container1, parent: page, page };
|
return { node: container1, parent: page, page, path: [] };
|
||||||
}
|
}
|
||||||
if (`${id}` === 'cont2') {
|
if (`${id}` === 'cont2') {
|
||||||
return { node: container2, parent: page, page };
|
return { node: container2, parent: page, page, path: [] };
|
||||||
}
|
}
|
||||||
return { node: null, parent: null, page: null };
|
return { node: null, parent: null, page: null, path: [] };
|
||||||
};
|
};
|
||||||
|
|
||||||
return { root, getNodeInfo };
|
return { root, getNodeInfo };
|
||||||
@ -750,9 +763,10 @@ describe('classifyDragSources', () => {
|
|||||||
node: { id: 'c1', type: 'text' },
|
node: { id: 'c1', type: 'text' },
|
||||||
parent: targetParent,
|
parent: targetParent,
|
||||||
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
|
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
|
||||||
|
path: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return { node: null, parent: null, page: null };
|
return { node: null, parent: null, page: null, path: [] };
|
||||||
});
|
});
|
||||||
expect(result.sameParentIndices).toEqual([0]);
|
expect(result.sameParentIndices).toEqual([0]);
|
||||||
expect(result.crossParentConfigs).toHaveLength(0);
|
expect(result.crossParentConfigs).toHaveLength(0);
|
||||||
@ -860,20 +874,20 @@ describe('change2Fixed / Fixed2Other', () => {
|
|||||||
|
|
||||||
test('change2Fixed 累加路径上的 left/top', () => {
|
test('change2Fixed 累加路径上的 left/top', () => {
|
||||||
const node = root.items[0]!.items![0] as MNode;
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
const style = editor.change2Fixed(node, root);
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
expect(style.left).toBeGreaterThan(0);
|
expect(style.left).toBeGreaterThan(0);
|
||||||
expect(style.top).toBeGreaterThan(0);
|
expect(style.top).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Fixed2Other 转回 absolute', async () => {
|
test('Fixed2Other 转回 absolute', async () => {
|
||||||
const node = root.items[0]!.items![0] as MNode;
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
const style = await editor.Fixed2Other(node, root, async () => Layout.ABSOLUTE);
|
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.ABSOLUTE);
|
||||||
expect(style.position).toBe('absolute');
|
expect(style.position).toBe('absolute');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Fixed2Other 转回 relative 时 right/top 重置', async () => {
|
test('Fixed2Other 转回 relative 时 right/top 重置', async () => {
|
||||||
const node = root.items[0]!.items![0] as MNode;
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
const style = await editor.Fixed2Other(node, root, async () => Layout.RELATIVE);
|
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.RELATIVE);
|
||||||
expect(style.position).toBe('relative');
|
expect(style.position).toBe('relative');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -941,6 +955,54 @@ describe('补充:getInitPositionStyle / setLayout / setChildrenLayout', () =>
|
|||||||
expect(style.left).toBe(0);
|
expect(style.left).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - left/right 同时存在,left 有效 right 无效时删掉 right', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: 20, right: '' }, Layout.ABSOLUTE);
|
||||||
|
expect(style.left).toBe(20);
|
||||||
|
expect('right' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - left/right 同时存在,right 有效 left 无效时删掉 left', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: 0, right: 30 }, Layout.ABSOLUTE);
|
||||||
|
expect(style.right).toBe(30);
|
||||||
|
expect('left' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - left/right 都无效时优先保留值为 0 的(right=0)', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: '', right: 0 }, Layout.ABSOLUTE);
|
||||||
|
expect(style.right).toBe(0);
|
||||||
|
expect('left' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - left/right 都无效且都非 0 时默认保留 left', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: undefined, right: null }, Layout.ABSOLUTE);
|
||||||
|
expect('left' in style).toBe(true);
|
||||||
|
expect('right' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - left/right 都有效时都保留', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: 10, right: 20 }, Layout.ABSOLUTE);
|
||||||
|
expect(style.left).toBe(10);
|
||||||
|
expect(style.right).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - top/bottom 同时存在,bottom 有效 top 无效时删掉 top', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ top: 0, bottom: 50 }, Layout.ABSOLUTE);
|
||||||
|
expect(style.bottom).toBe(50);
|
||||||
|
expect('top' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - top/bottom 都无效时优先保留值为 0 的(top=0)', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ top: 0, bottom: undefined }, Layout.ABSOLUTE);
|
||||||
|
expect(style.top).toBe(0);
|
||||||
|
expect('bottom' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Layout.ABSOLUTE - 仅存在 left 时不处理冲突', () => {
|
||||||
|
const style = editor.getInitPositionStyle({ left: '' }, Layout.ABSOLUTE);
|
||||||
|
expect(style.left).toBe('');
|
||||||
|
expect('right' in style).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
test('Layout.RELATIVE - 走 getRelativeStyle', () => {
|
test('Layout.RELATIVE - 走 getRelativeStyle', () => {
|
||||||
const style = editor.getInitPositionStyle({ color: 'red' }, Layout.RELATIVE);
|
const style = editor.getInitPositionStyle({ color: 'red' }, Layout.RELATIVE);
|
||||||
expect(style.position).toBe('relative');
|
expect(style.position).toBe('relative');
|
||||||
@ -1043,6 +1105,40 @@ describe('补充:fixNodeLeft / fixNodePosition / serializeConfig', () => {
|
|||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('fixNodePosition - 仅设置 right 时不修正 left', () => {
|
||||||
|
const style = { position: 'absolute', right: 10 } as any;
|
||||||
|
const result = editor.fixNodePosition({ id: 'a', style } as any, { id: 'p', items: [] } as any, null);
|
||||||
|
expect(result).toEqual(style);
|
||||||
|
expect(result?.left).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixNodePosition - 仅设置 bottom 时不修正 top', () => {
|
||||||
|
const style = { position: 'absolute', bottom: 20 } as any;
|
||||||
|
const result = editor.fixNodePosition({ id: 'a', style } as any, { id: 'p', items: [] } as any, null);
|
||||||
|
expect(result).toEqual(style);
|
||||||
|
expect(result?.top).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fixNodePosition - 设置 left 且无 right 时修正 left', () => {
|
||||||
|
const doc = document.implementation.createHTMLDocument();
|
||||||
|
const parent = doc.createElement('div');
|
||||||
|
parent.dataset.tmagicId = 'p3';
|
||||||
|
Object.defineProperty(parent, 'offsetWidth', { value: 100 });
|
||||||
|
const child = doc.createElement('div');
|
||||||
|
child.dataset.tmagicId = 'a3';
|
||||||
|
Object.defineProperty(child, 'offsetWidth', { value: 80 });
|
||||||
|
parent.appendChild(child);
|
||||||
|
doc.body.appendChild(parent);
|
||||||
|
|
||||||
|
const stage = { renderer: { contentWindow: { document: doc } } } as any;
|
||||||
|
const result = editor.fixNodePosition(
|
||||||
|
{ id: 'a3', style: { position: 'absolute', left: 50 } } as any,
|
||||||
|
{ id: 'p3', items: [] } as any,
|
||||||
|
stage,
|
||||||
|
);
|
||||||
|
expect(result?.left).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
test('serializeConfig - 输出去掉了 key 引号', () => {
|
test('serializeConfig - 输出去掉了 key 引号', () => {
|
||||||
const out = editor.serializeConfig({ a: 1 });
|
const out = editor.serializeConfig({ a: 1 });
|
||||||
expect(out).toContain('a:');
|
expect(out).toContain('a:');
|
||||||
@ -1210,7 +1306,7 @@ describe('补充:change2Fixed 边界', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const node = root.items[0]!.items![0] as MNode;
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
const style = editor.change2Fixed(node, root);
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
expect(style.left).toBe(5);
|
expect(style.left).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -1228,9 +1324,86 @@ describe('补充:change2Fixed 边界', () => {
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
const node = root.items[0]!.items![0] as MNode;
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
const style = editor.change2Fixed(node, root);
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
expect(style.left).toBeUndefined();
|
expect(style.left).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('节点以 right 锚定时累加路径上的 right', () => {
|
||||||
|
const root: MApp = {
|
||||||
|
id: 'app',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
style: { right: 10 },
|
||||||
|
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 5 } }],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
|
expect(style.right).toBe(15);
|
||||||
|
expect(style.left).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点以 bottom 锚定时累加路径上的 bottom', () => {
|
||||||
|
const root: MApp = {
|
||||||
|
id: 'app',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
style: { bottom: 20 },
|
||||||
|
items: [{ id: 'b', type: 'text', style: { position: 'absolute', bottom: 5 } }],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
|
expect(style.bottom).toBe(25);
|
||||||
|
expect(style.top).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('right: 0 不被误判为未设置 right,仍走 right 分支', () => {
|
||||||
|
const root: MApp = {
|
||||||
|
id: 'app',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
style: { left: 10, top: 20 },
|
||||||
|
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 0 } }],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
|
const style = editor.change2Fixed(node, buildNodePath(root, node.id));
|
||||||
|
// 父节点为 left 锚定,无法换算 right,放弃补偿,保持 right: 0 且不写入 left
|
||||||
|
expect(style.left).toBeUndefined();
|
||||||
|
expect(style.right).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fixed2Other 以 right 锚定时抵消路径上的 right', async () => {
|
||||||
|
const root: MApp = {
|
||||||
|
id: 'app',
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
style: { right: 10 },
|
||||||
|
items: [{ id: 'b', type: 'text', style: { position: 'absolute', right: 15 } }],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const node = root.items[0]!.items![0] as MNode;
|
||||||
|
const style = await editor.Fixed2Other(node, buildNodePath(root, node.id), async () => Layout.ABSOLUTE);
|
||||||
|
expect(style.position).toBe('absolute');
|
||||||
|
expect(style.right).toBe(5);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('补充:classifyDragSources 同父容器索引异常', () => {
|
describe('补充:classifyDragSources 同父容器索引异常', () => {
|
||||||
@ -1255,7 +1428,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const result = await editor.toggleFixedPosition(
|
const result = await editor.toggleFixedPosition(
|
||||||
{ id: 'a', type: 'text', style: { position: 'absolute', top: 0, left: 0 } } as any,
|
{ id: 'a', type: 'text', style: { position: 'absolute', top: 0, left: 0 } } as any,
|
||||||
{ id: 'a', type: 'text', style: { position: 'fixed' } } as any,
|
{ id: 'a', type: 'text', style: { position: 'fixed' } } as any,
|
||||||
root,
|
[...buildNodePath(root, 'a')],
|
||||||
async () => Layout.ABSOLUTE,
|
async () => Layout.ABSOLUTE,
|
||||||
);
|
);
|
||||||
expect(result.style?.position).toBeDefined();
|
expect(result.style?.position).toBeDefined();
|
||||||
@ -1277,7 +1450,7 @@ describe('toggleFixedPosition', () => {
|
|||||||
const result = await editor.toggleFixedPosition(
|
const result = await editor.toggleFixedPosition(
|
||||||
{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } } as any,
|
{ id: 'a', type: 'text', style: { position: 'fixed', left: 0, top: 0 } } as any,
|
||||||
{ id: 'a', type: 'text', style: { position: 'absolute' } } as any,
|
{ id: 'a', type: 'text', style: { position: 'absolute' } } as any,
|
||||||
root,
|
buildNodePath(root, 'a'),
|
||||||
async () => Layout.ABSOLUTE,
|
async () => Layout.ABSOLUTE,
|
||||||
);
|
);
|
||||||
expect(result.style?.position).toBe('fixed');
|
expect(result.style?.position).toBe('fixed');
|
||||||
|
|||||||
@ -10,13 +10,12 @@ import {
|
|||||||
createStackStep,
|
createStackStep,
|
||||||
describeRevertStep,
|
describeRevertStep,
|
||||||
deserializeStacks,
|
deserializeStacks,
|
||||||
detectPageTargetId,
|
|
||||||
detectPageTargetName,
|
|
||||||
detectStackOpType,
|
detectStackOpType,
|
||||||
|
detectTargetId,
|
||||||
|
detectTargetName,
|
||||||
getOrCreateStack,
|
getOrCreateStack,
|
||||||
markStackSaved,
|
markStackSaved,
|
||||||
mergePageSteps,
|
mergeSteps,
|
||||||
mergeStackSteps,
|
|
||||||
serializeStacks,
|
serializeStacks,
|
||||||
undoFloor,
|
undoFloor,
|
||||||
} from '@editor/utils/history';
|
} from '@editor/utils/history';
|
||||||
@ -121,24 +120,46 @@ describe('markStackSaved', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergeStackSteps', () => {
|
describe('mergeSteps(代码块 / 数据源等按 id 分栈类型)', () => {
|
||||||
test('连续 update 合并为一组', () => {
|
test('无 diff 的连续 update 不合并(无明确目标)', () => {
|
||||||
const list = [
|
const list = [
|
||||||
{ opType: 'update', uuid: '1' },
|
{ opType: 'update', uuid: '1' },
|
||||||
{ opType: 'update', uuid: '2' },
|
{ opType: 'update', uuid: '2' },
|
||||||
] as CodeBlockStepValue[];
|
] as CodeBlockStepValue[];
|
||||||
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
|
const groups = mergeSteps('code-block', 'code_1', list, 2);
|
||||||
expect(groups).toHaveLength(1);
|
expect(groups).toHaveLength(2);
|
||||||
expect(groups[0].steps).toHaveLength(2);
|
expect(groups[0].steps).toHaveLength(1);
|
||||||
|
expect(groups[1].steps).toHaveLength(1);
|
||||||
expect(groups[0].opType).toBe('update');
|
expect(groups[0].opType).toBe('update');
|
||||||
|
expect(groups[1].opType).toBe('update');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add / update 不合并', () => {
|
test('同目标连续 update 合并成一组(CodeBlockContent 无 id,回退 step.data.id)', () => {
|
||||||
|
const mkUpdate = (path: string) =>
|
||||||
|
({
|
||||||
|
opType: 'update',
|
||||||
|
data: { name: 'A', id: 'code_1' },
|
||||||
|
diff: [
|
||||||
|
{
|
||||||
|
newSchema: { name: 'A', content: 'x' },
|
||||||
|
oldSchema: { name: 'A', content: 'x' },
|
||||||
|
changeRecords: [{ propPath: path }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as unknown as CodeBlockStepValue;
|
||||||
|
const groups = mergeSteps('code-block', 'code_1', [mkUpdate('content'), mkUpdate('params')], 2);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0].steps).toHaveLength(2);
|
||||||
|
expect(groups[0].id).toBe('code_1');
|
||||||
|
expect(groups[0].kind).toBe('code-block');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('add / update 各自独立成组', () => {
|
||||||
const list = [
|
const list = [
|
||||||
{ opType: 'add', uuid: '1' },
|
{ opType: 'add', uuid: '1' },
|
||||||
{ opType: 'update', uuid: '2' },
|
{ opType: 'update', uuid: '2' },
|
||||||
] as CodeBlockStepValue[];
|
] as CodeBlockStepValue[];
|
||||||
const groups = mergeStackSteps('code-block', 'code_1', list, 2);
|
const groups = mergeSteps('code-block', 'code_1', list, 2);
|
||||||
expect(groups).toHaveLength(2);
|
expect(groups).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -147,22 +168,24 @@ describe('mergeStackSteps', () => {
|
|||||||
{ opType: 'update', uuid: '1' },
|
{ opType: 'update', uuid: '1' },
|
||||||
{ opType: 'update', uuid: '2' },
|
{ opType: 'update', uuid: '2' },
|
||||||
] as CodeBlockStepValue[];
|
] as CodeBlockStepValue[];
|
||||||
const groups = mergeStackSteps('code-block', 'code_1', list, 1);
|
const groups = mergeSteps('code-block', 'code_1', list, 1);
|
||||||
expect(groups[0].applied).toBe(false);
|
expect(groups[0].applied).toBe(true);
|
||||||
expect(groups[0].steps[0].applied).toBe(true);
|
expect(groups[0].steps[0].applied).toBe(true);
|
||||||
expect(groups[0].steps[0].isCurrent).toBe(true);
|
expect(groups[0].steps[0].isCurrent).toBe(true);
|
||||||
expect(groups[0].steps[1].applied).toBe(false);
|
expect(groups[0].isCurrent).toBe(true);
|
||||||
|
expect(groups[1].applied).toBe(false);
|
||||||
|
expect(groups[1].steps[0].applied).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('detectPageTargetId / detectPageTargetName', () => {
|
describe('detectTargetId / detectTargetName', () => {
|
||||||
test('单节点 update 返回 targetId 与名称', () => {
|
test('单节点 update 返回 targetId 与名称', () => {
|
||||||
const step = {
|
const step = {
|
||||||
opType: 'update',
|
opType: 'update',
|
||||||
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
diff: [{ newSchema: { id: 'btn_1', name: '按钮' }, oldSchema: { id: 'btn_1' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(detectPageTargetId(step)).toBe('btn_1');
|
expect(detectTargetId(step)).toBe('btn_1');
|
||||||
expect(detectPageTargetName(step)).toBe('按钮');
|
expect(detectTargetName(step)).toBe('按钮');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('多节点 update 不参与合并', () => {
|
test('多节点 update 不参与合并', () => {
|
||||||
@ -173,8 +196,8 @@ describe('detectPageTargetId / detectPageTargetName', () => {
|
|||||||
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
|
{ newSchema: { id: 'b' }, oldSchema: { id: 'b' } },
|
||||||
],
|
],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(detectPageTargetId(step)).toBeUndefined();
|
expect(detectTargetId(step)).toBeUndefined();
|
||||||
expect(detectPageTargetName(step)).toBe('2 个节点');
|
expect(detectTargetName(step)).toBe('2 个节点');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add 单节点返回名称', () => {
|
test('add 单节点返回名称', () => {
|
||||||
@ -182,12 +205,12 @@ describe('detectPageTargetId / detectPageTargetName', () => {
|
|||||||
opType: 'add',
|
opType: 'add',
|
||||||
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
|
diff: [{ newSchema: { id: 'n1', type: 'text' } }],
|
||||||
} as unknown as StepValue;
|
} as unknown as StepValue;
|
||||||
expect(detectPageTargetId(step)).toBeUndefined();
|
expect(detectTargetId(step)).toBeUndefined();
|
||||||
expect(detectPageTargetName(step)).toBe('text');
|
expect(detectTargetName(step)).toBe('text');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('mergePageSteps', () => {
|
describe('mergeSteps(页面)', () => {
|
||||||
test('相邻同 targetId 的 update 合并', () => {
|
test('相邻同 targetId 的 update 合并', () => {
|
||||||
const mkUpdate = (path: string) =>
|
const mkUpdate = (path: string) =>
|
||||||
({
|
({
|
||||||
@ -202,7 +225,7 @@ describe('mergePageSteps', () => {
|
|||||||
}) as unknown as StepValue;
|
}) as unknown as StepValue;
|
||||||
|
|
||||||
const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')];
|
const list = [mkUpdate('style.color'), mkUpdate('style.fontSize')];
|
||||||
const groups = mergePageSteps('p1', list, 2);
|
const groups = mergeSteps('page', 'p1', list, 2);
|
||||||
expect(groups).toHaveLength(1);
|
expect(groups).toHaveLength(1);
|
||||||
expect(groups[0].targetId).toBe('btn_1');
|
expect(groups[0].targetId).toBe('btn_1');
|
||||||
expect(groups[0].steps).toHaveLength(2);
|
expect(groups[0].steps).toHaveLength(2);
|
||||||
@ -219,7 +242,7 @@ describe('mergePageSteps', () => {
|
|||||||
diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }],
|
diff: [{ newSchema: { id: 'n1', name: 'A' }, oldSchema: { id: 'n1', name: 'A' } }],
|
||||||
},
|
},
|
||||||
] as unknown as StepValue[];
|
] as unknown as StepValue[];
|
||||||
const groups = mergePageSteps('p1', list, 2);
|
const groups = mergeSteps('page', 'p1', list, 2);
|
||||||
expect(groups).toHaveLength(2);
|
expect(groups).toHaveLength(2);
|
||||||
expect(groups[0].opType).toBe('add');
|
expect(groups[0].opType).toBe('add');
|
||||||
expect(groups[1].opType).toBe('update');
|
expect(groups[1].opType).toBe('update');
|
||||||
@ -236,7 +259,7 @@ describe('mergePageSteps', () => {
|
|||||||
diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }],
|
diff: [{ newSchema: { id: 'n1', name: '新名' }, oldSchema: { id: 'n1', name: '旧名' } }],
|
||||||
},
|
},
|
||||||
] as unknown as StepValue[];
|
] as unknown as StepValue[];
|
||||||
const groups = mergePageSteps('p1', list, 2);
|
const groups = mergeSteps('page', 'p1', list, 2);
|
||||||
expect(groups[0].targetName).toBe('新名');
|
expect(groups[0].targetName).toBe('新名');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
150
packages/editor/tests/unit/utils/indexed-db.spec.ts
Normal file
150
packages/editor/tests/unit/utils/indexed-db.spec.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { idbDelete, idbGet, idbSet, isIndexedDBSupported, openIndexedDB } from '@editor/utils/indexed-db';
|
||||||
|
|
||||||
|
type StoreRecord = Map<IDBValidKey, unknown>;
|
||||||
|
|
||||||
|
interface FakeDb {
|
||||||
|
version: number;
|
||||||
|
stores: Map<string, StoreRecord>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeDbs = new Map<string, FakeDb>();
|
||||||
|
|
||||||
|
const createRequest = <T>(result: T, error: DOMException | null = null) => {
|
||||||
|
const request = {
|
||||||
|
result,
|
||||||
|
error,
|
||||||
|
onsuccess: null as null | (() => void),
|
||||||
|
onerror: null as null | (() => void),
|
||||||
|
onupgradeneeded: null as null | (() => void),
|
||||||
|
};
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (error) {
|
||||||
|
request.onerror?.();
|
||||||
|
} else {
|
||||||
|
request.onsuccess?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
|
||||||
|
const installFakeIndexedDB = () => {
|
||||||
|
fakeDbs.clear();
|
||||||
|
const indexedDB = {
|
||||||
|
open: vi.fn((name: string, version?: number) => {
|
||||||
|
const existing = fakeDbs.get(name);
|
||||||
|
const nextVersion = version ?? existing?.version ?? 1;
|
||||||
|
const db: FakeDb = existing ?? { version: nextVersion, stores: new Map() };
|
||||||
|
if (version !== undefined && version > (existing?.version ?? 0)) {
|
||||||
|
db.version = version;
|
||||||
|
}
|
||||||
|
fakeDbs.set(name, db);
|
||||||
|
|
||||||
|
const idbDatabase = {
|
||||||
|
name,
|
||||||
|
version: db.version,
|
||||||
|
objectStoreNames: {
|
||||||
|
contains: (storeName: string) => db.stores.has(storeName),
|
||||||
|
},
|
||||||
|
createObjectStore: (storeName: string) => {
|
||||||
|
if (!db.stores.has(storeName)) {
|
||||||
|
db.stores.set(storeName, new Map());
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
close: vi.fn(),
|
||||||
|
transaction: (storeName: string, mode: IDBTransactionMode) => {
|
||||||
|
const store = db.stores.get(storeName) ?? new Map<IDBValidKey, unknown>();
|
||||||
|
if (!db.stores.has(storeName)) {
|
||||||
|
db.stores.set(storeName, store);
|
||||||
|
}
|
||||||
|
const tx = {
|
||||||
|
error: null as DOMException | null,
|
||||||
|
objectStore: () => ({
|
||||||
|
put: (value: unknown, key: IDBValidKey) => {
|
||||||
|
if (mode === 'readonly') {
|
||||||
|
return createRequest(undefined, new DOMException('readonly'));
|
||||||
|
}
|
||||||
|
store.set(key, value);
|
||||||
|
return createRequest(undefined);
|
||||||
|
},
|
||||||
|
get: (key: IDBValidKey) => createRequest(store.get(key)),
|
||||||
|
delete: (key: IDBValidKey) => {
|
||||||
|
if (mode === 'readonly') {
|
||||||
|
return createRequest(undefined, new DOMException('readonly'));
|
||||||
|
}
|
||||||
|
store.delete(key);
|
||||||
|
return createRequest(undefined);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
oncomplete: null as null | (() => void),
|
||||||
|
onabort: null as null | (() => void),
|
||||||
|
onerror: null as null | (() => void),
|
||||||
|
};
|
||||||
|
queueMicrotask(() => tx.oncomplete?.());
|
||||||
|
return tx;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = createRequest(idbDatabase);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
if (!db.stores.has('__placeholder__')) {
|
||||||
|
// no-op: upgrade hook exists for API compatibility
|
||||||
|
}
|
||||||
|
};
|
||||||
|
queueMicrotask(() => request.onupgradeneeded?.());
|
||||||
|
return request;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal('indexedDB', indexedDB);
|
||||||
|
return indexedDB;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('indexed-db utils', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
installFakeIndexedDB();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isIndexedDBSupported 在存在 indexedDB 时返回 true', () => {
|
||||||
|
expect(isIndexedDBSupported()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isIndexedDBSupported 在无 indexedDB 环境返回 false', () => {
|
||||||
|
vi.stubGlobal('indexedDB', undefined);
|
||||||
|
expect(isIndexedDBSupported()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openIndexedDB 在不支持 IndexedDB 时 reject', async () => {
|
||||||
|
vi.stubGlobal('indexedDB', undefined);
|
||||||
|
await expect(openIndexedDB('db', 'store')).rejects.toThrow('当前环境不支持 IndexedDB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('openIndexedDB 在 store 不存在时会升级创建', async () => {
|
||||||
|
const db = await openIndexedDB('tmagic-test', 'history');
|
||||||
|
expect(db.objectStoreNames.contains('history')).toBe(true);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idbSet / idbGet / idbDelete 可读写删除记录', async () => {
|
||||||
|
await idbSet('tmagic-kv', 'items', 'k1', { hello: 'world' });
|
||||||
|
await expect(idbGet('tmagic-kv', 'items', 'k1')).resolves.toEqual({ hello: 'world' });
|
||||||
|
|
||||||
|
await idbDelete('tmagic-kv', 'items', 'k1');
|
||||||
|
await expect(idbGet('tmagic-kv', 'items', 'k1')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('idbGet 读取不存在的 key 返回 undefined', async () => {
|
||||||
|
await expect(idbGet('tmagic-kv', 'items', 'missing')).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.0-beta.5",
|
"version": "1.8.0-beta.11",
|
||||||
"name": "@tmagic/element-plus-adapter",
|
"name": "@tmagic/element-plus-adapter",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user