mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-11-15 07:10:31 +00:00
Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1efd272516 | ||
|
|
0e8b676fe7 | ||
|
|
72664955bf | ||
|
|
c45b6706b6 | ||
|
|
86a3b4f0e7 | ||
|
|
86fe545e8f | ||
|
|
39ca916b88 | ||
|
|
8e40d8d797 | ||
|
|
5cc5ab872f | ||
|
|
abcd875731 | ||
|
|
b4747c3e89 | ||
|
|
c2272b12d8 | ||
|
|
8e4391972b | ||
|
|
657758dec2 | ||
|
|
5e7f7ad7f1 | ||
|
|
c50c245fbb | ||
|
|
9c740fb2be | ||
|
|
225850bce0 | ||
|
|
dfc4f55390 | ||
|
|
0c1d103cd0 | ||
|
|
35ed2cc0d0 | ||
|
|
6f2ffc3ca1 | ||
|
|
dfad691493 | ||
|
|
522a0068f5 | ||
|
|
2f8a504163 | ||
|
|
ccfc8c0733 | ||
|
|
c4cf3b9525 | ||
|
|
d01bc45b17 | ||
|
|
7e8cb1f9db | ||
|
|
d017f43573 | ||
|
|
cbd0442ffc | ||
|
|
8ac1a38601 | ||
|
|
7f0e78bf6a | ||
|
|
c6deac8524 | ||
|
|
f12f78c9e0 | ||
|
|
94fae10ae0 | ||
|
|
d9b096c91c | ||
|
|
a100462a52 |
6
.github/workflows/pages.yml
vendored
6
.github/workflows/pages.yml
vendored
@ -18,12 +18,12 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 11.21.0
|
version: 12.16.1
|
||||||
|
|
||||||
- name: Set node version to 22
|
- name: Set node version to 26
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 26
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
- run: pnpm bootstrap
|
- run: pnpm bootstrap
|
||||||
|
|||||||
@ -10,7 +10,7 @@ TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组
|
|||||||
编辑器本体使用 Vue 3;UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
|
编辑器本体使用 Vue 3;UI 通过 `@tmagic/design` + adapter 接入 Element Plus 或 TDesign Vue Next。
|
||||||
|
|
||||||
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
|
**技术栈:** Vue 3, TypeScript, Vite, rolldown, vitest, VitePress, Element Plus / TDesign Vue Next
|
||||||
**环境:** Node.js `^20.19.0 || >=22.12.0`,pnpm `11.21.0`
|
**环境:** Node.js `^20.19.0 || >=22.12.0`,pnpm `12.16.1`
|
||||||
**当前版本:** `1.8.0-beta.25`
|
**当前版本:** `1.8.0-beta.25`
|
||||||
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
|
||||||
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
|
||||||
@ -43,6 +43,13 @@ pre-commit 跑 lint-staged 与 `pnpm check:type`,pre-push 跑 `pnpm coverage`
|
|||||||
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
- 禁止直接修改 CHANGELOG.md,应通过 `pnpm changelog` 生成
|
||||||
- 禁止提交未达 85% 测试覆盖率的新增/修改代码
|
- 禁止提交未达 85% 测试覆盖率的新增/修改代码
|
||||||
|
|
||||||
|
## 编码原则
|
||||||
|
|
||||||
|
1. **防过度防御**:几乎不会发生的边界/并发问题,讲清概率并经用户确认后再加,严禁为“理论上更稳”私自膨胀代码。
|
||||||
|
2. **入口单次校验**:外部数据只在边界校验一次,内部靠类型系统流转,禁止层层重复判断。
|
||||||
|
3. **拒绝提前抽象**:单次使用的逻辑就近内联,不随意提前拆分无意义的 helper。
|
||||||
|
4. **控制复杂度**:非必要不用 try/catch,能单函数清晰搞定的别硬拆。
|
||||||
|
|
||||||
## 常用命令
|
## 常用命令
|
||||||
|
|
||||||
pnpm bootstrap # 安装依赖并构建
|
pnpm bootstrap # 安装依赖并构建
|
||||||
|
|||||||
83
CHANGELOG.md
83
CHANGELOG.md
@ -1,3 +1,86 @@
|
|||||||
|
## [1.8.5](https://github.com/Tencent/tmagic-editor/compare/v1.8.4...v1.8.5) (2026-11-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 补全样式面板 CSS 枚举选项并补齐图标 ([0e8b676](https://github.com/Tencent/tmagic-editor/commit/0e8b676fe7e4aacbce8036db70cf8f4ddb96e22e))
|
||||||
|
## [1.8.4](https://github.com/Tencent/tmagic-editor/compare/v1.8.3...v1.8.4) (2026-11-05)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** 节点销毁时移除其事件监听 ([522a006](https://github.com/Tencent/tmagic-editor/commit/522a0068f5807ec2212e6ca7e87b96891594b3fc))
|
||||||
|
* **data-source:** 依赖缓存按节点对象引用判断失效 ([6f2ffc3](https://github.com/Tencent/tmagic-editor/commit/6f2ffc3ca1a5702270f1d262d4776983d24d4ba0))
|
||||||
|
* **dep:** 修复共用节点 id 的多个根节点收集依赖时互相清除 ([c4cf3b9](https://github.com/Tencent/tmagic-editor/commit/c4cf3b9525af8a39888ff15c5afe06dc5fbfdda5))
|
||||||
|
* **design:** 修复 Popconfirm 浮层被裁剪及 Element Plus 复合方位失效 ([8e43919](https://github.com/Tencent/tmagic-editor/commit/8e4391972b9017ae04e1a78808d0c23890fec07b))
|
||||||
|
* **editor:** 事件动作与高级配置表单标签改用固定宽度 ([b4747c3](https://github.com/Tencent/tmagic-editor/commit/b4747c3e89ca56bb15f3557bbdbe5c53e8f2fae5))
|
||||||
|
* **editor:** 优化导航更多菜单宽度与溢出分割线 ([8ac1a38](https://github.com/Tencent/tmagic-editor/commit/8ac1a38601b49d1ccf47560e79bfee216b23cb42))
|
||||||
|
* **editor:** 修复组件名超长不省略的问题 ([8e40d8d](https://github.com/Tencent/tmagic-editor/commit/8e40d8d79768c9179c1621744be37cb2ebb1e193))
|
||||||
|
* **editor:** 数据源与代码块删除确认改用 Popconfirm ([c2272b1](https://github.com/Tencent/tmagic-editor/commit/c2272b12d84880b7d86ca29075508e3a58865847))
|
||||||
|
* **form:** 修复切换同类型节点时表单字段配置串台 ([abcd875](https://github.com/Tencent/tmagic-editor/commit/abcd875731ef41aae0b11a5655c4495279990cbe))
|
||||||
|
* **runtime:** 修复 useDsl 换页后更新失效与重复替换 ([0c1d103](https://github.com/Tencent/tmagic-editor/commit/0c1d103cd054caa49de70af6b97488c8474f2c27))
|
||||||
|
* **stage:** iframe 未就绪时跳过注入 magic,避免 contentWindow 为空报错 ([d017f43](https://github.com/Tencent/tmagic-editor/commit/d017f435731ce7742f896268963ac1009b190bd5))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **core:** 新增 setPageFragment/deletePageFragment 同步页面片实例 ([dfc4f55](https://github.com/Tencent/tmagic-editor/commit/dfc4f55390033b8ac7961c07c76b7ea2a8d96783))
|
||||||
|
* **data-source:** 支持批量更新数据源 ([dfad691](https://github.com/Tencent/tmagic-editor/commit/dfad6914931c369ef9b3d7e3477b454a151cc3a6))
|
||||||
|
* **editor:** 支持通过 insertIndex 指定 add 插入位置 ([5e7f7ad](https://github.com/Tencent/tmagic-editor/commit/5e7f7ad7f1f8fd520a6eec7e75c9ebf2517dffec))
|
||||||
|
* **editor:** 支持配置画布 iframe 的 sandbox 属性 ([7e8cb1f](https://github.com/Tencent/tmagic-editor/commit/7e8cb1f9db6b8241f02f4b1a5be073b46fcf3534))
|
||||||
|
* **editor:** 新增从组件面板点击/拖动添加组件前的处理函数配置 ([ccfc8c0](https://github.com/Tencent/tmagic-editor/commit/ccfc8c0733b511861fb2d27c23c45640d1da4ed3))
|
||||||
|
* **form:** 支持 table/group-list 行操作按钮文案与 props 配置 ([cbd0442](https://github.com/Tencent/tmagic-editor/commit/cbd0442ffca2747bed99b3683ab358f95e8574c4))
|
||||||
|
* **form:** 支持 table/group-list 行操作自定义按钮 ([d01bc45](https://github.com/Tencent/tmagic-editor/commit/d01bc45b1736d4ae443dda8739696d0b7791d618))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **data-source:** 迭代器容器数据上下文改为惰性深拷贝 ([35ed2cc](https://github.com/Tencent/tmagic-editor/commit/35ed2cc0d0d8a9b6cb555b6612e4ef4988552d09))
|
||||||
|
* **utils:** 优化节点查找与批量替换 ([2f8a504](https://github.com/Tencent/tmagic-editor/commit/2f8a504163909e179058a4321b236f6119991537))
|
||||||
|
## [1.8.4-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.3...v1.8.4-beta.1) (2026-11-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **core:** 节点销毁时移除其事件监听 ([522a006](https://github.com/Tencent/tmagic-editor/commit/522a0068f5807ec2212e6ca7e87b96891594b3fc))
|
||||||
|
* **data-source:** 依赖缓存按节点对象引用判断失效 ([6f2ffc3](https://github.com/Tencent/tmagic-editor/commit/6f2ffc3ca1a5702270f1d262d4776983d24d4ba0))
|
||||||
|
* **dep:** 修复共用节点 id 的多个根节点收集依赖时互相清除 ([c4cf3b9](https://github.com/Tencent/tmagic-editor/commit/c4cf3b9525af8a39888ff15c5afe06dc5fbfdda5))
|
||||||
|
* **editor:** 优化导航更多菜单宽度与溢出分割线 ([8ac1a38](https://github.com/Tencent/tmagic-editor/commit/8ac1a38601b49d1ccf47560e79bfee216b23cb42))
|
||||||
|
* **runtime:** 修复 useDsl 换页后更新失效与重复替换 ([0c1d103](https://github.com/Tencent/tmagic-editor/commit/0c1d103cd054caa49de70af6b97488c8474f2c27))
|
||||||
|
* **stage:** iframe 未就绪时跳过注入 magic,避免 contentWindow 为空报错 ([d017f43](https://github.com/Tencent/tmagic-editor/commit/d017f435731ce7742f896268963ac1009b190bd5))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **core:** 新增 setPageFragment/deletePageFragment 同步页面片实例 ([dfc4f55](https://github.com/Tencent/tmagic-editor/commit/dfc4f55390033b8ac7961c07c76b7ea2a8d96783))
|
||||||
|
* **data-source:** 支持批量更新数据源 ([dfad691](https://github.com/Tencent/tmagic-editor/commit/dfad6914931c369ef9b3d7e3477b454a151cc3a6))
|
||||||
|
* **editor:** 支持配置画布 iframe 的 sandbox 属性 ([7e8cb1f](https://github.com/Tencent/tmagic-editor/commit/7e8cb1f9db6b8241f02f4b1a5be073b46fcf3534))
|
||||||
|
* **editor:** 新增从组件面板点击/拖动添加组件前的处理函数配置 ([ccfc8c0](https://github.com/Tencent/tmagic-editor/commit/ccfc8c0733b511861fb2d27c23c45640d1da4ed3))
|
||||||
|
* **form:** 支持 table/group-list 行操作按钮文案与 props 配置 ([cbd0442](https://github.com/Tencent/tmagic-editor/commit/cbd0442ffca2747bed99b3683ab358f95e8574c4))
|
||||||
|
* **form:** 支持 table/group-list 行操作自定义按钮 ([d01bc45](https://github.com/Tencent/tmagic-editor/commit/d01bc45b1736d4ae443dda8739696d0b7791d618))
|
||||||
|
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
* **data-source:** 迭代器容器数据上下文改为惰性深拷贝 ([35ed2cc](https://github.com/Tencent/tmagic-editor/commit/35ed2cc0d0d8a9b6cb555b6612e4ef4988552d09))
|
||||||
|
* **utils:** 优化节点查找与批量替换 ([2f8a504](https://github.com/Tencent/tmagic-editor/commit/2f8a504163909e179058a4321b236f6119991537))
|
||||||
|
## [1.8.3](https://github.com/Tencent/tmagic-editor/compare/v1.8.2...v1.8.3) (2026-10-13)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **form:** 日期/时间字段支持 valueFormat 为 X 的秒级时间戳 ([f12f78c](https://github.com/Tencent/tmagic-editor/commit/f12f78c9e045d41cf03be3e867d15aec5b50d202))
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** 新增 SDK 层并将核心服务迁移至 sdk 目录 ([94fae10](https://github.com/Tencent/tmagic-editor/commit/94fae10ae00b822ca0e2219f9e9bbc2c51ea0a1c))
|
||||||
|
## [1.8.2](https://github.com/Tencent/tmagic-editor/compare/v1.8.1...v1.8.2) (2026-09-23)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **editor:** enablePropsFormValidate 改为响应式注入并接管 typeMatchValid / validateOnInit ([a100462](https://github.com/Tencent/tmagic-editor/commit/a100462a52dd57841f04e73f414f5041af838904))
|
||||||
## [1.8.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.0...v1.8.1) (2026-09-23)
|
## [1.8.1](https://github.com/Tencent/tmagic-editor/compare/v1.8.0...v1.8.1) (2026-09-23)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -228,6 +228,10 @@ export default defineConfig({
|
|||||||
text: 'storageService',
|
text: 'storageService',
|
||||||
link: '/api/editor/storageServiceMethods.md',
|
link: '/api/editor/storageServiceMethods.md',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
text: 'DSL SDK(Node)',
|
||||||
|
link: '/api/editor/sdk.md',
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -35,11 +35,13 @@ DSL 操作方法(`add` / `remove` / `update` 等)默认返回操作结果(
|
|||||||
- 数据源:[dataSourceService.revertById(uuids)](./dataSourceServiceMethods.md#revertbyid)
|
- 数据源:[dataSourceService.revertById(uuids)](./dataSourceServiceMethods.md#revertbyid)
|
||||||
- 代码块:[codeBlockService.revertById(uuids)](./codeBlockServiceMethods.md#revertbyid)
|
- 代码块:[codeBlockService.revertById(uuids)](./codeBlockServiceMethods.md#revertbyid)
|
||||||
|
|
||||||
::: details 查看 HistoryOpOptions / DslOpOptions / DslOpWithHistoryIdsResult / HistoryOpSource 类型定义
|
::: details 查看 HistoryOpOptions / DslOpOptions / AddOpOptions / 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#AddOpOptions{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts}
|
<<< @/../packages/editor/src/type.ts#DslOpWithHistoryIdsResult{ts}
|
||||||
|
|
||||||
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
<<< @/../packages/editor/src/type.ts#HistoryOpSource{ts}
|
||||||
@ -386,6 +388,9 @@ editorService.highlight("text_123");
|
|||||||
|
|
||||||
- {`MContainer`} parent 指定的容器节点
|
- {`MContainer`} parent 指定的容器节点
|
||||||
|
|
||||||
|
- `{Object}` options 可选配置(类型见 `AddOpOptions`)
|
||||||
|
- `{number | 'end'}` insertIndex 插入下标;指定后不依赖选中节点,详见 [add](#add)
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
- {Promise<`MNode`>} 新增的组件
|
- {Promise<`MNode`>} 新增的组件
|
||||||
|
|
||||||
@ -406,6 +411,7 @@ editorService.highlight("text_123");
|
|||||||
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
- `{boolean}` doNotSelect 添加后是否不更新当前选中节点(默认 false,添加后会选中新增的节点)
|
||||||
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
- `{boolean}` doNotSwitchPage 添加后是否不切换当前页面(默认 false;新增页面 / 跨页新增时为 true 会跳过会引发页面切换的选中操作)
|
||||||
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
- `{boolean}` doNotPushHistory 是否不写入历史记录(默认 false)
|
||||||
|
- `{number | 'end'}` insertIndex 插入下标,落在 `[0, 父容器 items.length]`,`'end'` 固定追加到末尾。指定后不按当前选中节点 / 上一次新增节点计算锚点,也不要求有选中节点;越界或非数字统一追加到末尾。批量添加时数字下标是本批节点的起始位置(按父容器分别累计偏移,保持传入顺序),`'end'` 逐个追加。人工添加不传,沿用锚点行为
|
||||||
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
- `{string}` historyDescription 见[历史记录相关 options](#历史记录相关-options)
|
||||||
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
- `{HistoryOpSource}` historySource 见[历史记录相关 options](#历史记录相关-options)
|
||||||
|
|
||||||
|
|||||||
@ -503,6 +503,32 @@ const renderFunction = async (stage) => {
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## iframeSandbox
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
画布 iframe 的 sandbox 属性值,仅 `renderType` 为 `iframe` 时生效。不传或传 `false` 时不设置 sandbox 属性。
|
||||||
|
|
||||||
|
token 用空格分隔,缺哪个 token,画布内对应能力就会静默失效。编辑器自身依赖 `allow-scripts` + `allow-same-origin`(需要读写 iframe 内的 DOM);组件内需要下载、弹窗、表单提交时,分别要放开 `allow-downloads`、`allow-popups`、`allow-forms` 等。
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- 仅在编辑器初始化时读取,运行期修改不会生效。
|
||||||
|
- runtime 与编辑器不同域时,会通过 `srcdoc` 加载,同样受 sandbox 约束;缺少 `allow-same-origin` 会导致编辑器无法访问 iframe 内的 DOM。
|
||||||
|
- `allow-scripts` 与 `allow-same-origin` 同时存在且 iframe 同源时,iframe 内脚本可以移除自身的 sandbox 属性,因此该配置只用于限制 `allow-top-navigation`、`allow-popups` 等能力,不能作为安全隔离手段。
|
||||||
|
|
||||||
|
- **默认值:** `undefined`(不设置 sandbox 属性)
|
||||||
|
|
||||||
|
- **类型:** `string | false`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor iframe-sandbox="allow-scripts allow-same-origin allow-downloads"></m-editor>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
## autoScrollIntoView
|
## autoScrollIntoView
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
@ -1311,6 +1337,8 @@ const guidesOptions = {
|
|||||||
|
|
||||||
关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。
|
关闭时保持原行为:属性 / 样式表单校验失败则丢弃本次改动,不写入节点。
|
||||||
|
|
||||||
|
该开关同时决定属性 / 样式表单内部 `MForm` 的 `typeMatchValid`(按字段 type 校验值形态)与 `validateOnInit`(初始化后立即校验一次):开启时才一并开启,关闭时两者均为 `false`。
|
||||||
|
|
||||||
:::tip
|
:::tip
|
||||||
校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。
|
校验错误以「来源」为维度分别记录 —— 属性表单来源记为 `props`,样式表单来源记为 `style`;两者指向同一节点,互不覆盖。节点只要任一来源存在错误即视为出错。
|
||||||
|
|
||||||
@ -1562,6 +1590,74 @@ const onLayerNodeDblclick = (event, data) => {
|
|||||||
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
|
- 返回 `false` 时,会同时阻断默认的"展开/收起"行为以及向上抛出的 [`layer-node-dblclick`](./events.md#layer-node-dblclick) 事件;返回其他值则继续触发默认行为并抛出事件。
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
## beforeAppendComponent
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
从组件列表新增组件前的钩子函数
|
||||||
|
|
||||||
|
在用户点击组件列表项时,先于写入节点执行。入参是当前组件列表项 `ComponentItem`(见 [`componentGroupList`](#componentgrouplist))。允许异步函数;返回 `false` 时取消本次新增。返回其他值(包括 `true`、`undefined`)则继续新增。
|
||||||
|
|
||||||
|
拖入画布不走该钩子,拖入请使用 [`beforeDropComponent`](#beforedropcomponent)。
|
||||||
|
|
||||||
|
可以直接修改入参(例如补全 `data`),修改会反映到最终写入的节点上。
|
||||||
|
|
||||||
|
- **默认值:** `undefined`
|
||||||
|
|
||||||
|
- **类型:** `(item: ComponentItem) => boolean | void | Promise<boolean | void>`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor :before-append-component="beforeAppendComponent"></m-editor>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const beforeAppendComponent = async (item) => {
|
||||||
|
if (item.type === 'paid-button') {
|
||||||
|
const allowed = await checkPermission(item.type);
|
||||||
|
if (!allowed) return false;
|
||||||
|
}
|
||||||
|
item.data = { ...item.data, from: 'component-list' };
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
该钩子只作用于组件列表的点击新增,不影响拖入画布、复制、粘贴、页面新增等其它写入路径。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## beforeDropComponent
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
组件列表拖入画布、落点前的钩子函数
|
||||||
|
|
||||||
|
仅当拖拽数据的 `dragType` 为组件列表(`component-list`)时,在位置计算完成、写入节点前执行。入参是即将新增的节点 `dragData` 和父节点 `parent`。允许异步函数;返回 `false` 时取消本次拖入。返回其他值(包括 `true`、`undefined`)则继续新增。
|
||||||
|
|
||||||
|
点击组件列表新增不会触发该钩子,点击请使用 [`beforeAppendComponent`](#beforeappendcomponent)。
|
||||||
|
|
||||||
|
- **默认值:** `undefined`
|
||||||
|
|
||||||
|
- **类型:** `(dragData: AddMNode, parent: MContainer) => boolean | void | Promise<boolean | void>`
|
||||||
|
|
||||||
|
- **示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<template>
|
||||||
|
<m-editor :before-drop-component="beforeDropComponent"></m-editor>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const beforeDropComponent = async (dragData, parent) => {
|
||||||
|
if (parent.type === 'page' && dragData.type === 'paid-button') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
## 表单业务上下文
|
## 表单业务上下文
|
||||||
|
|
||||||
- **详情:**
|
- **详情:**
|
||||||
|
|||||||
154
docs/api/editor/sdk.md
Normal file
154
docs/api/editor/sdk.md
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
# DSL SDK(`@tmagic/editor/sdk`)
|
||||||
|
|
||||||
|
不依赖 DOM / Vue 界面 / 画布的 DSL 操作 SDK,只负责对 DSL 做增删改查、历史撤销重做、依赖收集与合法性校验,可以直接在 Node 中运行(比如给 AI agent 调用的命令行工具使用)。
|
||||||
|
|
||||||
|
浏览器端的 `editorService`、`historyService` 等单例就是用同一个工厂创建的默认实例,因此 `import { editorService } from '@tmagic/editor'` 的用法不受影响。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createDslSdk } from '@tmagic/editor/sdk';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { createDslSdk } from '@tmagic/editor/sdk';
|
||||||
|
|
||||||
|
const sdk = createDslSdk({
|
||||||
|
// 初始 DSL,会被深拷贝,不会修改传入的对象
|
||||||
|
dsl: { type: 'app', id: 'app_1', items: [{ type: 'page', id: 'page_1', name: 'index', items: [] }] },
|
||||||
|
// 此前通过 sdk.serializeHistory() 导出的历史快照(可选)
|
||||||
|
// history,
|
||||||
|
// 组件列表:用来判断新增的组件类型是否合法
|
||||||
|
componentGroupList: [{ title: '基础', items: [{ text: '文本', type: 'text' }] }],
|
||||||
|
// 组件属性表单配置,key 是组件 type,值是该组件的表单配置(FormConfig,即表单项数组):用来校验属性是否合法
|
||||||
|
propsConfigs: {
|
||||||
|
text: [{ type: 'text', name: 'text', text: '文本', rules: [{ required: true, message: '文本不能为空' }] }],
|
||||||
|
},
|
||||||
|
// 组件被添加时的初始值,与 propsConfigs 的 key 一一对应
|
||||||
|
propsValues: { text: { text: '文本' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await sdk.ready; // 登记表(含异步的属性配置)全部就绪
|
||||||
|
|
||||||
|
// 没有选中节点时,默认添加到第一个页面;id 由 SDK 生成,后续操作使用返回值里的 id
|
||||||
|
const node = await sdk.editor.add({ type: 'text', text: 'hello' });
|
||||||
|
await sdk.editor.update({ id: node.id, text: 'world' });
|
||||||
|
await sdk.editor.undo();
|
||||||
|
|
||||||
|
// 属性不合法时(strict 模式)会抛出 DslValidationError,且不会修改 DSL
|
||||||
|
await sdk.editor.update({ id: node.id, text: '' }).catch((e) => console.log(e.errors)); // [{ ..., message: '文本 -> 文本不能为空' }]
|
||||||
|
|
||||||
|
const newDsl = sdk.getDsl(); // 深拷贝,已包含依赖收集结果
|
||||||
|
const newHistory = sdk.serializeHistory(); // 可 JSON.stringify,下次作为 history 传回
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
`propsConfigs` 的格式与 [Editor 的 propsConfigs](./props.md#propsconfigs) 相同。实际项目中通常直接使用 runtime 构建出来的 `magicPresetConfigs` / `magicPresetValues`(参考[快速开始](../../guide/index.md#propsconfigs-propsvalues)),在 Node 中加载构建产物后传入即可。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 无状态使用
|
||||||
|
|
||||||
|
每次调用传入「DSL + 历史快照」,操作完成后导出新的「DSL + 历史快照」:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const sdk = createDslSdk({ dsl: readJson('dsl.json'), history: readJson('history.json') });
|
||||||
|
await sdk.editor.update({ id: 'button_1', text: 'new' });
|
||||||
|
writeJson('dsl.json', sdk.getDsl());
|
||||||
|
writeJson('history.json', sdk.serializeHistory());
|
||||||
|
```
|
||||||
|
|
||||||
|
::: tip
|
||||||
|
- `serializeHistory()` 导出的快照可以直接 `JSON.stringify`;浏览器端 `historyService.serialize()` 默认保留 `Map`,适合写入 IndexedDB。
|
||||||
|
- Node 下默认的 `parseDSL` 只解析 JSON,不会执行代码;如果历史里包含函数 / 正则,请自行传入 `parseDSL`。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## createDslSdk(options)
|
||||||
|
|
||||||
|
每次调用返回一组相互连线、互相独立的 service,多个实例互不影响。
|
||||||
|
|
||||||
|
### options
|
||||||
|
|
||||||
|
| 参数 | 说明 | 默认值 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dsl` | 初始 DSL | - |
|
||||||
|
| `history` | 历史快照 | - |
|
||||||
|
| `validateMode` | 校验模式,见[校验](#校验) | `'strict'` |
|
||||||
|
| `autoWiring` | 是否自动绑定依赖收集连线 | `true` |
|
||||||
|
| `reactivity` | 响应式实现:`reactive` / `shallowReactive` / `toRaw` / `nextTick`,默认使用 Vue 的实现,可覆盖 | Vue |
|
||||||
|
| `storage` | 存储适配器(`localStorage` 天然满足),用于复制粘贴、代码块草稿 | 内存实现 `MemoryStorage` |
|
||||||
|
| `parseDSL` | 把序列化字符串还原成值 | 只解析 JSON |
|
||||||
|
| `historyPersistence` | 历史持久化适配器 `{ get, set }`(浏览器端对应 IndexedDB),不提供时 `saveToIndexedDB` / `restoreFromIndexedDB` 会 reject | - |
|
||||||
|
| `collectWorker` | 依赖收集 worker 适配器,不提供则在主线程收集 | - |
|
||||||
|
| `copyText` / `notify` | 复制到剪贴板、提示信息,不提供则不做任何事 | - |
|
||||||
|
| `componentGroupList` / `propsConfigs` / `propsValues` / `eventMethodList` / `datasourceList` / `datasourceConfigs` / `datasourceValues` / `datasourceEventMethodList` / `disabledCodeBlock` / `disabledDataSource` / `disabledPageFragment` / `disabledMultiSelect` / `alwaysMultiSelect` / `canDropIn` | 登记表,与 [Editor props](./props.md) 同名项一一对应 | - |
|
||||||
|
|
||||||
|
### 返回值
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `editor` / `history` / `props` / `dataSource` / `codeBlock` / `dep` / `storage` / `events` / `componentList` | 各 service 实例,方法与浏览器端同名 service 一致 |
|
||||||
|
| `validate` | 校验器,见[校验](#校验) |
|
||||||
|
| `ctx` | service 之间共享的上下文 |
|
||||||
|
| `ready` | 登记表全部就绪后 resolve |
|
||||||
|
| `getDsl()` | 导出当前 DSL(深拷贝) |
|
||||||
|
| `setDsl(dsl)` | 整体替换 DSL,会写入历史记录,返回的 Promise 在自动选中节点完成后 resolve |
|
||||||
|
| `serializeHistory()` | 导出可 JSON 序列化的历史快照 |
|
||||||
|
| `bindWiring()` | 绑定依赖收集连线(`autoWiring: false` 时手动调用),返回解绑函数。绑定时如果 DSL 已经设置,会立即补做一次依赖收集 |
|
||||||
|
| `destroy()` | 销毁 |
|
||||||
|
|
||||||
|
## 校验
|
||||||
|
|
||||||
|
`validateMode` 决定修改 DSL 时如何处理不合法的内容:
|
||||||
|
|
||||||
|
- `strict`:修改前校验,不通过抛出 `DslValidationError`(`errors` 里是全部问题),**DSL 不会被修改**,适合给 agent 调用,agent 可以根据 `errors` 修正后重试。Node 下默认。
|
||||||
|
- `record`:不抛错,把校验结果写进 `editor.state.invalidNodeIds`。
|
||||||
|
- `off`:不做额外校验。浏览器端默认实例使用这个模式(属性面板自己负责校验)。
|
||||||
|
|
||||||
|
可以通过 `sdk.ctx.validateMode = 'off'` 在运行时切换。
|
||||||
|
|
||||||
|
校验内容:
|
||||||
|
|
||||||
|
| 类别 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 结构(`kind: 'structure'`) | 组件类型是否在组件列表中(`page` / `page-fragment` / `container` 内置合法,组件列表为空时不校验)、父节点是否是容器、`canDropIn`、是否禁用页面片、id 是否重复、不允许修改节点类型、数据源类型是否可用 |
|
||||||
|
| 属性(`kind: 'props'`) | 用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的属性表单配置,包含 `typeMatch` |
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { DslValidationError } from '@tmagic/editor/sdk';
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sdk.editor.add({ type: 'unknown' });
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof DslValidationError) {
|
||||||
|
console.log(e.errors); // [{ target, id, path, kind, message, suggestion }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主动校验
|
||||||
|
await sdk.validate.node('text_1');
|
||||||
|
await sdk.validate.dataSource('ds_1');
|
||||||
|
await sdk.validate.dsl();
|
||||||
|
```
|
||||||
|
|
||||||
|
::: warning
|
||||||
|
- 数据源的 `add` / `update` 是同步方法,只做同步的结构校验(数据源类型是否可用);属性校验请用 `sdk.validate.dataSource(id)`。
|
||||||
|
- SDK 内只注册了 `@tmagic/form/headless` 的内置字段,编辑器自定义字段(`data-source-select`、`code-select` 等)的 `typeMatch` 依赖编辑器的单例 service,不在 SDK 校验范围内;需要时可在浏览器环境下通过 `@tmagic/editor/headless` 的 `editorFields` 注册。
|
||||||
|
:::
|
||||||
|
|
||||||
|
## 依赖收集
|
||||||
|
|
||||||
|
`autoWiring` 为 `true` 时,节点、数据源、代码块发生变化会同步收集依赖,并把结果写回 DSL 的 `dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`,所以操作返回时导出的 DSL 依赖信息已经是最新的。
|
||||||
|
|
||||||
|
不需要依赖收集(只做 DSL 增删改)时可以传 `autoWiring: false`,之后需要时再调用 `sdk.bindWiring()`;注意关闭期间不会同步数据源 / 代码块与依赖目标,也不会自动选中页面。
|
||||||
|
|
||||||
|
## 插件
|
||||||
|
|
||||||
|
所有 service 都支持 `usePlugin`,与浏览器端一致:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sdk.editor.usePlugin({
|
||||||
|
beforeAdd(addNode, parent) {
|
||||||
|
return [addNode, parent];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
@ -69,6 +69,18 @@
|
|||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## formKey
|
||||||
|
|
||||||
|
- **详情:** 当前表单绑定的数据实体标识(编辑器属性面板里是节点 id)。
|
||||||
|
|
||||||
|
配置结构相同但换了数据实体时(例如两个同类型组件之间切换选中),表单树会被复用,
|
||||||
|
字段组件在 setup 期从 `model` 派生的本地配置(如事件动作的参数字段)仍属于上一个实体,
|
||||||
|
会出现「值来自新节点、配置来自旧节点」的串台。传入 `formKey` 后,值变化时只重挂叶子字段组件;
|
||||||
|
tab、group-list 等容器保持复用,激活 tab、滚动位置和展开态不会丢失。
|
||||||
|
同一实体的 `initValues` 更新(例如编辑后重新下发节点数据)不会触发重挂。
|
||||||
|
|
||||||
|
- **类型:** `string | number`
|
||||||
|
|
||||||
## lastValues
|
## lastValues
|
||||||
|
|
||||||
- **详情:** 需对比的值(开启对比模式时传入)
|
- **详情:** 需对比的值(开启对比模式时传入)
|
||||||
|
|||||||
@ -29,7 +29,7 @@
|
|||||||
| `display` / `hidden` | 不校验 |
|
| `display` / `hidden` | 不校验 |
|
||||||
| `number` | `number`(非 NaN) |
|
| `number` | `number`(非 NaN) |
|
||||||
| `number-range` | 长度为 2 的数字数组 |
|
| `number-range` | 长度为 2 的数字数组 |
|
||||||
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `timestamp` 期望 `number`,其余按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
| `date` / `datetime` / `time` | 按 `valueFormat` 校验;`x` / `X` / `timestamp` 期望 `number`(`x`、`timestamp` 为毫秒,`X` 为秒)。日期组件会把选择器写出的秒字符串收成 number。其余格式按 [Day.js format](https://day.js.org/docs/en/display/format) 严格解析字符串 |
|
||||||
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
| `daterange` / `timerange` | 无 `names` 时为长度为 2 的数组,元素按 `valueFormat` 校验;有 `names` 时跳过 |
|
||||||
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
| `switch` / `checkbox` | 值必须是解析后的 `activeValue` / `inactiveValue` 之一(显式配置优先;未配置且 `filter === 'number'` 时为 `1`/`0`;否则为 `true`/`false`) |
|
||||||
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
| `select` | 单选:值 ∈ options;`multiple`:数组且每项 ∈ options;`allowCreate` / `remote`:只校验基础形态,不做 options 枚举 |
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
|
|
||||||
> 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。
|
> 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。
|
||||||
|
|
||||||
|
开启后,属性 / 样式表单内部 `MForm` 的 `typeMatchValid`(按字段 type 校验值形态)与 `validateOnInit`(初始化后立即校验一次)也会一并开启;关闭时两者同样关闭。开关可动态变化,表单会同步生效。
|
||||||
|
|
||||||
## 工作原理
|
## 工作原理
|
||||||
|
|
||||||
### 错误来源维度
|
### 错误来源维度
|
||||||
|
|||||||
@ -191,6 +191,33 @@ app.setPage('page_2');
|
|||||||
|
|
||||||
删除当前页面。
|
删除当前页面。
|
||||||
|
|
||||||
|
### setPageFragment
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{MComponent | MContainer} config` 页面片容器配置
|
||||||
|
- `{boolean} [force]` 是否强制重建,默认 `false`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{void}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
按页面片容器的 `pageFragmentId` 同步 `pageFragments` 中的页面片实例。容器新增、关联/切换/取消关联页面片时都需要调用,否则容器拿不到页面片内容。
|
||||||
|
|
||||||
|
默认在已关联同一个页面片时保持不变,避免容器每次更新都重建页面片;`force` 为 `true` 时即使关联的是同一个页面片也会重建,用于页面重建时拿到最新的页面片内容。
|
||||||
|
|
||||||
|
### deletePageFragment
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{Id} id` 页面片容器 id
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{void}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
删除页面片实例,页面片容器被删除时调用。
|
||||||
|
|
||||||
### getPage
|
### getPage
|
||||||
|
|
||||||
- **参数:**
|
- **参数:**
|
||||||
@ -361,6 +388,7 @@ app.destroy();
|
|||||||
|--------|----------|----------|
|
|--------|----------|----------|
|
||||||
| `dsl-change` | 调用 `setConfig` 时触发 | `({ dsl: MApp, curPage: Id })` |
|
| `dsl-change` | 调用 `setConfig` 时触发 | `({ dsl: MApp, curPage: Id })` |
|
||||||
| `page-change` | 调用 `setPage` 切换页面时触发;切换到不存在的页面时也会触发,此时回调无参数。**注意**:当 `pageConfig === this.page.data`(重复 setPage 到当前页)时会直接 `return`,**不**触发该事件 | `(page?: Page)` |
|
| `page-change` | 调用 `setPage` 切换页面时触发;切换到不存在的页面时也会触发,此时回调无参数。**注意**:当 `pageConfig === this.page.data`(重复 setPage 到当前页)时会直接 `return`,**不**触发该事件 | `(page?: Page)` |
|
||||||
|
| `page-fragments-change` | 调用 `setPageFragment` / `deletePageFragment` 导致页面片实例创建、替换或删除时触发 | `({ id: Id, pageFragmentId?: Id })` |
|
||||||
|
|
||||||
### 监听示例
|
### 监听示例
|
||||||
|
|
||||||
|
|||||||
@ -330,6 +330,53 @@ dataSourceManager.onDataChange('ds_1', 'user.name', (newVal) => {
|
|||||||
|
|
||||||
取消监听数据源数据变化。
|
取消监听数据源数据变化。
|
||||||
|
|
||||||
|
### batch
|
||||||
|
|
||||||
|
- **参数:**
|
||||||
|
- `{() => T} fn` 同步执行的函数
|
||||||
|
- `{BatchOptions} [options]` 批量更新选项,嵌套时以最外层为准
|
||||||
|
- `{boolean} [mergeChangeEvents=false]` 是否把同一数据源的多次变化合并成一次 `update-data`
|
||||||
|
|
||||||
|
- **返回:**
|
||||||
|
- `{T}` fn 的返回值
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
批量更新。`fn` 中产生的所有数据变化,会在 `fn` 执行结束后(同步)统一更新一次页面:受影响的节点只编译、替换一次,并按数据源分别触发 `update-data`。
|
||||||
|
|
||||||
|
- 数据本身(`data`、`onDataChange`、`change` 事件)仍然实时生效,只是页面节点的更新被合并
|
||||||
|
- 支持嵌套,最外层 `batch` 结束时才统一更新
|
||||||
|
- 仅支持同步函数,`fn` 返回 Promise 时不会等待
|
||||||
|
|
||||||
|
`mergeChangeEvents` 默认为 `false`:每次 `setData` 各触发一次 `update-data`,各自携带自己的 `changeEvent`(`path`、`updateData`),与不使用 `batch` 时一致,监听 `update-data` / `replaced-node` 的业务不会漏掉中间变化。页面节点只编译一次,且渲染层会按节点对象去重(同一份已编译节点只替换一次),因此事件变多不会带来重复的替换开销。
|
||||||
|
|
||||||
|
传 `true` 时每个数据源只触发一次 `update-data`,`changeEvent` 是该数据源最后一次变化,中间变化会丢失;适合不关心每次变化、只想少收事件的场景。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
dataSourceManager.batch(() => {
|
||||||
|
ds1.setData(list, 'list');
|
||||||
|
ds1.setData(total, 'total');
|
||||||
|
ds2.setData(title, 'title');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每个数据源只触发一次 update-data,只带最后一次 changeEvent
|
||||||
|
dataSourceManager.batch(
|
||||||
|
() => {
|
||||||
|
ds1.setData(list, 'list');
|
||||||
|
ds1.setData(total, 'total');
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: true },
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### isBatching
|
||||||
|
|
||||||
|
- **类型:** `{boolean}`
|
||||||
|
|
||||||
|
- **详情:**
|
||||||
|
|
||||||
|
当前是否处于 `batch` 中(只读属性)。
|
||||||
|
|
||||||
### destroy
|
### destroy
|
||||||
|
|
||||||
- **返回:**
|
- **返回:**
|
||||||
@ -349,6 +396,7 @@ DataSourceManager 继承自 EventEmitter,支持以下事件:
|
|||||||
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
| `init` | 所有数据源初始化完成;现代分支携带 `(data, errors)`,旧 Promise.all 分支为 `(this.data)` | `(data, errors?)` |
|
||||||
| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 |
|
| `mounted` | 由 runtime 在顶层组件渲染完成后触发,manager 收到后统一执行各数据源的 `mounted` | 无 |
|
||||||
| `registered-all` | 所有数据源注册完成 | 无 |
|
| `registered-all` | 所有数据源注册完成 | 无 |
|
||||||
|
| `batch-change` | `batch` 结束时发出,携带期间所有的数据变化,`createDataSourceManager` 据此统一更新页面 | `(changes: { sourceId: string, changeEvent: ChangeEvent }[])` |
|
||||||
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
| `update-data` | 由 `createDataSourceManager` 在数据变化后发出,用于通知节点重新渲染 | `(newNodes: MNode[], sourceId: string, changeEvent: ChangeEvent, pageId: Id)` |
|
||||||
|
|
||||||
### 事件监听示例
|
### 事件监听示例
|
||||||
|
|||||||
44
package.json
44
package.json
@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "tmagic",
|
"name": "tmagic",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@11.21.0",
|
"packageManager": "pnpm@12.16.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"bootstrap": "pnpm i && pnpm build",
|
"bootstrap": "pnpm i && pnpm build",
|
||||||
"clean:top": "rimraf packages/*/dist packages/*/types runtime/*/dist runtime/*/types vue-components/*/dist vue-components/*/types react-components/*/dist react-components/*/types playground/dist coverage .vitest dwt* temp packages/cli/lib",
|
"clean:top": "rimraf packages/*/dist packages/*/types runtime/*/dist runtime/*/types vue-components/*/dist vue-components/*/types react-components/*/dist react-components/*/types playground/dist coverage .vitest dwt* temp packages/cli/lib",
|
||||||
"clean:modules": "rimraf node_modules **/node_modules **/**/node_modules",
|
"clean:modules": "rimraf -g --impl=native --max-retries=5 \"**/node_modules\"",
|
||||||
"clean:all": "pnpm clean:top && pnpm clean:modules",
|
"clean:all": "pnpm clean:top && pnpm clean:modules",
|
||||||
"lint": "eslint --cache .",
|
"lint": "eslint --cache .",
|
||||||
"lint-fix": "eslint --fix --cache .",
|
"lint-fix": "eslint --fix --cache .",
|
||||||
@ -41,12 +41,12 @@
|
|||||||
"url": "https://github.com/Tencent/tmagic-editor.git"
|
"url": "https://github.com/Tencent/tmagic-editor.git"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "^21.2.3",
|
"@commitlint/cli": "^21.2.4",
|
||||||
"@commitlint/config-conventional": "^21.2.3",
|
"@commitlint/config-conventional": "^21.2.4",
|
||||||
"@tmagic/eslint-config": "workspace:*",
|
"@tmagic/eslint-config": "workspace:*",
|
||||||
"@types/node": "26.6.2",
|
"@types/node": "26.6.5",
|
||||||
"@vitejs/plugin-vue": "^6.0.9",
|
"@vitejs/plugin-vue": "^6.0.10",
|
||||||
"@vitest/coverage-v8": "^5.0.1",
|
"@vitest/coverage-v8": "^5.0.5",
|
||||||
"@vue/compiler-sfc": "catalog:",
|
"@vue/compiler-sfc": "catalog:",
|
||||||
"c8": "^12.0.0",
|
"c8": "^12.0.0",
|
||||||
"commitizen": "^4.3.2",
|
"commitizen": "^4.3.2",
|
||||||
@ -55,30 +55,30 @@
|
|||||||
"cz-conventional-changelog": "^3.3.0",
|
"cz-conventional-changelog": "^3.3.0",
|
||||||
"element-plus": "catalog:",
|
"element-plus": "catalog:",
|
||||||
"enquirer": "^2.4.1",
|
"enquirer": "^2.4.1",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.13.0",
|
||||||
"execa": "^10.0.1",
|
"execa": "^10.2.0",
|
||||||
"happy-dom": "^20.14.5",
|
"happy-dom": "^21.0.3",
|
||||||
"highlight.js": "^11.12.0",
|
"highlight.js": "^11.12.0",
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"jsdom": "^30.1.0",
|
"jsdom": "^30.1.3",
|
||||||
"lint-staged": "^17.5.1",
|
"lint-staged": "^17.6.0",
|
||||||
"minimist": "^1.2.8",
|
"minimist": "^1.2.8",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"prettier": "^3.9.8",
|
"prettier": "^3.9.10",
|
||||||
"recast": "^0.23.20",
|
"recast": "^0.24.0",
|
||||||
"rimraf": "^6.1.3",
|
"rimraf": "^6.1.3",
|
||||||
"rolldown": "^1.2.9",
|
"rolldown": "^1.2.14",
|
||||||
"rolldown-plugin-dts": "^0.28.6",
|
"rolldown-plugin-dts": "^0.28.8",
|
||||||
"sass-embedded": "^1.104.1",
|
"sass-embedded": "^1.107.1",
|
||||||
"semver": "^7.8.5",
|
"semver": "^7.8.5",
|
||||||
"serialize-javascript": "^7.1.1",
|
"serialize-javascript": "^7.1.2",
|
||||||
"shx": "^0.4.0",
|
"shx": "^0.4.0",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"vitepress": "2.0.0-alpha.20",
|
"vitepress": "2.0.0-alpha.20",
|
||||||
"vitest": "^5.0.1",
|
"vitest": "^5.0.5",
|
||||||
"vue": "^3.5.43",
|
"vue": "^3.5.44",
|
||||||
"vue-tsc": "^3.3.11"
|
"vue-tsc": "^3.3.13"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"commitizen": {
|
"commitizen": {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/cli",
|
"name": "@tmagic/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/core",
|
"name": "@tmagic/core",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -18,10 +18,10 @@
|
|||||||
|
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { isEmpty } from 'lodash-es';
|
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
import { createDataSourceManager, DataSourceManager, type DataSourceManagerData } from '@tmagic/data-source';
|
import { createDataSourceManager, DataSourceManager, type DataSourceManagerData } from '@tmagic/data-source';
|
||||||
import type { CodeBlockDSL, Id, JsEngine, MApp, RequestFunction } from '@tmagic/schema';
|
import type { CodeBlockDSL, Id, JsEngine, MApp, MComponent, MContainer, RequestFunction } from '@tmagic/schema';
|
||||||
|
|
||||||
import Env from './Env';
|
import Env from './Env';
|
||||||
import EventHelper from './EventHelper';
|
import EventHelper from './EventHelper';
|
||||||
@ -208,6 +208,66 @@ class App extends EventEmitter {
|
|||||||
this.page = undefined;
|
this.page = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按页面片容器的配置同步页面片实例
|
||||||
|
*
|
||||||
|
* 容器新增、关联/切换/取消关联页面片时都要调用,否则 app.pageFragments 里没有对应实例,页面片内容不会渲染
|
||||||
|
* @param config 页面片容器配置
|
||||||
|
* @param force 为 true 时即使关联的还是同一个页面片也重建,用于页面重建时拿到最新的页面片内容
|
||||||
|
*/
|
||||||
|
public setPageFragment(config: MComponent | MContainer, force = false) {
|
||||||
|
if (!(config.type && this.pageFragmentContainerType.has(config.type))) return;
|
||||||
|
|
||||||
|
const { id, pageFragmentId } = config;
|
||||||
|
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
const pageFragmentConfig = pageFragmentId
|
||||||
|
? this.dsl?.items?.find((page) => `${page.id}` === `${pageFragmentId}`)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const existPageFragment = this.pageFragments.get(id);
|
||||||
|
|
||||||
|
// 关联的还是同一个页面片时保持不变,避免容器每次更新都重建页面片,导致节点实例与数据源状态被重置
|
||||||
|
if (!force && pageFragmentConfig && existPageFragment && `${existPageFragment.data.id}` === `${pageFragmentId}`) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existPageFragment) {
|
||||||
|
existPageFragment.destroy();
|
||||||
|
this.pageFragments.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageFragmentConfig) {
|
||||||
|
this.pageFragments.set(
|
||||||
|
id,
|
||||||
|
new Page({
|
||||||
|
config: cloneDeep(pageFragmentConfig),
|
||||||
|
app: this,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.eventHelper?.initEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
super.emit('page-fragments-change', { id, pageFragmentId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除页面片实例,页面片容器被删除时调用
|
||||||
|
* @param id 页面片容器 id
|
||||||
|
*/
|
||||||
|
public deletePageFragment(id: Id) {
|
||||||
|
const pageFragment = this.pageFragments.get(id);
|
||||||
|
|
||||||
|
if (!pageFragment) return;
|
||||||
|
|
||||||
|
pageFragment.destroy();
|
||||||
|
this.pageFragments.delete(id);
|
||||||
|
|
||||||
|
super.emit('page-fragments-change', { id });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 兼容id参数
|
* 兼容id参数
|
||||||
* @param id 节点id
|
* @param id 节点id
|
||||||
|
|||||||
@ -55,6 +55,7 @@ export default class EventHelper extends EventEmitter {
|
|||||||
public eventQueue: EventCache[] = [];
|
public eventQueue: EventCache[] = [];
|
||||||
|
|
||||||
private nodeEventList = new Map<(fromCpt: TMagicNode, ...args: any[]) => void, symbol>();
|
private nodeEventList = new Map<(fromCpt: TMagicNode, ...args: any[]) => void, symbol>();
|
||||||
|
private nodeEventHandlers = new WeakMap<TMagicNode, ((fromCpt: TMagicNode, ...args: any[]) => void)[]>();
|
||||||
private dataSourceEventList = new Map<string, Map<string, (...args: any[]) => void>>();
|
private dataSourceEventList = new Map<string, Map<string, (...args: any[]) => void>>();
|
||||||
private beforeEventHandler?: BeforeEventHandler;
|
private beforeEventHandler?: BeforeEventHandler;
|
||||||
private afterEventHandler?: AfterEventHandler;
|
private afterEventHandler?: AfterEventHandler;
|
||||||
@ -125,6 +126,13 @@ export default class EventHelper extends EventEmitter {
|
|||||||
|
|
||||||
this.nodeEventList.set(eventHandler, eventName);
|
this.nodeEventList.set(eventHandler, eventName);
|
||||||
this.on(eventName, eventHandler);
|
this.on(eventName, eventHandler);
|
||||||
|
|
||||||
|
const handlers = this.nodeEventHandlers.get(node);
|
||||||
|
if (handlers) {
|
||||||
|
handlers.push(eventHandler);
|
||||||
|
} else {
|
||||||
|
this.nodeEventHandlers.set(node, [eventHandler]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,6 +143,25 @@ export default class EventHelper extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.nodeEventList.clear();
|
this.nodeEventList.clear();
|
||||||
|
this.nodeEventHandlers = new WeakMap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除单个节点绑定的事件,节点销毁时调用,避免迭代容器等场景反复重建节点导致监听泄漏
|
||||||
|
*/
|
||||||
|
public removeNodeEventsOf(node: TMagicNode) {
|
||||||
|
const handlers = this.nodeEventHandlers.get(node);
|
||||||
|
if (!handlers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
const name = this.nodeEventList.get(handler);
|
||||||
|
name && this.off(name, handler);
|
||||||
|
this.nodeEventList.delete(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nodeEventHandlers.delete(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
public bindDataSourceEvents() {
|
public bindDataSourceEvents() {
|
||||||
|
|||||||
@ -149,6 +149,7 @@ class Node extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
|
this.app?.eventHelper?.removeNodeEventsOf(this);
|
||||||
this.eventQueue.length = 0;
|
this.eventQueue.length = 0;
|
||||||
this.instance = null;
|
this.instance = null;
|
||||||
this.events = [];
|
this.events = [];
|
||||||
|
|||||||
@ -16,8 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
|
||||||
|
|
||||||
import type { Id, MComponent, MContainer, MPage, MPageFragment } from '@tmagic/schema';
|
import type { Id, MComponent, MContainer, MPage, MPageFragment } from '@tmagic/schema';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
@ -65,19 +63,8 @@ class Page extends Node {
|
|||||||
|
|
||||||
this.setNode(config.id, node);
|
this.setNode(config.id, node);
|
||||||
|
|
||||||
if (config.type && this.app.pageFragmentContainerType.has(config.type) && config.pageFragmentId) {
|
// 页面重建时强制同步,保证拿到的是页面片的最新内容
|
||||||
const pageFragment = this.app.dsl?.items?.find((page) => page.id === config.pageFragmentId);
|
this.app.setPageFragment(config, true);
|
||||||
|
|
||||||
if (pageFragment) {
|
|
||||||
this.app.pageFragments.set(
|
|
||||||
config.id,
|
|
||||||
new Page({
|
|
||||||
config: cloneDeep(pageFragment),
|
|
||||||
app: this.app,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(config.items)) {
|
if (Array.isArray(config.items)) {
|
||||||
config.items?.forEach((element: MComponent | MContainer) => {
|
config.items?.forEach((element: MComponent | MContainer) => {
|
||||||
|
|||||||
@ -72,6 +72,31 @@ const createAppDsl = (pageLength: number, nodeLength = 0) => {
|
|||||||
return dsl;
|
return dsl;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFragmentApp = () =>
|
||||||
|
new App({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'p1',
|
||||||
|
items: [{ id: 'pfc', type: 'page-fragment-container' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE_FRAGMENT,
|
||||||
|
id: 'f1',
|
||||||
|
items: [{ id: 'text_in_f1', type: 'text' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE_FRAGMENT,
|
||||||
|
id: 'f2',
|
||||||
|
items: [{ id: 'text_in_f2', type: 'text' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('App', () => {
|
describe('App', () => {
|
||||||
test('instance', () => {
|
test('instance', () => {
|
||||||
const app = new App({});
|
const app = new App({});
|
||||||
@ -497,6 +522,98 @@ describe('App 配置/方法/组件注册', () => {
|
|||||||
expect(cb).toHaveBeenCalledWith(node, 'arg1');
|
expect(cb).toHaveBeenCalledWith(node, 'arg1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setPageFragment: 关联页面片时创建实例', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'p1',
|
||||||
|
items: [{ id: 'pfc', type: 'page-fragment-container' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE_FRAGMENT,
|
||||||
|
id: 'f1',
|
||||||
|
items: [{ id: 'text_in_f1', type: 'text' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(app.pageFragments.size).toBe(0);
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
|
||||||
|
expect(app.pageFragments.get('pfc')?.data.id).toBe('f1');
|
||||||
|
expect(app.pageFragments.get('pfc')?.getNode('text_in_f1')?.data.id).toBe('text_in_f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPageFragment: 非页面片容器的配置被忽略', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [{ type: NodeType.PAGE, id: 'p1', items: [{ id: 'btn', type: 'button' }] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'btn', type: 'button', pageFragmentId: 'f1' });
|
||||||
|
app.setPageFragment({ type: 'page-fragment-container', pageFragmentId: 'f1' } as any);
|
||||||
|
|
||||||
|
expect(app.pageFragments.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPageFragment: 已关联同一个页面片时默认不重建', () => {
|
||||||
|
const app = createFragmentApp();
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
const fragment = app.pageFragments.get('pfc');
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
|
||||||
|
expect(app.pageFragments.get('pfc')).toBe(fragment);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPageFragment: force 时重建页面片实例', () => {
|
||||||
|
const app = createFragmentApp();
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
const fragment = app.pageFragments.get('pfc');
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' }, true);
|
||||||
|
|
||||||
|
expect(app.pageFragments.get('pfc')).not.toBe(fragment);
|
||||||
|
expect(app.pageFragments.get('pfc')?.data.id).toBe('f1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPageFragment: 关联不存在的页面片时清理旧实例', () => {
|
||||||
|
const app = createFragmentApp();
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'not-exist' });
|
||||||
|
|
||||||
|
expect(app.pageFragments.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setPageFragment / deletePageFragment 会触发 page-fragments-change', () => {
|
||||||
|
const app = createFragmentApp();
|
||||||
|
const cb = vi.fn();
|
||||||
|
app.on('page-fragments-change', cb);
|
||||||
|
|
||||||
|
app.setPageFragment({ id: 'pfc', type: 'page-fragment-container', pageFragmentId: 'f1' });
|
||||||
|
expect(cb).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
app.deletePageFragment('pfc');
|
||||||
|
expect(cb).toHaveBeenCalledTimes(2);
|
||||||
|
expect(app.pageFragments.size).toBe(0);
|
||||||
|
|
||||||
|
// 不存在的实例不触发
|
||||||
|
app.deletePageFragment('pfc');
|
||||||
|
expect(cb).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
test('destroy 清理所有资源', () => {
|
test('destroy 清理所有资源', () => {
|
||||||
const app = new App({
|
const app = new App({
|
||||||
config: {
|
config: {
|
||||||
|
|||||||
@ -187,6 +187,67 @@ describe('EventHelper - bindNodeEvents / initEvents / removeNodeEvents', () => {
|
|||||||
helper.removeNodeEvents();
|
helper.removeNodeEvents();
|
||||||
expect((helper as any).nodeEventList.size).toBe(0);
|
expect((helper as any).nodeEventList.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removeNodeEventsOf 只移除指定节点的监听,未绑定过的节点安全返回', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: createDsl({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'page_1',
|
||||||
|
items: [
|
||||||
|
{ id: 'btn_1', type: 'button', events: [{ name: 'click', actions: [] }] },
|
||||||
|
{ id: 'btn_2', type: 'button', events: [{ name: 'click', actions: [] }] },
|
||||||
|
{ id: 'btn_3', type: 'button' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const helper = app.eventHelper!;
|
||||||
|
const node1 = app.getNode('btn_1')!;
|
||||||
|
const node2 = app.getNode('btn_2')!;
|
||||||
|
const symbol1 = node1.eventKeys.get('click_btn_1')!;
|
||||||
|
const symbol2 = node2.eventKeys.get('click_btn_2')!;
|
||||||
|
expect((helper as any).nodeEventList.size).toBe(2);
|
||||||
|
|
||||||
|
expect(() => helper.removeNodeEventsOf(app.getNode('btn_3')!)).not.toThrow();
|
||||||
|
expect((helper as any).nodeEventList.size).toBe(2);
|
||||||
|
|
||||||
|
helper.removeNodeEventsOf(node1);
|
||||||
|
expect((helper as any).nodeEventList.size).toBe(1);
|
||||||
|
expect(helper.listenerCount(symbol1)).toBe(0);
|
||||||
|
expect(helper.listenerCount(symbol2)).toBe(1);
|
||||||
|
|
||||||
|
// 重复调用无副作用
|
||||||
|
helper.removeNodeEventsOf(node1);
|
||||||
|
expect((helper as any).nodeEventList.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('节点 destroy 时会移除其在 eventHelper 上的监听', () => {
|
||||||
|
const app = new App({
|
||||||
|
config: createDsl({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'page_1',
|
||||||
|
items: [{ id: 'btn_1', type: 'button', events: [{ name: 'click', actions: [] }] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const helper = app.eventHelper!;
|
||||||
|
const node = app.getNode('btn_1')!;
|
||||||
|
const symbol = node.eventKeys.get('click_btn_1')!;
|
||||||
|
expect(helper.listenerCount(symbol)).toBe(1);
|
||||||
|
|
||||||
|
node.destroy();
|
||||||
|
|
||||||
|
expect(helper.listenerCount(symbol)).toBe(0);
|
||||||
|
expect((helper as any).nodeEventList.size).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('EventHelper - 事件队列', () => {
|
describe('EventHelper - 事件队列', () => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/data-source",
|
"name": "@tmagic/data-source",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -34,6 +34,8 @@ import { SimpleObservedData } from './observed-data/SimpleObservedData';
|
|||||||
import { DataSource, HttpDataSource } from './data-sources';
|
import { DataSource, HttpDataSource } from './data-sources';
|
||||||
import { getDeps } from './depsCache';
|
import { getDeps } from './depsCache';
|
||||||
import type {
|
import type {
|
||||||
|
BatchChange,
|
||||||
|
BatchOptions,
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
DataSourceManagerData,
|
DataSourceManagerData,
|
||||||
DataSourceManagerOptions,
|
DataSourceManagerOptions,
|
||||||
@ -87,6 +89,10 @@ class DataSourceManager extends EventEmitter {
|
|||||||
/** 页面是否已经渲染完成 */
|
/** 页面是否已经渲染完成 */
|
||||||
public isMounted = false;
|
public isMounted = false;
|
||||||
|
|
||||||
|
private batchDepth = 0;
|
||||||
|
private batchChanges: BatchChange[] = [];
|
||||||
|
private batchOptions: BatchOptions = {};
|
||||||
|
|
||||||
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
constructor({ app, useMock, initialData }: DataSourceManagerOptions) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@ -223,9 +229,51 @@ class DataSourceManager extends EventEmitter {
|
|||||||
|
|
||||||
public setData(ds: DataSource, changeEvent: ChangeEvent) {
|
public setData(ds: DataSource, changeEvent: ChangeEvent) {
|
||||||
this.data[ds.id] = ds.data;
|
this.data[ds.id] = ds.data;
|
||||||
|
|
||||||
|
if (this.batchDepth > 0) {
|
||||||
|
this.batchChanges.push({ sourceId: ds.id, changeEvent });
|
||||||
|
}
|
||||||
|
|
||||||
this.emit('change', ds.id, changeEvent);
|
this.emit('change', ds.id, changeEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否处于 batch 中 */
|
||||||
|
public get isBatching() {
|
||||||
|
return this.batchDepth > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新:fn 中产生的所有数据源变化只会在 fn 执行结束后统一更新一次页面(同步),
|
||||||
|
* 页面节点只编译、替换一次,并按数据源分别触发 update-data。
|
||||||
|
* - 数据源的数据(ds.data / manager.data)仍然实时更新,onDataChange 监听也仍然实时触发,manager 的 change 事件也照常触发
|
||||||
|
* - 支持嵌套,最外层 batch 结束时才统一更新
|
||||||
|
* - 仅支持同步函数,fn 返回 Promise 时不会等待
|
||||||
|
* @param fn 同步执行的函数
|
||||||
|
* @param options 批量更新选项,嵌套时以最外层为准
|
||||||
|
* @returns fn 的返回值
|
||||||
|
*/
|
||||||
|
public batch<T>(fn: () => T, options?: BatchOptions): T {
|
||||||
|
const isOutermost = this.batchDepth === 0;
|
||||||
|
if (isOutermost) {
|
||||||
|
this.batchOptions = options ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.batchDepth += 1;
|
||||||
|
try {
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
this.batchDepth -= 1;
|
||||||
|
|
||||||
|
if (this.batchDepth === 0 && this.batchChanges.length) {
|
||||||
|
const changes = this.batchChanges;
|
||||||
|
const { batchOptions } = this;
|
||||||
|
this.batchChanges = [];
|
||||||
|
this.batchOptions = {};
|
||||||
|
this.emit('batch-change', changes, batchOptions);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public removeDataSource(id: string) {
|
public removeDataSource(id: string) {
|
||||||
this.get(id)?.destroy();
|
this.get(id)?.destroy();
|
||||||
delete this.data[id];
|
delete this.data[id];
|
||||||
@ -371,6 +419,9 @@ class DataSourceManager extends EventEmitter {
|
|||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
this.removeAllListeners();
|
this.removeAllListeners();
|
||||||
|
this.batchDepth = 0;
|
||||||
|
this.batchChanges = [];
|
||||||
|
this.batchOptions = {};
|
||||||
this.data = {};
|
this.data = {};
|
||||||
this.initialData = {};
|
this.initialData = {};
|
||||||
this.dataSourceMap.forEach((ds) => {
|
this.dataSourceMap.forEach((ds) => {
|
||||||
|
|||||||
@ -17,11 +17,11 @@
|
|||||||
*/
|
*/
|
||||||
import { cloneDeep, union } from 'lodash-es';
|
import { cloneDeep, union } from 'lodash-es';
|
||||||
|
|
||||||
import type { default as TMagicApp } from '@tmagic/core';
|
import type { default as TMagicApp, MNode, Page } from '@tmagic/core';
|
||||||
import { getDepNodeIds, getNodes, isPage, isPageFragment, replaceChildNode } from '@tmagic/core';
|
import { getDepNodeIds, getNodes, isPage, isPageFragment, replaceChildNodes } from '@tmagic/core';
|
||||||
|
|
||||||
import DataSourceManager from './DataSourceManager';
|
import DataSourceManager from './DataSourceManager';
|
||||||
import type { ChangeEvent, DataSourceManagerData } from './types';
|
import type { BatchChange, BatchOptions, ChangeEvent, DataSourceManagerData } from './types';
|
||||||
import { updateNode } from './utils';
|
import { updateNode } from './utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -56,11 +56,25 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
|||||||
return dataSourceManager;
|
return dataSourceManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
dataSourceManager.on('change', (sourceId: string, changeEvent: ChangeEvent) => {
|
const updateByChanges = (changes: BatchChange[], options: BatchOptions = {}) => {
|
||||||
|
// 按数据源归组,同一个数据源的多次变化按顺序保留
|
||||||
|
const sources = new Map<string, { nodeIds: Set<string>; changeEvents: ChangeEvent[] }>();
|
||||||
|
for (const { sourceId, changeEvent } of changes) {
|
||||||
const dep = dsl.dataSourceDeps?.[sourceId] || {};
|
const dep = dsl.dataSourceDeps?.[sourceId] || {};
|
||||||
const condDep = dsl.dataSourceCondDeps?.[sourceId] || {};
|
const condDep = dsl.dataSourceCondDeps?.[sourceId] || {};
|
||||||
|
const ids = [...Object.keys(condDep), ...Object.keys(dep)];
|
||||||
|
|
||||||
const nodeIds = union([...Object.keys(condDep), ...Object.keys(dep)]);
|
const source = sources.get(sourceId);
|
||||||
|
if (source) {
|
||||||
|
ids.forEach((id) => source.nodeIds.add(id));
|
||||||
|
source.changeEvents.push(changeEvent);
|
||||||
|
} else {
|
||||||
|
sources.set(sourceId, { nodeIds: new Set(ids), changeEvents: [changeEvent] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有数据源涉及的节点只编译一次
|
||||||
|
const nodeIds = union(...Array.from(sources.values()).map(({ nodeIds }) => Array.from(nodeIds)));
|
||||||
|
|
||||||
for (const page of dsl.items) {
|
for (const page of dsl.items) {
|
||||||
if (app.platform === 'editor' || (isPage(page) && page.id === app.page?.data.id) || isPageFragment(page)) {
|
if (app.platform === 'editor' || (isPage(page) && page.id === app.page?.data.id) || isPageFragment(page)) {
|
||||||
@ -69,16 +83,22 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
|||||||
node.condResult = dataSourceManager.compliedConds(node);
|
node.condResult = dataSourceManager.compliedConds(node);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newNode = dataSourceManager.compiledNode(node);
|
return dataSourceManager.compiledNode(node);
|
||||||
|
});
|
||||||
|
|
||||||
if (typeof app.page?.setData === 'function') {
|
if (typeof app.page?.setData === 'function') {
|
||||||
|
// 需要替换到页面数据、页面片数据中的节点,收集后批量替换,避免每个节点都做一次全树搜索
|
||||||
|
const replaceInPage: MNode[] = [];
|
||||||
|
const replaceInFragments = new Map<Page, MNode[]>();
|
||||||
|
|
||||||
|
for (const newNode of newNodes) {
|
||||||
if (isPage(newNode)) {
|
if (isPage(newNode)) {
|
||||||
app.page.setData(newNode);
|
app.page.setData(newNode);
|
||||||
} else if (page.id === app.page.data.id && !app.page.instance) {
|
} else if (page.id === app.page.data.id && !app.page.instance) {
|
||||||
replaceChildNode(newNode, [app.page.data]);
|
replaceInPage.push(newNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.getNode(node.id, { strict: true })?.setData(newNode);
|
app.getNode(newNode.id, { strict: true })?.setData(newNode);
|
||||||
|
|
||||||
for (const [, pageFragment] of app.pageFragments) {
|
for (const [, pageFragment] of app.pageFragments) {
|
||||||
if (pageFragment.data.id === newNode.id) {
|
if (pageFragment.data.id === newNode.id) {
|
||||||
@ -86,21 +106,57 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
|
|||||||
} else if (pageFragment.data.id === page.id) {
|
} else if (pageFragment.data.id === page.id) {
|
||||||
pageFragment.getNode(newNode.id, { strict: true })?.setData(cloneDeep(newNode));
|
pageFragment.getNode(newNode.id, { strict: true })?.setData(cloneDeep(newNode));
|
||||||
if (!pageFragment.instance) {
|
if (!pageFragment.instance) {
|
||||||
replaceChildNode(cloneDeep(newNode), [pageFragment.data]);
|
const list = replaceInFragments.get(pageFragment);
|
||||||
|
if (list) {
|
||||||
|
list.push(cloneDeep(newNode));
|
||||||
|
} else {
|
||||||
|
replaceInFragments.set(pageFragment, [cloneDeep(newNode)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return newNode;
|
replaceChildNodes(replaceInPage, [app.page.data]);
|
||||||
|
replaceInFragments.forEach((list, pageFragment) => {
|
||||||
|
replaceChildNodes(list, [pageFragment.data]);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (newNodes.length) {
|
if (newNodes.length) {
|
||||||
dataSourceManager.emit('update-data', newNodes, sourceId, changeEvent, page.id);
|
sources.forEach(({ nodeIds: sourceNodeIds, changeEvents }, sourceId) => {
|
||||||
|
const nodes =
|
||||||
|
sources.size === 1 ? newNodes : newNodes.filter((newNode) => sourceNodeIds.has(`${newNode.id}`));
|
||||||
|
|
||||||
|
if (!nodes.length) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.mergeChangeEvents === true) {
|
||||||
|
// 合并成一次,changeEvent 为该数据源最后一次变化,中间变化(path / updateData)会丢失
|
||||||
|
dataSourceManager.emit('update-data', nodes, sourceId, changeEvents[changeEvents.length - 1], page.id);
|
||||||
|
} else {
|
||||||
|
// 默认:每次变化各触发一次,各自携带自己的 changeEvent,中间变化不会丢失
|
||||||
|
for (const changeEvent of changeEvents) {
|
||||||
|
dataSourceManager.emit('update-data', nodes, sourceId, changeEvent, page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
dataSourceManager.on('change', (sourceId: string, changeEvent: ChangeEvent) => {
|
||||||
|
// batch 期间的变化在 batch 结束后统一更新
|
||||||
|
if (dataSourceManager.isBatching) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateByChanges([{ sourceId, changeEvent }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
dataSourceManager.on('batch-change', updateByChanges);
|
||||||
|
|
||||||
return dataSourceManager;
|
return dataSourceManager;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { DataSourceSchema, Id, MNode } from '@tmagic/core';
|
import type { DataSourceSchema, MNode } from '@tmagic/core';
|
||||||
import {
|
import {
|
||||||
DSL_NODE_KEY_COPY_PREFIX,
|
DSL_NODE_KEY_COPY_PREFIX,
|
||||||
isDataSourceCondTarget,
|
isDataSourceCondTarget,
|
||||||
@ -8,26 +8,43 @@ import {
|
|||||||
Watcher,
|
Watcher,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
|
|
||||||
const cache = new Map();
|
interface CacheEntry {
|
||||||
|
/** 计算依赖时的节点引用,用于判断节点是否被替换过 */
|
||||||
|
refs: MNode[];
|
||||||
|
result: any;
|
||||||
|
}
|
||||||
|
|
||||||
export const getDeps = (ds: DataSourceSchema, nodes: MNode[], inEditor: boolean) => {
|
/**
|
||||||
let cacheKey: string;
|
* 以第一个节点对象作为 WeakMap 的 key,节点被回收后缓存自动释放,
|
||||||
|
* 不同数据源 / 是否在编辑器中的结果存放在内层 Map。
|
||||||
|
* 通过比较节点对象引用(而不是 id)来判断缓存是否有效,节点被替换后(如修改了绑定的字段)会重新收集依赖
|
||||||
|
*/
|
||||||
|
const cache = new WeakMap<MNode, Map<string, CacheEntry>>();
|
||||||
|
|
||||||
if (inEditor) {
|
const getRefs = (nodes: MNode[], inEditor: boolean) => {
|
||||||
const ids: Id[] = [];
|
if (!inEditor) {
|
||||||
nodes.forEach((node) => {
|
return [...nodes];
|
||||||
traverseNode(node, (node) => {
|
|
||||||
ids.push(node.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
cacheKey = `${ds.id}:${ids.join(':')}`;
|
|
||||||
} else {
|
|
||||||
cacheKey = `${ds.id}:${nodes.map((node) => node.id).join(':')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cache.has(cacheKey)) {
|
const refs: MNode[] = [];
|
||||||
return cache.get(cacheKey);
|
nodes.forEach((node) => {
|
||||||
|
traverseNode(node, (item) => {
|
||||||
|
refs.push(item);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return refs;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSameRefs = (a: MNode[], b: MNode[]) => a.length === b.length && a.every((item, index) => item === b[index]);
|
||||||
|
|
||||||
|
export const getDeps = (ds: DataSourceSchema, nodes: MNode[], inEditor: boolean) => {
|
||||||
|
const refs = getRefs(nodes, inEditor);
|
||||||
|
const cacheKey = `${ds.id}:${inEditor}`;
|
||||||
|
const rootNode = nodes[0];
|
||||||
|
|
||||||
|
const entry = rootNode ? cache.get(rootNode)?.get(cacheKey) : undefined;
|
||||||
|
if (entry && isSameRefs(entry.refs, refs)) {
|
||||||
|
return entry.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
const watcher = new Watcher();
|
const watcher = new Watcher();
|
||||||
@ -60,7 +77,11 @@ export const getDeps = (ds: DataSourceSchema, nodes: MNode[], inEditor: boolean)
|
|||||||
|
|
||||||
const result = { deps, condDeps };
|
const result = { deps, condDeps };
|
||||||
|
|
||||||
cache.set(cacheKey, result);
|
if (rootNode) {
|
||||||
|
const entries = cache.get(rootNode) ?? new Map<string, CacheEntry>();
|
||||||
|
entries.set(cacheKey, { refs, result });
|
||||||
|
cache.set(rootNode, entries);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -61,6 +61,22 @@ export interface ChangeEvent {
|
|||||||
updateData: any;
|
updateData: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** DataSourceManager.batch 期间收集到的数据变化 */
|
||||||
|
export interface BatchChange {
|
||||||
|
sourceId: string;
|
||||||
|
changeEvent: ChangeEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOptions {
|
||||||
|
/**
|
||||||
|
* 是否把同一数据源在 batch 中的多次变化合并成一次 update-data,默认 true。
|
||||||
|
* - true:每个数据源只触发一次 update-data,changeEvent 为该数据源最后一次变化(中间变化会丢失)
|
||||||
|
* - false:每次变化各触发一次 update-data,各自携带自己的 changeEvent(如 path / updateData),
|
||||||
|
* 页面节点仍然只编译、替换一次
|
||||||
|
*/
|
||||||
|
mergeChangeEvents?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export type AsyncDataSourceResolveResult<T = typeof DataSource> = {
|
export type AsyncDataSourceResolveResult<T = typeof DataSource> = {
|
||||||
default: T;
|
default: T;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -75,6 +75,45 @@ export const updateNode = (node: MNode, dsl: MApp) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 source 的字段复制到 target,对象型字段使用惰性深拷贝(首次读取时拷贝并缓存)
|
||||||
|
* @param target 目标对象
|
||||||
|
* @param source 源对象
|
||||||
|
* @param skipKey 需要跳过的字段
|
||||||
|
*/
|
||||||
|
const assignLazyClone = (target: Record<string, any>, source: Record<string, any> | undefined, skipKey: string) => {
|
||||||
|
if (!source) return;
|
||||||
|
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
if (key === skipKey) continue;
|
||||||
|
|
||||||
|
const value = source[key];
|
||||||
|
|
||||||
|
if (typeof value !== 'object' || value === null) {
|
||||||
|
target[key] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cloned: any;
|
||||||
|
let isCloned = false;
|
||||||
|
Object.defineProperty(target, key, {
|
||||||
|
enumerable: true,
|
||||||
|
configurable: true,
|
||||||
|
get() {
|
||||||
|
if (!isCloned) {
|
||||||
|
cloned = cloneDeep(value);
|
||||||
|
isCloned = true;
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
},
|
||||||
|
set(newValue) {
|
||||||
|
cloned = newValue;
|
||||||
|
isCloned = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建迭代器容器编译的数据上下文
|
* 创建迭代器容器编译的数据上下文
|
||||||
* @param itemData 迭代数据
|
* @param itemData 迭代数据
|
||||||
@ -93,24 +132,26 @@ export const createIteratorContentData = (
|
|||||||
[dsId]: {},
|
[dsId]: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
let rawData = cloneDeep(dsData[dsId]);
|
// 沿 fields 路径逐层构造新对象,不修改原始数据。
|
||||||
|
// 路径之外的对象型字段不会预先整体深拷贝(迭代项很多时会导致 O(N²)),而是在首次被读取时才拷贝并缓存,
|
||||||
|
// 这样依然是与数据源隔离的快照(引用每次编译都是新的,保证 vue 组件的 props 能感知到按 path 原地修改的数据)。
|
||||||
|
let rawData: Record<string, any> | undefined = dsData[dsId];
|
||||||
let obj: Record<string, any> = data[dsId];
|
let obj: Record<string, any> = data[dsId];
|
||||||
|
|
||||||
fields.forEach((key, index) => {
|
fields.forEach((key, index) => {
|
||||||
Object.assign(obj, rawData);
|
assignLazyClone(obj, rawData, key);
|
||||||
|
|
||||||
if (index === fields.length - 1) {
|
if (index === fields.length - 1) {
|
||||||
obj[key] = itemData;
|
obj[key] = itemData;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(rawData[key])) {
|
const next = rawData?.[key];
|
||||||
rawData[key] = {};
|
const nextObj: Record<string, any> = {};
|
||||||
obj[key] = {};
|
obj[key] = nextObj;
|
||||||
}
|
|
||||||
|
|
||||||
rawData = rawData[key];
|
rawData = Array.isArray(next) ? undefined : next;
|
||||||
obj = obj[key];
|
obj = nextObj;
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
|
|||||||
@ -841,3 +841,314 @@ describe('createDataSourceManager - 自定义数据源类型尚未注册', () =>
|
|||||||
expect(manager?.get('ds_lazy')).toBeInstanceOf(LazyDataSource);
|
expect(manager?.get('ds_lazy')).toBeInstanceOf(LazyDataSource);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createDataSourceManager - batch 批量更新', () => {
|
||||||
|
let app: TMagicApp;
|
||||||
|
let manager: DataSourceManager;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const dsl: MApp = {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app_batch',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'page_1',
|
||||||
|
items: [
|
||||||
|
{ type: 'text', id: 'text_a', text: 'a ${ds_1.name}' } as any,
|
||||||
|
{ type: 'text', id: 'text_b', text: 'b ${ds_2.title}' } as any,
|
||||||
|
{ type: 'text', id: 'text_ab', text: '${ds_1.name}-${ds_2.title}' } as any,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSourceDeps: {
|
||||||
|
ds_1: {
|
||||||
|
text_a: { name: 'a', keys: ['text'] },
|
||||||
|
text_ab: { name: 'ab', keys: ['text'] },
|
||||||
|
},
|
||||||
|
ds_2: {
|
||||||
|
text_b: { name: 'b', keys: ['text'] },
|
||||||
|
text_ab: { name: 'ab', keys: ['text'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataSources: [
|
||||||
|
{ id: 'ds_1', type: 'base', fields: [{ name: 'name', defaultValue: 'n1' }], methods: [], events: [] },
|
||||||
|
{ id: 'ds_2', type: 'base', fields: [{ name: 'title', defaultValue: 't1' }], methods: [], events: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
app = new TMagicApp({ config: dsl, platform: 'mobile' });
|
||||||
|
manager = createDataSourceManager(app)!;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch 内多次 setData 只在结束后统一更新一次,数据本身实时更新', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
const change = vi.fn();
|
||||||
|
manager.on('change', change);
|
||||||
|
const dataChange = vi.fn();
|
||||||
|
manager.onDataChange('ds_1', 'name', dataChange);
|
||||||
|
|
||||||
|
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||||
|
|
||||||
|
const ds1 = manager.get('ds_1')!;
|
||||||
|
const result = manager.batch(
|
||||||
|
() => {
|
||||||
|
expect(manager.isBatching).toBe(true);
|
||||||
|
ds1.setData('x', 'name');
|
||||||
|
// 数据、change 事件、onDataChange 仍然实时生效
|
||||||
|
expect(manager.data.ds_1.name).toBe('x');
|
||||||
|
expect(change).toHaveBeenCalledTimes(1);
|
||||||
|
expect(dataChange).toHaveBeenCalledTimes(1);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
ds1.setData('y', 'name');
|
||||||
|
return 'ret';
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('ret');
|
||||||
|
expect(manager.isBatching).toBe(false);
|
||||||
|
expect(change).toHaveBeenCalledTimes(2);
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(compiledSpy).toHaveBeenCalledTimes(2); // text_a, text_ab 各编译一次
|
||||||
|
|
||||||
|
const [nodes, sourceId, changeEvent, pageId] = update.mock.calls[0];
|
||||||
|
expect(sourceId).toBe('ds_1');
|
||||||
|
expect(pageId).toBe('page_1');
|
||||||
|
// 同一数据源多次变化,只保留最后一次 changeEvent
|
||||||
|
expect(changeEvent.updateData).toBe('y');
|
||||||
|
expect(nodes.map((n: any) => n.id)).toEqual(['text_a', 'text_ab']);
|
||||||
|
expect(app.getNode('text_a')?.data.text).toBe('a y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('多个数据源的变化:节点只编译一次,按数据源分别发出 update-data', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||||
|
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('N', 'name');
|
||||||
|
manager.get('ds_2')!.setData('T', 'title');
|
||||||
|
});
|
||||||
|
|
||||||
|
// text_a、text_b、text_ab 各编译一次
|
||||||
|
expect(compiledSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
const bySource: Record<string, string[]> = {};
|
||||||
|
update.mock.calls.forEach(([nodes, sourceId]) => {
|
||||||
|
bySource[sourceId] = nodes.map((n: any) => n.id);
|
||||||
|
});
|
||||||
|
expect(bySource.ds_1).toEqual(['text_a', 'text_ab']);
|
||||||
|
expect(bySource.ds_2).toEqual(['text_b', 'text_ab']);
|
||||||
|
|
||||||
|
expect(app.getNode('text_ab')?.data.text).toBe('N-T');
|
||||||
|
expect(app.getNode('text_b')?.data.text).toBe('b T');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('嵌套 batch 只在最外层结束时更新', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('inner', 'name');
|
||||||
|
});
|
||||||
|
expect(manager.isBatching).toBe(true);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch 内没有数据变化时不触发更新;fn 抛错时也会更新并恢复状态', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(() => undefined);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('err', 'name');
|
||||||
|
throw new Error('boom');
|
||||||
|
}),
|
||||||
|
).toThrow('boom');
|
||||||
|
|
||||||
|
expect(manager.isBatching).toBe(false);
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.getNode('text_a')?.data.text).toBe('a err');
|
||||||
|
|
||||||
|
// 恢复后普通 setData 仍然同步更新
|
||||||
|
manager.get('ds_1')!.setData('after', 'name');
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeChangeEvents: false 时每次变化各触发一次 update-data,节点仍只编译一次', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
const compiledSpy = vi.spyOn(manager, 'compiledNode');
|
||||||
|
|
||||||
|
manager.batch(
|
||||||
|
() => {
|
||||||
|
manager.get('ds_1')!.setData('x', 'name');
|
||||||
|
manager.get('ds_1')!.setData('y', 'name');
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// 每次变化各一次,各自携带自己的 changeEvent
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(update.mock.calls.map((call) => call[2].updateData)).toEqual(['x', 'y']);
|
||||||
|
expect(update.mock.calls.map((call) => call[1])).toEqual(['ds_1', 'ds_1']);
|
||||||
|
|
||||||
|
// 页面节点仍然只编译一次
|
||||||
|
expect(compiledSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(app.getNode('text_a')?.data.text).toBe('a y');
|
||||||
|
expect(app.getNode('text_ab')?.data.text).toBe('y-t1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeChangeEvents: false 时多个数据源各自触发,且只带自己依赖的节点', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(
|
||||||
|
() => {
|
||||||
|
manager.get('ds_1')!.setData('N', 'name');
|
||||||
|
manager.get('ds_2')!.setData('T', 'title');
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
const bySource: Record<string, string[]> = {};
|
||||||
|
update.mock.calls.forEach(([nodes, sourceId]) => {
|
||||||
|
bySource[sourceId] = nodes.map((n: any) => n.id);
|
||||||
|
});
|
||||||
|
expect(bySource.ds_1).toEqual(['text_a', 'text_ab']);
|
||||||
|
expect(bySource.ds_2).toEqual(['text_b', 'text_ab']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('嵌套 batch 的选项以最外层为准', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(
|
||||||
|
() => {
|
||||||
|
manager.batch(
|
||||||
|
() => {
|
||||||
|
manager.get('ds_1')!.setData('x', 'name');
|
||||||
|
manager.get('ds_1')!.setData('y', 'name');
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: false },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ mergeChangeEvents: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(1);
|
||||||
|
expect(update.mock.calls[0][2].updateData).toBe('y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('默认(不传选项)每次变化各触发一次,各自携带自己的 changeEvent', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('x', 'name');
|
||||||
|
manager.get('ds_1')!.setData('y', 'name');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(update).toHaveBeenCalledTimes(2);
|
||||||
|
expect(update.mock.calls.map((call) => call[2].updateData)).toEqual(['x', 'y']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('destroy 会清空 batch 状态', () => {
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('x', 'name');
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(manager.isBatching).toBe(false);
|
||||||
|
expect(update).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createDataSourceManager - 父子节点同时依赖同一数据源', () => {
|
||||||
|
const create = () => {
|
||||||
|
const dsl: MApp = {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app_nested',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'page_1',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: 'container',
|
||||||
|
id: 'node_b',
|
||||||
|
title: 'b ${ds_1.name}',
|
||||||
|
items: [{ type: 'text', id: 'node_a', text: 'a ${ds_1.name}' }],
|
||||||
|
} as any,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSourceDeps: {
|
||||||
|
ds_1: {
|
||||||
|
node_b: { name: 'b', keys: ['title'] },
|
||||||
|
node_a: { name: 'a', keys: ['text'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dataSources: [
|
||||||
|
{ id: 'ds_1', type: 'base', fields: [{ name: 'name', defaultValue: 'n' }], methods: [], events: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const app = new TMagicApp({ config: dsl, platform: 'mobile' });
|
||||||
|
const manager = createDataSourceManager(app)!;
|
||||||
|
return { app, manager };
|
||||||
|
};
|
||||||
|
|
||||||
|
test('页面数据中父、子节点都是最新编译结果,子节点不会被覆盖', () => {
|
||||||
|
const { app, manager } = create();
|
||||||
|
manager.get('ds_1')!.setData('v1', 'name');
|
||||||
|
|
||||||
|
const pageData: any = app.page!.data;
|
||||||
|
expect(pageData.items[0].title).toBe('b v1');
|
||||||
|
expect(pageData.items[0].items[0].text).toBe('a v1');
|
||||||
|
|
||||||
|
manager.get('ds_1')!.setData('v2', 'name');
|
||||||
|
expect(pageData.items[0].title).toBe('b v2');
|
||||||
|
expect(pageData.items[0].items[0].text).toBe('a v2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('update-data 的节点顺序为父节点在前,运行时按顺序替换不会丢失子节点更新', () => {
|
||||||
|
const { app, manager } = create();
|
||||||
|
const update = vi.fn();
|
||||||
|
manager.on('update-data', update);
|
||||||
|
|
||||||
|
manager.get('ds_1')!.setData('v1', 'name');
|
||||||
|
|
||||||
|
const nodes = update.mock.calls[0][0] as any[];
|
||||||
|
expect(nodes.map((n) => n.id)).toEqual(['node_b', 'node_a']);
|
||||||
|
|
||||||
|
// 新父节点与原父节点共用同一个 items 数组
|
||||||
|
const container: any = app.page!.data.items![0];
|
||||||
|
expect(nodes[0].items).toBe(container.items);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('batch 中的父子节点同样不会互相覆盖', () => {
|
||||||
|
const { app, manager } = create();
|
||||||
|
manager.batch(() => {
|
||||||
|
manager.get('ds_1')!.setData('x', 'name');
|
||||||
|
manager.get('ds_1')!.setData('y', 'name');
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageData: any = app.page!.data;
|
||||||
|
expect(pageData.items[0].title).toBe('b y');
|
||||||
|
expect(pageData.items[0].items[0].text).toBe('a y');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
116
packages/data-source/tests/dataSourceFieldEvent.spec.ts
Normal file
116
packages/data-source/tests/dataSourceFieldEvent.spec.ts
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
import TMagicApp, { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, IteratorContainer, NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
const createApp = (beforeEventHandler?: (e: any) => void) =>
|
||||||
|
new TMagicApp({
|
||||||
|
platform: 'mobile',
|
||||||
|
beforeEventHandler,
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
type: NodeType.PAGE,
|
||||||
|
id: 'page_1',
|
||||||
|
items: [{ type: 'text', id: 'n1', text: '${ds_1.foo}' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dataSourceDeps: { ds_1: { n1: { name: 'n1', keys: ['text'] } } },
|
||||||
|
dataSources: [
|
||||||
|
{
|
||||||
|
id: 'ds_1',
|
||||||
|
type: 'base',
|
||||||
|
fields: [
|
||||||
|
{ name: 'foo', type: 'string', defaultValue: 'f0' },
|
||||||
|
{ name: 'bar', type: 'string', defaultValue: 'b0' },
|
||||||
|
],
|
||||||
|
methods: [],
|
||||||
|
events: [{ name: `${DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX}.foo`, actions: [] }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any,
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
describe('数据源字段更新的事件监听', () => {
|
||||||
|
test('onDataChange 在 batch 内同步触发,且每次 setData 都会触发', () => {
|
||||||
|
const app = createApp();
|
||||||
|
const manager = app.dataSourceManager!;
|
||||||
|
const ds = manager.get('ds_1')!;
|
||||||
|
|
||||||
|
const callback = vi.fn();
|
||||||
|
manager.onDataChange('ds_1', 'foo', callback);
|
||||||
|
|
||||||
|
const order: string[] = [];
|
||||||
|
callback.mockImplementation(() => order.push('callback'));
|
||||||
|
|
||||||
|
manager.batch(() => {
|
||||||
|
ds.setData('a', 'foo');
|
||||||
|
order.push('after-first');
|
||||||
|
ds.setData('b', 'foo');
|
||||||
|
order.push('after-second');
|
||||||
|
});
|
||||||
|
order.push('batch-end');
|
||||||
|
|
||||||
|
// 监听在 setData 时同步执行,不会被 batch 合并
|
||||||
|
expect(order).toEqual(['callback', 'after-first', 'callback', 'after-second', 'batch-end']);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(2);
|
||||||
|
expect(callback.mock.calls[1][0].updateData).toBe('b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源字段变更事件(联动动作)在 batch 内仍按每次 setData 触发', async () => {
|
||||||
|
const events: any[] = [];
|
||||||
|
const app = createApp((e) => events.push(e));
|
||||||
|
const manager = app.dataSourceManager!;
|
||||||
|
app.eventHelper!.bindDataSourceEvents();
|
||||||
|
const ds = manager.get('ds_1')!;
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
ds.setData('x', 'foo');
|
||||||
|
await flush();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
manager.batch(() => {
|
||||||
|
ds.setData('y', 'foo');
|
||||||
|
ds.setData('z', 'bar');
|
||||||
|
});
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
// foo 的监听触发 1 次(bar 没有配置事件),batch 不会合并字段变更事件
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
expect(events[0].args[0].updateData).toBe('y');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('反复重建迭代节点不会移除、也不会重复注册数据源字段变更监听', async () => {
|
||||||
|
const events: any[] = [];
|
||||||
|
const app = createApp((e) => events.push(e));
|
||||||
|
const manager = app.dataSourceManager!;
|
||||||
|
app.eventHelper!.bindDataSourceEvents();
|
||||||
|
const helper: any = app.eventHelper;
|
||||||
|
const ds = manager.get('ds_1')!;
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
const before = helper.dataSourceEventList.get('ds_1')?.size;
|
||||||
|
const offDataChange = vi.spyOn(ds, 'offDataChange');
|
||||||
|
|
||||||
|
const container = new IteratorContainer({ config: { id: 'iter', type: 'iterator-container' } as any, app });
|
||||||
|
for (let round = 0; round < 20; round++) {
|
||||||
|
container.resetNodes();
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
container.setNodes([{ id: `btn_${i}`, type: 'button', events: [{ name: 'click', actions: [] }] } as any], i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offDataChange).not.toHaveBeenCalled();
|
||||||
|
expect(helper.dataSourceEventList.get('ds_1')?.size).toBe(before);
|
||||||
|
|
||||||
|
events.length = 0;
|
||||||
|
ds.setData('after', 'foo');
|
||||||
|
await flush();
|
||||||
|
expect(events).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -54,4 +54,61 @@ describe('getDeps', () => {
|
|||||||
const r2 = getDeps(ds, nodes, false);
|
const r2 = getDeps(ds, nodes, false);
|
||||||
expect(r1).toBe(r2);
|
expect(r1).toBe(r2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ds: any = { id: 'ds_ref', fields: [{ name: 'list', type: 'array', fields: [{ name: 'name' }] }] };
|
||||||
|
|
||||||
|
test('节点对象不变(即使放进新数组)时命中缓存', () => {
|
||||||
|
const node: any = { id: 'n_hit', type: 'text', text: '${ds_ref.list.name}' };
|
||||||
|
const r1 = getDeps(ds, [node], false);
|
||||||
|
const r2 = getDeps(ds, [node], false);
|
||||||
|
expect(r1).toBe(r2);
|
||||||
|
expect(r1.deps.n_hit.keys).toEqual(['text']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([false, true])('同 id 的节点被替换且绑定的字段变了,重新收集依赖 (inEditor=%s)', (inEditor) => {
|
||||||
|
const before: any = { id: 'n_replaced', type: 'text', text: '${ds_ref.list.name}', title: 'plain' };
|
||||||
|
const after: any = { id: 'n_replaced', type: 'text', text: 'plain', title: '${ds_ref.list.name}' };
|
||||||
|
|
||||||
|
const r1 = getDeps(ds, [before], inEditor);
|
||||||
|
const r2 = getDeps(ds, [after], inEditor);
|
||||||
|
|
||||||
|
expect(r1.deps.n_replaced.keys).toEqual(['text']);
|
||||||
|
expect(r2.deps.n_replaced.keys).toEqual(['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inEditor=true 时子节点被替换(父节点不变)也会重新收集', () => {
|
||||||
|
const parent: any = {
|
||||||
|
id: 'n_parent',
|
||||||
|
type: 'container',
|
||||||
|
items: [{ id: 'n_child', type: 'text', text: '${ds_ref.list.name}' }],
|
||||||
|
};
|
||||||
|
const r1 = getDeps(ds, [parent], true);
|
||||||
|
expect(r1.deps.n_child.keys).toEqual(['text']);
|
||||||
|
|
||||||
|
parent.items.splice(0, 1, { id: 'n_child', type: 'text', title: '${ds_ref.list.name}' });
|
||||||
|
const r2 = getDeps(ds, [parent], true);
|
||||||
|
expect(r2.deps.n_child.keys).toEqual(['title']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不同数据源、不同 inEditor 的结果互不影响', () => {
|
||||||
|
const node: any = { id: 'n_multi', type: 'text', text: '${ds_ref.list.name} ${ds_other.list.name}' };
|
||||||
|
const other: any = { id: 'ds_other', fields: [{ name: 'list', type: 'array', fields: [{ name: 'name' }] }] };
|
||||||
|
|
||||||
|
const a = getDeps(ds, [node], false);
|
||||||
|
const b = getDeps(other, [node], false);
|
||||||
|
const c = getDeps(ds, [node], true);
|
||||||
|
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
expect(a).not.toBe(c);
|
||||||
|
expect(getDeps(ds, [node], false)).toBe(a);
|
||||||
|
expect(getDeps(other, [node], false)).toBe(b);
|
||||||
|
expect(getDeps(ds, [node], true)).toBe(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nodes 为空时不缓存也不报错', () => {
|
||||||
|
const r1 = getDeps(ds, [], false);
|
||||||
|
const r2 = getDeps(ds, [], false);
|
||||||
|
expect(r1.deps).toEqual({});
|
||||||
|
expect(r2.deps).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
156
packages/data-source/tests/iterator-reactivity.spec.ts
Normal file
156
packages/data-source/tests/iterator-reactivity.spec.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { afterAll, describe, expect, test } from 'vitest';
|
||||||
|
import { computed, createApp, defineComponent, h, nextTick, ref } from 'vue';
|
||||||
|
|
||||||
|
import TMagicApp, { NodeType } from '@tmagic/core';
|
||||||
|
|
||||||
|
import { DataSourceManager } from '@data-source/index';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模拟 IteratorContainer.vue:
|
||||||
|
* configs 是依赖 props.config 的 computed,内部调用 dataSourceManager.compliedIteratorItems,
|
||||||
|
* 每项渲染一个 Item,Item 把绑定到的“兄弟字段对象”作为 prop 继续传给 Cell。
|
||||||
|
*/
|
||||||
|
const setup = () => {
|
||||||
|
const dsId = 'ds_iter_reactivity';
|
||||||
|
const app = new TMagicApp({
|
||||||
|
config: {
|
||||||
|
type: NodeType.ROOT,
|
||||||
|
id: 'app_iter_reactivity',
|
||||||
|
items: [],
|
||||||
|
dataSources: [
|
||||||
|
{
|
||||||
|
type: 'base',
|
||||||
|
id: dsId,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'list',
|
||||||
|
type: 'array',
|
||||||
|
fields: [{ name: 'label' }],
|
||||||
|
defaultValue: [{ label: 'A' }, { label: 'B' }],
|
||||||
|
},
|
||||||
|
{ name: 'matrix', type: 'array', fields: [{ name: 'v' }], defaultValue: [{ v: 1 }] },
|
||||||
|
],
|
||||||
|
methods: [],
|
||||||
|
events: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const dsm = new DataSourceManager({ app });
|
||||||
|
const ds = dsm.get(dsId)!;
|
||||||
|
|
||||||
|
const rawMatrix = dsm.data[dsId].matrix;
|
||||||
|
|
||||||
|
const templateNodes = [
|
||||||
|
{
|
||||||
|
id: 'iter_reactivity_node',
|
||||||
|
type: 'text',
|
||||||
|
text: '${ds_iter_reactivity.list.label}',
|
||||||
|
// 绑定整个数据源,编译结果是迭代上下文(matrix 是迭代路径之外的兄弟字段)
|
||||||
|
cell: { isBindDataSource: true, dataSourceId: dsId },
|
||||||
|
},
|
||||||
|
] as any[];
|
||||||
|
|
||||||
|
const renderCount = { cell: 0, item: 0 };
|
||||||
|
|
||||||
|
const cellComp = defineComponent({
|
||||||
|
props: { matrix: { type: Array, required: true } },
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
renderCount.cell += 1;
|
||||||
|
return h('i', { class: 'cell' }, String((props.matrix[0] as any).v));
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const itemComp = defineComponent({
|
||||||
|
props: { config: { type: Object, required: true } },
|
||||||
|
setup(props) {
|
||||||
|
return () => {
|
||||||
|
renderCount.item += 1;
|
||||||
|
const node = (props.config as any).items[0];
|
||||||
|
return h('div', { class: 'item' }, [h('b', node.text), h(cellComp, { matrix: node.cell.matrix })]);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 与 IteratorContainer.vue 一样:数据源变化 -> 节点被替换(config 变成新对象) -> computed 重算
|
||||||
|
const containerConfig = ref<any>({ iteratorData: ds.data.list, items: templateNodes });
|
||||||
|
|
||||||
|
const containerComp = defineComponent({
|
||||||
|
setup() {
|
||||||
|
const configs = computed(() =>
|
||||||
|
(containerConfig.value.iteratorData as any[]).map((itemData) => ({
|
||||||
|
items: dsm.compliedIteratorItems(itemData, containerConfig.value.items, [dsId, 'list']),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
return () =>
|
||||||
|
h(
|
||||||
|
'div',
|
||||||
|
configs.value.map((config, index) => h(itemComp, { key: index, config })),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = document.createElement('div');
|
||||||
|
const vueApp = createApp(containerComp);
|
||||||
|
vueApp.mount(root);
|
||||||
|
|
||||||
|
// 模拟 createDataSourceManager 的 change 处理:触发容器节点被整体替换
|
||||||
|
const replaceContainerNode = () => {
|
||||||
|
containerConfig.value = { iteratorData: ds.data.list, items: templateNodes };
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ds, dsm, root, renderCount, rawMatrix, replaceContainerNode, unmount: () => vueApp.unmount() };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('迭代容器编译 + Vue 响应式', () => {
|
||||||
|
afterAll(() => {
|
||||||
|
DataSourceManager.clearDataSourceClass();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('路径之外的兄弟字段是与数据源隔离的快照,不修改数据源原始数据', async () => {
|
||||||
|
const { dsm, root, rawMatrix, unmount } = setup();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(root.querySelectorAll('.item').length).toBe(2);
|
||||||
|
expect(root.querySelector('.cell')?.textContent).toBe('1');
|
||||||
|
expect(rawMatrix).toEqual([{ v: 1 }]);
|
||||||
|
expect(dsm.data.ds_iter_reactivity.matrix).toBe(rawMatrix);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('整体替换数据源数据(setData 无 path)后,视图和子孙组件都会更新', async () => {
|
||||||
|
const { ds, root, replaceContainerNode, unmount } = setup();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
ds.setData([{ v: 2 }], 'matrix');
|
||||||
|
replaceContainerNode();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(root.querySelector('.cell')?.textContent).toBe('2');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按 path 原地修改兄弟字段对象内部值后,子孙组件也会更新', async () => {
|
||||||
|
const { ds, root, renderCount, replaceContainerNode, unmount } = setup();
|
||||||
|
await nextTick();
|
||||||
|
const cellRenderBefore = renderCount.cell;
|
||||||
|
|
||||||
|
// SimpleObservedData 按路径更新为原地修改,matrix[0] 的引用不变
|
||||||
|
ds.setData(9, 'matrix.0.v');
|
||||||
|
replaceContainerNode();
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// Item 一定会因 config 换新对象而重新渲染
|
||||||
|
expect(renderCount.item).toBeGreaterThan(2);
|
||||||
|
// 兄弟字段是新的快照,子孙组件的 props 会变化并拿到最新值
|
||||||
|
expect(renderCount.cell).toBeGreaterThan(cellRenderBefore);
|
||||||
|
expect(root.querySelector('.cell')?.textContent).toBe('9');
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -193,6 +193,63 @@ describe('createIteratorContentData', () => {
|
|||||||
});
|
});
|
||||||
expect(ctxData.ds.a.c.a).toBe(1);
|
expect(ctxData.ds.a.c.a).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('不修改原始数据源数据', () => {
|
||||||
|
const dsData: any = {
|
||||||
|
ds: {
|
||||||
|
a: [{ b: 0, c: [{ a: 1 }] }],
|
||||||
|
obj: { x: { y: 1 }, z: 2 },
|
||||||
|
b: 2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const snapshot = JSON.parse(JSON.stringify(dsData));
|
||||||
|
|
||||||
|
const ctxData: any = createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], dsData);
|
||||||
|
expect(ctxData.ds.a.c).toEqual({ a: 1 });
|
||||||
|
|
||||||
|
const objCtx: any = createIteratorContentData({ n: 1 }, 'ds', ['obj', 'x'], dsData);
|
||||||
|
expect(objCtx.ds.obj.x).toEqual({ n: 1 });
|
||||||
|
expect(objCtx.ds.obj.z).toBe(2);
|
||||||
|
expect(objCtx.ds.b).toBe(2);
|
||||||
|
|
||||||
|
expect(dsData).toEqual(snapshot);
|
||||||
|
expect(ctxData.ds).not.toBe(dsData.ds);
|
||||||
|
expect(objCtx.ds.obj).not.toBe(dsData.ds.obj);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('路径之外的对象型字段惰性深拷贝:与原数据隔离、多次读取引用稳定、可被覆盖', () => {
|
||||||
|
const dsData: any = { ds: { a: [{ b: 1 }], matrix: [{ v: 1 }], n: 1 } };
|
||||||
|
|
||||||
|
const ctxData: any = createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData);
|
||||||
|
|
||||||
|
expect(ctxData.ds.n).toBe(1);
|
||||||
|
const { matrix } = ctxData.ds;
|
||||||
|
expect(matrix).toEqual([{ v: 1 }]);
|
||||||
|
expect(matrix).not.toBe(dsData.ds.matrix);
|
||||||
|
expect(matrix[0]).not.toBe(dsData.ds.matrix[0]);
|
||||||
|
expect(ctxData.ds.matrix).toBe(matrix);
|
||||||
|
|
||||||
|
// 修改拷贝不影响原数据,原地修改原数据也不影响已读取过的快照
|
||||||
|
matrix[0].v = 2;
|
||||||
|
expect(dsData.ds.matrix[0].v).toBe(1);
|
||||||
|
dsData.ds.matrix[0].v = 3;
|
||||||
|
expect(ctxData.ds.matrix[0].v).toBe(2);
|
||||||
|
|
||||||
|
// 每次创建的上下文拿到的都是新的引用
|
||||||
|
expect(createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData).ds).not.toBe(ctxData.ds);
|
||||||
|
expect((createIteratorContentData({ b: 1 }, 'ds', ['a'], dsData) as any).ds.matrix).not.toBe(dsData.ds.matrix);
|
||||||
|
|
||||||
|
// 支持覆盖,对象展开可枚举
|
||||||
|
ctxData.ds.matrix = 'x';
|
||||||
|
expect(ctxData.ds.matrix).toBe('x');
|
||||||
|
expect(Object.keys(ctxData.ds)).toEqual(expect.arrayContaining(['a', 'matrix', 'n']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('数据源数据或路径中间层缺失时不抛错', () => {
|
||||||
|
expect((createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], {}) as any).ds.a.c).toEqual({ a: 1 });
|
||||||
|
expect((createIteratorContentData({ a: 1 }, 'ds', ['a', 'c'], { ds: {} }) as any).ds.a.c).toEqual({ a: 1 });
|
||||||
|
expect((createIteratorContentData({ a: 1 }, 'ds', [], { ds: { b: 1 } }) as any).ds).toEqual({});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('compliedConditions', () => {
|
describe('compliedConditions', () => {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/dep",
|
"name": "@tmagic/dep",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": false,
|
"sideEffects": false,
|
||||||
|
|||||||
@ -133,9 +133,13 @@ export default class Watcher {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems)
|
// 先清掉本次节点上的旧依赖,再统一收集。
|
||||||
|
// 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除,
|
||||||
|
// 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
this.removeTargetsDep(targets, node);
|
this.removeTargetsDep(targets, node);
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.collectItems(node, targets, depExtendedData, deep);
|
this.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -249,4 +249,86 @@ describe('Watcher', () => {
|
|||||||
expect(target1?.deps?.node_1).toBeUndefined();
|
expect(target1?.deps?.node_1).toBeUndefined();
|
||||||
expect(target1?.deps?.node_2).toBeUndefined();
|
expect(target1?.deps?.node_2).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('collect keeps deps when roots share node ids', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_a',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'a',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds_b',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'b',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'fragment_a',
|
||||||
|
name: 'fragmentA',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-a',
|
||||||
|
text: 'a',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fragment_b',
|
||||||
|
name: 'fragmentB',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'shared',
|
||||||
|
name: 'shared-b',
|
||||||
|
text: 'b',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetA = watcher.getTarget('ds_a', 'target');
|
||||||
|
const targetB = watcher.getTarget('ds_b', 'target');
|
||||||
|
|
||||||
|
expect(targetA?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetA?.deps?.shared.name).toBe('shared-a');
|
||||||
|
expect(targetB?.deps?.shared.keys).toEqual(['text']);
|
||||||
|
expect(targetB?.deps?.shared.name).toBe('shared-b');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collect merges keys when one target matches shared-id nodes in several roots', () => {
|
||||||
|
const watcher = new Watcher();
|
||||||
|
|
||||||
|
watcher.addTarget(
|
||||||
|
new Target({
|
||||||
|
type: 'target',
|
||||||
|
id: 'ds',
|
||||||
|
isTarget: (key: string | number, value: any) => key === 'text' && value === 'x',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.collect(
|
||||||
|
[
|
||||||
|
{ id: 'root_a', items: [{ id: 'shared', name: 'shared-a', text: 'x' }] },
|
||||||
|
{ id: 'root_b', items: [{ id: 'shared', name: 'shared-b', title: 'y', text: 'x' }] },
|
||||||
|
],
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const dep = watcher.getTarget('ds', 'target')?.deps?.shared;
|
||||||
|
expect(dep?.keys).toEqual(['text']);
|
||||||
|
// 同 id 的依赖只保留一条,name 取最后收集的节点
|
||||||
|
expect(dep?.name).toBe('shared-b');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/design",
|
"name": "@tmagic/design",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
|
|||||||
@ -24,7 +24,11 @@ defineOptions({
|
|||||||
|
|
||||||
const emit = defineEmits(['confirm', 'cancel']);
|
const emit = defineEmits(['confirm', 'cancel']);
|
||||||
|
|
||||||
const props = defineProps<PopconfirmProps>();
|
// Boolean 类型的属性缺省时会被 Vue 转成 false,会把浮层强制内联渲染到侧栏等 overflow 容器中,
|
||||||
|
// 导致 placement 计算出的位置被裁剪。与 PopconfirmProps 注释及底层组件默认值保持一致。
|
||||||
|
const props = withDefaults(defineProps<PopconfirmProps>(), {
|
||||||
|
teleported: true,
|
||||||
|
});
|
||||||
|
|
||||||
const ui = getDesignConfig('components')?.popconfirm;
|
const ui = getDesignConfig('components')?.popconfirm;
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": "1.8.1",
|
"version": "1.8.5",
|
||||||
"name": "@tmagic/editor",
|
"name": "@tmagic/editor",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"sideEffects": [
|
"sideEffects": [
|
||||||
@ -22,6 +22,11 @@
|
|||||||
"import": "./dist/es/headless.js",
|
"import": "./dist/es/headless.js",
|
||||||
"require": "./dist/tmagic-editor-headless.umd.cjs"
|
"require": "./dist/tmagic-editor-headless.umd.cjs"
|
||||||
},
|
},
|
||||||
|
"./sdk": {
|
||||||
|
"types": "./types/sdk.d.ts",
|
||||||
|
"import": "./dist/es/sdk.js",
|
||||||
|
"require": "./dist/tmagic-editor-sdk.umd.cjs"
|
||||||
|
},
|
||||||
"./dist/style.css": {
|
"./dist/style.css": {
|
||||||
"import": "./dist/style.css",
|
"import": "./dist/style.css",
|
||||||
"require": "./dist/style.css"
|
"require": "./dist/style.css"
|
||||||
|
|||||||
@ -148,17 +148,19 @@ import FormPanel from './layouts/props-panel/FormPanel.vue';
|
|||||||
import PropsPanel from './layouts/props-panel/PropsPanel.vue';
|
import PropsPanel from './layouts/props-panel/PropsPanel.vue';
|
||||||
import Sidebar from './layouts/sidebar/Sidebar.vue';
|
import Sidebar from './layouts/sidebar/Sidebar.vue';
|
||||||
import Workspace from './layouts/workspace/Workspace.vue';
|
import Workspace from './layouts/workspace/Workspace.vue';
|
||||||
import codeBlockService from './services/codeBlock';
|
|
||||||
import componentListService from './services/componentList';
|
|
||||||
import dataSourceService from './services/dataSource';
|
|
||||||
import depService from './services/dep';
|
|
||||||
import editorService from './services/editor';
|
|
||||||
import eventsService from './services/events';
|
|
||||||
import historyService from './services/history';
|
|
||||||
import keybindingService from './services/keybinding';
|
import keybindingService from './services/keybinding';
|
||||||
import propsService from './services/props';
|
import {
|
||||||
|
codeBlockService,
|
||||||
|
componentListService,
|
||||||
|
dataSourceService,
|
||||||
|
depService,
|
||||||
|
editorService,
|
||||||
|
eventsService,
|
||||||
|
historyService,
|
||||||
|
propsService,
|
||||||
|
storageService,
|
||||||
|
} from './services/sdk-instance';
|
||||||
import stageOverlayService from './services/stageOverlay';
|
import stageOverlayService from './services/stageOverlay';
|
||||||
import storageService from './services/storage';
|
|
||||||
import uiService from './services/ui';
|
import uiService from './services/ui';
|
||||||
import keybindingConfig from './utils/keybinding-config';
|
import keybindingConfig from './utils/keybinding-config';
|
||||||
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
|
import { defaultEditorProps, EditorProps, ENABLE_PROPS_FORM_VALIDATE } from './editorProps';
|
||||||
@ -222,11 +224,14 @@ const stageOptions: StageOptions = {
|
|||||||
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
containerHighlightAddOnly: props.containerHighlightAddOnly,
|
||||||
disabledDragStart: props.disabledDragStart,
|
disabledDragStart: props.disabledDragStart,
|
||||||
renderType: props.renderType,
|
renderType: props.renderType,
|
||||||
|
iframeSandbox: props.iframeSandbox,
|
||||||
guidesOptions: props.guidesOptions,
|
guidesOptions: props.guidesOptions,
|
||||||
disabledMultiSelect: props.disabledMultiSelect,
|
disabledMultiSelect: props.disabledMultiSelect,
|
||||||
alwaysMultiSelect: props.alwaysMultiSelect,
|
alwaysMultiSelect: props.alwaysMultiSelect,
|
||||||
disabledFlashTip: props.disabledFlashTip,
|
disabledFlashTip: props.disabledFlashTip,
|
||||||
beforeDblclick: props.beforeDblclick,
|
beforeDblclick: props.beforeDblclick,
|
||||||
|
beforeAppendComponent: props.beforeAppendComponent,
|
||||||
|
beforeDropComponent: props.beforeDropComponent,
|
||||||
};
|
};
|
||||||
|
|
||||||
stageOverlayService.set('stageOptions', stageOptions);
|
stageOverlayService.set('stageOptions', stageOptions);
|
||||||
@ -236,7 +241,12 @@ provide('services', services);
|
|||||||
provide('codeOptions', props.codeOptions);
|
provide('codeOptions', props.codeOptions);
|
||||||
provide('stageOptions', stageOptions);
|
provide('stageOptions', stageOptions);
|
||||||
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
|
/** 是否启用「属性配置表单校验」联动能力,供 PropsPanel / FormPanel 判断校验失败时是否仍更新节点并记录错误 */
|
||||||
provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
|
// 同 isLargeStageContainer:用 computed 包一层,否则传下去的是 provide 那一刻的值快照,
|
||||||
|
// props.enablePropsFormValidate 后续变化不会同步到子孙。
|
||||||
|
provide(
|
||||||
|
ENABLE_PROPS_FORM_VALIDATE,
|
||||||
|
computed(() => props.enablePropsFormValidate ?? false),
|
||||||
|
);
|
||||||
/**
|
/**
|
||||||
* 编辑器注入给整棵表单树的业务上下文(`services` / `stage`),属性面板、对比表单、
|
* 编辑器注入给整棵表单树的业务上下文(`services` / `stage`),属性面板、对比表单、
|
||||||
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承。
|
* 侧边栏里的嵌套 MForm 都通过 inject 自动继承。
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { InjectionKey } from 'vue';
|
import type { ComputedRef, InjectionKey } from 'vue';
|
||||||
|
|
||||||
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
import type { DataSourceSchema, EventOption, Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
|
||||||
import type { FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
@ -13,6 +13,8 @@ import StageCore, {
|
|||||||
import { getIdFromEl } from '@tmagic/utils';
|
import { getIdFromEl } from '@tmagic/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
BeforeAppendComponent,
|
||||||
|
BeforeDropComponent,
|
||||||
CanDropInFunction,
|
CanDropInFunction,
|
||||||
ComponentGroup,
|
ComponentGroup,
|
||||||
CustomContentMenuFunction,
|
CustomContentMenuFunction,
|
||||||
@ -31,8 +33,11 @@ import type {
|
|||||||
/**
|
/**
|
||||||
* 「属性配置表单校验」联动能力的 provide/inject 注入键。
|
* 「属性配置表单校验」联动能力的 provide/inject 注入键。
|
||||||
* 使用 Symbol 避免与其它字符串键冲突,供 PropsPanel / FormPanel 注入判断校验失败时是否仍更新节点并记录错误。
|
* 使用 Symbol 避免与其它字符串键冲突,供 PropsPanel / FormPanel 注入判断校验失败时是否仍更新节点并记录错误。
|
||||||
|
*
|
||||||
|
* 注入值是 ComputedRef:Editor 用 `computed(() => props.enablePropsFormValidate ?? false)` 提供,
|
||||||
|
* 保证外部动态切换该 prop 时子孙组件能同步拿到最新值;子孙侧用 `.value` 读取。
|
||||||
*/
|
*/
|
||||||
export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<boolean> = Symbol('enablePropsFormValidate');
|
export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<ComputedRef<boolean>> = Symbol('enablePropsFormValidate');
|
||||||
|
|
||||||
export interface EditorProps {
|
export interface EditorProps {
|
||||||
/** 是否是大屏模拟器容器,大屏容器时,左侧属性面板会扩展为两列(正常3列),颜色表单不扩展两列等 */
|
/** 是否是大屏模拟器容器,大屏容器时,左侧属性面板会扩展为两列(正常3列),颜色表单不扩展两列等 */
|
||||||
@ -59,6 +64,16 @@ export interface EditorProps {
|
|||||||
runtimeUrl?: string;
|
runtimeUrl?: string;
|
||||||
/** 是用iframe渲染还是直接渲染 */
|
/** 是用iframe渲染还是直接渲染 */
|
||||||
renderType?: RenderType;
|
renderType?: RenderType;
|
||||||
|
/**
|
||||||
|
* 画布 iframe 的 sandbox 属性值(仅 iframe 渲染时生效)
|
||||||
|
*
|
||||||
|
* - 不传 / `false`:不设置 sandbox 属性(默认)
|
||||||
|
* - 字符串:原样写入,token 空格分隔,如 `'allow-scripts allow-same-origin allow-downloads'`
|
||||||
|
*
|
||||||
|
* 需要注意编辑器自身依赖 `allow-scripts` + `allow-same-origin`(读写 iframe 内 DOM),
|
||||||
|
* 其余 token(`allow-downloads`、`allow-popups`、`allow-forms` 等)按业务需要放开。
|
||||||
|
*/
|
||||||
|
iframeSandbox?: string | false;
|
||||||
/** 选中时是否自动滚动到可视区域 */
|
/** 选中时是否自动滚动到可视区域 */
|
||||||
autoScrollIntoView?: boolean;
|
autoScrollIntoView?: boolean;
|
||||||
/** 组件的属性配置表单的dsl */
|
/** 组件的属性配置表单的dsl */
|
||||||
@ -147,6 +162,17 @@ export interface EditorProps {
|
|||||||
canDropIn?: CanDropInFunction;
|
canDropIn?: CanDropInFunction;
|
||||||
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
|
||||||
|
/**
|
||||||
|
* 点击组件列表新增组件前的钩子,入参为当前组件列表项,允许异步函数。
|
||||||
|
* 仅点击新增时调用,拖入画布不走该钩子;返回 false 取消本次新增。
|
||||||
|
* 可直接修改入参(例如补全 data),修改会反映到最终新增的节点上。
|
||||||
|
*/
|
||||||
|
beforeAppendComponent?: BeforeAppendComponent;
|
||||||
|
/**
|
||||||
|
* 组件列表拖入画布、写入节点前的钩子,入参为即将新增的节点和父节点,允许异步函数。
|
||||||
|
* 仅当拖拽类型为组件列表时,在位置计算完成之后调用;返回 false 取消本次拖入。
|
||||||
|
*/
|
||||||
|
beforeDropComponent?: BeforeDropComponent;
|
||||||
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
/** 组件树节点双击前的钩子函数,返回 false 则阻止默认的双击行为 */
|
||||||
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
beforeLayerNodeDblclick?: (event: MouseEvent, data: TreeNodeData) => Promise<boolean | void> | boolean | void;
|
||||||
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
/** 历史记录面板的自定义扩展 tab,追加在内置的页面/数据源/代码块 tab 之后 */
|
||||||
|
|||||||
@ -53,7 +53,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { computed, inject, ref, watch } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import { Edit, View } from '@element-plus/icons-vue';
|
import { Edit, View } from '@element-plus/icons-vue';
|
||||||
import { isEmpty, map } from 'lodash-es';
|
import { isEmpty, map } from 'lodash-es';
|
||||||
|
|
||||||
@ -132,16 +132,14 @@ const getParamItemsConfig = (codeId?: Id): CodeParamStatement[] => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const codeDsl = computed(() => codeBlockService.getCodeDsl());
|
const codeDsl = computed(() => codeBlockService.getCodeDsl());
|
||||||
const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[props.name]));
|
|
||||||
|
|
||||||
watch(
|
/**
|
||||||
() => props.model[props.name],
|
* 参数字段配置跟随当前 model / codeDsl 派生,而不是 setup 期的一次性快照。
|
||||||
(v, preV) => {
|
*
|
||||||
if (v !== preV) {
|
* 同类型组件切换选中时字段实例会被复用,且新节点的 codeId 可能与旧节点相同,
|
||||||
paramsConfig.value = getParamItemsConfig(v);
|
* 按值 watch 不会触发,参数配置会停留在上一个节点上。
|
||||||
}
|
*/
|
||||||
},
|
const paramsConfig = computed<CodeParamStatement[]>(() => getParamItemsConfig(props.model[props.name]));
|
||||||
);
|
|
||||||
|
|
||||||
const selectConfig: SelectConfig = {
|
const selectConfig: SelectConfig = {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
@ -161,8 +159,8 @@ const selectConfig: SelectConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onCodeIdChangeHandler = (value: any) => {
|
const onCodeIdChangeHandler = (value: any) => {
|
||||||
// 通过下拉框选择的codeId变化后修正model的值,避免写入其他codeId的params
|
// model 尚未写入新值(由 MForm 收到 change 后统一写回),参数字段配置先按新 codeId 算
|
||||||
paramsConfig.value = getParamItemsConfig(value);
|
const nextParamsConfig = getParamItemsConfig(value);
|
||||||
|
|
||||||
const changeRecords = [
|
const changeRecords = [
|
||||||
{
|
{
|
||||||
@ -173,8 +171,8 @@ const onCodeIdChangeHandler = (value: any) => {
|
|||||||
|
|
||||||
changeRecords.push({
|
changeRecords.push({
|
||||||
propPath: props.prop.replace(`${props.name}`, 'params'),
|
propPath: props.prop.replace(`${props.name}`, 'params'),
|
||||||
value: paramsConfig.value.length
|
value: nextParamsConfig.length
|
||||||
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
|
? createValues(mForm, nextParamsConfig as unknown as FormItemConfig[], {}, props.model.params)
|
||||||
: {},
|
: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -36,7 +36,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup name="">
|
<script lang="ts" setup name="">
|
||||||
import { computed, inject, ref } from 'vue';
|
import { computed, inject } from 'vue';
|
||||||
import { Edit, View } from '@element-plus/icons-vue';
|
import { Edit, View } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import type { Id } from '@tmagic/core';
|
import type { Id } from '@tmagic/core';
|
||||||
@ -85,6 +85,7 @@ const notEditable = computed(() => filterFunction(mForm, props.config.notEditabl
|
|||||||
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
const isCompare = computed(() => Boolean(mForm?.isCompare));
|
||||||
|
|
||||||
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
const dataSources = computed(() => dataSourceService.get('dataSources'));
|
||||||
|
const codeOptions = inject('codeOptions', {});
|
||||||
|
|
||||||
const isCustomMethod = computed(() => {
|
const isCustomMethod = computed(() => {
|
||||||
const [id, name] = props.model[props.name];
|
const [id, name] = props.model[props.name];
|
||||||
@ -125,7 +126,7 @@ const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']
|
|||||||
return type;
|
return type;
|
||||||
},
|
},
|
||||||
language: 'javascript',
|
language: 'javascript',
|
||||||
options: inject('codeOptions', {}),
|
options: codeOptions,
|
||||||
autosize: {
|
autosize: {
|
||||||
minRows: 1,
|
minRows: 1,
|
||||||
maxRows: 10,
|
maxRows: 10,
|
||||||
@ -146,7 +147,16 @@ const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[props.name || 'dataSourceMethod']));
|
/**
|
||||||
|
* 参数字段配置跟随当前 model 派生,而不是 setup 期的一次性快照。
|
||||||
|
*
|
||||||
|
* 属性面板在同类型组件之间切换选中时会复用表单字段实例,此时 model 已换成新节点;
|
||||||
|
* 快照会让「新节点的方法」配上「旧节点的参数字段」,看起来像两个组件的事件配置混在了一起。
|
||||||
|
* 数据源列表异步就绪时也能随 computed 自动补齐参数配置。
|
||||||
|
*/
|
||||||
|
const paramsConfig = computed<CodeParamStatement[]>(() =>
|
||||||
|
getParamItemsConfig(props.model[props.name || 'dataSourceMethod']),
|
||||||
|
);
|
||||||
|
|
||||||
const methodsOptions = computed(
|
const methodsOptions = computed(
|
||||||
() =>
|
() =>
|
||||||
@ -176,7 +186,8 @@ const cascaderConfig = computed<CascaderConfig>(() => ({
|
|||||||
* 参数值修改更新
|
* 参数值修改更新
|
||||||
*/
|
*/
|
||||||
const onChangeHandler = (value: any) => {
|
const onChangeHandler = (value: any) => {
|
||||||
paramsConfig.value = getParamItemsConfig(value);
|
// model 尚未写入新值(由 MForm 收到 change 后统一写回),参数字段配置先按新选中的方法算
|
||||||
|
const nextParamsConfig = getParamItemsConfig(value);
|
||||||
|
|
||||||
const changeRecords = [
|
const changeRecords = [
|
||||||
{
|
{
|
||||||
@ -187,8 +198,8 @@ const onChangeHandler = (value: any) => {
|
|||||||
|
|
||||||
changeRecords.push({
|
changeRecords.push({
|
||||||
propPath: props.prop.replace(`${props.name}`, 'params'),
|
propPath: props.prop.replace(`${props.name}`, 'params'),
|
||||||
value: paramsConfig.value.length
|
value: nextParamsConfig.length
|
||||||
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
|
? createValues(mForm, nextParamsConfig as unknown as FormItemConfig[], {}, props.model.params)
|
||||||
: {},
|
: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -8,14 +8,35 @@ import type { validateDataSourceFieldSelect } from '@editor/utils/type-match-rul
|
|||||||
|
|
||||||
import BackgroundPosition from './components/BackgroundPosition.vue';
|
import BackgroundPosition from './components/BackgroundPosition.vue';
|
||||||
import {
|
import {
|
||||||
|
AlignItemsBaseline,
|
||||||
AlignItemsCenter,
|
AlignItemsCenter,
|
||||||
|
AlignItemsEnd,
|
||||||
AlignItemsFlexEnd,
|
AlignItemsFlexEnd,
|
||||||
AlignItemsFlexStart,
|
AlignItemsFlexStart,
|
||||||
|
AlignItemsNormal,
|
||||||
AlignItemsSpaceAround,
|
AlignItemsSpaceAround,
|
||||||
AlignItemsSpaceBetween,
|
AlignItemsSpaceBetween,
|
||||||
|
AlignItemsStart,
|
||||||
|
AlignItemsStretch,
|
||||||
} from './icons/align-items';
|
} from './icons/align-items';
|
||||||
import { BackgroundNoRepeat, BackgroundRepeat, BackgroundRepeatX, BackgroundRepeatY } from './icons/background-repeat';
|
import {
|
||||||
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from './icons/display';
|
BackgroundNoRepeat,
|
||||||
|
BackgroundRepeat,
|
||||||
|
BackgroundRepeatRound,
|
||||||
|
BackgroundRepeatSpace,
|
||||||
|
BackgroundRepeatX,
|
||||||
|
BackgroundRepeatY,
|
||||||
|
} from './icons/background-repeat';
|
||||||
|
import {
|
||||||
|
DisplayBlock,
|
||||||
|
DisplayFlex,
|
||||||
|
DisplayGrid,
|
||||||
|
DisplayInline,
|
||||||
|
DisplayInlineBlock,
|
||||||
|
DisplayInlineFlex,
|
||||||
|
DisplayInlineGrid,
|
||||||
|
DisplayNone,
|
||||||
|
} from './icons/display';
|
||||||
import {
|
import {
|
||||||
FlexDirectionColumn,
|
FlexDirectionColumn,
|
||||||
FlexDirectionColumnReverse,
|
FlexDirectionColumnReverse,
|
||||||
@ -28,8 +49,17 @@ import {
|
|||||||
JustifyContentFlexStart,
|
JustifyContentFlexStart,
|
||||||
JustifyContentSpaceAround,
|
JustifyContentSpaceAround,
|
||||||
JustifyContentSpaceBetween,
|
JustifyContentSpaceBetween,
|
||||||
|
JustifyContentSpaceEvenly,
|
||||||
} from './icons/justify-content';
|
} from './icons/justify-content';
|
||||||
import { AlignCenter, AlignLeft, AlignRight } from './icons/text-align';
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignEnd,
|
||||||
|
AlignJustify,
|
||||||
|
AlignLeft,
|
||||||
|
AlignMatchParent,
|
||||||
|
AlignRight,
|
||||||
|
AlignStart,
|
||||||
|
} from './icons/text-align';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 「布局」面板配置。
|
* 「布局」面板配置。
|
||||||
@ -71,6 +101,9 @@ export const createLayoutConfig = (theme: string): FormConfig => [
|
|||||||
icon: markRaw(DisplayNone),
|
icon: markRaw(DisplayNone),
|
||||||
tooltip: '隐藏 none',
|
tooltip: '隐藏 none',
|
||||||
},
|
},
|
||||||
|
{ value: 'inline-flex', icon: markRaw(DisplayInlineFlex), tooltip: '内联弹性布局 inline-flex' },
|
||||||
|
{ value: 'grid', icon: markRaw(DisplayGrid), tooltip: '网格布局 grid' },
|
||||||
|
{ value: 'inline-grid', icon: markRaw(DisplayInlineGrid), tooltip: '内联网格布局 inline-grid' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
defineFormItem({
|
defineFormItem({
|
||||||
@ -121,6 +154,7 @@ export const createLayoutConfig = (theme: string): FormConfig => [
|
|||||||
icon: markRaw(JustifyContentSpaceAround),
|
icon: markRaw(JustifyContentSpaceAround),
|
||||||
tooltip: '横向平分 space-around',
|
tooltip: '横向平分 space-around',
|
||||||
},
|
},
|
||||||
|
{ value: 'space-evenly', icon: markRaw(JustifyContentSpaceEvenly), tooltip: '等间距分布 space-evenly' },
|
||||||
],
|
],
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
}),
|
}),
|
||||||
@ -145,6 +179,11 @@ export const createLayoutConfig = (theme: string): FormConfig => [
|
|||||||
icon: markRaw(AlignItemsSpaceAround),
|
icon: markRaw(AlignItemsSpaceAround),
|
||||||
tooltip: '横向平分 space-around',
|
tooltip: '横向平分 space-around',
|
||||||
},
|
},
|
||||||
|
{ value: 'stretch', icon: markRaw(AlignItemsStretch), tooltip: '拉伸填满 stretch' },
|
||||||
|
{ value: 'baseline', icon: markRaw(AlignItemsBaseline), tooltip: '基线对齐 baseline' },
|
||||||
|
{ value: 'normal', icon: markRaw(AlignItemsNormal), tooltip: '默认 normal' },
|
||||||
|
{ value: 'start', icon: markRaw(AlignItemsStart), tooltip: '起始对齐 start' },
|
||||||
|
{ value: 'end', icon: markRaw(AlignItemsEnd), tooltip: '结束对齐 end' },
|
||||||
],
|
],
|
||||||
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
|
||||||
}),
|
}),
|
||||||
@ -222,9 +261,9 @@ export const createLayoutConfig = (theme: string): FormConfig => [
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'data-source-field-select',
|
type: 'data-source-field-select',
|
||||||
text: '透明度(%)',
|
text: '透明度(0-1)',
|
||||||
name: 'opacity',
|
name: 'opacity',
|
||||||
labelWidth: '90px',
|
labelWidth: '100px',
|
||||||
dataSourceFieldType: ['string', 'number'],
|
dataSourceFieldType: ['string', 'number'],
|
||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
@ -445,6 +484,8 @@ export const createBackgroundConfig = (): FormConfig => [
|
|||||||
icon: markRaw(BackgroundRepeat),
|
icon: markRaw(BackgroundRepeat),
|
||||||
tooltip: '垂直和水平方向重复 repeat',
|
tooltip: '垂直和水平方向重复 repeat',
|
||||||
},
|
},
|
||||||
|
{ value: 'space', icon: markRaw(BackgroundRepeatSpace), tooltip: '不裁剪、均匀留白 space' },
|
||||||
|
{ value: 'round', icon: markRaw(BackgroundRepeatRound), tooltip: '缩放铺满 round' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
defineFormItem({
|
defineFormItem({
|
||||||
@ -502,9 +543,9 @@ export const createFontConfig = (validateDataSourceField: typeof validateDataSou
|
|||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
allowCreate: true,
|
allowCreate: true,
|
||||||
options: ['normal', 'bold']
|
options: ['normal', 'bold', 'bolder', 'lighter']
|
||||||
.concat(
|
.concat(
|
||||||
Array(7)
|
Array(9)
|
||||||
.fill(1)
|
.fill(1)
|
||||||
.map((x, i) => `${i + 1}00`),
|
.map((x, i) => `${i + 1}00`),
|
||||||
)
|
)
|
||||||
@ -575,10 +616,16 @@ export const createFontConfig = (validateDataSourceField: typeof validateDataSou
|
|||||||
type: 'radioGroup',
|
type: 'radioGroup',
|
||||||
childType: 'button',
|
childType: 'button',
|
||||||
labelWidth: '68px',
|
labelWidth: '68px',
|
||||||
|
// 7 个值统一用图标按钮:带中文文案会因字数不同撑出不同宽度,换行后列线和边框错开。
|
||||||
|
// 中文说明放在 tooltip 里,与 display / justifyContent 等布局选项保持一致。
|
||||||
options: [
|
options: [
|
||||||
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
|
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 left' },
|
||||||
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
|
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
|
||||||
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
|
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
|
||||||
|
{ value: 'start', icon: markRaw(AlignStart), tooltip: '起始对齐 start' },
|
||||||
|
{ value: 'end', icon: markRaw(AlignEnd), tooltip: '结束对齐 end' },
|
||||||
|
{ value: 'justify', icon: markRaw(AlignJustify), tooltip: '两端对齐 justify' },
|
||||||
|
{ value: 'match-parent', icon: markRaw(AlignMatchParent), tooltip: '按父级方向对齐 match-parent' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@ -680,10 +727,12 @@ export const createBorderDirectionConfig = (direction: string) =>
|
|||||||
type: 'data-source-field-select',
|
type: 'data-source-field-select',
|
||||||
fieldConfig: {
|
fieldConfig: {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: ['solid', 'dashed', 'dotted'].map((item) => ({
|
options: ['none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset'].map(
|
||||||
|
(item) => ({
|
||||||
value: item,
|
value: item,
|
||||||
text: item,
|
text: item,
|
||||||
})),
|
}),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="2" y="3" width="2.5" height="8" rx="0.5" />
|
||||||
|
<rect x="6.75" y="6" width="2.5" height="5" rx="0.5" />
|
||||||
|
<rect x="11.5" y="1" width="2.5" height="10" rx="0.5" />
|
||||||
|
<rect x="0.75" y="11.25" width="14.5" height="0.75" rx="0.375" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="2" y="2" width="9" height="1" rx="0.5" />
|
||||||
|
<path d="M11 0.75L14 2.5 11 4.25Z" />
|
||||||
|
<rect x="3" y="10" width="10" height="4" rx="0.5" fill="none" stroke="currentColor" />
|
||||||
|
<rect x="1" y="14.5" width="14" height="0.5" rx="0.25" fill-opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,6 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="1" y="1" width="14" height="14" rx="1" fill="none" stroke="currentColor" stroke-opacity="0.3" />
|
||||||
|
<rect x="3" y="5.5" width="10" height="5" rx="0.5" fill="none" stroke="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="2" y="2" width="9" height="1" rx="0.5" />
|
||||||
|
<path d="M11 0.75L14 2.5 11 4.25Z" />
|
||||||
|
<rect x="3" y="6" width="10" height="4" rx="0.5" fill="none" stroke="currentColor" />
|
||||||
|
<rect x="1" y="5" width="14" height="0.5" rx="0.25" fill-opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="1" y="1" width="14" height="14" rx="1" fill="none" stroke="currentColor" stroke-opacity="0.3" />
|
||||||
|
<rect x="3" y="2" width="10" height="12" rx="0.5" fill="none" stroke="currentColor" />
|
||||||
|
<path d="M8 4.5L9.75 6.5H8.75V9.5H9.75L8 11.5 6.25 9.5H7.25V6.5H6.25Z" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -1,5 +1,10 @@
|
|||||||
|
export { default as AlignItemsBaseline } from './Baseline.vue';
|
||||||
export { default as AlignItemsCenter } from './Center.vue';
|
export { default as AlignItemsCenter } from './Center.vue';
|
||||||
|
export { default as AlignItemsEnd } from './End.vue';
|
||||||
export { default as AlignItemsFlexEnd } from './FlexEnd.vue';
|
export { default as AlignItemsFlexEnd } from './FlexEnd.vue';
|
||||||
export { default as AlignItemsFlexStart } from './FlexStart.vue';
|
export { default as AlignItemsFlexStart } from './FlexStart.vue';
|
||||||
|
export { default as AlignItemsNormal } from './Normal.vue';
|
||||||
export { default as AlignItemsSpaceAround } from './SpaceAround.vue';
|
export { default as AlignItemsSpaceAround } from './SpaceAround.vue';
|
||||||
export { default as AlignItemsSpaceBetween } from './SpaceBetween.vue';
|
export { default as AlignItemsSpaceBetween } from './SpaceBetween.vue';
|
||||||
|
export { default as AlignItemsStart } from './Start.vue';
|
||||||
|
export { default as AlignItemsStretch } from './Stretch.vue';
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="1" y="1" width="6.5" height="6.5" rx="0.5" />
|
||||||
|
<rect x="8.5" y="1" width="6.5" height="6.5" rx="0.5" />
|
||||||
|
<rect x="1" y="8.5" width="6.5" height="6.5" rx="0.5" />
|
||||||
|
<rect x="8.5" y="8.5" width="6.5" height="6.5" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="0.5" y="0.5" width="15" height="15" rx="1" fill="none" stroke="currentColor" stroke-opacity="0.3" />
|
||||||
|
<rect x="3.5" y="3.5" width="3" height="3" rx="0.5" />
|
||||||
|
<rect x="9.5" y="3.5" width="3" height="3" rx="0.5" />
|
||||||
|
<rect x="3.5" y="9.5" width="3" height="3" rx="0.5" />
|
||||||
|
<rect x="9.5" y="9.5" width="3" height="3" rx="0.5" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -2,3 +2,5 @@ export { default as BackgroundRepeat } from './Repeat.vue';
|
|||||||
export { default as BackgroundRepeatX } from './RepeatX.vue';
|
export { default as BackgroundRepeatX } from './RepeatX.vue';
|
||||||
export { default as BackgroundRepeatY } from './RepeatY.vue';
|
export { default as BackgroundRepeatY } from './RepeatY.vue';
|
||||||
export { default as BackgroundNoRepeat } from './NoRepeat.vue';
|
export { default as BackgroundNoRepeat } from './NoRepeat.vue';
|
||||||
|
export { default as BackgroundRepeatRound } from './Round.vue';
|
||||||
|
export { default as BackgroundRepeatSpace } from './Space.vue';
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M960 128a64 64 0 0 1 64 64v640a64 64 0 0 1-64 64H64a64 64 0 0 1-64-64V192a64 64 0 0 1 64-64h896z m0 64H64v640h896V192zM416 256v512H368V256h48zM656 256v512H608V256h48zM768 368v48H256V368h512zM768 608v48H256V608h512z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M960 128a64 64 0 0 1 64 64v640a64 64 0 0 1-64 64H64a64 64 0 0 1-64-64V192a64 64 0 0 1 64-64h896z m0 64H64v640h896V192zM288 400v48H128V400h160zM288 576v48H128V576h160zM896 400v48H736V400h160zM896 576v48H736V576h160zM704 320v384H320V320h384zM656 368H368v288h288V368zM448 416v192H384V416h64zM544 416v192H480V416h64zM640 416v192H576V416h64z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M960 128a64 64 0 0 1 64 64v640a64 64 0 0 1-64 64H64a64 64 0 0 1-64-64V192a64 64 0 0 1 64-64h896z m0 64H64v640h896V192zM288 400v48H128V400h160zM288 576v48H128V576h160zM896 400v48H736V400h160zM896 576v48H736V576h160zM704 320v384H320V320h384zM656 368H368v288h288V368zM496 384v112H384V384h112zM640 384v112H528V384h112zM496 528v112H384V528h112zM640 528v112H528V528h112z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -1,5 +1,8 @@
|
|||||||
export { default as DisplayBlock } from './Block.vue';
|
export { default as DisplayBlock } from './Block.vue';
|
||||||
export { default as DisplayFlex } from './Flex.vue';
|
export { default as DisplayFlex } from './Flex.vue';
|
||||||
|
export { default as DisplayGrid } from './Grid.vue';
|
||||||
export { default as DisplayInline } from './Inline.vue';
|
export { default as DisplayInline } from './Inline.vue';
|
||||||
export { default as DisplayInlineBlock } from './InlineBlock.vue';
|
export { default as DisplayInlineBlock } from './InlineBlock.vue';
|
||||||
|
export { default as DisplayInlineFlex } from './InlineFlex.vue';
|
||||||
|
export { default as DisplayInlineGrid } from './InlineGrid.vue';
|
||||||
export { default as DisplayNone } from './None.vue';
|
export { default as DisplayNone } from './None.vue';
|
||||||
|
|||||||
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<rect x="3" y="4.5" width="1.5" height="7" rx="0.25" fill="none" stroke="currentColor" />
|
||||||
|
<rect x="7.5" y="4.5" width="1.5" height="7" rx="0.25" fill="none" stroke="currentColor" />
|
||||||
|
<rect x="12" y="4.5" width="1.5" height="7" rx="0.25" fill="none" stroke="currentColor" />
|
||||||
|
<rect x="1.25" y="2" width="0.5" height="12" rx="0.25" fill-opacity="0.3" />
|
||||||
|
<rect x="6" y="2" width="0.5" height="12" rx="0.25" fill-opacity="0.3" />
|
||||||
|
<rect x="10.5" y="2" width="0.5" height="12" rx="0.25" fill-opacity="0.3" />
|
||||||
|
<rect x="15" y="2" width="0.5" height="12" rx="0.25" fill-opacity="0.3" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -3,3 +3,4 @@ export { default as JustifyContentFlexEnd } from './FlexEnd.vue';
|
|||||||
export { default as JustifyContentFlexStart } from './FlexStart.vue';
|
export { default as JustifyContentFlexStart } from './FlexStart.vue';
|
||||||
export { default as JustifyContentSpaceAround } from './SpaceAround.vue';
|
export { default as JustifyContentSpaceAround } from './SpaceAround.vue';
|
||||||
export { default as JustifyContentSpaceBetween } from './SpaceBetween.vue';
|
export { default as JustifyContentSpaceBetween } from './SpaceBetween.vue';
|
||||||
|
export { default as JustifyContentSpaceEvenly } from './SpaceEvenly.vue';
|
||||||
|
|||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M128 96h64v192H128zM224 160h544v64H224zM768 96L896 192 768 288zM256 384h640v64H256zM128 512h768v64H128zM384 640h512v64H384zM192 768h704v64H192z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M128 128h768v85.333333H128z m0 170.666667h768v85.333333H128z m0 170.666666h768v85.333334H128z m0 170.666667h768v85.333333H128z m0 170.666667h768v85.333333H128z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M64 64h896v48H64z m0 848h896v48H64z m0-800h48v800H64z m848 0h48v800h-48zM192 208h416v64H192z m416-48L768 240 608 320zM464 368h96v96h-96z m-64 96h224l-112 128zM192 608h640v56H192z m0 112h512v56H192z m0 112h576v56H192z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path
|
||||||
|
d="M128 96h64v192H128zM224 160h544v64H224zM768 96L896 192 768 288zM128 384h640v64H128zM128 512h768v64H128zM128 640h512v64H128zM128 768h704v64H128z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@ -1,3 +1,7 @@
|
|||||||
export { default as AlignLeft } from './Left.vue';
|
export { default as AlignLeft } from './Left.vue';
|
||||||
export { default as AlignCenter } from './Center.vue';
|
export { default as AlignCenter } from './Center.vue';
|
||||||
export { default as AlignRight } from './Right.vue';
|
export { default as AlignRight } from './Right.vue';
|
||||||
|
export { default as AlignEnd } from './End.vue';
|
||||||
|
export { default as AlignJustify } from './Justify.vue';
|
||||||
|
export { default as AlignMatchParent } from './MatchParent.vue';
|
||||||
|
export { default as AlignStart } from './Start.vue';
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
<template v-if="val">
|
<template v-if="val">
|
||||||
<TMagicTooltip v-if="!isCompare" content="清除" placement="top">
|
<TMagicTooltip v-if="!isCompare" content="清除" placement="top">
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
|
class="m-fields-ui-select__clear"
|
||||||
style="padding: 0"
|
style="padding: 0"
|
||||||
type="danger"
|
type="danger"
|
||||||
:icon="Close"
|
:icon="Close"
|
||||||
@ -20,6 +21,7 @@
|
|||||||
|
|
||||||
<TMagicTooltip content="点击选中组件" placement="top">
|
<TMagicTooltip content="点击选中组件" placement="top">
|
||||||
<TMagicButton
|
<TMagicButton
|
||||||
|
class="m-fields-ui-select__name"
|
||||||
link
|
link
|
||||||
style="padding: 0; margin: 0"
|
style="padding: 0; margin: 0"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@ -27,7 +29,7 @@
|
|||||||
@click="selectNode(val)"
|
@click="selectNode(val)"
|
||||||
@mouseenter="highlight(val)"
|
@mouseenter="highlight(val)"
|
||||||
@mouseleave="unhighlight"
|
@mouseleave="unhighlight"
|
||||||
>{{ `${toName}_${val}` }}</TMagicButton
|
><span class="m-fields-ui-select__label">{{ `${toName}_${val}` }}</span></TMagicButton
|
||||||
>
|
>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -19,10 +19,7 @@
|
|||||||
import { isEmpty } from 'lodash-es';
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
import { HookCodeType, HookType } from '@tmagic/core';
|
import { HookCodeType, HookType } from '@tmagic/core';
|
||||||
import type { CodeSelectConfig, FormValue, GroupListConfig } from '@tmagic/form/headless';
|
import type { CodeSelectConfig, FormState, FormValue, GroupListConfig } from '@tmagic/form/headless';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
|
|
||||||
@ -42,21 +39,21 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
codeId: '',
|
codeId: '',
|
||||||
}),
|
}),
|
||||||
...stickyAddButton(`添加${config.text || ''}`),
|
...stickyAddButton(`添加${config.text || ''}`),
|
||||||
title: (_mForm: any, { model, index }: any) => {
|
title: (mForm: FormState | undefined, { model, index }: any) => {
|
||||||
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
|
||||||
if (Array.isArray(model.codeId)) {
|
if (Array.isArray(model.codeId)) {
|
||||||
if (model.codeId.length < 2) {
|
if (model.codeId.length < 2) {
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ds = dataSourceService.getDataSourceById(model.codeId[0]);
|
const ds = mForm?.services?.dataSourceService.getDataSourceById(model.codeId[0]);
|
||||||
return `${ds?.title} / ${model.codeId[1]}`;
|
return `${ds?.title} / ${model.codeId[1]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
return Array.isArray(model.codeId) ? model.codeId.join('/') : index;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeContent = codeBlockService.getCodeContentById(model.codeId);
|
const codeContent = mForm?.services?.codeBlockService.getCodeContentById(model.codeId);
|
||||||
|
|
||||||
if (codeContent) {
|
if (codeContent) {
|
||||||
return codeContent.name;
|
return codeContent.name;
|
||||||
@ -92,7 +89,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
|
|
||||||
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
display: (_mForm: any, { model }: any) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'data-source-method-select',
|
type: 'data-source-method-select',
|
||||||
@ -100,7 +97,7 @@ export const createCodeSelectConfig = (config: CodeSelectConfig): GroupListConfi
|
|||||||
text: '数据源字段',
|
text: '数据源字段',
|
||||||
rules: [{ typeMatch: true, trigger: 'change' }],
|
rules: [{ typeMatch: true, trigger: 'change' }],
|
||||||
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
display: (_mForm: any, { model }: any) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as any as GroupListConfig;
|
} as any as GroupListConfig;
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
|
import type { DisplayCondsConfig, FormState, GroupListConfig } from '@tmagic/form/headless';
|
||||||
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
import { removeDataSourceFieldPrefix } from '@tmagic/utils';
|
||||||
|
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import { getCascaderOptionsFromFields, getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
import { getCascaderOptionsFromFields, getFieldType, resolveFieldByPath } from '@editor/utils/data-source';
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
@ -38,15 +37,17 @@ export const createDisplayCondsConfig = (
|
|||||||
name: string,
|
name: string,
|
||||||
parentFields: string[],
|
parentFields: string[],
|
||||||
): GroupListConfig => {
|
): GroupListConfig => {
|
||||||
const resolveFieldPath = (path: Array<string | number>) => {
|
const resolveFieldPath = (formState: FormState | undefined, path: Array<string | number>) => {
|
||||||
const [id, ...fieldNames] = path;
|
const [id, ...fieldNames] = path;
|
||||||
const ds = id ? dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`)) : undefined;
|
const ds = id
|
||||||
|
? formState?.services?.dataSourceService.getDataSourceById(removeDataSourceFieldPrefix(`${id}`))
|
||||||
|
: undefined;
|
||||||
return { ds, fieldNames };
|
return { ds, fieldNames };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
|
// 字段变更后按新字段的类型把已填的值转成对应类型,避免类型校验与运行期取值不一致
|
||||||
const fieldOnChange = (_formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
const fieldOnChange = (formState: FormState | undefined, v: string[], { model }: { model: Record<string, any> }) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...v]);
|
const { ds, fieldNames } = resolveFieldPath(formState, [...parentFields, ...v]);
|
||||||
const type = getFieldType(ds, fieldNames);
|
const type = getFieldType(ds, fieldNames);
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
model.value = Number(model.value);
|
model.value = Number(model.value);
|
||||||
@ -63,8 +64,8 @@ export const createDisplayCondsConfig = (
|
|||||||
const fieldItem = parentFields.length
|
const fieldItem = parentFields.length
|
||||||
? {
|
? {
|
||||||
type: 'cascader',
|
type: 'cascader',
|
||||||
options: () => {
|
options: (formState: FormState | undefined) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath(parentFields);
|
const { ds, fieldNames } = resolveFieldPath(formState, parentFields);
|
||||||
if (!ds) {
|
if (!ds) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@ -141,8 +142,8 @@ export const createDisplayCondsConfig = (
|
|||||||
{
|
{
|
||||||
name: 'value',
|
name: 'value',
|
||||||
text: '值',
|
text: '值',
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
type: (mForm: FormState | undefined, { model }: any) => {
|
||||||
const { ds, fieldNames } = resolveFieldPath([...parentFields, ...(model.field || [])]);
|
const { ds, fieldNames } = resolveFieldPath(mForm, [...parentFields, ...(model.field || [])]);
|
||||||
const type = getFieldType(ds, fieldNames);
|
const type = getFieldType(ds, fieldNames);
|
||||||
|
|
||||||
if (type === 'number') {
|
if (type === 'number') {
|
||||||
|
|||||||
@ -31,18 +31,14 @@ import type {
|
|||||||
} from '@tmagic/form/headless';
|
} from '@tmagic/form/headless';
|
||||||
import { defineFormItem } from '@tmagic/form/headless';
|
import { defineFormItem } from '@tmagic/form/headless';
|
||||||
|
|
||||||
import codeBlockService from '@editor/services/codeBlock';
|
import type { Services } from '@editor/type';
|
||||||
import dataSourceService from '@editor/services/dataSource';
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import eventsService from '@editor/services/events';
|
|
||||||
import propsService from '@editor/services/props';
|
|
||||||
import {
|
import {
|
||||||
getCompActionAllowedValues,
|
getCompActionAllowedValues,
|
||||||
getCompActionOptions,
|
getCompActionOptions,
|
||||||
getEventNameAllowedValues,
|
getEventNameAllowedValues,
|
||||||
getEventNameOptions,
|
getEventNameOptions,
|
||||||
normalizeCompActionValue,
|
normalizeCompActionValue,
|
||||||
} from '@editor/utils';
|
} from '@editor/utils/event';
|
||||||
|
|
||||||
import { stickyAddButton } from './stickyAddButton';
|
import { stickyAddButton } from './stickyAddButton';
|
||||||
|
|
||||||
@ -67,11 +63,14 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
|
|||||||
labelWidth: '70px',
|
labelWidth: '70px',
|
||||||
checkStrictly: () => config.src !== 'component',
|
checkStrictly: () => config.src !== 'component',
|
||||||
valueSeparator: '.',
|
valueSeparator: '.',
|
||||||
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(config.src, formValue),
|
options: (mForm: FormState, { formValue }: any) =>
|
||||||
|
mForm?.services ? getEventNameOptions(config.src, formValue, mForm.services) : [],
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
validator: ({ value, callback }: any, { formValue }: any) => {
|
validator: ({ value, callback }: any, { formValue }: any, mForm?: FormState) => {
|
||||||
const allowedNames = getEventNameAllowedValues(config as any, formValue);
|
const allowedNames = mForm?.services
|
||||||
|
? getEventNameAllowedValues(config as any, formValue, mForm.services)
|
||||||
|
: null;
|
||||||
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
if (allowedNames && allowedNames.size > 0 && value && !allowedNames.has(value)) {
|
||||||
return callback(`事件名(${value})不存在`);
|
return callback(`事件名(${value})不存在`);
|
||||||
}
|
}
|
||||||
@ -84,7 +83,7 @@ export const createEventNameConfig = (config: EventSelectConfig) => {
|
|||||||
return { ...defaultEventNameConfig, ...config.eventNameConfig };
|
return { ...defaultEventNameConfig, ...config.eventNameConfig };
|
||||||
};
|
};
|
||||||
|
|
||||||
const createActionTypeOptions = () => {
|
const createActionTypeOptions = (services?: Services) => {
|
||||||
const o: {
|
const o: {
|
||||||
text: string;
|
text: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -98,6 +97,12 @@ const createActionTypeOptions = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (!services) {
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { propsService, codeBlockService } = services;
|
||||||
|
|
||||||
if (!propsService.getDisabledCodeBlock()) {
|
if (!propsService.getDisabledCodeBlock()) {
|
||||||
o.push({
|
o.push({
|
||||||
text: '代码',
|
text: '代码',
|
||||||
@ -125,7 +130,7 @@ const createActionTypeConfig = (config: EventSelectConfig) => {
|
|||||||
text: '联动类型',
|
text: '联动类型',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
defaultValue: ActionType.COMP,
|
defaultValue: ActionType.COMP,
|
||||||
options: createActionTypeOptions(),
|
options: (mForm: FormState | undefined) => createActionTypeOptions(mForm?.services),
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
@ -171,8 +176,8 @@ const createCompActionConfig = (config: EventSelectConfig) => {
|
|||||||
const defaultCompActionConfig: DynamicTypeConfig = {
|
const defaultCompActionConfig: DynamicTypeConfig = {
|
||||||
name: 'method',
|
name: 'method',
|
||||||
text: '动作',
|
text: '动作',
|
||||||
type: (_mForm: FormState | undefined, { model }: any) => {
|
type: (mForm: FormState | undefined, { model }: any) => {
|
||||||
const to = editorService.getNodeById(model.to);
|
const to = mForm?.services?.editorService.getNodeById(model.to);
|
||||||
|
|
||||||
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
|
if (to?.type === 'page-fragment-container' && to.pageFragmentId) {
|
||||||
return 'cascader';
|
return 'cascader';
|
||||||
@ -182,12 +187,15 @@ const createCompActionConfig = (config: EventSelectConfig) => {
|
|||||||
},
|
},
|
||||||
checkStrictly: () => config.src !== 'component',
|
checkStrictly: () => config.src !== 'component',
|
||||||
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
display: (_mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
|
||||||
options: (_mForm: FormState, { model }: any) => getCompActionOptions(model.to),
|
options: (mForm: FormState, { model }: any) =>
|
||||||
|
mForm?.services ? getCompActionOptions(model.to, mForm.services) : [],
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
trigger: 'blur',
|
trigger: 'blur',
|
||||||
validator: ({ value, callback }: any, { model }: any) => {
|
validator: ({ value, callback }: any, { model }: any, mForm?: FormState) => {
|
||||||
const allowedMethods = getCompActionAllowedValues(config as any, model);
|
const allowedMethods = mForm?.services
|
||||||
|
? getCompActionAllowedValues(config as any, model, mForm.services)
|
||||||
|
: null;
|
||||||
const normalized = normalizeCompActionValue(value);
|
const normalized = normalizeCompActionValue(value);
|
||||||
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
if (allowedMethods && allowedMethods.size > 0 && normalized && !allowedMethods.has(normalized)) {
|
||||||
return callback(`动作名(${normalized})不存在`);
|
return callback(`动作名(${normalized})不存在`);
|
||||||
@ -206,7 +214,7 @@ const createCodeActionConfig = (config: EventSelectConfig) => {
|
|||||||
type: 'code-select-col',
|
type: 'code-select-col',
|
||||||
text: '代码块',
|
text: '代码块',
|
||||||
name: 'codeId',
|
name: 'codeId',
|
||||||
notEditable: () => !codeBlockService.getEditStatus(),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.codeBlockService.getEditStatus(),
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
|
display: (_mForm, { model }) => model.actionType === ActionType.CODE,
|
||||||
};
|
};
|
||||||
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
|
return { ...defaultCodeActionConfig, ...config.codeActionConfig };
|
||||||
@ -218,7 +226,7 @@ const createDataSourceActionConfig = (config: EventSelectConfig) => {
|
|||||||
type: 'data-source-method-select',
|
type: 'data-source-method-select',
|
||||||
text: '数据源方法',
|
text: '数据源方法',
|
||||||
name: 'dataSourceMethod',
|
name: 'dataSourceMethod',
|
||||||
notEditable: () => !dataSourceService.get('editable'),
|
notEditable: (mForm: FormState | undefined) => !mForm?.services?.dataSourceService.get('editable'),
|
||||||
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
display: (_mForm, { model }) => model.actionType === ActionType.DATA_SOURCE,
|
||||||
};
|
};
|
||||||
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
|
return { ...defaultDataSourceActionConfig, ...config.dataSourceActionConfig };
|
||||||
@ -232,7 +240,7 @@ export const createActionsConfig = (config: EventSelectConfig): GroupListConfig
|
|||||||
expandAll: true,
|
expandAll: true,
|
||||||
enableToggleMode: false,
|
enableToggleMode: false,
|
||||||
titlePrefix: '动作',
|
titlePrefix: '动作',
|
||||||
labelPosition: 'top',
|
labelWidth: '90px',
|
||||||
flat: true,
|
flat: true,
|
||||||
...stickyAddButton('新增动作'),
|
...stickyAddButton('新增动作'),
|
||||||
items: [
|
items: [
|
||||||
@ -272,13 +280,17 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
label: '事件名',
|
label: '事件名',
|
||||||
type: createEventNameConfig(config).type,
|
type: createEventNameConfig(config).type,
|
||||||
options: (_mForm: FormState, { formValue }: any) =>
|
options: (mForm: FormState, { formValue }: any) => {
|
||||||
eventsService
|
const services = mForm?.services;
|
||||||
.getEvent(formValue.type, { node: editorService.getNodeById(formValue.id) })
|
if (!services) return [];
|
||||||
|
|
||||||
|
return services.eventsService
|
||||||
|
.getEvent(formValue.type, { node: services.editorService.getNodeById(formValue.id) })
|
||||||
.map((option: any) => ({
|
.map((option: any) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
})),
|
}));
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'to',
|
name: 'to',
|
||||||
@ -289,11 +301,14 @@ export const createLegacyTableConfig = (config: EventSelectConfig): TableConfig
|
|||||||
name: 'method',
|
name: 'method',
|
||||||
label: '动作',
|
label: '动作',
|
||||||
type: createCompActionConfig(config).type,
|
type: createCompActionConfig(config).type,
|
||||||
options: (_mForm: FormState, { model, formValue }: any) => {
|
options: (mForm: FormState, { model, formValue }: any) => {
|
||||||
const node = editorService.getNodeById(model.to) || (formValue as MNode);
|
const services = mForm?.services;
|
||||||
|
if (!services) return [];
|
||||||
|
|
||||||
|
const node = services.editorService.getNodeById(model.to) || (formValue as MNode);
|
||||||
if (!node?.type) return [];
|
if (!node?.type) return [];
|
||||||
|
|
||||||
return eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
return services.eventsService.getMethod(node.type, { targetId: model.to, node }).map((option: any) => ({
|
||||||
text: option.label,
|
text: option.label,
|
||||||
value: option.value,
|
value: option.value,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import type { MNode } from '@tmagic/core';
|
|||||||
import StageCore, { GuidesType, RemoveEventData, SortEventData, UpdateEventData } from '@tmagic/stage';
|
import StageCore, { GuidesType, RemoveEventData, SortEventData, UpdateEventData } from '@tmagic/stage';
|
||||||
import { getIdFromEl } from '@tmagic/utils';
|
import { getIdFromEl } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
import { editorService } from '@editor/services/sdk-instance';
|
||||||
import uiService from '@editor/services/ui';
|
import uiService from '@editor/services/ui';
|
||||||
import type { StageOptions } from '@editor/type';
|
import type { StageOptions } from '@editor/type';
|
||||||
import { H_GUIDE_LINE_STORAGE_KEY, UI_SELECT_MODE_EVENT_NAME, V_GUIDE_LINE_STORAGE_KEY } from '@editor/utils/const';
|
import { H_GUIDE_LINE_STORAGE_KEY, UI_SELECT_MODE_EVENT_NAME, V_GUIDE_LINE_STORAGE_KEY } from '@editor/utils/const';
|
||||||
@ -31,6 +31,7 @@ export const useStage = (stageOptions: StageOptions) => {
|
|||||||
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
|
||||||
disabledDragStart: stageOptions.disabledDragStart,
|
disabledDragStart: stageOptions.disabledDragStart,
|
||||||
renderType: stageOptions.renderType,
|
renderType: stageOptions.renderType,
|
||||||
|
iframeSandbox: stageOptions.iframeSandbox,
|
||||||
canSelect: (el, event, stop) => {
|
canSelect: (el, event, stop) => {
|
||||||
if (!stageOptions.canSelect) return true;
|
if (!stageOptions.canSelect) return true;
|
||||||
|
|
||||||
|
|||||||
@ -37,17 +37,19 @@ export * from './utils';
|
|||||||
export { isGlobalFlat } from './utils/config';
|
export { isGlobalFlat } from './utils/config';
|
||||||
export { default as TMagicEditor } from './Editor.vue';
|
export { default as TMagicEditor } from './Editor.vue';
|
||||||
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
|
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
|
||||||
export { default as editorService } from './services/editor';
|
export {
|
||||||
export { default as propsService } from './services/props';
|
codeBlockService,
|
||||||
export { default as historyService } from './services/history';
|
componentListService,
|
||||||
export { default as storageService } from './services/storage';
|
dataSourceService,
|
||||||
export { default as eventsService } from './services/events';
|
depService,
|
||||||
export { default as dataSourceService } from './services/dataSource';
|
editorService,
|
||||||
|
eventsService,
|
||||||
|
historyService,
|
||||||
|
propsService,
|
||||||
|
storageService,
|
||||||
|
} from './services/sdk-instance';
|
||||||
export { default as stageOverlayService } from './services/stageOverlay';
|
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 depService } from './services/dep';
|
|
||||||
export { default as componentListService } from './services/componentList';
|
|
||||||
export { default as keybindingService } from './services/keybinding';
|
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';
|
||||||
|
|||||||
@ -12,23 +12,22 @@ import type {
|
|||||||
MPage,
|
MPage,
|
||||||
MPageFragment,
|
MPageFragment,
|
||||||
} from '@tmagic/core';
|
} from '@tmagic/core';
|
||||||
import {
|
import { createCodeBlockTarget, DepTargetType, NodeType, Target, updateNode } from '@tmagic/core';
|
||||||
createCodeBlockTarget,
|
|
||||||
createDataSourceCondTarget,
|
|
||||||
createDataSourceMethodTarget,
|
|
||||||
createDataSourceTarget,
|
|
||||||
DepTargetType,
|
|
||||||
NODE_CONDS_KEY,
|
|
||||||
NodeType,
|
|
||||||
Target,
|
|
||||||
updateNode,
|
|
||||||
} from '@tmagic/core';
|
|
||||||
import { ChangeRecord } from '@tmagic/form';
|
import { ChangeRecord } from '@tmagic/form';
|
||||||
import StageCore from '@tmagic/stage';
|
import StageCore from '@tmagic/stage';
|
||||||
import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmagic/utils';
|
import { getDepNodeIds, getNodes, isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
import PropsPanel from './layouts/PropsPanel.vue';
|
import PropsPanel from './layouts/PropsPanel.vue';
|
||||||
import { isIncludeDataSource } from './utils/editor';
|
import type { Reactivity } from './sdk/type';
|
||||||
|
import {
|
||||||
|
addDataSourceTargets,
|
||||||
|
classifyUpdatedNodes,
|
||||||
|
getDataSourceRecollectTypes,
|
||||||
|
removeDataSourceTargets,
|
||||||
|
removeTargetDeps,
|
||||||
|
resetDepTargets,
|
||||||
|
writeTargetDeps,
|
||||||
|
} from './sdk/utils/dep';
|
||||||
import { error as logError } from './utils/logger';
|
import { error as logError } from './utils/logger';
|
||||||
import { EditorProps } from './editorProps';
|
import { EditorProps } from './editorProps';
|
||||||
import { Services } from './type';
|
import { Services } from './type';
|
||||||
@ -247,6 +246,16 @@ export const initServiceState = (
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 同步给 SDK 的结构合法性校验(浏览器端默认 validateMode 为 off,主动调用 `validate` 时才会用到)
|
||||||
|
watch(
|
||||||
|
() => [props.disabledPageFragment, props.canDropIn] as const,
|
||||||
|
([disabledPageFragment, canDropIn]) =>
|
||||||
|
editorService.setConfig({ disabledPageFragment: disabledPageFragment ?? false, canDropIn }),
|
||||||
|
{
|
||||||
|
immediate: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
editorService.resetState();
|
editorService.resetState();
|
||||||
historyService.resetState();
|
historyService.resetState();
|
||||||
@ -476,12 +485,6 @@ export const initServiceEvents = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const initDataSourceDepTarget = (ds: DataSourceSchema) => {
|
|
||||||
depService.addTarget(createDataSourceTarget(ds, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceMethodTarget(ds, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceCondTarget(ds, reactive({})));
|
|
||||||
};
|
|
||||||
|
|
||||||
const rootChangeHandler = (value: MApp | null) => {
|
const rootChangeHandler = (value: MApp | null) => {
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
|
|
||||||
@ -491,15 +494,12 @@ export const initServiceEvents = (
|
|||||||
codeBlockService.setCodeDsl(value.codeBlocks);
|
codeBlockService.setCodeDsl(value.codeBlocks);
|
||||||
dataSourceService.set('dataSources', value.dataSources);
|
dataSourceService.set('dataSources', value.dataSources);
|
||||||
|
|
||||||
depService.clearTargets();
|
resetDepTargets(
|
||||||
|
depService,
|
||||||
for (const [id, code] of Object.entries(value.codeBlocks)) {
|
value.codeBlocks,
|
||||||
depService.addTarget(createCodeBlockTarget(id, code));
|
dataSourceService.get('dataSources'),
|
||||||
}
|
reactive as Reactivity['reactive'],
|
||||||
|
);
|
||||||
for (const ds of dataSourceService.get('dataSources')) {
|
|
||||||
initDataSourceDepTarget(ds);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(value.items)) {
|
if (Array.isArray(value.items)) {
|
||||||
updateStageDsl(value);
|
updateStageDsl(value);
|
||||||
@ -549,49 +549,7 @@ export const initServiceEvents = (
|
|||||||
// 节点更新,收集依赖
|
// 节点更新,收集依赖
|
||||||
// 仅当修改到数据源相关的才收集
|
// 仅当修改到数据源相关的才收集
|
||||||
const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => {
|
const nodeUpdateHandler = (data: { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] }[]) => {
|
||||||
const needRecollectNodes: MComponent[] = [];
|
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes({ dep: depService }, data);
|
||||||
const normalNodes: MComponent[] = [];
|
|
||||||
for (const { newNode, oldNode, changeRecords } of data) {
|
|
||||||
if (newNode.type === NodeType.ROOT) {
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
} else if (changeRecords?.length) {
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
forChangeRecords: for (const record of changeRecords) {
|
|
||||||
if (!record.propPath) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
// NODE_CONDS_KEY为显示条件key
|
|
||||||
if (
|
|
||||||
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond`).test(record.propPath) ||
|
|
||||||
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond.(\\d)+.value`).test(record.propPath) ||
|
|
||||||
record.propPath === NODE_CONDS_KEY ||
|
|
||||||
isValueIncludeDataSource(record.value)
|
|
||||||
) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改的key在收集的依赖中,则需要触发重新收集
|
|
||||||
for (const target of Object.values(depService.getTargets(DepTargetType.DATA_SOURCE))) {
|
|
||||||
if (!target.deps[newNode.id]) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (target.deps[newNode.id].keys.includes(record.propPath)) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
break forChangeRecords;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
}
|
|
||||||
} else if (isIncludeDataSource(newNode, oldNode)) {
|
|
||||||
needRecollectNodes.push(newNode);
|
|
||||||
} else {
|
|
||||||
normalNodes.push(newNode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needRecollectNodes.length) {
|
if (needRecollectNodes.length) {
|
||||||
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
// 有数据源依赖,需要等依赖重新收集完才更新stage
|
||||||
@ -603,13 +561,14 @@ export const initServiceEvents = (
|
|||||||
handler();
|
handler();
|
||||||
} else {
|
} else {
|
||||||
updateStageNodes(normalNodes);
|
updateStageNodes(normalNodes);
|
||||||
|
|
||||||
// 在上面判断是否需要收集数据源依赖中已经更新stage
|
|
||||||
Promise.all([
|
|
||||||
collectIdle(normalNodes, true, DepTargetType.CODE_BLOCK),
|
|
||||||
collectIdle(normalNodes, true, DepTargetType.DATA_SOURCE_METHOD),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉
|
||||||
|
const updatedNodes = [...needRecollectNodes, ...normalNodes];
|
||||||
|
Promise.all([
|
||||||
|
collectIdle(updatedNodes, true, DepTargetType.CODE_BLOCK),
|
||||||
|
collectIdle(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD),
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 节点删除,清除对齐的依赖收集
|
// 节点删除,清除对齐的依赖收集
|
||||||
@ -634,7 +593,7 @@ export const initServiceEvents = (
|
|||||||
|
|
||||||
const dataSourceAddHandler = (config: DataSourceSchema) => {
|
const dataSourceAddHandler = (config: DataSourceSchema) => {
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
initDataSourceDepTarget(config);
|
addDataSourceTargets(depService, config, reactive as Reactivity['reactive']);
|
||||||
const app = await getTMagicApp();
|
const app = await getTMagicApp();
|
||||||
|
|
||||||
if (!app?.dataSourceManager) {
|
if (!app?.dataSourceManager) {
|
||||||
@ -669,65 +628,22 @@ export const initServiceEvents = (
|
|||||||
ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData());
|
ds.setData(config.mocks?.find((mock) => mock.useInEditor)?.data || ds.getDefaultData());
|
||||||
};
|
};
|
||||||
|
|
||||||
let needRecollectDep = false;
|
const types = getDataSourceRecollectTypes(changeRecords);
|
||||||
let isModifyField = false;
|
|
||||||
let isModifyMock = false;
|
|
||||||
let isModifyMethod = false;
|
|
||||||
for (const changeRecord of changeRecords) {
|
|
||||||
if (!changeRecord.propPath) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
isModifyField =
|
|
||||||
changeRecord.propPath === 'fields' ||
|
|
||||||
/fields.(\d)+.name/.test(changeRecord.propPath) ||
|
|
||||||
/fields.(\d)+.defaultValue/.test(changeRecord.propPath) ||
|
|
||||||
/fields.(\d)+$/.test(changeRecord.propPath);
|
|
||||||
|
|
||||||
isModifyMock = changeRecord.propPath === 'mocks';
|
|
||||||
|
|
||||||
isModifyMethod =
|
|
||||||
changeRecord.propPath === 'methods' ||
|
|
||||||
/methods.(\d)+.name/.test(changeRecord.propPath) ||
|
|
||||||
/methods.(\d)+$/.test(changeRecord.propPath);
|
|
||||||
|
|
||||||
needRecollectDep = isModifyField || isModifyMock || isModifyMethod;
|
|
||||||
|
|
||||||
if (needRecollectDep) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = editorService.get('root');
|
const root = editorService.get('root');
|
||||||
if (needRecollectDep) {
|
|
||||||
if (Array.isArray(root?.items)) {
|
|
||||||
depService.clearIdleTasks();
|
|
||||||
|
|
||||||
let collectIdlePromises: Promise<boolean>[] = [];
|
if (!types) {
|
||||||
if (isModifyField) {
|
if (root?.dataSources) updateDsData();
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
return;
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceTarget(config, reactive({})));
|
|
||||||
depService.addTarget(createDataSourceCondTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [
|
|
||||||
collectIdle(root.items, true, DepTargetType.DATA_SOURCE),
|
|
||||||
collectIdle(root.items, true, DepTargetType.DATA_SOURCE_COND),
|
|
||||||
];
|
|
||||||
} else if (isModifyMock) {
|
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE)];
|
|
||||||
} else if (isModifyMethod) {
|
|
||||||
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_METHOD);
|
|
||||||
|
|
||||||
depService.addTarget(createDataSourceMethodTarget(config, reactive({})));
|
|
||||||
|
|
||||||
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(root?.items)) return;
|
||||||
|
|
||||||
|
depService.clearIdleTasks();
|
||||||
|
removeDataSourceTargets(depService, config.id, types);
|
||||||
|
addDataSourceTargets(depService, config, reactive as Reactivity['reactive'], types);
|
||||||
|
|
||||||
|
const collectIdlePromises = types.map((type) => collectIdle(root.items, true, type));
|
||||||
|
|
||||||
const handler = async () => {
|
const handler = async () => {
|
||||||
const results = await Promise.all(collectIdlePromises);
|
const results = await Promise.all(collectIdlePromises);
|
||||||
if (!results.every(Boolean)) return;
|
if (!results.every(Boolean)) return;
|
||||||
@ -737,16 +653,6 @@ export const initServiceEvents = (
|
|||||||
updateStageNodes(root.items);
|
updateStageNodes(root.items);
|
||||||
};
|
};
|
||||||
handler();
|
handler();
|
||||||
}
|
|
||||||
} else if (root?.dataSources) {
|
|
||||||
updateDsData();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeDataSourceTarget = (id: string) => {
|
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE);
|
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE_COND);
|
|
||||||
depService.removeTarget(id, DepTargetType.DATA_SOURCE_METHOD);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const dataSourceRemoveHandler = (id: string) => {
|
const dataSourceRemoveHandler = (id: string) => {
|
||||||
@ -773,7 +679,7 @@ export const initServiceEvents = (
|
|||||||
app?.dataSourceManager?.removeDataSource(id);
|
app?.dataSourceManager?.removeDataSource(id);
|
||||||
|
|
||||||
updateStageNodes(nodes);
|
updateStageNodes(nodes);
|
||||||
removeDataSourceTarget(id);
|
removeDataSourceTargets(depService, id);
|
||||||
};
|
};
|
||||||
|
|
||||||
handler();
|
handler();
|
||||||
@ -799,45 +705,10 @@ export const initServiceEvents = (
|
|||||||
codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler);
|
codeBlockService.on('addOrUpdate', codeBlockAddOrUpdateHandler);
|
||||||
codeBlockService.on('remove', codeBlockRemoveHandler);
|
codeBlockService.on('remove', codeBlockRemoveHandler);
|
||||||
|
|
||||||
const targetAddHandler = (target: Target) => {
|
const targetAddHandler = (target: Target) => writeTargetDeps(editorService.get('root'), target);
|
||||||
const root = editorService.get('root');
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (target.type === DepTargetType.DATA_SOURCE) {
|
const targetRemoveHandler = (id: string | number, type: DepTargetType) =>
|
||||||
if (!root.dataSourceDeps) {
|
removeTargetDeps(editorService.get('root'), id, type);
|
||||||
root.dataSourceDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceDeps[target.id] = target.deps;
|
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_COND) {
|
|
||||||
if (!root.dataSourceCondDeps) {
|
|
||||||
root.dataSourceCondDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceCondDeps[target.id] = target.deps;
|
|
||||||
} else if (target.type === DepTargetType.DATA_SOURCE_METHOD) {
|
|
||||||
if (!root.dataSourceMethodDeps) {
|
|
||||||
root.dataSourceMethodDeps = {};
|
|
||||||
}
|
|
||||||
root.dataSourceMethodDeps[target.id] = target.deps;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const targetRemoveHandler = (id: string | number, type: DepTargetType) => {
|
|
||||||
const root = editorService.get('root');
|
|
||||||
|
|
||||||
if (!root) return;
|
|
||||||
|
|
||||||
if (root.dataSourceDeps && type === DepTargetType.DATA_SOURCE) {
|
|
||||||
delete root.dataSourceDeps[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.dataSourceCondDeps && type === DepTargetType.DATA_SOURCE_COND) {
|
|
||||||
delete root.dataSourceCondDeps[id];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (root.dataSourceMethodDeps && type === DepTargetType.DATA_SOURCE_METHOD) {
|
|
||||||
delete root.dataSourceMethodDeps[id];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
depService.on('add-target', targetAddHandler);
|
depService.on('add-target', targetAddHandler);
|
||||||
depService.on('remove-target', targetRemoveHandler);
|
depService.on('remove-target', targetRemoveHandler);
|
||||||
|
|||||||
@ -76,7 +76,7 @@ import type { MPage, MPageFragment } from '@tmagic/core';
|
|||||||
|
|
||||||
import SplitView from '@editor/components/SplitView.vue';
|
import SplitView from '@editor/components/SplitView.vue';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type';
|
import type { FrameworkSlots, GetColumnWidth, PageBarSortOptions } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
import { getEditorConfig } from '@editor/utils/config';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@ -62,13 +62,14 @@ const props = withDefaults(
|
|||||||
width?: number;
|
width?: number;
|
||||||
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
/** 子元素之间的间距,需与 SCSS gap 保持一致 */
|
||||||
gap?: number;
|
gap?: number;
|
||||||
/** Popover 内容宽度 */
|
/**
|
||||||
popoverWidth?: number;
|
* Popover 内容宽度。不传时按内容撑开,避免下拉框超出菜单。
|
||||||
|
*/
|
||||||
|
popoverWidth?: number | string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
items: () => [],
|
items: () => [],
|
||||||
gap: 3,
|
gap: 3,
|
||||||
popoverWidth: 180,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -110,7 +111,18 @@ const containerWidth = ref(0);
|
|||||||
const hiddenIndexSet = ref<Set<number>>(new Set());
|
const hiddenIndexSet = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
const hasOverflow = computed(() => hiddenIndexSet.value.size > 0);
|
||||||
const overflowItems = computed(() => props.items.filter((_, index) => hiddenIndexSet.value.has(index)));
|
// 更多菜单是纵向列表:分割线改成横线;收进去的首尾分割线没有分组意义,直接去掉。
|
||||||
|
const overflowItems = computed(() => {
|
||||||
|
const items = props.items
|
||||||
|
.filter((_, index) => hiddenIndexSet.value.has(index))
|
||||||
|
.map((item) => (item.type === 'divider' ? { ...item, direction: 'horizontal' as const } : item));
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
let end = items.length;
|
||||||
|
while (start < end && items[start].type === 'divider') start += 1;
|
||||||
|
while (end > start && items[end - 1].type === 'divider') end -= 1;
|
||||||
|
return items.slice(start, end);
|
||||||
|
});
|
||||||
|
|
||||||
const measureAndCompute = () => {
|
const measureAndCompute = () => {
|
||||||
if (!columnEl.value) return;
|
if (!columnEl.value) return;
|
||||||
|
|||||||
@ -13,18 +13,14 @@
|
|||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面',
|
text: '页面',
|
||||||
handler: () => {
|
handler: addNormalPage,
|
||||||
addPage(NodeType.PAGE);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
:data="{
|
:data="{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
text: '页面片',
|
text: '页面片',
|
||||||
handler: () => {
|
handler: addPageFragment,
|
||||||
addPage(NodeType.PAGE_FRAGMENT);
|
|
||||||
},
|
|
||||||
}"
|
}"
|
||||||
></ToolButton>
|
></ToolButton>
|
||||||
</TMagicPopover>
|
</TMagicPopover>
|
||||||
@ -62,4 +58,7 @@ const addPage = (type: NodeType.PAGE | NodeType.PAGE_FRAGMENT) => {
|
|||||||
};
|
};
|
||||||
editorService.add(pageConfig);
|
editorService.add(pageConfig);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addNormalPage = () => addPage(NodeType.PAGE);
|
||||||
|
const addPageFragment = () => addPage(NodeType.PAGE_FRAGMENT);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -12,9 +12,10 @@
|
|||||||
:size="propsPanelSize"
|
:size="propsPanelSize"
|
||||||
:init-values="values"
|
:init-values="values"
|
||||||
:config="config"
|
:config="config"
|
||||||
:type-match-valid="true"
|
:form-key="formKey"
|
||||||
|
:type-match-valid="enablePropsFormValidate"
|
||||||
:context="formContext"
|
:context="formContext"
|
||||||
:validate-on-init="true"
|
:validate-on-init="enablePropsFormValidate"
|
||||||
@change="submit"
|
@change="submit"
|
||||||
@error="errorHandler"
|
@error="errorHandler"
|
||||||
></MForm>
|
></MForm>
|
||||||
@ -72,6 +73,8 @@ defineOptions({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
config: FormConfig;
|
config: FormConfig;
|
||||||
values: FormValue;
|
values: FormValue;
|
||||||
|
/** 当前选中节点的 id:切换节点后让 MForm 重挂叶子字段,避免表单实例状态串台 */
|
||||||
|
formKey?: string | number;
|
||||||
disabledShowSrc?: boolean;
|
disabledShowSrc?: boolean;
|
||||||
labelWidth?: string;
|
labelWidth?: string;
|
||||||
codeValueKey?: string;
|
codeValueKey?: string;
|
||||||
@ -86,7 +89,10 @@ const emit = defineEmits<{
|
|||||||
unmounted: [];
|
unmounted: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
|
const enablePropsFormValidate = inject(
|
||||||
|
ENABLE_PROPS_FORM_VALIDATE,
|
||||||
|
computed(() => false),
|
||||||
|
);
|
||||||
|
|
||||||
const services = useServices();
|
const services = useServices();
|
||||||
const { uiService } = services;
|
const { uiService } = services;
|
||||||
@ -116,7 +122,7 @@ const submit = async (v: FormValue, eventData: ContainerChangeEventData) => {
|
|||||||
// 校验成功:正常更新节点(第三个参数 error 为空,表示清除该来源的错误记录)
|
// 校验成功:正常更新节点(第三个参数 error 为空,表示清除该来源的错误记录)
|
||||||
emit('submit', values, eventData);
|
emit('submit', values, eventData);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (enablePropsFormValidate) {
|
if (enablePropsFormValidate.value) {
|
||||||
// 启用校验联动:校验失败时仍以当前表单值更新节点,并把错误信息一并抛给上层记录
|
// 启用校验联动:校验失败时仍以当前表单值更新节点,并把错误信息一并抛给上层记录
|
||||||
emit('submit', v, eventData, e);
|
emit('submit', v, eventData, e);
|
||||||
} else {
|
} else {
|
||||||
@ -146,7 +152,7 @@ const formatValidateErrorHtml = (error: string): string =>
|
|||||||
const saveCode = async (values: any) => {
|
const saveCode = async (values: any) => {
|
||||||
const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values;
|
const newValues = props.codeValueKey ? { [props.codeValueKey]: values } : values;
|
||||||
|
|
||||||
if (!enablePropsFormValidate) {
|
if (!enablePropsFormValidate.value) {
|
||||||
// 未启用校验联动:保持原行为,直接提交源码保存的值
|
// 未启用校验联动:保持原行为,直接提交源码保存的值
|
||||||
emit('submit', newValues);
|
emit('submit', newValues);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
:class="{ 'show-style-panel': showStylePanel }"
|
:class="{ 'show-style-panel': showStylePanel }"
|
||||||
:config="curFormConfig"
|
:config="curFormConfig"
|
||||||
:values="values"
|
:values="values"
|
||||||
|
:form-key="formKey"
|
||||||
:disabledShowSrc="disabledShowSrc"
|
:disabledShowSrc="disabledShowSrc"
|
||||||
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
|
@submit="(v, eventData, error) => submit(v, eventData, error, 'props')"
|
||||||
@submit-error="errorHandler"
|
@submit-error="errorHandler"
|
||||||
@ -24,6 +25,7 @@
|
|||||||
code-value-key="style"
|
code-value-key="style"
|
||||||
:config="styleFormConfig"
|
:config="styleFormConfig"
|
||||||
:values="values"
|
:values="values"
|
||||||
|
:form-key="formKey"
|
||||||
:disabledShowSrc="disabledShowSrc"
|
:disabledShowSrc="disabledShowSrc"
|
||||||
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
|
@submit="(v, eventData, error) => submit(v, eventData, error, 'style')"
|
||||||
@submit-error="errorHandler"
|
@submit-error="errorHandler"
|
||||||
@ -66,7 +68,7 @@ import MIcon from '@editor/components/Icon.vue';
|
|||||||
import Resizer from '@editor/components/Resizer.vue';
|
import Resizer from '@editor/components/Resizer.vue';
|
||||||
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
|
||||||
import { useServices } from '@editor/hooks/use-services';
|
import { useServices } from '@editor/hooks/use-services';
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
|
import type { NodeInvalidSource, PropsPanelSlots } from '@editor/type';
|
||||||
import { styleTabConfig } from '@editor/utils';
|
import { styleTabConfig } from '@editor/utils';
|
||||||
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { PROPS_PANEL_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
@ -93,13 +95,21 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { editorService, uiService, propsService, storageService } = useServices();
|
const { editorService, uiService, propsService, storageService } = useServices();
|
||||||
|
|
||||||
const enablePropsFormValidate = inject(ENABLE_PROPS_FORM_VALIDATE, false);
|
const enablePropsFormValidate = inject(
|
||||||
|
ENABLE_PROPS_FORM_VALIDATE,
|
||||||
|
computed(() => false),
|
||||||
|
);
|
||||||
|
|
||||||
const values = ref<FormValue>({});
|
const values = ref<FormValue>({});
|
||||||
// ts类型应该是FormConfig, 但是打包时会出错,所以暂时用any
|
// ts类型应该是FormConfig, 但是打包时会出错,所以暂时用any
|
||||||
const curFormConfig = ref<any>([]);
|
const curFormConfig = ref<any>([]);
|
||||||
const node = computed(() => editorService.get('node'));
|
const node = computed(() => editorService.get('node'));
|
||||||
const nodes = computed(() => editorService.get('nodes'));
|
const nodes = computed(() => editorService.get('nodes'));
|
||||||
|
// 同类型组件的配置结构完全一致,MForm 不会重建整棵表单树;用节点 id 标识数据实体,
|
||||||
|
// 让叶子字段在切换选中时重挂,避免上一个节点的字段本地状态(如事件动作参数)串到新节点。
|
||||||
|
// 必须跟随 values 而不是 node:values 在 getPropsConfig 异步返回后才更新,
|
||||||
|
// 若 key 先于 values 变化,叶子字段会以旧节点的值重挂,新值到达后不再重挂。
|
||||||
|
const formKey = computed(() => values.value.id ?? '');
|
||||||
|
|
||||||
const styleFormConfig = [
|
const styleFormConfig = [
|
||||||
{
|
{
|
||||||
@ -205,7 +215,9 @@ const submit = async (
|
|||||||
replace,
|
replace,
|
||||||
// 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录;
|
// 启用校验联动时,仅校验失败(error 存在)才把错误信息随更新传入 editorService 记录;
|
||||||
// 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。
|
// 其余情况(含表单校验成功、CodeEditor 源码保存)不携带 invalidInfo,由 editorService 在执行 update 时统一清除该节点错误。
|
||||||
...(enablePropsFormValidate && error ? { invalidInfo: { id: newValue.id, source, error: error?.message } } : {}),
|
...(enablePropsFormValidate.value && error
|
||||||
|
? { invalidInfo: { id: newValue.id, source, error: error?.message } }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
emit('submit-error', e);
|
emit('submit-error', e);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { computed, type Ref, watch } from 'vue';
|
import { computed, type Ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Protocol } from '@editor/services/storage';
|
import { Protocol } from '@editor/services/sdk-instance';
|
||||||
import { Services } from '@editor/type';
|
import { Services } from '@editor/type';
|
||||||
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
import { RIGHT_COLUMN_WIDTH_STORAGE_KEY } from '@editor/utils/const';
|
||||||
|
|
||||||
|
|||||||
@ -97,7 +97,13 @@ let timeout: ReturnType<typeof setTimeout> | undefined;
|
|||||||
let clientX: number;
|
let clientX: number;
|
||||||
let clientY: number;
|
let clientY: number;
|
||||||
|
|
||||||
const appendComponent = ({ text, type, data = {} }: ComponentItem): void => {
|
const appendComponent = async (item: ComponentItem): Promise<void> => {
|
||||||
|
if (stageOptions?.beforeAppendComponent) {
|
||||||
|
const result = await stageOptions.beforeAppendComponent(item);
|
||||||
|
if (result === false) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text, type, data = {} } = item;
|
||||||
editorService.add(
|
editorService.add(
|
||||||
{
|
{
|
||||||
name: text,
|
name: text,
|
||||||
|
|||||||
@ -20,12 +20,29 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #tree-node-tool="{ data }">
|
<template #tree-node-tool="{ data }">
|
||||||
<TMagicTooltip v-if="data.type === 'code'" effect="dark" :content="editable ? '编辑' : '查看'" placement="bottom">
|
<TMagicTooltip v-if="data.type === 'code'" effect="dark" :content="editable ? '编辑' : '查看'" placement="left">
|
||||||
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editCode(`${data.key}`)"></Icon>
|
<TMagicButton
|
||||||
|
link
|
||||||
|
:icon="editable ? Edit : View"
|
||||||
|
class="edit-icon"
|
||||||
|
@click.stop="editCode(`${data.key}`)"
|
||||||
|
></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
<TMagicTooltip v-if="data.type === 'code' && editable" effect="dark" content="删除" placement="bottom">
|
<TMagicPopconfirm
|
||||||
<Icon :icon="Close" class="edit-icon" @click.stop="deleteCode(`${data.key}`, { historySource: 'tree' })"></Icon>
|
v-if="data.type === 'code' && editable"
|
||||||
|
title="确定删除该代码块吗?"
|
||||||
|
placement="right"
|
||||||
|
:teleported="true"
|
||||||
|
@confirm="deleteCode(`${data.key}`, { historySource: 'tree' })"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<span class="delete-icon-wrapper" @click.stop="deleteClickHandler(`${data.key}`)">
|
||||||
|
<TMagicTooltip effect="dark" content="删除" placement="right">
|
||||||
|
<TMagicButton link :icon="Close" class="edit-icon"></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</TMagicPopconfirm>
|
||||||
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
|
<slot name="code-block-panel-tool" :id="data.key" :data="data"></slot>
|
||||||
</template>
|
</template>
|
||||||
</Tree>
|
</Tree>
|
||||||
@ -37,9 +54,8 @@ import { Close, Edit, View } from '@element-plus/icons-vue';
|
|||||||
|
|
||||||
import type { Id, MNode } from '@tmagic/core';
|
import type { Id, MNode } from '@tmagic/core';
|
||||||
import { DepTargetType } from '@tmagic/core';
|
import { DepTargetType } from '@tmagic/core';
|
||||||
import { tMagicMessage, tMagicMessageBox, TMagicTooltip } from '@tmagic/design';
|
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
|
||||||
|
|
||||||
import Icon from '@editor/components/Icon.vue';
|
|
||||||
import Tree from '@editor/components/Tree.vue';
|
import Tree from '@editor/components/Tree.vue';
|
||||||
import { useFilter } from '@editor/hooks/use-filter';
|
import { useFilter } from '@editor/hooks/use-filter';
|
||||||
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
||||||
@ -142,29 +158,38 @@ const editCode = (id: string) => {
|
|||||||
emit('edit', id);
|
emit('edit', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCode = async (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
/** 返回不能删除的原因,可删除时返回 undefined */
|
||||||
|
const getDeleteErrorType = (id: string): CodeDeleteErrorType | undefined => {
|
||||||
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
|
const currentCode = codeList.value.find((codeItem) => codeItem.id === id);
|
||||||
const existBinds = Boolean(currentCode?.items?.length);
|
if (currentCode?.items?.length) {
|
||||||
const undeleteableList = codeBlockService.getUndeletableList() || [];
|
return CodeDeleteErrorType.BIND;
|
||||||
if (!existBinds && !undeleteableList.includes(id)) {
|
}
|
||||||
await tMagicMessageBox.confirm('确定删除该代码块吗?', '提示', {
|
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 无绑定关系,且不在不可删除列表中
|
return (codeBlockService.getUndeletableList() || []).includes(id) ? CodeDeleteErrorType.UNDELETEABLE : undefined;
|
||||||
emit('remove', id, { historySource });
|
};
|
||||||
} else {
|
|
||||||
|
/** 删除确认由 TMagicPopconfirm 完成,这里只做校验与删除 */
|
||||||
|
const deleteCode = (id: string, { historySource }: { historySource?: HistoryOpSource } = {}) => {
|
||||||
|
const errorType = getDeleteErrorType(id);
|
||||||
|
|
||||||
|
if (errorType) {
|
||||||
if (typeof props.customError === 'function') {
|
if (typeof props.customError === 'function') {
|
||||||
props.customError(id, existBinds ? CodeDeleteErrorType.BIND : CodeDeleteErrorType.UNDELETEABLE);
|
props.customError(id, errorType);
|
||||||
} else {
|
} else if (errorType === CodeDeleteErrorType.BIND) {
|
||||||
if (existBinds) {
|
|
||||||
tMagicMessage.error('代码块存在绑定关系,不可删除');
|
tMagicMessage.error('代码块存在绑定关系,不可删除');
|
||||||
} else {
|
} else {
|
||||||
tMagicMessage.error('代码块不可删除');
|
tMagicMessage.error('代码块不可删除');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emit('remove', id, { historySource });
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 不可删除的代码块点击后直接说明原因,无需等待确认 */
|
||||||
|
const deleteClickHandler = (id: string) => {
|
||||||
|
if (getDeleteErrorType(id)) {
|
||||||
|
deleteCode(id, { historySource: 'tree' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -19,12 +19,29 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #tree-node-tool="{ data }">
|
<template #tree-node-tool="{ data }">
|
||||||
<TMagicTooltip v-if="data.type === 'ds'" effect="dark" :content="editable ? '编辑' : '查看'" placement="bottom">
|
<TMagicTooltip v-if="data.type === 'ds'" effect="dark" :content="editable ? '编辑' : '查看'" placement="left">
|
||||||
<Icon :icon="editable ? Edit : View" class="edit-icon" @click.stop="editHandler(`${data.key}`)"></Icon>
|
<TMagicButton
|
||||||
|
link
|
||||||
|
:icon="editable ? Edit : View"
|
||||||
|
class="edit-icon"
|
||||||
|
@click.stop="editHandler(`${data.key}`)"
|
||||||
|
></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
<TMagicTooltip v-if="data.type === 'ds' && editable" effect="dark" content="删除" placement="bottom">
|
<TMagicPopconfirm
|
||||||
<Icon :icon="Close" class="edit-icon" @click.stop="removeHandler(`${data.key}`)"></Icon>
|
v-if="data.type === 'ds' && editable"
|
||||||
|
title="确定删除?"
|
||||||
|
placement="right"
|
||||||
|
:teleported="true"
|
||||||
|
@confirm="removeHandler(`${data.key}`)"
|
||||||
|
>
|
||||||
|
<template #reference>
|
||||||
|
<span class="delete-icon-wrapper" @click.stop>
|
||||||
|
<TMagicTooltip effect="dark" content="删除" placement="right">
|
||||||
|
<TMagicButton link :icon="Close" class="edit-icon"></TMagicButton>
|
||||||
</TMagicTooltip>
|
</TMagicTooltip>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</TMagicPopconfirm>
|
||||||
<slot name="data-source-panel-tool" :data="data"></slot>
|
<slot name="data-source-panel-tool" :data="data"></slot>
|
||||||
</template>
|
</template>
|
||||||
</Tree>
|
</Tree>
|
||||||
@ -35,9 +52,8 @@ import { computed } from 'vue';
|
|||||||
import { Close, Edit, View } from '@element-plus/icons-vue';
|
import { Close, Edit, View } from '@element-plus/icons-vue';
|
||||||
|
|
||||||
import { DepData, DepTargetType, Id, MNode } from '@tmagic/core';
|
import { DepData, DepTargetType, Id, MNode } from '@tmagic/core';
|
||||||
import { TMagicTooltip } from '@tmagic/design';
|
import { TMagicButton, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
|
||||||
|
|
||||||
import Icon from '@editor/components/Icon.vue';
|
|
||||||
import Tree from '@editor/components/Tree.vue';
|
import Tree from '@editor/components/Tree.vue';
|
||||||
import { useFilter } from '@editor/hooks/use-filter';
|
import { useFilter } from '@editor/hooks/use-filter';
|
||||||
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
import { useNodeStatus } from '@editor/hooks/use-node-status';
|
||||||
@ -156,7 +172,7 @@ const editHandler = (id: string) => {
|
|||||||
emit('edit', id);
|
emit('edit', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeHandler = async (id: string) => {
|
const removeHandler = (id: string) => {
|
||||||
emit('remove', id);
|
emit('remove', id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,7 @@
|
|||||||
import { computed, inject, ref, 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 { TMagicScrollbar } from '@tmagic/design';
|
||||||
|
|
||||||
import ContentMenu from '@editor/components/ContentMenu.vue';
|
import ContentMenu from '@editor/components/ContentMenu.vue';
|
||||||
import SearchInput from '@editor/components/SearchInput.vue';
|
import SearchInput from '@editor/components/SearchInput.vue';
|
||||||
@ -133,13 +133,8 @@ const addHandler = (type: string) => {
|
|||||||
editDialog.value.show();
|
editDialog.value.show();
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeHandler = async (id: string) => {
|
/** 删除确认由 DataSourceList 中的 TMagicPopconfirm 完成,这里只负责执行删除 */
|
||||||
await tMagicMessageBox.confirm('确定删除?', '提示', {
|
const removeHandler = (id: string) => {
|
||||||
confirmButtonText: '确定',
|
|
||||||
cancelButtonText: '取消',
|
|
||||||
type: 'warning',
|
|
||||||
});
|
|
||||||
|
|
||||||
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
|
dataSourceService.remove(id, { historySource: 'tree-contextmenu' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -450,6 +450,11 @@ const dropHandler = async (e: DragEvent) => {
|
|||||||
|
|
||||||
dragData.inputEvent = e;
|
dragData.inputEvent = e;
|
||||||
|
|
||||||
|
if (props.stageOptions.beforeDropComponent) {
|
||||||
|
const result = await props.stageOptions.beforeDropComponent(dragData, parent);
|
||||||
|
if (result === false) return;
|
||||||
|
}
|
||||||
|
|
||||||
editorService.add(dragData, parent, { historySource: 'component-panel' });
|
editorService.add(dragData, parent, { historySource: 'component-panel' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
19
packages/editor/src/sdk.ts
Normal file
19
packages/editor/src/sdk.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './sdk/index';
|
||||||
264
packages/editor/src/sdk/index.ts
Normal file
264
packages/editor/src/sdk/index.ts
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview `@tmagic/editor/sdk`:不依赖 DOM 的 DSL 操作 SDK。
|
||||||
|
*
|
||||||
|
* 每次调用 {@link createDslSdk} 得到一组独立的 service(editor / history / props / dataSource /
|
||||||
|
* codeBlock / dep / storage / events / componentList),可在 Node 中运行。浏览器端的
|
||||||
|
* `editorService` 等单例就是用同一个工厂创建的默认实例。
|
||||||
|
*
|
||||||
|
* @module @tmagic/editor/sdk
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { nextTick, reactive, shallowReactive, toRaw } from 'vue';
|
||||||
|
import { cloneDeep, isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { MApp } from '@tmagic/core';
|
||||||
|
|
||||||
|
import type { PersistedHistoryState } from '@editor/type';
|
||||||
|
|
||||||
|
import CodeBlock from './services/CodeBlock';
|
||||||
|
import ComponentList from './services/ComponentList';
|
||||||
|
import DataSource from './services/DataSource';
|
||||||
|
import Dep from './services/Dep';
|
||||||
|
import Editor from './services/Editor';
|
||||||
|
import Events from './services/Events';
|
||||||
|
import History from './services/History';
|
||||||
|
import Props from './services/Props';
|
||||||
|
import WebStorage from './services/WebStorage';
|
||||||
|
import type { Reactivity, SdkContext, SdkOptions } from './type';
|
||||||
|
import { createValidator, type Validator } from './validate';
|
||||||
|
import { bindWiring, whenWiringSettled, type WiringHandle } from './wiring';
|
||||||
|
|
||||||
|
const SERVICE_NAMES = [
|
||||||
|
'editor',
|
||||||
|
'props',
|
||||||
|
'history',
|
||||||
|
'dataSource',
|
||||||
|
'codeBlock',
|
||||||
|
'dep',
|
||||||
|
'storage',
|
||||||
|
'events',
|
||||||
|
'componentList',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node 下默认的 DSL 反序列化:序列化出来的内容是被括号包起来的 JSON(函数、正则等需要自己传入 `parseDSL`)。
|
||||||
|
* 刻意不使用 `eval`,避免执行调用方传入的历史快照里的任意代码。
|
||||||
|
*/
|
||||||
|
export const defaultParseDSL = (code: string): any => {
|
||||||
|
const text = code.trim();
|
||||||
|
const body = text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text;
|
||||||
|
try {
|
||||||
|
return JSON.parse(body);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(
|
||||||
|
`默认的 parseDSL 只能解析 JSON,无法还原含函数 / undefined 等的内容(${e?.message ?? e});请在 createDslSdk 的 parseDSL 选项中传入自己的实现`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface DslSdk {
|
||||||
|
readonly editor: Editor;
|
||||||
|
readonly history: History;
|
||||||
|
readonly props: Props;
|
||||||
|
readonly dataSource: DataSource;
|
||||||
|
readonly codeBlock: CodeBlock;
|
||||||
|
readonly dep: Dep;
|
||||||
|
readonly storage: WebStorage;
|
||||||
|
readonly events: Events;
|
||||||
|
readonly componentList: ComponentList;
|
||||||
|
readonly validate: Validator;
|
||||||
|
readonly ctx: SdkContext;
|
||||||
|
|
||||||
|
/** 登记表(组件属性配置等,其中一部分是异步填充的)全部就绪后 resolve */
|
||||||
|
ready: Promise<void>;
|
||||||
|
|
||||||
|
/** 导出当前 DSL(深拷贝,含依赖收集结果) */
|
||||||
|
getDsl(): MApp | null;
|
||||||
|
/** 整体替换 DSL,会按 `set root` 的语义写入历史记录 */
|
||||||
|
setDsl(dsl: MApp | null): Promise<void>;
|
||||||
|
/** 绑定依赖收集连线;`autoWiring` 为 true 时创建时已绑定。返回解绑函数 */
|
||||||
|
bindWiring(): () => void;
|
||||||
|
/** 导出历史快照,下次 `createDslSdk({ history })` 时传回即可恢复 */
|
||||||
|
serializeHistory(): PersistedHistoryState;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Services = Pick<
|
||||||
|
SdkContext,
|
||||||
|
'editor' | 'props' | 'history' | 'dataSource' | 'codeBlock' | 'dep' | 'storage' | 'events' | 'componentList'
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** 把 options 里的登记表写入各 service,返回需要等待的异步任务(如属性表单配置) */
|
||||||
|
const registerOptions = (
|
||||||
|
{ editor, props, events, componentList, dataSource }: Services,
|
||||||
|
options: SdkOptions,
|
||||||
|
): Promise<unknown>[] => {
|
||||||
|
if (options.componentGroupList) componentList.setList(options.componentGroupList);
|
||||||
|
if (options.datasourceList) dataSource.set('datasourceTypeList', options.datasourceList);
|
||||||
|
|
||||||
|
props.setDisabledCodeBlock(options.disabledCodeBlock ?? false);
|
||||||
|
props.setDisabledDataSource(options.disabledDataSource ?? false);
|
||||||
|
|
||||||
|
const pending = [props.setPropsConfigs(options.propsConfigs ?? {}), props.setPropsValues(options.propsValues ?? {})];
|
||||||
|
|
||||||
|
const eventMethodList = Object.entries(options.eventMethodList ?? {});
|
||||||
|
events.setEvents(Object.fromEntries(eventMethodList.map(([type, item]) => [type, item.events])));
|
||||||
|
events.setMethods(Object.fromEntries(eventMethodList.map(([type, item]) => [type, item.methods])));
|
||||||
|
|
||||||
|
for (const [type, config] of Object.entries(options.datasourceConfigs ?? {})) {
|
||||||
|
dataSource.setFormConfig(type, config);
|
||||||
|
}
|
||||||
|
for (const [type, value] of Object.entries(options.datasourceValues ?? {})) {
|
||||||
|
dataSource.setFormValue(type, value);
|
||||||
|
}
|
||||||
|
for (const [type, item] of Object.entries(options.datasourceEventMethodList ?? {})) {
|
||||||
|
dataSource.setFormEvent(type, item.events);
|
||||||
|
dataSource.setFormMethod(type, item.methods);
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.set('disabledMultiSelect', options.disabledMultiSelect ?? false);
|
||||||
|
editor.set('alwaysMultiSelect', options.alwaysMultiSelect ?? false);
|
||||||
|
editor.setConfig({
|
||||||
|
disabledPageFragment: options.disabledPageFragment ?? false,
|
||||||
|
canDropIn: options.canDropIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个相互连线、互相独立的 DSL SDK 实例。
|
||||||
|
*
|
||||||
|
* @param options 见 {@link SdkOptions}
|
||||||
|
*/
|
||||||
|
export const createDslSdk = (options: SdkOptions = {}): DslSdk => {
|
||||||
|
const reactivity: Reactivity = {
|
||||||
|
// Vue 的 reactive 返回 Reactive<T>,与接口中的 T 类型不完全一致,这里做断言
|
||||||
|
|
||||||
|
reactive: reactive as Reactivity['reactive'],
|
||||||
|
|
||||||
|
shallowReactive: shallowReactive as Reactivity['shallowReactive'],
|
||||||
|
toRaw,
|
||||||
|
nextTick,
|
||||||
|
...options.reactivity,
|
||||||
|
};
|
||||||
|
const parseDSL = options.parseDSL ?? defaultParseDSL;
|
||||||
|
|
||||||
|
// service 之间互相访问用懒 getter,构造期互不依赖,从而解开 editor <-> props / history 的循环
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const services = {} as { -readonly [K in keyof Services]: Services[K] };
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||||
|
const ctx = {
|
||||||
|
reactivity,
|
||||||
|
options,
|
||||||
|
validateMode: options.validateMode ?? 'strict',
|
||||||
|
parseDSL: (code: string) => parseDSL(code),
|
||||||
|
ready: Promise.resolve(),
|
||||||
|
} as SdkContext;
|
||||||
|
|
||||||
|
for (const name of SERVICE_NAMES) {
|
||||||
|
Object.defineProperty(ctx, name, { get: () => services[name], enumerable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
services.storage = new WebStorage(ctx, options.storage);
|
||||||
|
services.history = new History(ctx);
|
||||||
|
services.props = new Props(ctx);
|
||||||
|
services.events = new Events(ctx);
|
||||||
|
services.componentList = new ComponentList(ctx);
|
||||||
|
services.dep = new Dep(ctx);
|
||||||
|
services.codeBlock = new CodeBlock(ctx);
|
||||||
|
services.dataSource = new DataSource(ctx);
|
||||||
|
services.editor = new Editor(ctx);
|
||||||
|
|
||||||
|
const { editor, history } = services;
|
||||||
|
|
||||||
|
const validate = createValidator(ctx);
|
||||||
|
ctx.validator = validate;
|
||||||
|
|
||||||
|
const pending = registerOptions(services, options);
|
||||||
|
|
||||||
|
let wiring: WiringHandle | null = null;
|
||||||
|
const unbind = () => {
|
||||||
|
wiring?.unbind();
|
||||||
|
wiring = null;
|
||||||
|
};
|
||||||
|
const bind = () => {
|
||||||
|
wiring ??= bindWiring(ctx);
|
||||||
|
return unbind;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.autoWiring ?? true) {
|
||||||
|
bind();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先恢复历史再设置 root:恢复出的初始基线不会被 set root 重复创建
|
||||||
|
if (options.history) {
|
||||||
|
history.restore(options.history);
|
||||||
|
}
|
||||||
|
|
||||||
|
const setDsl = async (dsl: MApp | null) => {
|
||||||
|
editor.set('root', dsl ? cloneDeep(dsl) : null);
|
||||||
|
await whenWiringSettled(ctx);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.dsl) {
|
||||||
|
pending.push(setDsl(options.dsl));
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ready = Promise.all(pending).then(() => undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...services,
|
||||||
|
validate,
|
||||||
|
ctx,
|
||||||
|
get ready() {
|
||||||
|
return ctx.ready;
|
||||||
|
},
|
||||||
|
getDsl() {
|
||||||
|
const root = reactivity.toRaw(editor.get('root'));
|
||||||
|
return root && !isEmpty(root) ? cloneDeep(root) : null;
|
||||||
|
},
|
||||||
|
setDsl,
|
||||||
|
bindWiring: bind,
|
||||||
|
serializeHistory: () => history.serialize({ jsonSafe: true }),
|
||||||
|
destroy() {
|
||||||
|
unbind();
|
||||||
|
for (const service of Object.values(services)) {
|
||||||
|
service.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export { default as BaseService } from './services/BaseService';
|
||||||
|
export { default as CodeBlockService } from './services/CodeBlock';
|
||||||
|
export { default as ComponentListService } from './services/ComponentList';
|
||||||
|
export { default as DataSourceService } from './services/DataSource';
|
||||||
|
export { default as DepService } from './services/Dep';
|
||||||
|
export { default as EditorService } from './services/Editor';
|
||||||
|
export { default as EventsService } from './services/Events';
|
||||||
|
export { default as HistoryService } from './services/History';
|
||||||
|
export { default as PropsService } from './services/Props';
|
||||||
|
export { default as StorageService, MemoryStorage, Protocol } from './services/WebStorage';
|
||||||
|
export * from './type';
|
||||||
|
export * from './validate';
|
||||||
|
export * from './wiring';
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, get, keys, pick } from 'lodash-es';
|
import { cloneDeep, get, keys, pick } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,9 +24,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { TableColumnConfig } from '@tmagic/form';
|
import type { TableColumnConfig } from '@tmagic/form';
|
||||||
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
import { getValueByKeyPath, setValueByKeyPath } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
CodeBlockStepValue,
|
CodeBlockStepValue,
|
||||||
@ -37,11 +33,13 @@ import type {
|
|||||||
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 { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
import { COPY_CODE_STORAGE_KEY } from '@editor/utils/editor';
|
||||||
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
async: ['setCodeDslById', 'setEditStatus', 'setCombineIds', 'setUndeleteableList', 'deleteCodeDslByIds'] as const,
|
async: ['setCodeDslById', 'setEditStatus', 'setCombineIds', 'setUndeleteableList', 'deleteCodeDslByIds'] as const,
|
||||||
@ -51,13 +49,7 @@ const canUsePluginMethods = {
|
|||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
|
|
||||||
class CodeBlock extends BaseService {
|
class CodeBlock extends BaseService {
|
||||||
private state = reactive<CodeState>({
|
private state: CodeState;
|
||||||
codeDsl: null,
|
|
||||||
editable: true,
|
|
||||||
combineIds: [],
|
|
||||||
undeletableList: [],
|
|
||||||
paramsColConfig: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
|
* 最近一次写入历史栈的代码块历史记录 uuid(单条写入场景:新增 / 更新)。
|
||||||
@ -70,11 +62,18 @@ class CodeBlock extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastDeletedHistoryIds: string[] = [];
|
private lastDeletedHistoryIds: string[] = [];
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
]);
|
]);
|
||||||
|
this.state = ctx.reactivity.reactive<CodeState>({
|
||||||
|
codeDsl: null,
|
||||||
|
editable: true,
|
||||||
|
combineIds: [],
|
||||||
|
undeletableList: [],
|
||||||
|
paramsColConfig: undefined,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -170,9 +169,9 @@ class CodeBlock extends BaseService {
|
|||||||
const codeConfigProcessed = cloneDeep(codeConfig);
|
const codeConfigProcessed = cloneDeep(codeConfig);
|
||||||
if (codeConfigProcessed.content) {
|
if (codeConfigProcessed.content) {
|
||||||
// 在保存的时候转换代码内容
|
// 在保存的时候转换代码内容
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
// 没有注入 parseDSL(如 Node 默认)时保持源码字符串,不会执行代码,导出的 DSL 里仍是字符串
|
||||||
if (typeof codeConfigProcessed.content === 'string') {
|
if (typeof codeConfigProcessed.content === 'string' && this.ctx.options.parseDSL) {
|
||||||
codeConfigProcessed.content = parseDSL<(...args: any[]) => any>(codeConfigProcessed.content);
|
codeConfigProcessed.content = this.ctx.parseDSL(codeConfigProcessed.content) as (...args: any[]) => any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -196,7 +195,7 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('codeBlock', step, id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('codeBlock', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('addOrUpdate', id, codeDsl[id]);
|
this.emit('addOrUpdate', id, codeDsl[id]);
|
||||||
@ -268,21 +267,21 @@ class CodeBlock extends BaseService {
|
|||||||
* 设置代码草稿
|
* 设置代码草稿
|
||||||
*/
|
*/
|
||||||
public setCodeDraft(codeId: Id, content: string): void {
|
public setCodeDraft(codeId: Id, content: string): void {
|
||||||
globalThis.localStorage.setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
|
this.ctx.storage.getStorage().setItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取代码草稿
|
* 获取代码草稿
|
||||||
*/
|
*/
|
||||||
public getCodeDraft(codeId: Id): string | null {
|
public getCodeDraft(codeId: Id): string | null {
|
||||||
return globalThis.localStorage.getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
return this.ctx.storage.getStorage().getItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 删除代码草稿
|
* 删除代码草稿
|
||||||
*/
|
*/
|
||||||
public removeCodeDraft(codeId: Id): void {
|
public removeCodeDraft(codeId: Id): void {
|
||||||
globalThis.localStorage.removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
this.ctx.storage.getStorage().removeItem(`${CODE_DRAFT_STORAGE_KEY}_${codeId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -314,7 +313,7 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
const uuid = step ? historyService.push('codeBlock', step, id)?.uuid : undefined;
|
const uuid = step ? this.ctx.history.push('codeBlock', step, id)?.uuid : undefined;
|
||||||
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
if (uuid) this.lastDeletedHistoryIds.push(uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -390,7 +389,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.undo('codeBlock', id);
|
const step = this.ctx.history.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;
|
||||||
@ -402,7 +401,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.redo('codeBlock', id);
|
const step = this.ctx.history.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;
|
||||||
@ -410,12 +409,12 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定代码块撤销。 */
|
/** 是否可对指定代码块撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndo('codeBlock', id);
|
return this.ctx.history.canUndo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定代码块重做。 */
|
/** 是否可对指定代码块重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedo('codeBlock', id);
|
return this.ctx.history.canRedo('codeBlock', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -426,7 +425,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.getCursor('codeBlock', id);
|
let cursor = this.ctx.history.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);
|
||||||
@ -452,7 +451,7 @@ 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.getStepList('codeBlock', id);
|
const list = this.ctx.history.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 才支持回滚,否则只能整内容替换,会冲掉后续无关变更。
|
||||||
@ -473,7 +472,7 @@ class CodeBlock extends BaseService {
|
|||||||
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
|
public async revertById(uuids: string[]): Promise<(CodeBlockStepValue | null)[]> {
|
||||||
const results: (CodeBlockStepValue | null)[] = [];
|
const results: (CodeBlockStepValue | null)[] = [];
|
||||||
for (const uuid of uuids) {
|
for (const uuid of uuids) {
|
||||||
const location = historyService.findStepLocationByUuid('codeBlock', uuid);
|
const location = this.ctx.history.findStepLocationByUuid('codeBlock', uuid);
|
||||||
results.push(location ? await this.revert(location.id, location.index) : null);
|
results.push(location ? await this.revert(location.id, location.index) : null);
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@ -513,7 +512,7 @@ class CodeBlock extends BaseService {
|
|||||||
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
||||||
|
|
||||||
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
||||||
const node = editorService.getNodeById(nodeId);
|
const node = this.ctx.editor.getNodeById(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
customTarget!.deps[nodeId].keys.forEach((key) => {
|
customTarget!.deps[nodeId].keys.forEach((key) => {
|
||||||
const relateCodeId = get(node, key);
|
const relateCodeId = get(node, key);
|
||||||
@ -527,7 +526,7 @@ class CodeBlock extends BaseService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
storageService.setItem(COPY_CODE_STORAGE_KEY, copyData, {
|
this.ctx.storage.setItem(COPY_CODE_STORAGE_KEY, copyData, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -537,7 +536,7 @@ class CodeBlock extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public paste() {
|
public paste() {
|
||||||
const codeDsl: CodeBlockDSL = storageService.getItem(COPY_CODE_STORAGE_KEY);
|
const codeDsl: CodeBlockDSL = this.ctx.storage.getItem(COPY_CODE_STORAGE_KEY);
|
||||||
Object.keys(codeDsl).forEach((codeId: Id) => {
|
Object.keys(codeDsl).forEach((codeId: Id) => {
|
||||||
// 不覆盖同样id的代码块
|
// 不覆盖同样id的代码块
|
||||||
this.setCodeDslByIdSync(codeId, codeDsl[codeId], false);
|
this.setCodeDslByIdSync(codeId, codeDsl[codeId], false);
|
||||||
@ -575,13 +574,16 @@ class CodeBlock extends BaseService {
|
|||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
await this.deleteCodeDslByIds([id], { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.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, {
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
});
|
||||||
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -605,11 +607,14 @@ class CodeBlock extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, { historyDescription, historySource: 'rollback' });
|
this.setCodeDslByIdSync(id, cloneDeep(oldSchema), true, {
|
||||||
return historyService.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
});
|
||||||
|
return this.ctx.history.getStepList('codeBlock', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -680,4 +685,4 @@ class CodeBlock extends BaseService {
|
|||||||
|
|
||||||
export type CodeBlockService = CodeBlock;
|
export type CodeBlockService = CodeBlock;
|
||||||
|
|
||||||
export default new CodeBlock();
|
export default CodeBlock;
|
||||||
@ -16,19 +16,20 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { shallowReactive } from 'vue';
|
|
||||||
|
|
||||||
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
|
import type { ComponentGroup, ComponentGroupState } from '@editor/type';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
class ComponentList extends BaseService {
|
class ComponentList extends BaseService {
|
||||||
private state = shallowReactive<ComponentGroupState>({
|
private state: ComponentGroupState;
|
||||||
|
|
||||||
|
constructor(ctx: SdkContext) {
|
||||||
|
super([]);
|
||||||
|
this.state = ctx.reactivity.shallowReactive<ComponentGroupState>({
|
||||||
list: [],
|
list: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -55,4 +56,4 @@ class ComponentList extends BaseService {
|
|||||||
|
|
||||||
export type ComponentListService = ComponentList;
|
export type ComponentListService = ComponentList;
|
||||||
|
|
||||||
export default new ComponentList();
|
export default ComponentList;
|
||||||
@ -1,4 +1,3 @@
|
|||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, get } from 'lodash-es';
|
import { cloneDeep, get } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -7,9 +6,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
import { getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
DataSourceStepValue,
|
DataSourceStepValue,
|
||||||
DatasourceTypeOption,
|
DatasourceTypeOption,
|
||||||
@ -22,7 +18,10 @@ 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 { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
import { createStackStep, describeRevertStep, getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
datasourceTypeList: DatasourceTypeOption[];
|
datasourceTypeList: DatasourceTypeOption[];
|
||||||
@ -57,15 +56,7 @@ const canUsePluginMethods = {
|
|||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
class DataSource extends BaseService {
|
class DataSource extends BaseService {
|
||||||
private state = reactive<State>({
|
private state: State;
|
||||||
datasourceTypeList: [],
|
|
||||||
dataSources: [],
|
|
||||||
editable: true,
|
|
||||||
configs: {},
|
|
||||||
values: {},
|
|
||||||
events: {},
|
|
||||||
methods: {},
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 最近一次写入历史栈的数据源历史记录 uuid。
|
* 最近一次写入历史栈的数据源历史记录 uuid。
|
||||||
@ -74,8 +65,17 @@ class DataSource extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastPushedHistoryId: string | null = null;
|
private lastPushedHistoryId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
||||||
|
this.state = ctx.reactivity.reactive<State>({
|
||||||
|
datasourceTypeList: [],
|
||||||
|
dataSources: [],
|
||||||
|
editable: true,
|
||||||
|
configs: {},
|
||||||
|
values: {},
|
||||||
|
events: {},
|
||||||
|
methods: {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public set<K extends StateKey, T extends State[K]>(name: K, value: T) {
|
public set<K extends StateKey, T extends State[K]>(name: K, value: T) {
|
||||||
@ -129,6 +129,10 @@ class DataSource extends BaseService {
|
|||||||
config: DataSourceSchema,
|
config: DataSourceSchema,
|
||||||
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
{ doNotPushHistory = false, historyDescription, historySource }: HistoryOpOptions = {},
|
||||||
) {
|
) {
|
||||||
|
// 数据源的新增 / 更新是同步方法,这里只能做同步的结构校验(类型是否可用);
|
||||||
|
// 属性表单校验见 `sdk.validate.dataSource(id)`
|
||||||
|
this.ctx.validator.guardDataSource(config, true);
|
||||||
|
|
||||||
const newConfig = {
|
const newConfig = {
|
||||||
...config,
|
...config,
|
||||||
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
id: config.id && !this.getDataSourceById(config.id) ? config.id : this.createId(),
|
||||||
@ -143,7 +147,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('add', newConfig);
|
this.emit('add', newConfig);
|
||||||
@ -169,6 +173,8 @@ class DataSource extends BaseService {
|
|||||||
historySource,
|
historySource,
|
||||||
}: HistoryOpOptionsWithChangeRecords = {},
|
}: HistoryOpOptionsWithChangeRecords = {},
|
||||||
) {
|
) {
|
||||||
|
this.ctx.validator.guardDataSource(config, false);
|
||||||
|
|
||||||
const dataSources = this.get('dataSources');
|
const dataSources = this.get('dataSources');
|
||||||
|
|
||||||
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
const index = dataSources.findIndex((ds) => ds.id === config.id);
|
||||||
@ -186,7 +192,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, newConfig.id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('update', newConfig, {
|
this.emit('update', newConfig, {
|
||||||
@ -218,7 +224,7 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
});
|
});
|
||||||
this.lastPushedHistoryId = (step ? historyService.push('dataSource', step, id) : null)?.uuid ?? null;
|
this.lastPushedHistoryId = (step ? this.ctx.history.push('dataSource', step, id) : null)?.uuid ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emit('remove', id);
|
this.emit('remove', id);
|
||||||
@ -273,7 +279,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
* @returns 撤销的 step;栈不存在或已无可撤销时返回 null
|
||||||
*/
|
*/
|
||||||
public undo(id: Id) {
|
public undo(id: Id) {
|
||||||
const step = historyService.undo('dataSource', id);
|
const step = this.ctx.history.undo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, true);
|
this.applyHistoryStep(step, true);
|
||||||
return step;
|
return step;
|
||||||
@ -285,7 +291,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
* @returns 重做的 step;栈不存在或已无可重做时返回 null
|
||||||
*/
|
*/
|
||||||
public redo(id: Id) {
|
public redo(id: Id) {
|
||||||
const step = historyService.redo('dataSource', id);
|
const step = this.ctx.history.redo('dataSource', id);
|
||||||
if (!step) return null;
|
if (!step) return null;
|
||||||
this.applyHistoryStep(step, false);
|
this.applyHistoryStep(step, false);
|
||||||
return step;
|
return step;
|
||||||
@ -293,12 +299,12 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
/** 是否可对指定数据源撤销。 */
|
/** 是否可对指定数据源撤销。 */
|
||||||
public canUndo(id: Id): boolean {
|
public canUndo(id: Id): boolean {
|
||||||
return historyService.canUndo('dataSource', id);
|
return this.ctx.history.canUndo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 是否可对指定数据源重做。 */
|
/** 是否可对指定数据源重做。 */
|
||||||
public canRedo(id: Id): boolean {
|
public canRedo(id: Id): boolean {
|
||||||
return historyService.canRedo('dataSource', id);
|
return this.ctx.history.canRedo('dataSource', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -309,7 +315,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns 实际移动到的最终游标位置
|
* @returns 实际移动到的最终游标位置
|
||||||
*/
|
*/
|
||||||
public goto(id: Id, targetCursor: number): number {
|
public goto(id: Id, targetCursor: number): number {
|
||||||
let cursor = historyService.getCursor('dataSource', id);
|
let cursor = this.ctx.history.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;
|
||||||
@ -333,7 +339,7 @@ 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.getStepList('dataSource', id);
|
const list = this.ctx.history.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 替换,会冲掉后续无关变更。
|
||||||
@ -352,7 +358,7 @@ class DataSource extends BaseService {
|
|||||||
*/
|
*/
|
||||||
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
public revertById(uuids: string[]): (DataSourceStepValue | null)[] {
|
||||||
return uuids.map((uuid) => {
|
return uuids.map((uuid) => {
|
||||||
const location = historyService.findStepLocationByUuid('dataSource', uuid);
|
const location = this.ctx.history.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);
|
||||||
});
|
});
|
||||||
@ -401,7 +407,7 @@ class DataSource extends BaseService {
|
|||||||
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
|
||||||
|
|
||||||
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
|
||||||
const node = editorService.getNodeById(nodeId);
|
const node = this.ctx.editor.getNodeById(nodeId);
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
customTarget!.deps[nodeId].keys.forEach((key) => {
|
customTarget!.deps[nodeId].keys.forEach((key) => {
|
||||||
const [relateDsId] = get(node, key);
|
const [relateDsId] = get(node, key);
|
||||||
@ -415,7 +421,7 @@ class DataSource extends BaseService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
storageService.setItem(COPY_DS_STORAGE_KEY, copyData, {
|
this.ctx.storage.setItem(COPY_DS_STORAGE_KEY, copyData, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -424,7 +430,7 @@ class DataSource extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public paste() {
|
public paste() {
|
||||||
const dataSource: DataSourceSchema[] = storageService.getItem(COPY_DS_STORAGE_KEY);
|
const dataSource: DataSourceSchema[] = this.ctx.storage.getItem(COPY_DS_STORAGE_KEY);
|
||||||
dataSource.forEach((item: DataSourceSchema) => {
|
dataSource.forEach((item: DataSourceSchema) => {
|
||||||
// 不覆盖同样id的数据源
|
// 不覆盖同样id的数据源
|
||||||
if (!this.getDataSourceById(item.id)) {
|
if (!this.getDataSourceById(item.id)) {
|
||||||
@ -444,13 +450,13 @@ class DataSource extends BaseService {
|
|||||||
// 原本是新增 → revert 即删除
|
// 原本是新增 → revert 即删除
|
||||||
if (!oldSchema && newSchema) {
|
if (!oldSchema && newSchema) {
|
||||||
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
this.remove(`${id}`, { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.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.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!oldSchema || !newSchema) return null;
|
if (!oldSchema || !newSchema) return null;
|
||||||
@ -474,11 +480,11 @@ class DataSource extends BaseService {
|
|||||||
historyDescription,
|
historyDescription,
|
||||||
historySource: 'rollback',
|
historySource: 'rollback',
|
||||||
});
|
});
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
this.update(cloneDeep(oldSchema), { historyDescription, historySource: 'rollback' });
|
||||||
return historyService.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
return this.ctx.history.getStepList('dataSource', id).slice(-1)[0]?.step ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -549,4 +555,4 @@ class DataSource extends BaseService {
|
|||||||
|
|
||||||
export type DataSourceService = DataSource;
|
export type DataSourceService = DataSource;
|
||||||
|
|
||||||
export default new DataSource();
|
export default DataSource;
|
||||||
@ -15,19 +15,28 @@
|
|||||||
* See the License for the specific language governing permissions and
|
* See the License for the specific language governing permissions and
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
import { reactive, shallowReactive } from 'vue';
|
|
||||||
import { throttle } from 'lodash-es';
|
import { throttle } from 'lodash-es';
|
||||||
|
|
||||||
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
|
||||||
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
|
||||||
import { isPage } from '@tmagic/utils';
|
import { isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
|
|
||||||
import { IdleTask } from '@editor/utils/dep/idle-task';
|
import { IdleTask } from '@editor/utils/dep/idle-task';
|
||||||
import type { DepsData } from '@editor/utils/dep/worker';
|
import type { DepsData } from '@editor/utils/dep/worker';
|
||||||
|
|
||||||
|
import type { CollectWorkerLike, SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
|
/** Node 下没有 worker 时使用:始终不可用,依赖收集退回主线程空闲队列 */
|
||||||
|
const noopCollectWorker: CollectWorkerLike = {
|
||||||
|
isSupported: false,
|
||||||
|
collectDsl: () => Promise.resolve(null),
|
||||||
|
collect: () => Promise.resolve(null),
|
||||||
|
terminate: () => {},
|
||||||
|
abort: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
export interface DepEvents {
|
export interface DepEvents {
|
||||||
'add-target': [target: Target];
|
'add-target': [target: Target];
|
||||||
'remove-target': [id: string | number, type: string | DepTargetType];
|
'remove-target': [id: string | number, type: string | DepTargetType];
|
||||||
@ -59,16 +68,13 @@ interface CollectBatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Dep extends BaseService {
|
class Dep extends BaseService {
|
||||||
private state = shallowReactive<State>({
|
private state: State;
|
||||||
collecting: false,
|
|
||||||
taskLength: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
private idleTask = new IdleTask<void>();
|
private idleTask = new IdleTask<void>();
|
||||||
|
|
||||||
private collectWorker = new CollectWorkerClient();
|
private collectWorker: CollectWorkerLike;
|
||||||
|
|
||||||
private watcher = new Watcher({ initialTargets: reactive({}) });
|
private watcher: Watcher;
|
||||||
|
|
||||||
private waitingWorker?: Promise<void>;
|
private waitingWorker?: Promise<void>;
|
||||||
|
|
||||||
@ -78,9 +84,16 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
private activeBatches = new Set<CollectBatch>();
|
private activeBatches = new Set<CollectBatch>();
|
||||||
|
|
||||||
constructor() {
|
constructor(private ctx: SdkContext) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
|
this.state = ctx.reactivity.shallowReactive<State>({
|
||||||
|
collecting: false,
|
||||||
|
taskLength: 0,
|
||||||
|
});
|
||||||
|
this.collectWorker = ctx.options.collectWorker ?? noopCollectWorker;
|
||||||
|
this.watcher = new Watcher({ initialTargets: ctx.reactivity.reactive({}) });
|
||||||
|
|
||||||
this.idleTask.on(
|
this.idleTask.on(
|
||||||
'update-task-length',
|
'update-task-length',
|
||||||
throttle(({ length }) => {
|
throttle(({ length }) => {
|
||||||
@ -137,8 +150,10 @@ class Dep extends BaseService {
|
|||||||
const targets = this.watcher.getCollectableTargets(type);
|
const targets = this.watcher.getCollectableTargets(type);
|
||||||
|
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
|
// 先统一删除所有节点的旧依赖,再统一收集。
|
||||||
|
// 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片),
|
||||||
|
// 后一棵树的删除会清掉前一棵树刚写入的依赖。
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
// 先删除原有依赖,再批量收集
|
|
||||||
if (isPage(node)) {
|
if (isPage(node)) {
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
this.removePageDep(target, depExtendedData);
|
this.removePageDep(target, depExtendedData);
|
||||||
@ -146,6 +161,8 @@ class Dep extends BaseService {
|
|||||||
} else {
|
} else {
|
||||||
this.watcher.removeTargetsDep(targets, node);
|
this.watcher.removeTargetsDep(targets, node);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
for (const node of nodes) {
|
||||||
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
this.watcher.collectItems(node, targets, depExtendedData, deep);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -229,7 +246,7 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
traverseTarget(this.watcher.getTargetsList(), (target) => {
|
||||||
if (depsData[target.type]?.[target.id]) {
|
if (depsData[target.type]?.[target.id]) {
|
||||||
target.deps = reactive(depsData[target.type][target.id]);
|
target.deps = this.ctx.reactivity.reactive(depsData[target.type][target.id]);
|
||||||
|
|
||||||
if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) {
|
if (target.type === DepTargetType.DATA_SOURCE && dsl.dataSourceDeps) {
|
||||||
dsl.dataSourceDeps[target.id] = target.deps;
|
dsl.dataSourceDeps[target.id] = target.deps;
|
||||||
@ -554,4 +571,4 @@ class Dep extends BaseService {
|
|||||||
|
|
||||||
export type DepService = Dep;
|
export type DepService = Dep;
|
||||||
|
|
||||||
export default new Dep();
|
export default Dep;
|
||||||
@ -16,13 +16,11 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { nextTick, reactive, toRaw } from 'vue';
|
|
||||||
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
|
||||||
|
|
||||||
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
|
||||||
import { NodeType } from '@tmagic/core';
|
import { NodeType } from '@tmagic/core';
|
||||||
import type { ChangeRecord } from '@tmagic/form';
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
import { isFixed } from '@tmagic/stage';
|
|
||||||
import {
|
import {
|
||||||
getNodeInfo,
|
getNodeInfo,
|
||||||
getNodePath,
|
getNodePath,
|
||||||
@ -35,14 +33,12 @@ import {
|
|||||||
traverseNode,
|
traverseNode,
|
||||||
} from '@tmagic/utils';
|
} from '@tmagic/utils';
|
||||||
|
|
||||||
import BaseService from '@editor/services//BaseService';
|
|
||||||
import propsService from '@editor/services//props';
|
|
||||||
import historyService from '@editor/services/history';
|
|
||||||
import storageService, { Protocol } from '@editor/services/storage';
|
|
||||||
import type {
|
import type {
|
||||||
AddMNode,
|
AddMNode,
|
||||||
|
AddOpOptions,
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
AsyncMethodName,
|
AsyncMethodName,
|
||||||
|
CanDropInFunction,
|
||||||
DslOpOptions,
|
DslOpOptions,
|
||||||
DslOpWithHistoryIdsResult,
|
DslOpWithHistoryIdsResult,
|
||||||
EditorEvents,
|
EditorEvents,
|
||||||
@ -80,13 +76,21 @@ import {
|
|||||||
toggleFixedPosition,
|
toggleFixedPosition,
|
||||||
} from '@editor/utils/editor';
|
} from '@editor/utils/editor';
|
||||||
import { getLastPushedHistoryIds } from '@editor/utils/history';
|
import { getLastPushedHistoryIds } from '@editor/utils/history';
|
||||||
import { beforePaste, getAddParent } from '@editor/utils/operator';
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
import { beforePaste, getAddParent } from '../utils/operator';
|
||||||
|
import { isFixed } from '../utils/position';
|
||||||
|
|
||||||
|
import BaseService from './BaseService';
|
||||||
|
import { Protocol } from './WebStorage';
|
||||||
|
|
||||||
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
type MoveItem = { node: MNode; parent: MContainer; pageForOp: { name: string; id: Id } | null };
|
||||||
|
|
||||||
/** 历史插回时把记录的下标收敛到 [0, length],越界(含未记录)一律追加到末尾 */
|
/** 把插入下标收敛到 [0, length]:非整数取整,非法 / 越界(含未记录)一律追加到末尾 */
|
||||||
const clampIndex = (index: number | undefined, length: number): number =>
|
const clampIndex = (index: number | undefined, length: number): number => {
|
||||||
typeof index === 'number' && index >= 0 && index <= length ? index : length;
|
const normalized = typeof index === 'number' ? Math.trunc(index) : length;
|
||||||
|
return Number.isFinite(normalized) && normalized >= 0 && normalized <= length ? normalized : length;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
|
* 把「变更前后节点快照」列表归一成 update 类型的 {@link StepDiffItem} 列表,供 {@link StepValue.diff} 使用。
|
||||||
@ -102,23 +106,13 @@ const buildUpdateDiff = (
|
|||||||
...(changeRecords?.length ? { changeRecords } : {}),
|
...(changeRecords?.length ? { changeRecords } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export interface EditorServiceConfig {
|
||||||
|
disabledPageFragment?: boolean;
|
||||||
|
canDropIn?: CanDropInFunction;
|
||||||
|
}
|
||||||
|
|
||||||
class Editor extends BaseService {
|
class Editor extends BaseService {
|
||||||
public state: StoreState = reactive({
|
public state: StoreState;
|
||||||
root: null,
|
|
||||||
page: null,
|
|
||||||
parent: null,
|
|
||||||
node: null,
|
|
||||||
nodes: [],
|
|
||||||
stage: null,
|
|
||||||
stageLoading: true,
|
|
||||||
highlightNode: null,
|
|
||||||
modifiedNodeIds: new Map(),
|
|
||||||
invalidNodeIds: new Map(),
|
|
||||||
pageLength: 0,
|
|
||||||
pageFragmentLength: 0,
|
|
||||||
disabledMultiSelect: false,
|
|
||||||
alwaysMultiSelect: false,
|
|
||||||
});
|
|
||||||
private selectionBeforeOp: Id[] | null = null;
|
private selectionBeforeOp: Id[] | null = null;
|
||||||
/**
|
/**
|
||||||
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
|
* 操作前的节点校验错误快照,与 selectionBeforeOp 同时在 captureSelectionBeforeOp 中捕获,
|
||||||
@ -137,12 +131,32 @@ class Editor extends BaseService {
|
|||||||
*/
|
*/
|
||||||
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
|
private lastAdded: { selectedId: Id; nodeId: Id } | null = null;
|
||||||
|
|
||||||
constructor() {
|
/** 与编辑器 props 中 `disabledPageFragment` / `canDropIn` 对应,供结构合法性校验使用 */
|
||||||
|
private config: EditorServiceConfig = {};
|
||||||
|
|
||||||
|
constructor(private ctx: SdkContext) {
|
||||||
super(
|
super(
|
||||||
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
|
// 需要注意循环依赖问题,如果函数间有相互调用的话,不能设置为串行调用
|
||||||
['select', 'update', 'moveLayer'],
|
['select', 'update', 'moveLayer'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.state = ctx.reactivity.reactive<StoreState>({
|
||||||
|
root: null,
|
||||||
|
page: null,
|
||||||
|
parent: null,
|
||||||
|
node: null,
|
||||||
|
nodes: [],
|
||||||
|
stage: null,
|
||||||
|
stageLoading: true,
|
||||||
|
highlightNode: null,
|
||||||
|
modifiedNodeIds: new Map(),
|
||||||
|
invalidNodeIds: new Map(),
|
||||||
|
pageLength: 0,
|
||||||
|
pageFragmentLength: 0,
|
||||||
|
disabledMultiSelect: false,
|
||||||
|
alwaysMultiSelect: false,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -185,8 +199,8 @@ class Editor extends BaseService {
|
|||||||
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
// 重复创建,set root 不额外产生记录,由恢复出的历史栈作为当前状态来源。
|
||||||
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
// 标记不进入撤销/重做栈,仅作为该页历史列表底部的初始基线展示。
|
||||||
app.items?.forEach((pageNode) => {
|
app.items?.forEach((pageNode) => {
|
||||||
if (pageNode?.id !== undefined && !historyService.getMarker('page', pageNode.id)) {
|
if (pageNode?.id !== undefined && !this.ctx.history.getMarker('page', pageNode.id)) {
|
||||||
historyService.setMarker('page', pageNode.id, {
|
this.ctx.history.setMarker('page', pageNode.id, {
|
||||||
name: pageNode.name,
|
name: pageNode.name,
|
||||||
source: options.historySource,
|
source: options.historySource,
|
||||||
});
|
});
|
||||||
@ -224,7 +238,7 @@ class Editor extends BaseService {
|
|||||||
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
|
public getNodeInfo(id: Id, raw = true): EditorNodeInfo {
|
||||||
let root = this.get('root');
|
let root = this.get('root');
|
||||||
if (raw) {
|
if (raw) {
|
||||||
root = toRaw(root);
|
root = this.toRaw(root);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!root) {
|
if (!root) {
|
||||||
@ -340,11 +354,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async selectNextNode(): Promise<MNode | null> | never {
|
public async selectNextNode(): Promise<MNode | null> | never {
|
||||||
const node = toRaw(this.get('node'));
|
const node = this.toRaw(this.get('node'));
|
||||||
|
|
||||||
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
|
if (!node || isPage(node) || node.type === NodeType.ROOT) return node;
|
||||||
|
|
||||||
const parent = toRaw(this.getParentById(node.id));
|
const parent = this.toRaw(this.getParentById(node.id));
|
||||||
|
|
||||||
if (!parent) return node;
|
if (!parent) return node;
|
||||||
|
|
||||||
@ -359,8 +373,8 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async selectNextPage(): Promise<MNode> | never {
|
public async selectNextPage(): Promise<MNode> | never {
|
||||||
const root = toRaw(this.get('root'));
|
const root = this.toRaw(this.get('root'));
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
|
|
||||||
if (!page) throw new Error('page不能为空');
|
if (!page) throw new Error('page不能为空');
|
||||||
if (!root) throw new Error('root不能为空');
|
if (!root) throw new Error('root不能为空');
|
||||||
@ -414,32 +428,55 @@ class Editor extends BaseService {
|
|||||||
this.set('highlightNode', null);
|
this.set('highlightNode', null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async doAdd(node: MNode, parent: MContainer, _options: DslOpOptions = {}): Promise<MNode> {
|
/** 设置结构合法性校验用到的配置,只覆盖传入的字段 */
|
||||||
|
public setConfig(config: Partial<EditorServiceConfig>): void {
|
||||||
|
this.config = { ...this.config, ...config };
|
||||||
|
}
|
||||||
|
|
||||||
|
public getConfig(): Readonly<EditorServiceConfig> {
|
||||||
|
return this.config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async doAdd(node: MNode, parent: MContainer, options: AddOpOptions = {}): Promise<MNode> {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
|
|
||||||
if (!root) throw new Error('root为空');
|
if (!root) throw new Error('root为空');
|
||||||
|
|
||||||
const curNode = this.get('node');
|
const curNode = this.get('node');
|
||||||
const stage = this.get('stage');
|
const stage = this.get('stage');
|
||||||
|
const explicitIndex = options.insertIndex;
|
||||||
|
|
||||||
if (!curNode) throw new Error('当前选中节点为空');
|
// 未指定 insertIndex 时需要选中节点作为锚点;显式下标不依赖选中状态
|
||||||
|
if (!curNode && explicitIndex === undefined) throw new Error('当前选中节点为空');
|
||||||
|
|
||||||
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
|
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
|
||||||
throw new Error('app下不能添加组件');
|
throw new Error('app下不能添加组件');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后;
|
// 默认:连续 add 时接在上一个新增节点之后,否则接在当前选中节点之后;
|
||||||
// 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾
|
// 锚点节点可能已不在 items 中(被删 / DSL 整体替换),回退到当前选中节点,都不在则追加到末尾。
|
||||||
const anchorId = this.lastAdded?.selectedId === curNode.id ? this.lastAdded.nodeId : curNode.id;
|
// 指定 insertIndex 时按显式下标插入,批量写入顺序不再受编辑器选中状态影响。
|
||||||
|
let insertIndex: number;
|
||||||
|
if (explicitIndex === 'end') {
|
||||||
|
insertIndex = parent.items.length;
|
||||||
|
} else if (typeof explicitIndex === 'number') {
|
||||||
|
insertIndex = clampIndex(explicitIndex, parent.items.length);
|
||||||
|
} else {
|
||||||
|
// 上面的校验已保证锚点分支存在选中节点
|
||||||
|
const selectedId = curNode!.id;
|
||||||
|
const anchorId = this.lastAdded?.selectedId === selectedId ? this.lastAdded.nodeId : selectedId;
|
||||||
const anchorIndex = isPageOrFragment(node)
|
const anchorIndex = isPageOrFragment(node)
|
||||||
? -1
|
? -1
|
||||||
: Math.max(getNodeIndex(anchorId, parent), getNodeIndex(curNode.id, parent));
|
: Math.max(getNodeIndex(anchorId, parent), getNodeIndex(selectedId, parent));
|
||||||
const insertIndex = anchorIndex < 0 ? parent.items.length : anchorIndex + 1;
|
insertIndex = anchorIndex < 0 ? parent.items.length : anchorIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
parent.items.splice(insertIndex, 0, node);
|
parent.items.splice(insertIndex, 0, node);
|
||||||
|
if (curNode) {
|
||||||
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
|
this.lastAdded = { selectedId: curNode.id, nodeId: node.id };
|
||||||
|
}
|
||||||
|
|
||||||
const layout = await this.getLayout(toRaw(parent), node as MNode);
|
const layout = await this.getLayout(this.toRaw(parent), node as MNode);
|
||||||
node.style = getInitPositionStyle(node.style, layout);
|
node.style = getInitPositionStyle(node.style, layout);
|
||||||
|
|
||||||
await stage?.add({
|
await stage?.add({
|
||||||
@ -472,7 +509,7 @@ class Editor extends BaseService {
|
|||||||
public async add(
|
public async add(
|
||||||
addNode: AddMNode | MNode[],
|
addNode: AddMNode | MNode[],
|
||||||
parent?: MContainer | null,
|
parent?: MContainer | null,
|
||||||
options: DslOpOptions = {},
|
options: AddOpOptions = {},
|
||||||
): Promise<MNode | MNode[]> {
|
): Promise<MNode | MNode[]> {
|
||||||
this.captureSelectionBeforeOp();
|
this.captureSelectionBeforeOp();
|
||||||
|
|
||||||
@ -493,7 +530,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!type) throw new Error('组件类型不能为空');
|
if (!type) throw new Error('组件类型不能为空');
|
||||||
|
|
||||||
addNodes.push({ ...toRaw(await propsService.getPropsValue(type, config)) });
|
addNodes.push({ ...this.toRaw(await this.ctx.props.getPropsValue(type, config)) });
|
||||||
} else {
|
} else {
|
||||||
addNodes.push(...addNode);
|
addNodes.push(...addNode);
|
||||||
}
|
}
|
||||||
@ -501,13 +538,31 @@ class Editor extends BaseService {
|
|||||||
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
|
// 必须串行:每个节点的插入位置依赖上一个已插入的节点,并行时插入顺序会受
|
||||||
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
|
// doAdd 前置耗时(如异步 beforeDoAdd 插件钩子)影响而错乱
|
||||||
const newNodes: MNode[] = [];
|
const newNodes: MNode[] = [];
|
||||||
|
const targets: { node: MNode; parentNode: MContainer }[] = [];
|
||||||
for (const node of addNodes) {
|
for (const node of addNodes) {
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(node));
|
const parentNode = isPageOrFragment(node) && root ? root : (parent ?? getAddParent(this.ctx, node));
|
||||||
if (!parentNode) throw new Error('未找到父元素');
|
if (!parentNode) throw new Error('未找到父元素');
|
||||||
newNodes.push(await this.doAdd(node, parentNode, options));
|
targets.push({ node, parentNode });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验放在真正修改 DSL 之前,strict 模式下校验不通过则整批都不会被添加
|
||||||
|
const commitInvalid = await this.runGuard(() => this.ctx.validator.guardAdd(targets));
|
||||||
|
|
||||||
|
// 数字下标是整批节点的起始位置:按父容器分别累计偏移,保证批量写入保持传入顺序
|
||||||
|
const explicitIndex = options.insertIndex;
|
||||||
|
const offsets = new Map<Id, number>();
|
||||||
|
for (const { node, parentNode } of targets) {
|
||||||
|
const offset = offsets.get(parentNode.id) ?? 0;
|
||||||
|
offsets.set(parentNode.id, offset + 1);
|
||||||
|
const nodeOptions =
|
||||||
|
typeof explicitIndex === 'number' ? { ...options, insertIndex: Math.trunc(explicitIndex) + offset } : options;
|
||||||
|
newNodes.push(await this.doAdd(node, parentNode, nodeOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
// record 模式:在写入历史记录之前落库,使历史快照包含本次的错误状态
|
||||||
|
commitInvalid?.();
|
||||||
|
|
||||||
if (newNodes.length > 1) {
|
if (newNodes.length > 1) {
|
||||||
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
// 多选时只要任一新增节点位于非当前页面,触发的 multiSelect 就会引起页面切换
|
||||||
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
|
const wouldSwitchPage = newNodes.some((n) => this.isOnDifferentPage(n));
|
||||||
@ -544,7 +599,7 @@ class Editor extends BaseService {
|
|||||||
const p = this.getParentById(n.id, false) as MContainer;
|
const p = this.getParentById(n.id, false) as MContainer;
|
||||||
const idx = p ? getNodeIndex(n.id, p) : -1;
|
const idx = p ? getNodeIndex(n.id, p) : -1;
|
||||||
return {
|
return {
|
||||||
newSchema: cloneDeep(toRaw(n)),
|
newSchema: cloneDeep(this.toRaw(n)),
|
||||||
parentId,
|
parentId,
|
||||||
index: typeof idx === 'number' ? idx : -1,
|
index: typeof idx === 'number' ? idx : -1,
|
||||||
};
|
};
|
||||||
@ -569,7 +624,7 @@ class Editor extends BaseService {
|
|||||||
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (addedPages.length) {
|
if (addedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
|
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: [] });
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
return Array.isArray(addNode) ? newNodes : newNodes[0];
|
||||||
@ -626,7 +681,7 @@ class Editor extends BaseService {
|
|||||||
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
|
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
|
||||||
await this.select(rootItems[0]);
|
await this.select(rootItems[0]);
|
||||||
stage?.select(rootItems[0].id);
|
stage?.select(rootItems[0].id);
|
||||||
await nextTick();
|
await this.ctx.reactivity.nextTick();
|
||||||
stage?.remove(removeData);
|
stage?.remove(removeData);
|
||||||
} else {
|
} else {
|
||||||
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
|
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer,删除通知必须在卸载前发出,
|
||||||
@ -653,7 +708,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!rootItems.length) {
|
if (!rootItems.length) {
|
||||||
this.resetModifiedNodeId();
|
this.resetModifiedNodeId();
|
||||||
historyService.reset();
|
this.ctx.history.reset();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -685,7 +740,7 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
const idx = getNodeIndex(curNode.id, parent);
|
const idx = getNodeIndex(curNode.id, parent);
|
||||||
removedItems.push({
|
removedItems.push({
|
||||||
oldSchema: cloneDeep(toRaw(curNode)),
|
oldSchema: cloneDeep(this.toRaw(curNode)),
|
||||||
parentId: parent.id,
|
parentId: parent.id,
|
||||||
index: typeof idx === 'number' ? idx : -1,
|
index: typeof idx === 'number' ? idx : -1,
|
||||||
});
|
});
|
||||||
@ -717,7 +772,7 @@ class Editor extends BaseService {
|
|||||||
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
|
||||||
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
|
||||||
if (removedPages.length) {
|
if (removedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
|
this.ctx.history.notifyPageStructureChange({ add: [], remove: removedPages });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -735,12 +790,12 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
|
if (!info.node) throw new Error(`获取不到id为${config.id}的节点`);
|
||||||
|
|
||||||
const node = toRaw(info.node);
|
const node = this.toRaw(info.node);
|
||||||
|
|
||||||
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
// replace=true 时跳过 toggleFixedPosition / mergeWith / setChildrenLayout,直接用传入配置整节点替换
|
||||||
let newConfig = replace
|
let newConfig = replace
|
||||||
? cloneDeep(toRaw(config))
|
? cloneDeep(this.toRaw(config))
|
||||||
: await toggleFixedPosition(toRaw(config), node, info.path, this.getLayout);
|
: await toggleFixedPosition(this.toRaw(config), node, info.path, this.getLayout);
|
||||||
|
|
||||||
if (!replace) {
|
if (!replace) {
|
||||||
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
|
||||||
@ -821,6 +876,8 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const nodes = Array.isArray(config) ? config : [config];
|
const nodes = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
|
const commitInvalid = await this.runGuard(() => this.ctx.validator.guardUpdate(nodes, { replace: data.replace }));
|
||||||
|
|
||||||
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
|
// 多节点必须使用 changeRecordList 为每个节点提供独立的记录;
|
||||||
// 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
|
// 否则同一份 changeRecords 会被复用到每个节点上,nodeUpdateHandler / 历史回放都会按错误的 propPath 处理。
|
||||||
const updateData = await Promise.all(
|
const updateData = await Promise.all(
|
||||||
@ -832,6 +889,7 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
|
// 校验错误信息在 pushOpHistory 之前落库,保证历史快照包含本次变更对应的错误状态。
|
||||||
this.applyInvalidInfo(config, invalidInfo);
|
this.applyInvalidInfo(config, invalidInfo);
|
||||||
|
commitInvalid?.();
|
||||||
|
|
||||||
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
|
||||||
const curNodes = this.get('nodes');
|
const curNodes = this.get('nodes');
|
||||||
@ -891,7 +949,7 @@ class Editor extends BaseService {
|
|||||||
const node = this.get('node');
|
const node = this.get('node');
|
||||||
if (!node) throw new Error('当前节点为空');
|
if (!node) throw new Error('当前节点为空');
|
||||||
|
|
||||||
const parent = cloneDeep(toRaw(this.get('parent')));
|
const parent = cloneDeep(this.toRaw(this.get('parent')));
|
||||||
if (!parent) throw new Error('父节点为空');
|
if (!parent) throw new Error('父节点为空');
|
||||||
|
|
||||||
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
|
const index2 = parent.items.findIndex((node: MNode) => `${node.id}` === `${id2}`);
|
||||||
@ -919,7 +977,7 @@ class Editor extends BaseService {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public copy(config: MNode | MNode[]): void {
|
public copy(config: MNode | MNode[]): void {
|
||||||
storageService.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
|
this.ctx.storage.setItem(COPY_STORAGE_KEY, Array.isArray(config) ? config : [config], {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -936,7 +994,7 @@ class Editor extends BaseService {
|
|||||||
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
|
this.ctx.storage.setItem(COPY_STORAGE_KEY, copyNodes, {
|
||||||
protocol: Protocol.OBJECT,
|
protocol: Protocol.OBJECT,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -962,7 +1020,7 @@ class Editor extends BaseService {
|
|||||||
historySource,
|
historySource,
|
||||||
}: DslOpOptions = {},
|
}: DslOpOptions = {},
|
||||||
): Promise<MNode | MNode[] | void> {
|
): Promise<MNode | MNode[] | void> {
|
||||||
const config: MNode[] = storageService.getItem(COPY_STORAGE_KEY);
|
const config: MNode[] = this.ctx.storage.getItem(COPY_STORAGE_KEY);
|
||||||
if (!Array.isArray(config)) return;
|
if (!Array.isArray(config)) return;
|
||||||
|
|
||||||
const node = this.get('node');
|
const node = this.get('node');
|
||||||
@ -978,7 +1036,7 @@ class Editor extends BaseService {
|
|||||||
const pasteConfigs = await this.doPaste(config, position);
|
const pasteConfigs = await this.doPaste(config, position);
|
||||||
|
|
||||||
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
|
||||||
propsService.replaceRelateId(config, pasteConfigs, collectorOptions);
|
this.ctx.props.replaceRelateId(config, pasteConfigs, collectorOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.add(pasteConfigs, parent, {
|
return this.add(pasteConfigs, parent, {
|
||||||
@ -991,9 +1049,9 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
|
public async doPaste(config: MNode[], position: PastePosition = {}): Promise<MNode[]> {
|
||||||
propsService.clearRelateId();
|
this.ctx.props.clearRelateId();
|
||||||
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||||
const pasteConfigs = beforePaste(position, cloneDeep(config), doc);
|
const pasteConfigs = beforePaste(this.ctx, position, cloneDeep(config), doc);
|
||||||
return pasteConfigs;
|
return pasteConfigs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1001,7 +1059,7 @@ class Editor extends BaseService {
|
|||||||
const parent = this.getParentById(config.id);
|
const parent = this.getParentById(config.id);
|
||||||
if (!parent) throw new Error('找不到父节点');
|
if (!parent) throw new Error('找不到父节点');
|
||||||
|
|
||||||
const node = cloneDeep(toRaw(config));
|
const node = cloneDeep(this.toRaw(config));
|
||||||
const layout = await this.getLayout(parent, node);
|
const layout = await this.getLayout(parent, node);
|
||||||
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
const doc = this.get('stage')?.renderer?.contentWindow?.document;
|
||||||
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
|
||||||
@ -1030,7 +1088,11 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
|
const newNodes = await Promise.all(nodes.map((node) => this.doAlignCenter(node)));
|
||||||
|
|
||||||
const newNode = await this.update(newNodes, { doNotPushHistory, historyDescription, historySource });
|
const newNode = await this.update(newNodes, {
|
||||||
|
doNotPushHistory,
|
||||||
|
historyDescription,
|
||||||
|
historySource,
|
||||||
|
});
|
||||||
|
|
||||||
if (!doNotSelect) {
|
if (!doNotSelect) {
|
||||||
if (newNodes.length > 1) {
|
if (newNodes.length > 1) {
|
||||||
@ -1075,7 +1137,7 @@ class Editor extends BaseService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldParent = cloneDeep(toRaw(parent));
|
const oldParent = cloneDeep(this.toRaw(parent));
|
||||||
|
|
||||||
brothers.splice(index, 1);
|
brothers.splice(index, 1);
|
||||||
brothers.splice(offsetIndex, 0, node);
|
brothers.splice(offsetIndex, 0, node);
|
||||||
@ -1083,7 +1145,7 @@ class Editor extends BaseService {
|
|||||||
const grandparent = this.getParentById(parent.id);
|
const grandparent = this.getParentById(parent.id);
|
||||||
|
|
||||||
this.get('stage')?.update({
|
this.get('stage')?.update({
|
||||||
config: cloneDeep(toRaw(parent)),
|
config: cloneDeep(this.toRaw(parent)),
|
||||||
parentId: grandparent?.id,
|
parentId: grandparent?.id,
|
||||||
root: cloneDeep(root),
|
root: cloneDeep(root),
|
||||||
});
|
});
|
||||||
@ -1095,7 +1157,7 @@ class Editor extends BaseService {
|
|||||||
'update',
|
'update',
|
||||||
|
|
||||||
{
|
{
|
||||||
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }]),
|
diff: buildUpdateDiff([{ oldNode: oldParent, newNode: cloneDeep(this.toRaw(parent)) }]),
|
||||||
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
pageData: { name: pageForOp?.name || '', id: pageForOp!.id },
|
||||||
historyDescription,
|
historyDescription,
|
||||||
source: historySource,
|
source: historySource,
|
||||||
@ -1156,12 +1218,20 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const root = this.get('root');
|
const root = this.get('root');
|
||||||
const stage = this.get('stage');
|
|
||||||
|
|
||||||
if (!root || !stage) {
|
if (!root) {
|
||||||
throw new Error('root 或 stage为空');
|
throw new Error('root 为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验放在过滤之前:源节点不存在也要作为校验错误报出
|
||||||
|
await this.runGuard(() =>
|
||||||
|
this.ctx.validator.guardMove(
|
||||||
|
configs.map((item) => item.id),
|
||||||
|
target,
|
||||||
|
'stage-drag',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
|
// 收集 (节点, 源父) 信息,过滤掉异常节点(找不到父或源父等于目标本身)
|
||||||
const moves: MoveItem[] = [];
|
const moves: MoveItem[] = [];
|
||||||
for (const { id } of configs) {
|
for (const { id } of configs) {
|
||||||
@ -1176,10 +1246,10 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
|
// 记录所有涉及的源父容器(按 id 去重)+ 目标容器的前置快照;同一父容器只快照一次。
|
||||||
const beforeSnapshots = new Map<Id, MNode>();
|
const beforeSnapshots = new Map<Id, MNode>();
|
||||||
beforeSnapshots.set(target.id, cloneDeep(toRaw(target)));
|
beforeSnapshots.set(target.id, cloneDeep(this.toRaw(target)));
|
||||||
for (const { parent } of moves) {
|
for (const { parent } of moves) {
|
||||||
if (!beforeSnapshots.has(parent.id)) {
|
if (!beforeSnapshots.has(parent.id)) {
|
||||||
beforeSnapshots.set(parent.id, cloneDeep(toRaw(parent)));
|
beforeSnapshots.set(parent.id, cloneDeep(this.toRaw(parent)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1198,7 +1268,7 @@ class Editor extends BaseService {
|
|||||||
// 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
|
// 整批只入栈一条历史:updatedItems 包含所有源父容器 + 目标容器的前后快照(撤销/重做最小依赖)。
|
||||||
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
|
const updatedItems = Array.from(beforeSnapshots.entries()).map(([id, oldNode]) => ({
|
||||||
oldNode,
|
oldNode,
|
||||||
newNode: cloneDeep(toRaw(this.getNodeById(id, false))) as MNode,
|
newNode: cloneDeep(this.toRaw(this.getNodeById(id, false))) as MNode,
|
||||||
}));
|
}));
|
||||||
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
|
const historyPage = moves[0].pageForOp ?? { name: '', id: target.id };
|
||||||
this.pushOpHistory('update', {
|
this.pushOpHistory('update', {
|
||||||
@ -1226,15 +1296,23 @@ class Editor extends BaseService {
|
|||||||
|
|
||||||
const configs = Array.isArray(config) ? config : [config];
|
const configs = Array.isArray(config) ? config : [config];
|
||||||
|
|
||||||
|
await this.runGuard(() =>
|
||||||
|
this.ctx.validator.guardMove(
|
||||||
|
configs.map((item) => item.id),
|
||||||
|
targetParent,
|
||||||
|
'layer',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
const beforeSnapshots = new Map<string, MNode>();
|
const beforeSnapshots = new Map<string, MNode>();
|
||||||
for (const cfg of configs) {
|
for (const cfg of configs) {
|
||||||
const { parent } = this.getNodeInfo(cfg.id, false);
|
const { parent } = this.getNodeInfo(cfg.id, false);
|
||||||
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
|
||||||
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
|
beforeSnapshots.set(`${parent.id}`, cloneDeep(this.toRaw(parent)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
if (!beforeSnapshots.has(`${targetParent.id}`)) {
|
||||||
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
|
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(this.toRaw(targetParent)));
|
||||||
}
|
}
|
||||||
|
|
||||||
const newLayout = await this.getLayout(targetParent);
|
const newLayout = await this.getLayout(targetParent);
|
||||||
@ -1276,7 +1354,7 @@ class Editor extends BaseService {
|
|||||||
for (const oldNode of beforeSnapshots.values()) {
|
for (const oldNode of beforeSnapshots.values()) {
|
||||||
const newNode = this.getNodeById(oldNode.id, false);
|
const newNode = this.getNodeById(oldNode.id, false);
|
||||||
if (newNode) {
|
if (newNode) {
|
||||||
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
|
updatedItems.push({ oldNode, newNode: cloneDeep(this.toRaw(newNode)) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!doNotPushHistory) {
|
if (!doNotPushHistory) {
|
||||||
@ -1315,7 +1393,7 @@ class Editor extends BaseService {
|
|||||||
public async addAndGetHistoryId(
|
public async addAndGetHistoryId(
|
||||||
addNode: AddMNode | MNode[],
|
addNode: AddMNode | MNode[],
|
||||||
parent?: MContainer | null,
|
parent?: MContainer | null,
|
||||||
options: DslOpOptions = {},
|
options: AddOpOptions = {},
|
||||||
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
): Promise<DslOpWithHistoryIdsResult<MNode | MNode[]>> {
|
||||||
this.lastPushedHistoryId = null;
|
this.lastPushedHistoryId = null;
|
||||||
const result = await this.add(addNode, parent, options);
|
const result = await this.add(addNode, parent, options);
|
||||||
@ -1383,7 +1461,7 @@ class Editor extends BaseService {
|
|||||||
public async undo(): Promise<StepValue | null> {
|
public async undo(): Promise<StepValue | null> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
if (pageId === undefined) return null;
|
if (pageId === undefined) return null;
|
||||||
const value = historyService.undo('page', pageId);
|
const value = this.ctx.history.undo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, true);
|
await this.applyHistoryOp(value, true);
|
||||||
}
|
}
|
||||||
@ -1397,7 +1475,7 @@ class Editor extends BaseService {
|
|||||||
public async redo(): Promise<StepValue | null> {
|
public async redo(): Promise<StepValue | null> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
if (pageId === undefined) return null;
|
if (pageId === undefined) return null;
|
||||||
const value = historyService.redo('page', pageId);
|
const value = this.ctx.history.redo('page', pageId);
|
||||||
if (value) {
|
if (value) {
|
||||||
await this.applyHistoryOp(value, false);
|
await this.applyHistoryOp(value, false);
|
||||||
}
|
}
|
||||||
@ -1421,7 +1499,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.getStepList('page', this.get('page')?.id);
|
const list = this.ctx.history.getStepList('page', this.get('page')?.id);
|
||||||
const entry = list[index];
|
const entry = list[index];
|
||||||
if (!entry?.applied) return null;
|
if (!entry?.applied) return null;
|
||||||
|
|
||||||
@ -1443,11 +1521,16 @@ class Editor extends BaseService {
|
|||||||
const captureRevert = (_pageId: Id, s: StepValue) => {
|
const captureRevert = (_pageId: Id, s: StepValue) => {
|
||||||
revertedStep = s;
|
revertedStep = s;
|
||||||
};
|
};
|
||||||
historyService.once('change', captureRevert);
|
this.ctx.history.once('change', captureRevert);
|
||||||
|
|
||||||
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
const historyDescription = `回滚 #${index + 1}: ${describeStepForRevert(step)}`;
|
||||||
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
// revert 走 public add/remove/update,让操作以一条普通新 step 入栈;不要切换选区与页面,避免打断用户。
|
||||||
const opts = { doNotSelect: true, doNotSwitchPage: true, historyDescription, historySource: 'rollback' } as const;
|
const opts = {
|
||||||
|
doNotSelect: true,
|
||||||
|
doNotSwitchPage: true,
|
||||||
|
historyDescription,
|
||||||
|
historySource: 'rollback',
|
||||||
|
} as const;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (step.opType) {
|
switch (step.opType) {
|
||||||
@ -1506,11 +1589,11 @@ class Editor extends BaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
historyService.off('change', captureRevert);
|
this.ctx.history.off('change', captureRevert);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
|
// 通知一次 history-change,让上层(如属性面板)按当前最新 DSL 刷新
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
if (page) {
|
if (page) {
|
||||||
this.emit('history-change', page as MPage | MPageFragment);
|
this.emit('history-change', page as MPage | MPageFragment);
|
||||||
}
|
}
|
||||||
@ -1529,7 +1612,7 @@ class Editor extends BaseService {
|
|||||||
const results: (StepValue | null)[] = [];
|
const results: (StepValue | null)[] = [];
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
for (const uuid of uuids) {
|
for (const uuid of uuids) {
|
||||||
const location = historyService.findStepLocationByUuid('page', uuid, pageId);
|
const location = this.ctx.history.findStepLocationByUuid('page', uuid, pageId);
|
||||||
results.push(!location ? null : await this.revertPageStep(location.index));
|
results.push(!location ? null : await this.revertPageStep(location.index));
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@ -1546,8 +1629,8 @@ class Editor extends BaseService {
|
|||||||
*/
|
*/
|
||||||
public async gotoPageStep(targetCursor: number): Promise<number> {
|
public async gotoPageStep(targetCursor: number): Promise<number> {
|
||||||
const pageId = this.get('page')?.id;
|
const pageId = this.get('page')?.id;
|
||||||
let cursor = historyService.getCursor('page', pageId);
|
let cursor = this.ctx.history.getCursor('page', pageId);
|
||||||
const { length } = historyService.getStepList('page', pageId);
|
const { length } = this.ctx.history.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();
|
||||||
@ -1567,7 +1650,7 @@ class Editor extends BaseService {
|
|||||||
top: number,
|
top: number,
|
||||||
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
{ doNotPushHistory = false, historyDescription, historySource }: DslOpOptions = {},
|
||||||
) {
|
) {
|
||||||
const node = toRaw(this.get('node'));
|
const node = this.toRaw(this.get('node'));
|
||||||
if (!node || isPage(node)) return;
|
if (!node || isPage(node)) return;
|
||||||
|
|
||||||
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
const newStyle = calcMoveStyle(node.style || {}, left, top);
|
||||||
@ -1760,6 +1843,20 @@ class Editor extends BaseService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行校验。校验不通过(strict 抛错)时操作不会继续,必须清掉 captureSelectionBeforeOp 留下的快照,
|
||||||
|
* 否则下一次成功的操作会沿用这份过期快照,写出错误的 selectedBefore / invalidNodeIdsBefore。
|
||||||
|
*/
|
||||||
|
private async runGuard<T>(guard: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await guard();
|
||||||
|
} catch (e) {
|
||||||
|
this.selectionBeforeOp = null;
|
||||||
|
this.invalidNodeIdsBeforeOp = null;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
@ -1794,15 +1891,22 @@ class Editor extends BaseService {
|
|||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'add',
|
'add',
|
||||||
nextPage,
|
nextPage,
|
||||||
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
|
{
|
||||||
|
newSchema: cloneDeep(this.toRaw(nextPage)),
|
||||||
|
parentId: nextRoot.id,
|
||||||
|
index: indexInItems(nextRoot, nextPage.id),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
addedPages.push(nextPage);
|
addedPages.push(nextPage);
|
||||||
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
|
} else if (!isEqual(this.toRaw(prevPage), this.toRaw(nextPage))) {
|
||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'update',
|
'update',
|
||||||
nextPage,
|
nextPage,
|
||||||
{ oldSchema: cloneDeep(toRaw(prevPage)), newSchema: cloneDeep(toRaw(nextPage)) },
|
{
|
||||||
|
oldSchema: cloneDeep(this.toRaw(prevPage)),
|
||||||
|
newSchema: cloneDeep(this.toRaw(nextPage)),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1813,7 +1917,11 @@ class Editor extends BaseService {
|
|||||||
this.pushPageDiffStep(
|
this.pushPageDiffStep(
|
||||||
'remove',
|
'remove',
|
||||||
prevPage,
|
prevPage,
|
||||||
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
|
{
|
||||||
|
oldSchema: cloneDeep(this.toRaw(prevPage)),
|
||||||
|
parentId: preRoot.id,
|
||||||
|
index: indexInItems(preRoot, prevPage.id),
|
||||||
|
},
|
||||||
source,
|
source,
|
||||||
);
|
);
|
||||||
removedPages.push(prevPage);
|
removedPages.push(prevPage);
|
||||||
@ -1821,7 +1929,7 @@ class Editor extends BaseService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (addedPages.length || removedPages.length) {
|
if (addedPages.length || removedPages.length) {
|
||||||
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
|
this.ctx.history.notifyPageStructureChange({ add: addedPages, remove: removedPages });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1852,11 +1960,11 @@ class Editor extends BaseService {
|
|||||||
};
|
};
|
||||||
if (source) step.source = source;
|
if (source) step.source = source;
|
||||||
|
|
||||||
const top = historyService.getCurrentPageStep(page.id);
|
const top = this.ctx.history.getCurrentPageStep(page.id);
|
||||||
if (top?.rootStep && top.source === source) {
|
if (top?.rootStep && top.source === source) {
|
||||||
historyService.replaceCurrentStep('page', step, page.id);
|
this.ctx.history.replaceCurrentStep('page', step, page.id);
|
||||||
} else {
|
} else {
|
||||||
historyService.push('page', step, page.id);
|
this.ctx.history.push('page', step, page.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1892,7 +2000,7 @@ class Editor extends BaseService {
|
|||||||
if (source) step.source = source;
|
if (source) step.source = source;
|
||||||
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
// 显式按 step.data.id 入栈:跨页操作(如 moveToContainer 从源页搬到目标页)
|
||||||
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
// 必须落到正确的页面栈,否则会把记录错误地推到当前活动页 / 操作发起页。
|
||||||
const pushed = historyService.push('page', step, pageData.id);
|
const pushed = this.ctx.history.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;
|
||||||
@ -2051,7 +2159,7 @@ class Editor extends BaseService {
|
|||||||
this.set('invalidNodeIds', new Map(invalidToRestore));
|
this.set('invalidNodeIds', new Map(invalidToRestore));
|
||||||
this.emit('invalid-node-change', this.get('invalidNodeIds'));
|
this.emit('invalid-node-change', this.get('invalidNodeIds'));
|
||||||
|
|
||||||
const page = toRaw(this.get('page'));
|
const page = this.toRaw(this.get('page'));
|
||||||
if (page) {
|
if (page) {
|
||||||
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
|
const selectIds = (reverse ? step.extra?.selectedBefore : step.extra?.selectedAfter) ?? [];
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@ -2089,8 +2197,13 @@ class Editor extends BaseService {
|
|||||||
doNotPushHistory: true,
|
doNotPushHistory: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 经由 reactivity 注入的 toRaw(默认是 Vue 的 toRaw) */
|
||||||
|
private toRaw<T>(value: T): T {
|
||||||
|
return this.ctx.reactivity.toRaw(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditorService = Editor;
|
export type EditorService = Editor;
|
||||||
|
|
||||||
export default new Editor();
|
export default Editor;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep } from 'lodash-es';
|
import { cloneDeep } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,6 +24,8 @@ import { toLine } from '@tmagic/utils';
|
|||||||
|
|
||||||
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
|
import type { AsyncHookPlugin, SyncHookPlugin } from '@editor/type';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
@ -35,15 +36,17 @@ const canUsePluginMethods = {
|
|||||||
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
const eventMap: Record<string, EventOption[]> = reactive({});
|
|
||||||
const methodMap: Record<string, EventOption[]> = reactive({});
|
|
||||||
|
|
||||||
class Events extends BaseService {
|
class Events extends BaseService {
|
||||||
constructor() {
|
private eventMap: Record<string, EventOption[]>;
|
||||||
|
private methodMap: Record<string, EventOption[]>;
|
||||||
|
|
||||||
|
constructor(ctx: SdkContext) {
|
||||||
super([
|
super([
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
]);
|
]);
|
||||||
|
this.eventMap = ctx.reactivity.reactive({});
|
||||||
|
this.methodMap = ctx.reactivity.reactive({});
|
||||||
}
|
}
|
||||||
|
|
||||||
public setEvents(events: Record<string, EventOption[]>) {
|
public setEvents(events: Record<string, EventOption[]>) {
|
||||||
@ -53,11 +56,11 @@ class Events extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setEvent(type: string, events: EventOption[]) {
|
public setEvent(type: string, events: EventOption[]) {
|
||||||
eventMap[toLine(type)] = [...events];
|
this.eventMap[toLine(type)] = [...events];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
|
public getEvent(type: string, _data: { node?: MNode | null } = {}): EventOption[] {
|
||||||
return cloneDeep(eventMap[toLine(type)]) || [];
|
return cloneDeep(this.eventMap[toLine(type)]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public setMethods(methods: Record<string, EventOption[]>) {
|
public setMethods(methods: Record<string, EventOption[]>) {
|
||||||
@ -67,17 +70,17 @@ class Events extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setMethod(type: string, method: EventOption[]) {
|
public setMethod(type: string, method: EventOption[]) {
|
||||||
methodMap[toLine(type)] = [...method];
|
this.methodMap[toLine(type)] = [...method];
|
||||||
}
|
}
|
||||||
|
|
||||||
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
|
public getMethod(type: string, _data: { node?: MNode | null; targetId?: Id } = {}) {
|
||||||
return cloneDeep(methodMap[toLine(type)]) || [];
|
return cloneDeep(this.methodMap[toLine(type)]) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public resetState() {
|
public resetState() {
|
||||||
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
|
// 原地清空而不是重新赋值,保留 reactive 代理身份,已建立的 computed / 渲染副作用才能收到更新
|
||||||
Object.keys(eventMap).forEach((type) => delete eventMap[type]);
|
Object.keys(this.eventMap).forEach((type) => delete this.eventMap[type]);
|
||||||
Object.keys(methodMap).forEach((type) => delete methodMap[type]);
|
Object.keys(this.methodMap).forEach((type) => delete this.methodMap[type]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public destroy() {
|
public destroy() {
|
||||||
@ -93,4 +96,4 @@ class Events extends BaseService {
|
|||||||
|
|
||||||
export type EventsService = Events;
|
export type EventsService = Events;
|
||||||
|
|
||||||
export default new Events();
|
export default Events;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { Id, MPage, MPageFragment } from '@tmagic/core';
|
import type { Id, MPage, MPageFragment } from '@tmagic/core';
|
||||||
@ -37,7 +36,6 @@ import type {
|
|||||||
StepValue,
|
StepValue,
|
||||||
SyncHookPlugin,
|
SyncHookPlugin,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
|
||||||
import {
|
import {
|
||||||
deserializeStacks,
|
deserializeStacks,
|
||||||
getOrCreateStack,
|
getOrCreateStack,
|
||||||
@ -46,11 +44,11 @@ import {
|
|||||||
serializeStacks,
|
serializeStacks,
|
||||||
undoFloor,
|
undoFloor,
|
||||||
} from '@editor/utils/history';
|
} from '@editor/utils/history';
|
||||||
import { idbGet, idbSet } from '@editor/utils/indexed-db';
|
|
||||||
import { UndoRedo } from '@editor/utils/undo-redo';
|
import { UndoRedo } from '@editor/utils/undo-redo';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
import editorService from './editor';
|
|
||||||
|
|
||||||
const canUsePluginMethods = {
|
const canUsePluginMethods = {
|
||||||
sync: ['push', 'undo', 'redo'] as const,
|
sync: ['push', 'undo', 'redo'] as const,
|
||||||
@ -80,8 +78,50 @@ const DEFAULT_KEY: IDBValidKey = 'default';
|
|||||||
// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。
|
// 全部历史栈统一收敛到 steps 字段(见 HistorySteps)。
|
||||||
const PERSIST_VERSION = 3;
|
const PERSIST_VERSION = 3;
|
||||||
|
|
||||||
|
const EXTRA_MAP_KEYS = ['modifiedNodeIds', 'invalidNodeIdsBefore', 'invalidNodeIdsAfter'] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 转换各栈 step.extra 中的 Map 字段,返回新对象(不修改入参,避免污染运行中的历史状态)。
|
||||||
|
* - 导出:Map -> entries 数组,使快照可以直接 `JSON.stringify`
|
||||||
|
* - 还原:entries 数组 -> Map
|
||||||
|
*/
|
||||||
|
const convertExtraMaps = (
|
||||||
|
stacks: Record<string, any> | undefined,
|
||||||
|
restoring: boolean,
|
||||||
|
): Record<string, any> | undefined => {
|
||||||
|
if (!stacks) return stacks;
|
||||||
|
|
||||||
|
const convertValue = (value: any) => {
|
||||||
|
if (restoring) return Array.isArray(value) ? new Map(value) : value;
|
||||||
|
return value instanceof Map ? [...value.entries()] : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
Object.entries(stacks).forEach(([id, stack]: [string, any]) => {
|
||||||
|
result[id] = !stack?.elementList
|
||||||
|
? stack
|
||||||
|
: {
|
||||||
|
...stack,
|
||||||
|
elementList: stack.elementList.map((step: any) => {
|
||||||
|
if (!step?.extra) return step;
|
||||||
|
const extra = { ...step.extra };
|
||||||
|
// 导出与还原使用同一份 key 清单,保证往返对称
|
||||||
|
EXTRA_MAP_KEYS.forEach((key) => {
|
||||||
|
if (extra[key]) extra[key] = convertValue(extra[key]);
|
||||||
|
});
|
||||||
|
return { ...step, extra };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
class History extends BaseService {
|
class History extends BaseService {
|
||||||
public state = reactive<HistoryState>({
|
public state: HistoryState;
|
||||||
|
|
||||||
|
constructor(private ctx: SdkContext) {
|
||||||
|
super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]);
|
||||||
|
this.state = ctx.reactivity.reactive<HistoryState>({
|
||||||
steps: {
|
steps: {
|
||||||
page: {},
|
page: {},
|
||||||
codeBlock: {},
|
codeBlock: {},
|
||||||
@ -89,9 +129,6 @@ class History extends BaseService {
|
|||||||
},
|
},
|
||||||
stepNames: { ...DEFAULT_STEP_NAMES },
|
stepNames: { ...DEFAULT_STEP_NAMES },
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super([...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false }))]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -148,7 +185,12 @@ class History extends BaseService {
|
|||||||
public setMarker(
|
public setMarker(
|
||||||
stepType: HistoryStepType,
|
stepType: HistoryStepType,
|
||||||
id: Id,
|
id: Id,
|
||||||
options: { name?: string; description?: string; source?: HistoryOpSource; extra?: Record<string, any> } = {},
|
options: {
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
source?: HistoryOpSource;
|
||||||
|
extra?: Record<string, any>;
|
||||||
|
} = {},
|
||||||
): StepValue | null {
|
): StepValue | null {
|
||||||
if (!this.isValidStackId(id)) return null;
|
if (!this.isValidStackId(id)) return null;
|
||||||
|
|
||||||
@ -385,21 +427,46 @@ class History extends BaseService {
|
|||||||
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
|
||||||
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
||||||
|
|
||||||
|
const persistence = this.getPersistence();
|
||||||
|
const snapshot = this.serialize();
|
||||||
|
|
||||||
|
await persistence.set(this.resolveDbName(dbName, appId), storeName, key, snapshot);
|
||||||
|
this.emit('save-to-indexed-db', snapshot);
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把当前内存中的全部历史栈导出成可持久化的快照(纯同步,不依赖 IndexedDB),
|
||||||
|
* 可用于无状态调用:下次通过 {@link restore} 或 `createDslSdk({ history })` 还原。
|
||||||
|
*/
|
||||||
|
public serialize({ jsonSafe = false }: { jsonSafe?: boolean } = {}): PersistedHistoryState {
|
||||||
// serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留,
|
// serializeStacks 会在序列化各栈时只把每条 step 的 diff(可能含函数)序列化成字符串,其余字段原样保留,
|
||||||
// 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
|
// 因此整份快照可直接按对象写入 IndexedDB(结构化克隆),避免序列化整份快照的开销;读取时再用 parseDSL 还原 diff。
|
||||||
const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
const steps: PersistedHistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
||||||
Object.entries(this.state.steps).forEach(([stepType, bucket]) => {
|
Object.entries(this.state.steps).forEach(([stepType, bucket]) => {
|
||||||
steps[stepType] = serializeStacks(bucket);
|
steps[stepType] = serializeStacks(bucket);
|
||||||
|
// `extra` 里的校验错误记录是 Map,JSON 无法表示;jsonSafe 时转成 entries 数组,restore 时会还原
|
||||||
|
if (jsonSafe) steps[stepType] = convertExtraMaps(steps[stepType], false) as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot: PersistedHistoryState = {
|
return {
|
||||||
version: PERSIST_VERSION,
|
version: PERSIST_VERSION,
|
||||||
steps,
|
steps,
|
||||||
savedAt: Date.now(),
|
savedAt: Date.now(),
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot);
|
/**
|
||||||
this.emit('save-to-indexed-db', snapshot);
|
* 用 {@link serialize} 导出的快照重建全部撤销/重做栈,会整体覆盖当前内存中的历史状态。
|
||||||
|
*/
|
||||||
|
public restore(snapshot: PersistedHistoryState): PersistedHistoryState {
|
||||||
|
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
|
||||||
|
const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
||||||
|
Object.entries(snapshot.steps).forEach(([stepType, bucket]) => {
|
||||||
|
// 兼容 jsonSafe 导出的快照(Map 被转成了 entries 数组)
|
||||||
|
steps[stepType] = deserializeStacks(convertExtraMaps(bucket, true) as any, this.ctx.parseDSL);
|
||||||
|
});
|
||||||
|
this.state.steps = steps;
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -414,17 +481,14 @@ class History extends BaseService {
|
|||||||
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
|
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
|
||||||
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
|
||||||
|
|
||||||
const snapshot = await idbGet<PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
|
const snapshot = await this.getPersistence().get<PersistedHistoryState>(
|
||||||
|
this.resolveDbName(dbName, appId),
|
||||||
|
storeName,
|
||||||
|
key,
|
||||||
|
);
|
||||||
if (!snapshot) return null;
|
if (!snapshot) return null;
|
||||||
|
|
||||||
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
|
this.restore(snapshot);
|
||||||
const parseDSL = getEditorConfig('parseDSL');
|
|
||||||
|
|
||||||
const steps: HistoryState['steps'] = { page: {}, codeBlock: {}, dataSource: {} };
|
|
||||||
Object.entries(snapshot.steps).forEach(([stepType, bucket]) => {
|
|
||||||
steps[stepType] = deserializeStacks(bucket, parseDSL);
|
|
||||||
});
|
|
||||||
this.state.steps = steps;
|
|
||||||
// initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。
|
// initial 基线作为各栈 index 0 的 step 随 steps 一并还原,无需单独恢复;活动页由 editorService 维护。
|
||||||
this.emit('restore-from-indexed-db', snapshot);
|
this.emit('restore-from-indexed-db', snapshot);
|
||||||
return snapshot;
|
return snapshot;
|
||||||
@ -583,17 +647,25 @@ class History extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getPersistence() {
|
||||||
|
const persistence = this.ctx.options.historyPersistence;
|
||||||
|
if (!persistence) {
|
||||||
|
throw new Error('未提供 historyPersistence,无法读写历史快照;请改用 serialize() / restore()');
|
||||||
|
}
|
||||||
|
return persistence;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
* 把基础 dbName 与当前 DSL(root app)的 id 拼成最终库名,实现不同应用历史隔离。
|
||||||
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
* 取不到 app id(如尚未加载 DSL)时退回基础 dbName。
|
||||||
*/
|
*/
|
||||||
private resolveDbName(dbName: string, appId?: Id): string {
|
private resolveDbName(dbName: string, appId?: Id): string {
|
||||||
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
|
// 优先用显式传入的 appId(「先恢复再 set root」时 root 尚未就绪);否则回退到当前 root.id。
|
||||||
const resolvedAppId = appId ?? editorService.get('root')?.id;
|
const resolvedAppId = appId ?? this.ctx.editor.get('root')?.id;
|
||||||
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
|
return resolvedAppId ? `${dbName}-${resolvedAppId}` : dbName;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HistoryService = History;
|
export type HistoryService = History;
|
||||||
|
|
||||||
export default new History();
|
export default History;
|
||||||
@ -16,7 +16,6 @@
|
|||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive } from 'vue';
|
|
||||||
import { cloneDeep, mergeWith } from 'lodash-es';
|
import { cloneDeep, mergeWith } from 'lodash-es';
|
||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ import { Target, Watcher } from '@tmagic/core';
|
|||||||
import type { FormConfig } from '@tmagic/form';
|
import type { FormConfig } from '@tmagic/form';
|
||||||
import { getNodePath, getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
import { getNodePath, getValueByKeyPath, guid, setValueByKeyPath, toLine } from '@tmagic/utils';
|
||||||
|
|
||||||
import editorService from '@editor/services/editor';
|
|
||||||
import type {
|
import type {
|
||||||
AsyncHookPlugin,
|
AsyncHookPlugin,
|
||||||
PropsFormConfigFunction,
|
PropsFormConfigFunction,
|
||||||
@ -33,7 +31,9 @@ import type {
|
|||||||
PropsState,
|
PropsState,
|
||||||
SyncHookPlugin,
|
SyncHookPlugin,
|
||||||
} from '@editor/type';
|
} from '@editor/type';
|
||||||
import { fillConfig } from '@editor/utils/props';
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
import { fillConfig } from '../utils/props';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -53,7 +53,14 @@ type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
|
|||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
class Props extends BaseService {
|
class Props extends BaseService {
|
||||||
private state = reactive<PropsState>({
|
private state: PropsState;
|
||||||
|
|
||||||
|
constructor(private ctx: SdkContext) {
|
||||||
|
super([
|
||||||
|
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
||||||
|
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
||||||
|
]);
|
||||||
|
this.state = ctx.reactivity.reactive<PropsState>({
|
||||||
propsConfigMap: {},
|
propsConfigMap: {},
|
||||||
propsValueMap: {},
|
propsValueMap: {},
|
||||||
relateIdMap: {},
|
relateIdMap: {},
|
||||||
@ -62,12 +69,6 @@ class Props extends BaseService {
|
|||||||
/** 禁用代码块 */
|
/** 禁用代码块 */
|
||||||
disabledCodeBlock: false,
|
disabledCodeBlock: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super([
|
|
||||||
...canUsePluginMethods.async.map((methodName) => ({ name: methodName, isAsync: true })),
|
|
||||||
...canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setDisabledDataSource(disabled: boolean) {
|
public setDisabledDataSource(disabled: boolean) {
|
||||||
@ -86,10 +87,9 @@ class Props extends BaseService {
|
|||||||
return this.state.disabledCodeBlock;
|
return this.state.disabledCodeBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
|
/** 批量设置组件属性表单配置,返回的 Promise 在全部配置处理完(含异步的)后 resolve */
|
||||||
Object.keys(configs).forEach((type: string) => {
|
public async setPropsConfigs(configs: Record<string, FormConfig | PropsFormConfigFunction>) {
|
||||||
this.setPropsConfig(toLine(type), configs[type]);
|
await Promise.all(Object.keys(configs).map((type: string) => this.setPropsConfig(toLine(type), configs[type])));
|
||||||
});
|
|
||||||
this.emit('props-configs-change');
|
this.emit('props-configs-change');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,13 +102,15 @@ class Props extends BaseService {
|
|||||||
labelWidth,
|
labelWidth,
|
||||||
disabledDataSource: this.getDisabledDataSource(),
|
disabledDataSource: this.getDisabledDataSource(),
|
||||||
disabledCodeBlock: this.getDisabledCodeBlock(),
|
disabledCodeBlock: this.getDisabledCodeBlock(),
|
||||||
|
copyText: this.ctx.options.copyText,
|
||||||
|
notify: this.ctx.options.notify,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
|
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
|
||||||
let c: FormConfig;
|
let c: FormConfig;
|
||||||
if (typeof config === 'function') {
|
if (typeof config === 'function') {
|
||||||
c = config({ editorService });
|
c = config({ editorService: this.ctx.editor });
|
||||||
} else {
|
} else {
|
||||||
c = config;
|
c = config;
|
||||||
}
|
}
|
||||||
@ -134,9 +136,7 @@ class Props extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
|
public setPropsValues(values: Record<string, Partial<MNode> | PropsFormValueFunction>) {
|
||||||
Object.keys(values).forEach((type: string) => {
|
return Promise.all(Object.keys(values).map((type: string) => this.setPropsValue(toLine(type), values[type])));
|
||||||
this.setPropsValue(toLine(type), values[type]);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public getPropsValues(): Record<string, Partial<MNode>> {
|
public getPropsValues(): Record<string, Partial<MNode>> {
|
||||||
@ -151,7 +151,7 @@ class Props extends BaseService {
|
|||||||
public async setPropsValue(type: string, value: Partial<MNode> | PropsFormValueFunction) {
|
public async setPropsValue(type: string, value: Partial<MNode> | PropsFormValueFunction) {
|
||||||
let v = value;
|
let v = value;
|
||||||
if (typeof value === 'function') {
|
if (typeof value === 'function') {
|
||||||
v = value({ editorService });
|
v = value({ editorService: this.ctx.editor });
|
||||||
}
|
}
|
||||||
this.state.propsValueMap[toLine(type)] = v;
|
this.state.propsValueMap[toLine(type)] = v;
|
||||||
}
|
}
|
||||||
@ -206,7 +206,7 @@ class Props extends BaseService {
|
|||||||
*/
|
*/
|
||||||
/* eslint no-param-reassign: ["error", { "props": false }] */
|
/* eslint no-param-reassign: ["error", { "props": false }] */
|
||||||
public setNewItemId(config: MNode, force = true) {
|
public setNewItemId(config: MNode, force = true) {
|
||||||
if (force || editorService.getNodeById(config.id)) {
|
if (force || this.ctx.editor.getNodeById(config.id)) {
|
||||||
const newId = this.createId(config.type || 'component');
|
const newId = this.createId(config.type || 'component');
|
||||||
this.setRelateId(config.id, newId);
|
this.setRelateId(config.id, newId);
|
||||||
config.id = newId;
|
config.id = newId;
|
||||||
@ -325,4 +325,4 @@ class Props extends BaseService {
|
|||||||
|
|
||||||
export type PropsService = Props;
|
export type PropsService = Props;
|
||||||
|
|
||||||
export default new Props();
|
export default Props;
|
||||||
@ -2,7 +2,8 @@ import serialize from 'serialize-javascript';
|
|||||||
import type { Writable } from 'type-fest';
|
import type { Writable } from 'type-fest';
|
||||||
|
|
||||||
import type { SyncHookPlugin } from '@editor/type';
|
import type { SyncHookPlugin } from '@editor/type';
|
||||||
import { getEditorConfig } from '@editor/utils/config';
|
|
||||||
|
import type { SdkContext, StorageLike } from '../type';
|
||||||
|
|
||||||
import BaseService from './BaseService';
|
import BaseService from './BaseService';
|
||||||
|
|
||||||
@ -26,15 +27,48 @@ const canUsePluginMethods = {
|
|||||||
|
|
||||||
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
type SyncMethodName = Writable<(typeof canUsePluginMethods)['sync']>;
|
||||||
|
|
||||||
|
/** 默认的内存存储,Node 下没有 localStorage 时使用 */
|
||||||
|
export class MemoryStorage implements StorageLike {
|
||||||
|
private map = new Map<string, string>();
|
||||||
|
|
||||||
|
get length() {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
getItem(key: string) {
|
||||||
|
return this.map.has(key) ? (this.map.get(key) as string) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
key(index: number) {
|
||||||
|
return Array.from(this.map.keys())[index] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeItem(key: string) {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
setItem(key: string, value: string) {
|
||||||
|
this.map.set(key, `${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 数据存储服务
|
* 数据存储服务
|
||||||
*/
|
*/
|
||||||
export class WebStorage extends BaseService {
|
export class WebStorage extends BaseService {
|
||||||
private storage: Storage = globalThis.localStorage;
|
private storage: StorageLike;
|
||||||
private namespace = 'tmagic';
|
private namespace = 'tmagic';
|
||||||
|
|
||||||
constructor() {
|
constructor(
|
||||||
|
private ctx: SdkContext,
|
||||||
|
storage?: StorageLike,
|
||||||
|
) {
|
||||||
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
super(canUsePluginMethods.sync.map((methodName) => ({ name: methodName, isAsync: false })));
|
||||||
|
this.storage = storage ?? new MemoryStorage();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,12 +76,12 @@ export class WebStorage extends BaseService {
|
|||||||
* const storageService = new StorageService();
|
* const storageService = new StorageService();
|
||||||
* storageService.usePlugin({
|
* storageService.usePlugin({
|
||||||
* // 替换存储对象为 sessionStorage
|
* // 替换存储对象为 sessionStorage
|
||||||
* async afterGetStorage(): Promise<Storage> {
|
* async afterGetStorage(): Promise<StorageLike> {
|
||||||
* return window.sessionStorage;
|
* return window.sessionStorage;
|
||||||
* },
|
* },
|
||||||
* });
|
* });
|
||||||
*/
|
*/
|
||||||
public getStorage(): Storage {
|
public getStorage(): StorageLike {
|
||||||
return this.storage;
|
return this.storage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,7 +110,7 @@ export class WebStorage extends BaseService {
|
|||||||
|
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case Protocol.OBJECT:
|
case Protocol.OBJECT:
|
||||||
return getEditorConfig('parseDSL')(`(${item})`);
|
return this.ctx.parseDSL(`(${item})`);
|
||||||
case Protocol.JSON:
|
case Protocol.JSON:
|
||||||
return JSON.parse(item);
|
return JSON.parse(item);
|
||||||
case Protocol.NUMBER:
|
case Protocol.NUMBER:
|
||||||
@ -153,4 +187,4 @@ export class WebStorage extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StorageService = WebStorage;
|
export type StorageService = WebStorage;
|
||||||
export default new WebStorage();
|
export default WebStorage;
|
||||||
191
packages/editor/src/sdk/type.ts
Normal file
191
packages/editor/src/sdk/type.ts
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview DSL SDK 的公共类型:响应式注入、适配器、上下文、校验结果。
|
||||||
|
*
|
||||||
|
* 这里只放类型,禁止引入运行时依赖(vue / stage / design 等)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DataSourceSchema, EventOption, Id, MApp, MNode } from '@tmagic/core';
|
||||||
|
import type { FormConfig } from '@tmagic/form';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
CanDropInFunction,
|
||||||
|
ComponentGroup,
|
||||||
|
DatasourceTypeOption,
|
||||||
|
PropsFormConfigFunction,
|
||||||
|
PropsFormValueFunction,
|
||||||
|
} from '@editor/type';
|
||||||
|
import type { CollectWorkerPayload, CollectWorkerResult, DepsData } from '@editor/utils/dep/worker';
|
||||||
|
|
||||||
|
import type CodeBlock from './services/CodeBlock';
|
||||||
|
import type ComponentList from './services/ComponentList';
|
||||||
|
import type DataSource from './services/DataSource';
|
||||||
|
import type Dep from './services/Dep';
|
||||||
|
import type Editor from './services/Editor';
|
||||||
|
import type Events from './services/Events';
|
||||||
|
import type History from './services/History';
|
||||||
|
import type Props from './services/Props';
|
||||||
|
import type WebStorage from './services/WebStorage';
|
||||||
|
import type { Validator } from './validate';
|
||||||
|
|
||||||
|
// #region Reactivity
|
||||||
|
/**
|
||||||
|
* 响应式实现,默认直接使用 Vue 的 `reactive` / `shallowReactive` / `toRaw` / `nextTick`;
|
||||||
|
* 可通过 `SdkOptions.reactivity` 覆盖。
|
||||||
|
*/
|
||||||
|
export interface Reactivity {
|
||||||
|
reactive: <T extends object>(target: T) => T;
|
||||||
|
shallowReactive: <T extends object>(target: T) => T;
|
||||||
|
toRaw: <T>(value: T) => T;
|
||||||
|
nextTick: () => Promise<void>;
|
||||||
|
}
|
||||||
|
// #endregion Reactivity
|
||||||
|
|
||||||
|
// #region StorageLike
|
||||||
|
/** 存储适配器接口,`localStorage` 天然满足 */
|
||||||
|
export interface StorageLike {
|
||||||
|
readonly length: number;
|
||||||
|
clear(): void;
|
||||||
|
getItem(key: string): string | null;
|
||||||
|
key(index: number): string | null;
|
||||||
|
removeItem(key: string): void;
|
||||||
|
setItem(key: string, value: string): void;
|
||||||
|
}
|
||||||
|
// #endregion StorageLike
|
||||||
|
|
||||||
|
// #region HistoryPersistence
|
||||||
|
/** 历史快照的持久化适配器(浏览器端对应 IndexedDB)。不提供则 `saveToIndexedDB` / `restoreFromIndexedDB` 会 reject */
|
||||||
|
export interface HistoryPersistence {
|
||||||
|
get<T = unknown>(dbName: string, storeName: string, key: IDBValidKey): Promise<T | undefined>;
|
||||||
|
set(dbName: string, storeName: string, key: IDBValidKey, value: unknown): Promise<void>;
|
||||||
|
}
|
||||||
|
// #endregion HistoryPersistence
|
||||||
|
|
||||||
|
// #region CollectWorkerLike
|
||||||
|
/**
|
||||||
|
* 依赖收集 worker 适配器。浏览器端注入基于 Web Worker 的实现,Node 下不提供,
|
||||||
|
* 依赖收集会退回主线程的空闲队列(`IdleTask`,Node 下由 setTimeout 驱动)。
|
||||||
|
*/
|
||||||
|
export interface CollectWorkerLike {
|
||||||
|
readonly isSupported: boolean;
|
||||||
|
collectDsl(dsl: MApp): Promise<DepsData | null>;
|
||||||
|
collect(payload: CollectWorkerPayload): Promise<CollectWorkerResult | null>;
|
||||||
|
terminate(): void;
|
||||||
|
abort(): void;
|
||||||
|
}
|
||||||
|
// #endregion CollectWorkerLike
|
||||||
|
|
||||||
|
// #region ValidateMode
|
||||||
|
/**
|
||||||
|
* 校验模式
|
||||||
|
* - `strict`:修改前校验,不通过抛出 {@link DslValidationError},DSL 不被修改
|
||||||
|
* - `record`:只把校验结果写进 `invalidNodeIds`
|
||||||
|
* - `off`:不做额外校验
|
||||||
|
*/
|
||||||
|
export type ValidateMode = 'strict' | 'record' | 'off';
|
||||||
|
// #endregion ValidateMode
|
||||||
|
|
||||||
|
// #region ValidationError
|
||||||
|
export interface DslValidationIssue {
|
||||||
|
/** 出错对象类型 */
|
||||||
|
target: 'node' | 'data-source' | 'code-block' | 'dsl';
|
||||||
|
/** 出错对象 id */
|
||||||
|
id?: Id;
|
||||||
|
/** 出错属性路径,结构性错误时为空 */
|
||||||
|
path?: string;
|
||||||
|
/** 错误分类:structure 为结构合法性,props 为属性表单校验 */
|
||||||
|
kind: 'structure' | 'props';
|
||||||
|
message: string;
|
||||||
|
/** 可选的修正建议(如可选值列表) */
|
||||||
|
suggestion?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DslValidationResult {
|
||||||
|
valid: boolean;
|
||||||
|
errors: DslValidationIssue[];
|
||||||
|
}
|
||||||
|
// #endregion ValidationError
|
||||||
|
|
||||||
|
// #region SdkOptions
|
||||||
|
export interface SdkOptions {
|
||||||
|
/** 初始 DSL,会被深拷贝,不会修改传入的对象 */
|
||||||
|
dsl?: MApp | null;
|
||||||
|
/** 此前通过 `history.serialize()` 导出的历史快照 */
|
||||||
|
history?: import('@editor/type').PersistedHistoryState | null;
|
||||||
|
|
||||||
|
reactivity?: Partial<Reactivity>;
|
||||||
|
/** 存储适配器,默认内存实现 */
|
||||||
|
storage?: StorageLike;
|
||||||
|
/** 把序列化后的字符串还原成值(含函数),默认 `JSON.parse`;浏览器端传入 `eval` 实现 */
|
||||||
|
parseDSL?: (code: string) => any;
|
||||||
|
historyPersistence?: HistoryPersistence;
|
||||||
|
collectWorker?: CollectWorkerLike;
|
||||||
|
/** 提示信息,默认不做任何事 */
|
||||||
|
notify?: (type: 'success' | 'error' | 'warning' | 'info', message: string) => void;
|
||||||
|
/** 复制文本到剪贴板,默认不做任何事 */
|
||||||
|
copyText?: (text: string) => Promise<void> | void;
|
||||||
|
|
||||||
|
/** 校验模式,默认 `strict` */
|
||||||
|
validateMode?: ValidateMode;
|
||||||
|
/** 是否自动绑定依赖收集连线,默认 true;浏览器默认实例传 false,由 initService 挂载时绑定 */
|
||||||
|
autoWiring?: boolean;
|
||||||
|
|
||||||
|
// 登记表,与 EditorProps 同名项一一对应
|
||||||
|
componentGroupList?: ComponentGroup[];
|
||||||
|
propsConfigs?: Record<string, FormConfig | PropsFormConfigFunction>;
|
||||||
|
propsValues?: Record<string, Partial<MNode> | PropsFormValueFunction>;
|
||||||
|
eventMethodList?: Record<string, { events: EventOption[]; methods: EventOption[] }>;
|
||||||
|
datasourceList?: DatasourceTypeOption[];
|
||||||
|
datasourceConfigs?: Record<string, FormConfig>;
|
||||||
|
datasourceValues?: Record<string, Partial<DataSourceSchema>>;
|
||||||
|
datasourceEventMethodList?: Record<string, { events: EventOption[]; methods: EventOption[] }>;
|
||||||
|
disabledCodeBlock?: boolean;
|
||||||
|
disabledDataSource?: boolean;
|
||||||
|
disabledPageFragment?: boolean;
|
||||||
|
disabledMultiSelect?: boolean;
|
||||||
|
alwaysMultiSelect?: boolean;
|
||||||
|
canDropIn?: CanDropInFunction;
|
||||||
|
}
|
||||||
|
// #endregion SdkOptions
|
||||||
|
|
||||||
|
// #region SdkContext
|
||||||
|
/** service 之间互相访问的上下文,用懒 getter 解开循环依赖 */
|
||||||
|
export interface SdkContext {
|
||||||
|
readonly reactivity: Reactivity;
|
||||||
|
readonly storage: WebStorage;
|
||||||
|
readonly editor: Editor;
|
||||||
|
readonly props: Props;
|
||||||
|
readonly history: History;
|
||||||
|
readonly dataSource: DataSource;
|
||||||
|
readonly codeBlock: CodeBlock;
|
||||||
|
readonly dep: Dep;
|
||||||
|
readonly events: Events;
|
||||||
|
readonly componentList: ComponentList;
|
||||||
|
|
||||||
|
readonly options: Readonly<SdkOptions>;
|
||||||
|
/** 校验模式,可在运行时通过 `sdk.ctx.validateMode = ...` 切换 */
|
||||||
|
validateMode: ValidateMode;
|
||||||
|
/** 校验器,`createDslSdk` 之后可用 */
|
||||||
|
validator: Validator;
|
||||||
|
/** 登记表全部就绪后 resolve */
|
||||||
|
ready: Promise<void>;
|
||||||
|
parseDSL: (code: string) => any;
|
||||||
|
}
|
||||||
|
// #endregion SdkContext
|
||||||
196
packages/editor/src/sdk/utils/dep.ts
Normal file
196
packages/editor/src/sdk/utils/dep.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview 依赖收集的纯规则:哪些变化需要重新收集、依赖目标怎么建、依赖信息怎么写回 root。
|
||||||
|
*
|
||||||
|
* SDK 的 `wiring`(同步收集)与浏览器端 `initService`(空闲队列收集 + 画布同步)共用,
|
||||||
|
* 两边只在「怎么收集」上不同,规则必须保持一致。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CodeBlockDSL, DataSourceSchema, Id, MApp, MComponent, Target } from '@tmagic/core';
|
||||||
|
import {
|
||||||
|
createCodeBlockTarget,
|
||||||
|
createDataSourceCondTarget,
|
||||||
|
createDataSourceMethodTarget,
|
||||||
|
createDataSourceTarget,
|
||||||
|
DepTargetType,
|
||||||
|
NODE_CONDS_KEY,
|
||||||
|
NodeType,
|
||||||
|
} from '@tmagic/core';
|
||||||
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
|
import { isValueIncludeDataSource } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import { isIncludeDataSource } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type Dep from '../services/Dep';
|
||||||
|
import type { Reactivity } from '../type';
|
||||||
|
|
||||||
|
type DataSourceDepType = DepTargetType.DATA_SOURCE | DepTargetType.DATA_SOURCE_COND | DepTargetType.DATA_SOURCE_METHOD;
|
||||||
|
|
||||||
|
/** 数据源依赖类型 -> 写回 root 的字段 */
|
||||||
|
const DEPS_KEY = {
|
||||||
|
[DepTargetType.DATA_SOURCE]: 'dataSourceDeps',
|
||||||
|
[DepTargetType.DATA_SOURCE_COND]: 'dataSourceCondDeps',
|
||||||
|
[DepTargetType.DATA_SOURCE_METHOD]: 'dataSourceMethodDeps',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const DATA_SOURCE_DEP_TYPES: DataSourceDepType[] = [
|
||||||
|
DepTargetType.DATA_SOURCE,
|
||||||
|
DepTargetType.DATA_SOURCE_COND,
|
||||||
|
DepTargetType.DATA_SOURCE_METHOD,
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATA_SOURCE_TARGET_CREATORS = {
|
||||||
|
[DepTargetType.DATA_SOURCE]: createDataSourceTarget,
|
||||||
|
[DepTargetType.DATA_SOURCE_COND]: createDataSourceCondTarget,
|
||||||
|
[DepTargetType.DATA_SOURCE_METHOD]: createDataSourceMethodTarget,
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDataSourceDepType = (type: string): type is DataSourceDepType => type in DEPS_KEY;
|
||||||
|
|
||||||
|
type UpdatedItem = { newNode: MComponent; oldNode: MComponent; changeRecords?: ChangeRecord[] };
|
||||||
|
|
||||||
|
/** 显示条件(`NODE_CONDS_KEY`)相关的字段 */
|
||||||
|
const isCondsPath = (propPath: string) =>
|
||||||
|
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond`).test(propPath) ||
|
||||||
|
new RegExp(`${NODE_CONDS_KEY}.(\\d)+.cond.(\\d)+.value`).test(propPath) ||
|
||||||
|
propPath === NODE_CONDS_KEY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一批已更新的节点哪些需要重新收集数据源依赖。
|
||||||
|
*
|
||||||
|
* - 根节点、数据源相关的字段变化、修改的 key 已经在收集到的依赖中:需要重新收集
|
||||||
|
* - 其余只需要收集代码块、数据源方法依赖
|
||||||
|
*/
|
||||||
|
export const classifyUpdatedNodes = (
|
||||||
|
ctx: { dep: Pick<Dep, 'getTargets'> },
|
||||||
|
data: UpdatedItem[],
|
||||||
|
): { needRecollectNodes: MComponent[]; normalNodes: MComponent[] } => {
|
||||||
|
const needRecollectNodes: MComponent[] = [];
|
||||||
|
const normalNodes: MComponent[] = [];
|
||||||
|
|
||||||
|
const needRecollect = ({ newNode, oldNode, changeRecords }: UpdatedItem) => {
|
||||||
|
if (!changeRecords?.length) return isIncludeDataSource(newNode, oldNode);
|
||||||
|
|
||||||
|
const dataSourceTargets = Object.values(ctx.dep.getTargets(DepTargetType.DATA_SOURCE) || {});
|
||||||
|
|
||||||
|
return changeRecords.some(({ propPath, value }) => {
|
||||||
|
if (!propPath || isCondsPath(propPath) || isValueIncludeDataSource(value)) return true;
|
||||||
|
|
||||||
|
// 修改的key在收集的依赖中,则需要触发重新收集
|
||||||
|
return dataSourceTargets.some((target) => target.deps[newNode.id]?.keys.includes(propPath));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
// 同一节点只归入其中一类
|
||||||
|
const isRecollect = item.newNode.type !== NodeType.ROOT && needRecollect(item);
|
||||||
|
(isRecollect ? needRecollectNodes : normalNodes).push(item.newNode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { needRecollectNodes, normalNodes };
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 数据源变化规则:命中的 propPath 需要重建并重新收集哪些依赖 */
|
||||||
|
const DATA_SOURCE_RECOLLECT_RULES: { types: DataSourceDepType[]; test: (propPath: string) => boolean }[] = [
|
||||||
|
{
|
||||||
|
types: [DepTargetType.DATA_SOURCE, DepTargetType.DATA_SOURCE_COND],
|
||||||
|
test: (path) =>
|
||||||
|
path === 'fields' ||
|
||||||
|
/fields.(\d)+.name/.test(path) ||
|
||||||
|
/fields.(\d)+.defaultValue/.test(path) ||
|
||||||
|
/fields.(\d)+$/.test(path),
|
||||||
|
},
|
||||||
|
{ types: [DepTargetType.DATA_SOURCE], test: (path) => path === 'mocks' },
|
||||||
|
{
|
||||||
|
types: [DepTargetType.DATA_SOURCE_METHOD],
|
||||||
|
test: (path) => path === 'methods' || /methods.(\d)+.name/.test(path) || /methods.(\d)+$/.test(path),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据源更新后需要重建 / 重新收集的依赖类型;没有影响依赖的变化时返回 `null`。
|
||||||
|
* 按变更记录顺序,取第一条命中规则的记录。
|
||||||
|
*/
|
||||||
|
export const getDataSourceRecollectTypes = (changeRecords: ChangeRecord[]): DataSourceDepType[] | null => {
|
||||||
|
for (const { propPath } of changeRecords) {
|
||||||
|
if (!propPath) continue;
|
||||||
|
|
||||||
|
const rule = DATA_SOURCE_RECOLLECT_RULES.find(({ test }) => test(propPath));
|
||||||
|
if (rule) return rule.types;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 为数据源创建依赖目标(默认三类都建),`reactive` 用于使依赖结果在浏览器端可被订阅 */
|
||||||
|
export const addDataSourceTargets = (
|
||||||
|
dep: Pick<Dep, 'addTarget'>,
|
||||||
|
ds: DataSourceSchema,
|
||||||
|
reactive: Reactivity['reactive'],
|
||||||
|
types: DataSourceDepType[] = DATA_SOURCE_DEP_TYPES,
|
||||||
|
) => {
|
||||||
|
for (const type of types) {
|
||||||
|
dep.addTarget(DATA_SOURCE_TARGET_CREATORS[type](ds, reactive({})));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeDataSourceTargets = (
|
||||||
|
dep: Pick<Dep, 'removeTarget'>,
|
||||||
|
id: string,
|
||||||
|
types: DataSourceDepType[] = DATA_SOURCE_DEP_TYPES,
|
||||||
|
) => {
|
||||||
|
for (const type of types) {
|
||||||
|
dep.removeTarget(id, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** root 变化后重建所有依赖目标:代码块 + 数据源 */
|
||||||
|
export const resetDepTargets = (
|
||||||
|
dep: Pick<Dep, 'clearTargets' | 'addTarget'>,
|
||||||
|
codeBlocks: CodeBlockDSL,
|
||||||
|
dataSources: DataSourceSchema[],
|
||||||
|
reactive: Reactivity['reactive'],
|
||||||
|
) => {
|
||||||
|
dep.clearTargets();
|
||||||
|
|
||||||
|
for (const [id, code] of Object.entries(codeBlocks)) {
|
||||||
|
dep.addTarget(createCodeBlockTarget(id, code));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ds of dataSources) {
|
||||||
|
addDataSourceTargets(dep, ds, reactive);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 依赖目标新增后,把依赖信息写回 root */
|
||||||
|
export const writeTargetDeps = (root: MApp | null | undefined, target: Target) => {
|
||||||
|
if (!root || !isDataSourceDepType(target.type)) return;
|
||||||
|
|
||||||
|
const key = DEPS_KEY[target.type];
|
||||||
|
root[key] = root[key] || {};
|
||||||
|
root[key]![target.id] = target.deps;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 依赖目标移除后,从 root 上清掉对应的依赖信息 */
|
||||||
|
export const removeTargetDeps = (root: MApp | null | undefined, id: Id, type: string) => {
|
||||||
|
if (!root || !isDataSourceDepType(type)) return;
|
||||||
|
|
||||||
|
delete root[DEPS_KEY[type]]?.[id];
|
||||||
|
};
|
||||||
114
packages/editor/src/sdk/utils/operator.ts
Normal file
114
packages/editor/src/sdk/utils/operator.ts
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import { isEmpty } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { Id, MContainer, MNode } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import { calcValueByFontsize, getElById, isPage, isPageOrFragment } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { AddMNode, PastePosition } from '@editor/type';
|
||||||
|
import { generatePageNameByApp, getInitPositionStyle } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type { SdkContext } from '../type';
|
||||||
|
|
||||||
|
type OperatorContext = Pick<SdkContext, 'editor' | 'props' | 'reactivity'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 粘贴前置操作:返回分配了新id以及校准了坐标的配置
|
||||||
|
* @param position 粘贴的坐标
|
||||||
|
* @param config 待粘贴的元素配置(复制时保存的那份配置)
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export const beforePaste = (
|
||||||
|
ctx: OperatorContext,
|
||||||
|
position: PastePosition,
|
||||||
|
config: MNode[],
|
||||||
|
doc?: Document,
|
||||||
|
): MNode[] => {
|
||||||
|
if (!config[0]?.style) return config;
|
||||||
|
const curNode = ctx.editor.get('node');
|
||||||
|
// 将数组中第一个元素的坐标作为参照点
|
||||||
|
const { left: referenceLeft, top: referenceTop } = config[0].style;
|
||||||
|
// 坐标校准后的粘贴数据
|
||||||
|
const pasteConfigs: MNode[] = config.map((configItem: MNode): MNode => {
|
||||||
|
// 解构 position 对象,从 position 删除 offsetX、offsetY字段
|
||||||
|
const { offsetX = 0, offsetY = 0, ...positionClone } = position;
|
||||||
|
let pastePosition = positionClone;
|
||||||
|
|
||||||
|
if (!isEmpty(pastePosition) && curNode?.items) {
|
||||||
|
// 如果没有传入粘贴坐标则可能为键盘操作,不再转换
|
||||||
|
// 如果粘贴时选中了容器,则将元素粘贴到容器内,坐标需要转换为相对于容器的坐标
|
||||||
|
pastePosition = getPositionInContainer(ctx, pastePosition, curNode.id, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将所有待粘贴元素坐标相对于多选第一个元素坐标重新计算,以保证多选粘贴后元素间距不变
|
||||||
|
if (pastePosition.left && configItem.style?.left) {
|
||||||
|
pastePosition.left = configItem.style.left - referenceLeft + pastePosition.left;
|
||||||
|
}
|
||||||
|
if (pastePosition.top && configItem.style?.top) {
|
||||||
|
pastePosition.top = configItem.style?.top - referenceTop + pastePosition.top;
|
||||||
|
}
|
||||||
|
const pasteConfig = ctx.props.setNewItemId(configItem, false);
|
||||||
|
|
||||||
|
if (pasteConfig.style) {
|
||||||
|
const { left, top } = pasteConfig.style;
|
||||||
|
// 判断能转换为数字时,做粘贴偏移量计算
|
||||||
|
if (typeof left === 'number' || (!!left && !isNaN(Number(left)))) {
|
||||||
|
pasteConfig.style.left = Number(left) + offsetX;
|
||||||
|
}
|
||||||
|
if (typeof top === 'number' || (!!top && !isNaN(Number(top)))) {
|
||||||
|
pasteConfig.style.top = Number(top) + offsetY;
|
||||||
|
}
|
||||||
|
|
||||||
|
pasteConfig.style = {
|
||||||
|
...pasteConfig.style,
|
||||||
|
...pastePosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const root = ctx.editor.get('root');
|
||||||
|
if (isPageOrFragment(pasteConfig) && root) {
|
||||||
|
pasteConfig.name = generatePageNameByApp(root, isPage(pasteConfig) ? NodeType.PAGE : NodeType.PAGE_FRAGMENT);
|
||||||
|
}
|
||||||
|
return pasteConfig as MNode;
|
||||||
|
});
|
||||||
|
return pasteConfigs;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将元素粘贴到容器内时,将相对于画布坐标转换为相对于容器的坐标
|
||||||
|
* @param position PastePosition 粘贴时相对于画布的坐标
|
||||||
|
* @param id 元素id
|
||||||
|
* @returns PastePosition 转换后的坐标
|
||||||
|
*/
|
||||||
|
export const getPositionInContainer = (ctx: OperatorContext, position: PastePosition = {}, id: Id, doc?: Document) => {
|
||||||
|
let { left = 0, top = 0 } = position;
|
||||||
|
const stageDoc = ctx.editor.get('stage')?.renderer?.contentWindow?.document;
|
||||||
|
const parentEl = stageDoc && getElById()(stageDoc, `${id}`);
|
||||||
|
const parentElRect = parentEl?.getBoundingClientRect();
|
||||||
|
left = left - calcValueByFontsize(doc, parentElRect?.left || 0);
|
||||||
|
top = top - calcValueByFontsize(doc, parentElRect?.top || 0);
|
||||||
|
return {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAddParent = (ctx: OperatorContext, node: MNode) => {
|
||||||
|
const curNode = ctx.editor.get('node');
|
||||||
|
|
||||||
|
let parentNode;
|
||||||
|
if (isPage(node)) {
|
||||||
|
parentNode = ctx.editor.get('root');
|
||||||
|
} else if (curNode?.items) {
|
||||||
|
parentNode = curNode as MContainer;
|
||||||
|
} else if (curNode?.id) {
|
||||||
|
parentNode = ctx.editor.getParentById(curNode.id, false);
|
||||||
|
}
|
||||||
|
return parentNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDefaultConfig = async (ctx: OperatorContext, addNode: AddMNode, parentNode: MContainer) => {
|
||||||
|
const { type, inputEvent, ...config } = addNode;
|
||||||
|
const layout = await ctx.editor.getLayout(ctx.reactivity.toRaw(parentNode), addNode as MNode);
|
||||||
|
const newNode = { ...ctx.reactivity.toRaw(await ctx.props.getPropsValue(type, config)) };
|
||||||
|
newNode.style = getInitPositionStyle(newNode.style, layout);
|
||||||
|
return newNode;
|
||||||
|
};
|
||||||
23
packages/editor/src/sdk/utils/position.ts
Normal file
23
packages/editor/src/sdk/utils/position.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否是 fixed 定位。与 `@tmagic/stage` 的同名函数等价,在这里单独实现,
|
||||||
|
* 避免 SDK 在 Node 下为了这一行逻辑引入整个画布依赖。
|
||||||
|
*/
|
||||||
|
export const isFixed = (style: { position?: string }): boolean => style.position === 'fixed';
|
||||||
368
packages/editor/src/sdk/utils/props.ts
Normal file
368
packages/editor/src/sdk/utils/props.ts
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/naming-convention */
|
||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
HookType,
|
||||||
|
NODE_CONDS_KEY,
|
||||||
|
NODE_CONDS_RESULT_KEY,
|
||||||
|
NODE_DISABLE_CODE_BLOCK_KEY,
|
||||||
|
NODE_DISABLE_DATA_SOURCE_KEY,
|
||||||
|
} from '@tmagic/core';
|
||||||
|
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
|
||||||
|
|
||||||
|
export const arrayOptions = [
|
||||||
|
{ text: '包含', value: 'include' },
|
||||||
|
{ text: '不包含', value: 'not_include' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const eqOptions = [
|
||||||
|
{ text: '等于', value: '=' },
|
||||||
|
{ text: '不等于', value: '!=' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const numberOptions = [
|
||||||
|
{ text: '大于', value: '>' },
|
||||||
|
{ text: '大于等于', value: '>=' },
|
||||||
|
{ text: '小于', value: '<' },
|
||||||
|
{ text: '小于等于', value: '<=' },
|
||||||
|
{ text: '在范围内', value: 'between' },
|
||||||
|
{ text: '不在范围内', value: 'not_between' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const booleanOptions = [
|
||||||
|
{ text: '是', value: 'is' },
|
||||||
|
{ text: '不是', value: 'not' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 按字段类型返回条件运算符选项(UI 与 typeMatch 校验共用) */
|
||||||
|
export const getCondOpOptionsByFieldType = (type: string) => {
|
||||||
|
if (type === 'array') {
|
||||||
|
return arrayOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'boolean' || type === 'null') {
|
||||||
|
return booleanOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'number') {
|
||||||
|
return [...eqOptions, ...numberOptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'string') {
|
||||||
|
return [...arrayOptions, ...eqOptions];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...arrayOptions, ...eqOptions, ...numberOptions];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const styleTabConfig: TabPaneConfig = {
|
||||||
|
title: '样式',
|
||||||
|
display: ({ services }: any) => !(services?.uiService?.get('showStylePanel') ?? true),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'style',
|
||||||
|
labelWidth: '100px',
|
||||||
|
type: 'style-setter',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
names: [
|
||||||
|
'display',
|
||||||
|
'flexDirection',
|
||||||
|
'justifyContent',
|
||||||
|
'alignItems',
|
||||||
|
'flexWrap',
|
||||||
|
'marginTop',
|
||||||
|
'marginRight',
|
||||||
|
'marginBottom',
|
||||||
|
'marginLeft',
|
||||||
|
'paddingTop',
|
||||||
|
'paddingRight',
|
||||||
|
'paddingBottom',
|
||||||
|
'paddingLeft',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'overflow',
|
||||||
|
'fontSize',
|
||||||
|
'lineHeight',
|
||||||
|
'fontWeight',
|
||||||
|
'color',
|
||||||
|
'textAlign',
|
||||||
|
'backgroundColor',
|
||||||
|
'backgroundImage',
|
||||||
|
'backgroundSize',
|
||||||
|
'backgroundPosition',
|
||||||
|
'backgroundRepeat',
|
||||||
|
'position',
|
||||||
|
'zIndex',
|
||||||
|
'top',
|
||||||
|
'right',
|
||||||
|
'bottom',
|
||||||
|
'left',
|
||||||
|
'borderRadius',
|
||||||
|
'borderTopWidth',
|
||||||
|
'borderTopStyle',
|
||||||
|
'borderTopColor',
|
||||||
|
'borderRightColor',
|
||||||
|
'borderRightWidth',
|
||||||
|
'borderRightStyle',
|
||||||
|
'borderRightColor',
|
||||||
|
'borderBottomWidth',
|
||||||
|
'borderBottomStyle',
|
||||||
|
'borderBottomColor',
|
||||||
|
'borderLeftStyle',
|
||||||
|
'borderLeftWidth',
|
||||||
|
'borderLeftColor',
|
||||||
|
'borderWidth',
|
||||||
|
'borderStyle',
|
||||||
|
'borderColor',
|
||||||
|
'opacity',
|
||||||
|
],
|
||||||
|
} as unknown as ChildConfig,
|
||||||
|
{
|
||||||
|
name: 'transform',
|
||||||
|
defaultValue: () => ({}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const eventTabConfig: TabPaneConfig = {
|
||||||
|
title: '事件',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: 'events',
|
||||||
|
src: 'component',
|
||||||
|
labelWidth: '100px',
|
||||||
|
type: 'event-select',
|
||||||
|
defaultValue: [],
|
||||||
|
rules: [{ typeMatch: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const advancedTabConfig: TabPaneConfig = {
|
||||||
|
title: '高级',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: NODE_DISABLE_CODE_BLOCK_KEY,
|
||||||
|
text: '禁用代码块',
|
||||||
|
type: 'switch',
|
||||||
|
defaultValue: false,
|
||||||
|
extra: '开启后,配置的代码块将不会被执行',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: NODE_DISABLE_DATA_SOURCE_KEY,
|
||||||
|
text: '禁用数据源',
|
||||||
|
type: 'switch',
|
||||||
|
defaultValue: false,
|
||||||
|
extra: '开启后,组件内配置的数据源相关配置将不会被编译,显隐条件将失效',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'created',
|
||||||
|
text: 'Created',
|
||||||
|
type: 'code-select',
|
||||||
|
titleExtra: '组件初始化时执行',
|
||||||
|
labelWidth: '90px',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'mounted',
|
||||||
|
text: 'Mounted',
|
||||||
|
type: 'code-select',
|
||||||
|
labelWidth: '90px',
|
||||||
|
titleExtra: '组件挂载到dom时执行',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'display',
|
||||||
|
text: 'Display',
|
||||||
|
titleExtra: '控制组件是否渲染,关系的代码块返回值为false时不渲染',
|
||||||
|
labelWidth: '90px',
|
||||||
|
type: 'code-select',
|
||||||
|
rules: [
|
||||||
|
{ typeMatch: true, trigger: 'change' },
|
||||||
|
{
|
||||||
|
validator: ({ value, callback }: any) => {
|
||||||
|
if (value && value.hookType !== HookType.CODE) {
|
||||||
|
return callback('hookType 必须是 code');
|
||||||
|
}
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
|
||||||
|
title: '显示条件',
|
||||||
|
display: (_state, { model }) => model.type !== 'page',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
name: NODE_CONDS_RESULT_KEY,
|
||||||
|
type: 'select',
|
||||||
|
text: '条件成立时',
|
||||||
|
defaultValue: false,
|
||||||
|
options: [
|
||||||
|
{ text: '显示', value: false },
|
||||||
|
{ text: '隐藏', value: true },
|
||||||
|
],
|
||||||
|
extra: (_state, { model }) =>
|
||||||
|
`条件成立时${model[NODE_CONDS_RESULT_KEY] ? '隐藏' : '显示'},不成立时${model[NODE_CONDS_RESULT_KEY] ? '显示' : '隐藏'};<br />同一条件组内的所有条件配置同时成立时表示该条件组成立,任意一个条件组成立时表示条件成立(条件组内为且的关系,条件组间为或的关系);<br />条件为空时表示成立;`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: '条件组配置',
|
||||||
|
static: true,
|
||||||
|
className: 'display-conds-title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'display-conds',
|
||||||
|
name: NODE_CONDS_KEY,
|
||||||
|
titlePrefix: '条件组',
|
||||||
|
defaultValue: [],
|
||||||
|
rules: [{ typeMatch: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一为组件属性表单加上事件、高级、样式配置
|
||||||
|
* @param config 组件属性配置
|
||||||
|
* @returns Object
|
||||||
|
*/
|
||||||
|
export interface FillConfigOptions {
|
||||||
|
labelWidth?: string;
|
||||||
|
disabledDataSource?: boolean;
|
||||||
|
disabledCodeBlock?: boolean;
|
||||||
|
/** 点击 ID 后面的复制按钮时调用,Node 下不提供则按钮不做任何事 */
|
||||||
|
copyText?: (text: string) => Promise<void> | void;
|
||||||
|
/** 复制结果提示 */
|
||||||
|
notify?: (type: 'success' | 'error', message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fillConfig = (
|
||||||
|
config: FormConfig = [],
|
||||||
|
{
|
||||||
|
labelWidth = '80px',
|
||||||
|
disabledDataSource = false,
|
||||||
|
disabledCodeBlock = false,
|
||||||
|
copyText,
|
||||||
|
notify,
|
||||||
|
}: FillConfigOptions = {},
|
||||||
|
): FormConfig => {
|
||||||
|
const propsConfig: FormConfig = [];
|
||||||
|
|
||||||
|
// 组件类型,必须要有
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'type')) {
|
||||||
|
propsConfig.push({
|
||||||
|
text: 'type',
|
||||||
|
name: 'type',
|
||||||
|
type: 'hidden',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'id')) {
|
||||||
|
// 组件id,必须要有
|
||||||
|
propsConfig.push({
|
||||||
|
name: 'id',
|
||||||
|
text: 'ID',
|
||||||
|
type: 'text',
|
||||||
|
// 走纯文本渲染,避免出现 disabled 的灰底输入框;append 仍正常显示。
|
||||||
|
static: true,
|
||||||
|
|
||||||
|
append: {
|
||||||
|
type: 'icon',
|
||||||
|
text: 'https://vip.image.video.qpic.cn/vupload/20260615/36cf7e1781493669935.svg',
|
||||||
|
extra: '复制',
|
||||||
|
handler: (vm, { model }) => {
|
||||||
|
if (!copyText) return;
|
||||||
|
|
||||||
|
Promise.resolve(copyText(`${model.id}`))
|
||||||
|
.then(() => {
|
||||||
|
notify?.('success', '已复制');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
notify?.('error', '复制失败');
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.find((item) => 'name' in item && item.name === 'name')) {
|
||||||
|
propsConfig.push({
|
||||||
|
name: 'name',
|
||||||
|
text: '组件名称',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const noCodeAdvancedTabItems = advancedTabConfig.items.filter(
|
||||||
|
(item) => 'type' in item && item.type !== 'code-select',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (noCodeAdvancedTabItems.length > 0 && disabledCodeBlock) {
|
||||||
|
advancedTabConfig.items = noCodeAdvancedTabItems;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabConfig: TabConfig = {
|
||||||
|
type: 'tab',
|
||||||
|
labelWidth,
|
||||||
|
className: 'magic-right-panel-tabs-top',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
title: '属性',
|
||||||
|
items: [...propsConfig, ...config],
|
||||||
|
},
|
||||||
|
{ ...styleTabConfig },
|
||||||
|
{ ...eventTabConfig },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!disabledCodeBlock) {
|
||||||
|
tabConfig.items.push({ ...advancedTabConfig });
|
||||||
|
} else if (noCodeAdvancedTabItems.length > 0) {
|
||||||
|
tabConfig.items.push({ ...advancedTabConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!disabledDataSource) {
|
||||||
|
tabConfig.items.push({ ...displayTabConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
return [tabConfig];
|
||||||
|
};
|
||||||
501
packages/editor/src/sdk/validate/index.ts
Normal file
501
packages/editor/src/sdk/validate/index.ts
Normal file
@ -0,0 +1,501 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview DSL 校验:
|
||||||
|
*
|
||||||
|
* - 结构合法性:组件类型是否在组件列表中、父节点是否是容器、`canDropIn`、
|
||||||
|
* 页面片是否被禁用、id 是否重复、数据源类型是否可用;
|
||||||
|
* - 属性合法性:用 `@tmagic/form/headless` 的 `validateForm` 校验节点 / 数据源的表单配置。
|
||||||
|
*
|
||||||
|
* 模式见 {@link ValidateMode}:`strict` 抛 {@link DslValidationError},`record` 写入
|
||||||
|
* `editor.state.invalidNodeIds`,`off` 什么也不做。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { cloneDeep, mergeWith } from 'lodash-es';
|
||||||
|
|
||||||
|
import type { DataSourceSchema, Id, MContainer, MNode } from '@tmagic/core';
|
||||||
|
import { NodeType } from '@tmagic/core';
|
||||||
|
import type { FormConfig } from '@tmagic/form/headless';
|
||||||
|
import { builtInFields, registerBuiltInFields, validateForm } from '@tmagic/form/headless';
|
||||||
|
import { isPage, isPageFragment, isPageOrFragment, toLine, traverseNode } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import type { CanDropInScene } from '@editor/type';
|
||||||
|
import { editorNodeMergeCustomizer } from '@editor/utils/editor';
|
||||||
|
|
||||||
|
import type { DslValidationIssue, DslValidationResult, SdkContext } from '../type';
|
||||||
|
|
||||||
|
/** 校验不通过时抛出,`errors` 里是全部问题,调用方(如 agent)可据此修正后重试 */
|
||||||
|
export class DslValidationError extends Error {
|
||||||
|
public readonly errors: DslValidationIssue[];
|
||||||
|
|
||||||
|
constructor(errors: DslValidationIssue[]) {
|
||||||
|
super(
|
||||||
|
`DSL 校验失败:${errors
|
||||||
|
.map((item) => `${item.id !== undefined ? `[${item.target}:${item.id}] ` : ''}${item.message}`)
|
||||||
|
.join(';')}`,
|
||||||
|
);
|
||||||
|
this.name = 'DslValidationError';
|
||||||
|
this.errors = errors;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 内置的、不需要出现在组件列表里也合法的节点类型 */
|
||||||
|
const BUILTIN_NODE_TYPES = new Set<string>([NodeType.ROOT, NodeType.PAGE, NodeType.PAGE_FRAGMENT, 'container']);
|
||||||
|
|
||||||
|
/** 内置的数据源类型 */
|
||||||
|
const BUILTIN_DATA_SOURCE_TYPES = new Set<string>(['base', 'http']);
|
||||||
|
|
||||||
|
const BR_REG = /<br\s*\/?>/gi;
|
||||||
|
|
||||||
|
/** `validateForm` 返回的是以 `<br>` 分隔的文案,拆成单条问题 */
|
||||||
|
const splitErrorText = (error: string): string[] =>
|
||||||
|
error
|
||||||
|
.split(BR_REG)
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `record` 模式下,校验结果要等到修改落进 DSL 之后再写入 `invalidNodeIds`(修改过程中会先清掉旧记录),
|
||||||
|
* 因此 guard 返回一个提交函数交给调用方在合适的时机调用;`strict` 抛错,`off` 返回空。
|
||||||
|
*/
|
||||||
|
export type GuardCommit = (() => void) | undefined;
|
||||||
|
|
||||||
|
export interface Validator {
|
||||||
|
/** 校验 DSL 中已有节点:结构(类型 / 父子关系)+ 属性表单 */
|
||||||
|
node(id: Id): Promise<DslValidationResult>;
|
||||||
|
/** 校验 DSL 中已有的数据源 */
|
||||||
|
dataSource(id: Id): Promise<DslValidationResult>;
|
||||||
|
/** 校验整份 DSL */
|
||||||
|
dsl(): Promise<DslValidationResult>;
|
||||||
|
|
||||||
|
/** 校验一个节点配置的属性表单(不要求它已经在 DSL 中) */
|
||||||
|
checkNodeProps(node: MNode): Promise<DslValidationIssue[]>;
|
||||||
|
/** 同步的结构校验:新增 */
|
||||||
|
checkAddStructure(node: MNode, parent: MContainer | null | undefined): DslValidationIssue[];
|
||||||
|
/** 同步的结构校验:移动 */
|
||||||
|
checkMoveStructure(sourceIds: Id[], target: MContainer, scene: CanDropInScene): DslValidationIssue[];
|
||||||
|
/** 同步的结构校验:数据源配置 */
|
||||||
|
checkDataSourceStructure(config: DataSourceSchema, isNew: boolean): DslValidationIssue[];
|
||||||
|
|
||||||
|
/** 供 service 在修改前调用:按 `validateMode` 抛出 / 记录 / 忽略。 */
|
||||||
|
guardAdd(items: { node: MNode; parentNode: MContainer }[]): Promise<GuardCommit>;
|
||||||
|
/** `replace` 与 `editor.update` 的同名选项一致:为 true 时整节点替换,否则与旧节点深合并 */
|
||||||
|
guardUpdate(nodes: MNode[], options?: { replace?: boolean }): Promise<GuardCommit>;
|
||||||
|
guardMove(sourceIds: Id[], target: MContainer, scene: CanDropInScene): Promise<void>;
|
||||||
|
guardDataSource(config: DataSourceSchema, isNew: boolean): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toResult = (errors: DslValidationIssue[]): DslValidationResult => ({
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
const structureIssue = (
|
||||||
|
target: DslValidationIssue['target'],
|
||||||
|
id: Id | undefined,
|
||||||
|
message: string,
|
||||||
|
extra: Partial<DslValidationIssue> = {},
|
||||||
|
): DslValidationIssue => ({ target, id, kind: 'structure', message, ...extra });
|
||||||
|
|
||||||
|
const nodeIssue = (id: Id | undefined, message: string, extra?: Partial<DslValidationIssue>) =>
|
||||||
|
structureIssue('node', id, message, extra);
|
||||||
|
|
||||||
|
/** 节点及其所有子孙(先序) */
|
||||||
|
const flattenNode = (node: MNode): MNode[] => {
|
||||||
|
const nodes: MNode[] = [];
|
||||||
|
traverseNode(node, (item) => {
|
||||||
|
nodes.push(item);
|
||||||
|
});
|
||||||
|
return nodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isEmptyId = (id: Id | undefined) => id === undefined || id === null || id === '';
|
||||||
|
|
||||||
|
const DUPLICATE_ID_SUGGESTION = '请使用唯一的 id,或不传 id 由编辑器自动生成';
|
||||||
|
|
||||||
|
export const createValidator = (ctx: SdkContext): Validator => {
|
||||||
|
const mode = () => ctx.validateMode;
|
||||||
|
|
||||||
|
let builtInRegistered = false;
|
||||||
|
const ensureFields = () => {
|
||||||
|
if (builtInRegistered) return;
|
||||||
|
registerBuiltInFields(builtInFields);
|
||||||
|
builtInRegistered = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let cachedFormContext: object | undefined;
|
||||||
|
const formContext = () =>
|
||||||
|
(cachedFormContext ??= {
|
||||||
|
services: {
|
||||||
|
editorService: ctx.editor,
|
||||||
|
propsService: ctx.props,
|
||||||
|
historyService: ctx.history,
|
||||||
|
dataSourceService: ctx.dataSource,
|
||||||
|
codeBlockService: ctx.codeBlock,
|
||||||
|
depService: ctx.dep,
|
||||||
|
storageService: ctx.storage,
|
||||||
|
eventsService: ctx.events,
|
||||||
|
componentListService: ctx.componentList,
|
||||||
|
},
|
||||||
|
stage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const knownTypes = (): Set<string> | null => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const group of ctx.componentList.getList()) {
|
||||||
|
for (const item of group.items ?? []) {
|
||||||
|
set.add(toLine(item.type));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 没有登记组件列表时无从判断,不做类型校验
|
||||||
|
return set.size ? set : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isKnownType = (type: string, known: Set<string> | null) =>
|
||||||
|
BUILTIN_NODE_TYPES.has(type) || BUILTIN_NODE_TYPES.has(toLine(type)) || !known || known.has(toLine(type));
|
||||||
|
|
||||||
|
/** 节点类型合法性:在组件列表中、没有被禁用的页面片 */
|
||||||
|
const checkNodeType = (node: MNode, known = knownTypes()): DslValidationIssue[] => {
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (!isKnownType(node.type || '', known)) {
|
||||||
|
issues.push(
|
||||||
|
nodeIssue(node.id, `组件类型「${node.type}」不在组件列表中`, {
|
||||||
|
path: 'type',
|
||||||
|
suggestion: `可用类型:${[...(known ?? [])].join('、')}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPageFragment(node) && ctx.editor.getConfig().disabledPageFragment) {
|
||||||
|
issues.push(nodeIssue(node.id, '当前配置禁用了页面片(disabledPageFragment)', { path: 'type' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新增的节点 id 在这批节点内、或与当前 DSL 里已有节点重复 */
|
||||||
|
const findDuplicateIds = (nodes: MNode[]): DslValidationIssue[] => {
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
const seen = new Set<Id>();
|
||||||
|
|
||||||
|
for (const { id } of nodes.flatMap(flattenNode)) {
|
||||||
|
if (isEmptyId(id)) continue;
|
||||||
|
|
||||||
|
if (seen.has(id)) {
|
||||||
|
issues.push(nodeIssue(id, `节点 id「${id}」重复`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(id);
|
||||||
|
if (ctx.editor.getNodeById(id, false)) {
|
||||||
|
issues.push(nodeIssue(id, `节点 id「${id}」已存在于当前 DSL`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canDropIn = (sourceIds: Id[], target: MContainer, scene: CanDropInScene): DslValidationIssue | null => {
|
||||||
|
const fn = ctx.editor.getConfig().canDropIn;
|
||||||
|
if (!fn) return null;
|
||||||
|
|
||||||
|
const result = fn(sourceIds, target.id, scene);
|
||||||
|
if (result === false) {
|
||||||
|
return nodeIssue(target.id, `不允许放入容器「${target.id}」(canDropIn 返回 false)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof result !== 'string' && typeof result !== 'number') return null;
|
||||||
|
|
||||||
|
// 重定向:被重定向的目标必须存在,且同样是容器
|
||||||
|
const redirected = ctx.editor.getNodeById(result, false);
|
||||||
|
if (redirected && Array.isArray((redirected as MContainer).items)) return null;
|
||||||
|
|
||||||
|
return nodeIssue(target.id, `canDropIn 将容器「${target.id}」重定向到「${result}」,但该节点不存在或不是容器`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 新增节点对父节点的要求 */
|
||||||
|
const checkAddParent = (node: MNode, parent: MContainer | null | undefined): DslValidationIssue | null => {
|
||||||
|
if (!parent) return nodeIssue(node.id, '未找到父节点');
|
||||||
|
if (!Array.isArray(parent.items)) return nodeIssue(node.id, `父节点「${parent.id}」不是容器(没有 items)`);
|
||||||
|
|
||||||
|
if (isPageOrFragment(node)) {
|
||||||
|
return parent.type === NodeType.ROOT ? null : nodeIssue(node.id, '页面 / 页面片只能添加到 app 下');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parent.type === NodeType.ROOT) return nodeIssue(node.id, 'app 下只能添加页面或页面片');
|
||||||
|
|
||||||
|
return canDropIn([], parent, 'stage-add');
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkAddStructure: Validator['checkAddStructure'] = (node, parent) => {
|
||||||
|
const known = knownTypes();
|
||||||
|
|
||||||
|
const issues = flattenNode(node).flatMap((item) =>
|
||||||
|
item.type ? checkNodeType(item, known) : [nodeIssue(item.id, '组件类型不能为空')],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentIssue = checkAddParent(node, parent);
|
||||||
|
if (parentIssue) issues.push(parentIssue);
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 被移动的节点:必须存在,且不能移动到自己或子孙里 */
|
||||||
|
const checkMoveSource = (id: Id, target: MContainer): DslValidationIssue[] => {
|
||||||
|
const source = ctx.editor.getNodeById(id, false);
|
||||||
|
if (!source) return [nodeIssue(id, `要移动的节点「${id}」不存在`)];
|
||||||
|
if (isPageOrFragment(source)) return [];
|
||||||
|
|
||||||
|
return flattenNode(source).some((item) => item.id === target.id)
|
||||||
|
? [nodeIssue(id, `不能把节点「${id}」移动到它自己或子节点里`)]
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMoveStructure: Validator['checkMoveStructure'] = (sourceIds, target, scene) => {
|
||||||
|
if (!Array.isArray(target.items)) {
|
||||||
|
return [nodeIssue(target.id, `目标节点「${target.id}」不是容器(没有 items)`)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = sourceIds.flatMap((id) => checkMoveSource(id, target));
|
||||||
|
|
||||||
|
const targetIssue =
|
||||||
|
target.type === NodeType.ROOT
|
||||||
|
? nodeIssue(target.id, 'app 下只能放页面或页面片')
|
||||||
|
: canDropIn(sourceIds, target, scene);
|
||||||
|
if (targetIssue) issues.push(targetIssue);
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
};
|
||||||
|
|
||||||
|
// add 在 id 冲突时会自动重新生成 id,不算错误,所以不区分是否新增
|
||||||
|
const checkDataSourceStructure: Validator['checkDataSourceStructure'] = (config) => {
|
||||||
|
const type = config.type || 'base';
|
||||||
|
|
||||||
|
const known = new Set<string>([
|
||||||
|
...BUILTIN_DATA_SOURCE_TYPES,
|
||||||
|
...ctx.dataSource.get('datasourceTypeList').map((item) => toLine(item.type)),
|
||||||
|
...Object.keys(ctx.dataSource.get('configs')),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (known.has(toLine(type))) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
structureIssue('data-source', config.id, `数据源类型「${type}」不可用`, {
|
||||||
|
path: 'type',
|
||||||
|
suggestion: `可用类型:${[...known].join('、')}`,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 用 `validateForm` 校验属性表单,配置不存在时不校验;校验本身出错也作为一条问题返回 */
|
||||||
|
const checkProps = async (
|
||||||
|
target: 'node' | 'data-source',
|
||||||
|
id: Id,
|
||||||
|
values: MNode | DataSourceSchema,
|
||||||
|
getConfig: () => Promise<FormConfig | undefined> | FormConfig | undefined,
|
||||||
|
options: { useFieldTextInError?: boolean } = {},
|
||||||
|
): Promise<DslValidationIssue[]> => {
|
||||||
|
const toIssues = (messages: string[]): DslValidationIssue[] =>
|
||||||
|
messages.map((message) => ({ target, id, kind: 'props', message }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
if (!config?.length) return [];
|
||||||
|
|
||||||
|
ensureFields();
|
||||||
|
|
||||||
|
const error = await validateForm({
|
||||||
|
config,
|
||||||
|
typeMatchValid: true,
|
||||||
|
initValues: values,
|
||||||
|
context: formContext() as any,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return toIssues(splitErrorText(error));
|
||||||
|
} catch (e: any) {
|
||||||
|
return toIssues([`属性校验异常:${e?.message ?? e}`]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkNodeProps: Validator['checkNodeProps'] = async (node) => {
|
||||||
|
if (!node.type || node.type === NodeType.ROOT) return [];
|
||||||
|
|
||||||
|
return checkProps('node', node.id, node, () => ctx.props.getPropsConfig(node.type!, { node }), {
|
||||||
|
useFieldTextInError: true,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDataSourceProps = (config: DataSourceSchema) =>
|
||||||
|
checkProps('data-source', config.id, config, () => ctx.dataSource.getFormConfig(config.type || 'base'));
|
||||||
|
|
||||||
|
/** 已存在节点的结构校验:类型合法 */
|
||||||
|
const checkExistingNodeStructure = (node: MNode) => checkNodeType(node);
|
||||||
|
|
||||||
|
const validateNode = async (id: Id): Promise<DslValidationIssue[]> => {
|
||||||
|
const node = ctx.editor.getNodeById(id, false);
|
||||||
|
if (!node) return [nodeIssue(id, `节点「${id}」不存在`)];
|
||||||
|
|
||||||
|
const propsIssues = !isPage(node) || node.items ? await checkNodeProps(node) : [];
|
||||||
|
return [...checkExistingNodeStructure(node), ...propsIssues];
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateDataSource = async (id: Id): Promise<DslValidationIssue[]> => {
|
||||||
|
const ds = ctx.dataSource.getDataSourceById(`${id}`);
|
||||||
|
if (!ds) return [structureIssue('data-source', id, `数据源「${id}」不存在`)];
|
||||||
|
|
||||||
|
return [...checkDataSourceStructure(ds, false), ...(await checkDataSourceProps(ds))];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 按模式处理问题:strict 抛错;record 返回写入 invalidNodeIds 的提交函数 */
|
||||||
|
const handle = (issues: DslValidationIssue[], recordIds: Id[] = []): GuardCommit => {
|
||||||
|
if (mode() === 'strict') {
|
||||||
|
if (issues.length) throw new DslValidationError(issues);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode() !== 'record') return undefined;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const id of recordIds) {
|
||||||
|
ctx.editor.deleteInvalidNode(id, 'props');
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = new Map<Id, string[]>();
|
||||||
|
for (const issue of issues) {
|
||||||
|
if (issue.target !== 'node' || issue.id === undefined) continue;
|
||||||
|
grouped.set(issue.id, [...(grouped.get(issue.id) ?? []), issue.message]);
|
||||||
|
}
|
||||||
|
for (const [id, messages] of grouped) {
|
||||||
|
ctx.editor.setInvalidNode(id, 'props', messages.join('<br>'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/** update 的单个节点:类型不可变,并校验 update 实际会写入的结果 */
|
||||||
|
const checkUpdatedNode = async (
|
||||||
|
node: MNode,
|
||||||
|
replace: boolean,
|
||||||
|
): Promise<{ id?: Id; issues: DslValidationIssue[] }> => {
|
||||||
|
const old = ctx.editor.getNodeById(node.id, false);
|
||||||
|
if (!old) return { issues: [nodeIssue(node.id, `节点「${node.id}」不存在`)] };
|
||||||
|
if (old.type === NodeType.ROOT) return { issues: [] };
|
||||||
|
|
||||||
|
const issues: DslValidationIssue[] = [];
|
||||||
|
|
||||||
|
if (node.type && node.type !== old.type) {
|
||||||
|
issues.push(nodeIssue(node.id, `不允许修改节点类型(${old.type} -> ${node.type})`, { path: 'type' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 与 `Editor.doUpdate` 一致:replace 时整节点替换,否则深合并
|
||||||
|
const merged: MNode = replace
|
||||||
|
? cloneDeep(node)
|
||||||
|
: mergeWith(cloneDeep(ctx.reactivity.toRaw(old)), cloneDeep(node), editorNodeMergeCustomizer);
|
||||||
|
issues.push(...checkExistingNodeStructure(merged), ...(await checkNodeProps(merged)));
|
||||||
|
|
||||||
|
return { id: node.id, issues };
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
async node(id) {
|
||||||
|
return toResult(await validateNode(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async dataSource(id) {
|
||||||
|
return toResult(await validateDataSource(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
async dsl() {
|
||||||
|
const root = ctx.editor.get('root');
|
||||||
|
if (!root) return toResult([structureIssue('dsl', undefined, 'DSL 为空')]);
|
||||||
|
|
||||||
|
const ids = flattenNode(root)
|
||||||
|
.filter((node) => node.type !== NodeType.ROOT)
|
||||||
|
.map((node) => node.id);
|
||||||
|
|
||||||
|
// 重复 id:同一个 DSL 内出现两次
|
||||||
|
const seen = new Set<Id>();
|
||||||
|
const issues = ids
|
||||||
|
.filter((id) => {
|
||||||
|
const duplicated = seen.has(id);
|
||||||
|
seen.add(id);
|
||||||
|
return duplicated;
|
||||||
|
})
|
||||||
|
.map((id) => nodeIssue(id, `节点 id「${id}」重复`, { suggestion: DUPLICATE_ID_SUGGESTION }));
|
||||||
|
|
||||||
|
for (const id of seen) {
|
||||||
|
issues.push(...(await validateNode(id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ds of ctx.dataSource.get('dataSources')) {
|
||||||
|
issues.push(...(await validateDataSource(ds.id)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return toResult(issues);
|
||||||
|
},
|
||||||
|
|
||||||
|
checkNodeProps,
|
||||||
|
checkAddStructure,
|
||||||
|
checkMoveStructure,
|
||||||
|
checkDataSourceStructure,
|
||||||
|
|
||||||
|
async guardAdd(items) {
|
||||||
|
if (mode() === 'off') return undefined;
|
||||||
|
|
||||||
|
const nodes = items.flatMap(({ node }) => flattenNode(node));
|
||||||
|
|
||||||
|
const issues = [
|
||||||
|
...findDuplicateIds(items.map(({ node }) => node)),
|
||||||
|
...items.flatMap(({ node, parentNode }) => checkAddStructure(node, parentNode)),
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
issues.push(...(await checkNodeProps(node)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return handle(
|
||||||
|
issues,
|
||||||
|
nodes.map((node) => node.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async guardUpdate(nodes, { replace = false } = {}) {
|
||||||
|
if (mode() === 'off') return undefined;
|
||||||
|
|
||||||
|
const results = await Promise.all(nodes.map((node) => checkUpdatedNode(node, replace)));
|
||||||
|
|
||||||
|
return handle(
|
||||||
|
results.flatMap(({ issues }) => issues),
|
||||||
|
results.flatMap(({ id }) => (id === undefined ? [] : [id])),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 移动 / 数据源只有结构问题,没有可挂载到节点属性上的错误,因此只在 strict 模式下生效
|
||||||
|
async guardMove(sourceIds, target, scene) {
|
||||||
|
if (mode() !== 'strict') return;
|
||||||
|
handle(checkMoveStructure(sourceIds, target, scene));
|
||||||
|
},
|
||||||
|
|
||||||
|
guardDataSource(config, isNew) {
|
||||||
|
if (mode() !== 'strict') return;
|
||||||
|
handle(checkDataSourceStructure(config, isNew));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
211
packages/editor/src/sdk/wiring.ts
Normal file
211
packages/editor/src/sdk/wiring.ts
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* Tencent is pleased to support the open source community by making TMagicEditor available.
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 Tencent. All rights reserved.
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @fileoverview 依赖收集连线(只处理 DSL,不碰画布 / runtime)。
|
||||||
|
*
|
||||||
|
* 数据源、代码块的依赖信息(`dataSourceDeps` / `dataSourceCondDeps` / `dataSourceMethodDeps`)
|
||||||
|
* 会写回 DSL。这里把「节点变化 -> 重新收集 -> 写回 root」的链路连起来,使 Node 下导出的 DSL
|
||||||
|
* 依赖信息完整。浏览器端画布同步仍由 `initService` 负责,两边共用 `./utils/dep` 里的规则。
|
||||||
|
*
|
||||||
|
* 收集用的是同步的 `dep.collect`,不经过空闲队列,所以操作返回时依赖已经是最新的。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { CodeBlockContent, DataSourceSchema, Id, MApp, MComponent, MNode, Target } from '@tmagic/core';
|
||||||
|
import { createCodeBlockTarget, DepTargetType, NodeType } from '@tmagic/core';
|
||||||
|
import type { ChangeRecord } from '@tmagic/form';
|
||||||
|
import { getNodes, isPage } from '@tmagic/utils';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addDataSourceTargets,
|
||||||
|
classifyUpdatedNodes,
|
||||||
|
getDataSourceRecollectTypes,
|
||||||
|
removeDataSourceTargets,
|
||||||
|
removeTargetDeps,
|
||||||
|
resetDepTargets,
|
||||||
|
writeTargetDeps,
|
||||||
|
} from './utils/dep';
|
||||||
|
import type { SdkContext } from './type';
|
||||||
|
|
||||||
|
export { classifyUpdatedNodes } from './utils/dep';
|
||||||
|
|
||||||
|
export interface WiringHandle {
|
||||||
|
unbind(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingSelects = new WeakMap<object, Promise<unknown>>();
|
||||||
|
|
||||||
|
/** root 变化后自动选中节点是异步的,需要时可以等它完成 */
|
||||||
|
export const whenWiringSettled = (ctx: SdkContext): Promise<unknown> => pendingSelects.get(ctx) ?? Promise.resolve();
|
||||||
|
|
||||||
|
export const bindWiring = (ctx: SdkContext): WiringHandle => {
|
||||||
|
const { editor, codeBlock, dataSource, dep, reactivity } = ctx;
|
||||||
|
|
||||||
|
const getPageIdByNode = (node: MComponent): Id | undefined => {
|
||||||
|
if (isPage(node)) return node.id;
|
||||||
|
return editor.getNodeInfo(node.id).page?.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const collect = (nodes: MComponent[], deep: boolean, type?: DepTargetType) => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.type === NodeType.ROOT) continue;
|
||||||
|
dep.collect([node], { pageId: getPageIdByNode(node) }, deep, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** root 变化后选中原来的节点,没有则选中第一个页面(与浏览器端一致) */
|
||||||
|
const selectAfterRootChange = (root: MApp) => {
|
||||||
|
const nodeId = editor.get('node')?.id;
|
||||||
|
const node = nodeId ? editor.getNodeById(nodeId) : null;
|
||||||
|
|
||||||
|
let selecting: Promise<unknown> | undefined;
|
||||||
|
if (node && node !== root) {
|
||||||
|
selecting = editor.select(node.id);
|
||||||
|
} else if (root.items?.length) {
|
||||||
|
selecting = editor.select(root.items[0]);
|
||||||
|
} else if (root.id) {
|
||||||
|
editor.set('nodes', [root]);
|
||||||
|
editor.set('parent', null);
|
||||||
|
editor.set('page', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selecting) {
|
||||||
|
pendingSelects.set(
|
||||||
|
ctx,
|
||||||
|
selecting.catch(() => {}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootChangeHandler = (root: MApp | null) => {
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
root.codeBlocks = root.codeBlocks || {};
|
||||||
|
root.dataSources = root.dataSources || [];
|
||||||
|
|
||||||
|
codeBlock.setCodeDsl(root.codeBlocks);
|
||||||
|
dataSource.set('dataSources', root.dataSources);
|
||||||
|
|
||||||
|
resetDepTargets(dep, root.codeBlocks, dataSource.get('dataSources'), reactivity.reactive);
|
||||||
|
|
||||||
|
if (Array.isArray(root.items)) {
|
||||||
|
dep.clearIdleTasks();
|
||||||
|
collect(root.items, true);
|
||||||
|
} else {
|
||||||
|
dep.clear();
|
||||||
|
delete root.dataSourceDeps;
|
||||||
|
delete root.dataSourceCondDeps;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAfterRootChange(root);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeAddHandler = (nodes: MComponent[]) => collect(nodes, true);
|
||||||
|
|
||||||
|
const nodeUpdateHandler = (data: Parameters<typeof classifyUpdatedNodes>[1]) => {
|
||||||
|
const { needRecollectNodes, normalNodes } = classifyUpdatedNodes(ctx, data);
|
||||||
|
|
||||||
|
// 数据源引用发生变化的节点需要重新收集数据源依赖
|
||||||
|
collect(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
|
||||||
|
collect(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
|
||||||
|
|
||||||
|
// 代码块、数据源方法依赖对所有被更新的节点都要收集:同一批里既改了数据源引用又改了方法时不能漏掉
|
||||||
|
const updatedNodes = [...needRecollectNodes, ...normalNodes];
|
||||||
|
collect(updatedNodes, true, DepTargetType.CODE_BLOCK);
|
||||||
|
collect(updatedNodes, true, DepTargetType.DATA_SOURCE_METHOD);
|
||||||
|
};
|
||||||
|
|
||||||
|
const nodeRemoveHandler = (nodes: MComponent[]) => dep.clear(nodes);
|
||||||
|
|
||||||
|
const historyChangeHandler = (page: MNode) => collect([page as MComponent], true);
|
||||||
|
|
||||||
|
const dataSourceAddHandler = (config: DataSourceSchema) => addDataSourceTargets(dep, config, reactivity.reactive);
|
||||||
|
|
||||||
|
const dataSourceUpdateHandler = (config: DataSourceSchema, { changeRecords }: { changeRecords: ChangeRecord[] }) => {
|
||||||
|
const types = getDataSourceRecollectTypes(changeRecords);
|
||||||
|
const items = editor.get('root')?.items;
|
||||||
|
if (!types || !Array.isArray(items)) return;
|
||||||
|
|
||||||
|
dep.clearIdleTasks();
|
||||||
|
removeDataSourceTargets(dep, config.id, types);
|
||||||
|
addDataSourceTargets(dep, config, reactivity.reactive, types);
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
collect(items, true, type);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dataSourceRemoveHandler = (id: string) => {
|
||||||
|
const root = editor.get('root');
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const nodes = getNodes(Object.keys(root.dataSourceDeps?.[id] || {}), root.items) as MComponent[];
|
||||||
|
|
||||||
|
for (const type of [DepTargetType.DATA_SOURCE, DepTargetType.DATA_SOURCE_COND, DepTargetType.DATA_SOURCE_METHOD]) {
|
||||||
|
collect(nodes, false, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeDataSourceTargets(dep, id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockAddOrUpdateHandler = (id: Id, content: CodeBlockContent) => {
|
||||||
|
if (dep.hasTarget(id, DepTargetType.CODE_BLOCK)) {
|
||||||
|
dep.getTarget(id, DepTargetType.CODE_BLOCK)!.name = content.name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dep.addTarget(createCodeBlockTarget(id, content));
|
||||||
|
};
|
||||||
|
|
||||||
|
const codeBlockRemoveHandler = (id: Id) => dep.removeTarget(id, DepTargetType.CODE_BLOCK);
|
||||||
|
|
||||||
|
const targetAddHandler = (target: Target) => writeTargetDeps(editor.get('root'), target);
|
||||||
|
|
||||||
|
const targetRemoveHandler = (id: string | number, type: string) => removeTargetDeps(editor.get('root'), id, type);
|
||||||
|
|
||||||
|
const listeners: [{ on: Function; off: Function }, string, (...args: any[]) => void][] = [
|
||||||
|
[editor, 'history-change', historyChangeHandler],
|
||||||
|
[editor, 'root-change', rootChangeHandler],
|
||||||
|
[editor, 'add', nodeAddHandler],
|
||||||
|
[editor, 'remove', nodeRemoveHandler],
|
||||||
|
[editor, 'update', nodeUpdateHandler],
|
||||||
|
[dataSource, 'add', dataSourceAddHandler],
|
||||||
|
[dataSource, 'update', dataSourceUpdateHandler],
|
||||||
|
[dataSource, 'remove', dataSourceRemoveHandler],
|
||||||
|
[codeBlock, 'addOrUpdate', codeBlockAddOrUpdateHandler],
|
||||||
|
[codeBlock, 'remove', codeBlockRemoveHandler],
|
||||||
|
[dep, 'add-target', targetAddHandler],
|
||||||
|
[dep, 'remove-target', targetRemoveHandler],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [service, event, handler] of listeners) {
|
||||||
|
service.on(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定时 DSL 已经设置过(如 `autoWiring: false` 之后再手动绑定):补做一次初始化与收集
|
||||||
|
const root = editor.get('root');
|
||||||
|
if (root) rootChangeHandler(root);
|
||||||
|
|
||||||
|
return {
|
||||||
|
unbind() {
|
||||||
|
for (const [service, event, handler] of listeners) {
|
||||||
|
service.off(event, handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
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