Compare commits

..

38 Commits

Author SHA1 Message Date
roymondchen
1efd272516 chore: release v1.8.5 2026-11-13 11:56:21 +08:00
roymondchen
0e8b676fe7 feat(editor): 补全样式面板 CSS 枚举选项并补齐图标
- textAlign/display/justifyContent/alignItems/backgroundRepeat/fontWeight/borderStyle 按 CSS 规范补齐关键字
- 新增 15 个图标组件,新增项统一为图标按钮;textAlign 去掉不等宽文案,避免换行后边框错位
- 修正 opacity 标签由「透明度(%)」改为「透明度(0-1)」
- 新增 configs.spec.ts 并更新 Layout.spec.ts / icons.spec.ts
2026-11-12 20:12:56 +08:00
roymondchen
72664955bf chore(runtime): runtime-help 版本转正式版并同步依赖引用 2026-11-05 17:50:14 +08:00
roymondchen
c45b6706b6 chore: release v1.8.4 2026-11-05 17:45:57 +08:00
roymondchen
86a3b4f0e7 chore(deps): 升级 pnpm 至 12.16.1
同步更新 CI workflow 的 pnpm 版本与 Node 版本,并更新锁文件。
2026-11-05 17:33:07 +08:00
roymondchen
86fe545e8f test: 修复 Popconfirm 浮层断言在多浮层场景下定位错误
侧栏删除图标外层新增 Tooltip 后,querySelector('.el-popper') 可能命中 Tooltip 浮层。

改为按文案定位 Popconfirm 浮层,table 用例改为取最后挂载的 popper。
2026-11-05 17:27:17 +08:00
roymondchen
39ca916b88 refactor(form): 分组列表行操作按钮改为纯图标加 Tooltip 提示
- 复制、上移、下移、挪到、删除等按钮去掉文案,改用 Tooltip 承载
- 自定义按钮有 icon 时只渲染图标,无 icon 时保留文案避免出现空白按钮
- 相邻按钮间距统一交给 gap,覆盖 design 包默认的 12px margin
2026-11-05 17:26:34 +08:00
roymondchen
8e40d8d797 fix(editor): 修复组件名超长不省略的问题 2026-11-05 17:18:04 +08:00
roymondchen
5cc5ab872f chore(deps): 升级依赖版本并优化 clean:modules 脚本 2026-11-05 17:14:20 +08:00
roymondchen
abcd875731 fix(form): 修复切换同类型节点时表单字段配置串台
- MForm 新增 formKey,数据实体切换时只重挂叶子字段,保留 tab 等容器状态;异步 initValue 仅采用最后一次结果
- 代码块 / 数据源方法参数配置改为按 model 派生,避免 setup 期快照残留旧节点配置
- 补充相关单测与 form-props 文档
2026-11-05 15:17:03 +08:00
roymondchen
b4747c3e89 fix(editor): 事件动作与高级配置表单标签改用固定宽度 2026-11-05 13:57:21 +08:00
roymondchen
c2272b12d8 fix(editor): 数据源与代码块删除确认改用 Popconfirm 2026-11-05 13:53:38 +08:00
roymondchen
8e4391972b fix(design): 修复 Popconfirm 浮层被裁剪及 Element Plus 复合方位失效 2026-11-05 13:52:02 +08:00
roymondchen
657758dec2 docs: 补充 AGENTS.md 编码原则 2026-11-05 11:13:56 +08:00
roymondchen
5e7f7ad7f1 feat(editor): 支持通过 insertIndex 指定 add 插入位置
显式下标不再依赖选中节点锚点,批量添加按父容器累计偏移保持传入顺序;越界或非数字追加到末尾,并补充单测与 API 文档
2026-11-05 11:12:57 +08:00
roymondchen
c50c245fbb chore(release): @tmagic/react-runtime-help 升级至 0.2.3-beta.1 2026-11-02 15:45:00 +08:00
roymondchen
9c740fb2be chore(release): @tmagic/vue-runtime-help 升级至 2.0.6-beta.1 2026-11-02 15:22:22 +08:00
roymondchen
225850bce0 chore: release v1.8.4-beta.1 2026-11-02 15:14:21 +08:00
roymondchen
dfc4f55390 feat(core): 新增 setPageFragment/deletePageFragment 同步页面片实例
App 新增 setPageFragment / deletePageFragment 方法,用于同步页面片实例。
容器关联、切换、取消关联页面片时同步 pageFragments,并触发 page-fragments-change。

- Page 重建时强制同步页面片内容(force)
- useDsl 改用动态获取页面片实例,避免 setup 快照过期
- useEditorDsl 在关联变更、删除容器时同步页面片实例
- 补充 core 与 vue-runtime-help 单测,更新 runtime-api 文档
2026-11-02 14:39:56 +08:00
roymondchen
0c1d103cd0 fix(runtime): 修复 useDsl 换页后更新失效与重复替换
- 事件处理改为读取最新页面配置(ref),换页后不再更新到旧页面
- react 版事件监听移入 useEffect 并依赖 app,避免监听泄漏
- 按节点对象去重并批量 replaceChildNodes,同一次批量更新的多份相同节点只替换一次
- 忽略非当前页面的 update-data
2026-10-30 16:49:34 +08:00
roymondchen
35ed2cc0d0 perf(data-source): 迭代器容器数据上下文改为惰性深拷贝
createIteratorContentData 原先整体深拷贝数据源数据,迭代项很多时为 O(N^2)。
改为沿 fields 路径逐层构造新对象,路径之外的对象型字段在首次读取时才拷贝并缓存,
既避免修改原始数据,也保证引用变化可被渲染层感知
2026-10-30 16:49:02 +08:00
roymondchen
6f2ffc3ca1 fix(data-source): 依赖缓存按节点对象引用判断失效
此前缓存键由节点 id 拼接而成,节点被替换(如修改绑定字段)后 id 不变仍命中旧缓存,
导致新依赖未被收集。改为以首节点为 WeakMap key 并比较节点对象引用,
节点替换后重新收集依赖,节点回收后缓存自动释放
2026-10-30 16:48:30 +08:00
roymondchen
dfad691493 feat(data-source): 支持批量更新数据源
新增 dataSourceManager.batch(fn, options),fn 中的多次 setData 在结束后
统一更新一次页面:受影响节点只编译、替换一次,并按数据源分别触发 update-data。

- mergeChangeEvents 默认 false,每次变化各触发一次 update-data 并携带自己的
  changeEvent,不丢失中间变化;传 true 则每个数据源只触发一次
- 支持嵌套,仅支持同步函数;isBatching 暴露当前是否处于批量更新中
- 页面/页面片节点的替换改为批量 replaceChildNodes,避免每个节点全树搜索
- 数据源字段变更事件在 batch 内仍按每次 setData 触发
2026-10-30 16:47:56 +08:00
roymondchen
522a0068f5 fix(core): 节点销毁时移除其事件监听
迭代容器等场景会反复重建节点,此前 destroy 只清理事件队列,
事件监听仍留在 EventHelper 上导致泄漏。新增 removeNodeEventsOf,
在 Node.destroy 时按节点移除已绑定的监听
2026-10-30 16:47:23 +08:00
roymondchen
2f8a504163 perf(utils): 优化节点查找与批量替换
- getNodes 改用 Map 统计待查 id,命中后提前结束遍历,避免每个节点都做一次数组查找
- 新增 replaceChildNodes,一次遍历定位所有目标节点后批量替换(O(K*N) -> O(N+K)),
  并按层级由浅到深替换,避免父节点覆盖子节点更新
- compiledNode 读取依赖 keys 时兼容节点依赖缺失
2026-10-30 16:46:35 +08:00
manmanyu
ccfc8c0733 feat(editor): 新增从组件面板点击/拖动添加组件前的处理函数配置 2026-10-30 08:28:44 +00:00
roymondchen
c4cf3b9525 fix(dep): 修复共用节点 id 的多个根节点收集依赖时互相清除
多个根节点(如复制出的页面片)共用节点 id 时,逐节点“删除旧依赖 + 收集”会让后一棵树的删除清掉前一棵树刚写入的依赖。改为先统一删除所有节点的旧依赖,再统一收集。
2026-10-27 20:52:23 +08:00
roymondchen
d01bc45b17 feat(form): 支持 table/group-list 行操作自定义按钮
新增 actionButtons 配置,渲染在内置按钮之后,支持 text/props/icon/display/disabled/handler,
回调可获得当前行数据、列表下标、整体数据等上下文。
2026-10-27 20:12:23 +08:00
roymondchen
7e8cb1f9db feat(editor): 支持配置画布 iframe 的 sandbox 属性
新增 iframeSandbox 配置,透传链路为 EditorProps -> StageOptions ->
StageCore -> StageRender,在 createIframe 中设置 src 之前写入 sandbox
属性。默认不设置该属性,保持存量行为不变。

- stage: StageRenderConfig / StageCoreConfig 新增 iframeSandbox 类型
- editor: EditorProps / StageOptions 新增 iframeSandbox 并透传
- docs: 补充 iframeSandbox 说明与 srcdoc、同源限制的注意事项
- test: 覆盖未配置 / false / 空字符串 / 多 token 与 StageCore 透传
2026-10-27 16:12:45 +08:00
roymondchen
d017f43573 fix(stage): iframe 未就绪时跳过注入 magic,避免 contentWindow 为空报错 2026-10-19 17:08:02 +08:00
roymondchen
cbd0442ffc feat(form): 支持 table/group-list 行操作按钮文案与 props 配置
通过 ActionButtonConfig 统一 delete/copy/move/operate 等按钮的 text 与 props,并补充对应单测。
2026-10-19 17:08:02 +08:00
roymondchen
8ac1a38601 fix(editor): 优化导航更多菜单宽度与溢出分割线
不传 popoverWidth 时按内容撑开,并将溢出项中的分割线改为横向且去掉首尾无意义分割线。
2026-10-19 17:08:02 +08:00
roymondchen
7f0e78bf6a chore: 升级根目录与 catalog 依赖版本 2026-10-19 16:42:31 +08:00
roymondchen
c6deac8524 chore: release v1.8.3 2026-10-13 19:24:00 +08:00
roymondchen
f12f78c9e0 fix(form): 日期/时间字段支持 valueFormat 为 X 的秒级时间戳
- 日期类字段 change 时经 normalizeTimestampValue 把选择器输出收成秒级 number,避免 Element Plus 写出秒字符串导致校验失败
- Date/DateTime/Time/Daterange/Timerange 回填 model 时,valueFormat 为 X 直接使用秒级数字,不再拼接展示格式
- typeMatch 对 X 格式要求有限整数,示例值与报错文案区分秒级/毫秒级
- datetimeFormatter 支持 X 格式输出秒级数字
- 补充相应单元测试,并更新 docs/form-config/rules.md 中的校验规则说明
2026-10-13 17:11:02 +08:00
roymondchen
94fae10ae0 feat(editor): 新增 SDK 层并将核心服务迁移至 sdk 目录
- 新增 packages/editor/src/sdk,通过 sdk.ts 对外导出
- 将 services 下的核心服务迁移至 sdk/services
- 工具函数拆分至 sdk/utils,校验逻辑迁移至 sdk/validate,新增 sdk/wiring
- services/sdk-instance.ts 负责在编辑器中实例化 SDK
- 补充 SDK 相关单元测试,新增 docs/api/editor/sdk.md 文档
2026-10-08 19:58:03 +08:00
roymondchen
d9b096c91c chore: release v1.8.2 2026-09-23 16:38:14 +08:00
roymondchen
a100462a52 feat(editor): enablePropsFormValidate 改为响应式注入并接管 typeMatchValid / validateOnInit
ENABLE_PROPS_FORM_VALIDATE 原先 provide 的是布尔快照,父组件动态切换
enablePropsFormValidate 后子孙组件拿不到新值。改为与 isLargeStageContainer
一致用 computed 注入,注入键类型变为 InjectionKey<ComputedRef<boolean>>,
PropsPanel / FormPanel 侧统一按 .value 读取。

同时 FormPanel 内 MForm 的 type-match-valid / validate-on-init 由硬编码
true 改为跟随 enablePropsFormValidate,未开启时不再做 typeMatch 校验与
初始化校验。
2026-09-23 16:34:08 +08:00
234 changed files with 12656 additions and 2939 deletions

View File

@ -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

View File

@ -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 # 安装依赖并构建

View File

@ -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)

View File

@ -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',
},
] ]
}, },
{ {

View File

@ -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)

View File

@ -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
View 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];
},
});
```

View File

@ -69,6 +69,18 @@
</script> </script>
``` ```
## formKey
- **详情:** 当前表单绑定的数据实体标识(编辑器属性面板里是节点 id)。
配置结构相同但换了数据实体时(例如两个同类型组件之间切换选中),表单树会被复用,
字段组件在 setup 期从 `model` 派生的本地配置(如事件动作的参数字段)仍属于上一个实体,
会出现「值来自新节点、配置来自旧节点」的串台。传入 `formKey` 后,值变化时只重挂叶子字段组件;
tab、group-list 等容器保持复用,激活 tab、滚动位置和展开态不会丢失。
同一实体的 `initValues` 更新(例如编辑后重新下发节点数据)不会触发重挂。
- **类型:** `string | number`
## lastValues ## lastValues
- **详情:** 需对比的值(开启对比模式时传入) - **详情:** 需对比的值(开启对比模式时传入)

View File

@ -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 枚举 |

View File

@ -10,6 +10,8 @@
> 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。 > 该能力默认关闭,需通过 `<m-editor :enable-props-form-validate="true">` 显式开启。
开启后,属性 / 样式表单内部 `MForm` 的 `typeMatchValid`(按字段 type 校验值形态)与 `validateOnInit`(初始化后立即校验一次)也会一并开启;关闭时两者同样关闭。开关可动态变化,表单会同步生效。
## 工作原理 ## 工作原理
### 错误来源维度 ### 错误来源维度

View File

@ -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 })` |
### 监听示例 ### 监听示例

View File

@ -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)` |
### 事件监听示例 ### 事件监听示例

View File

@ -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": {

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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() {

View File

@ -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 = [];

View File

@ -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) => {

View File

@ -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: {

View File

@ -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 - 事件队列', () => {

View File

@ -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,

View File

@ -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) => {

View File

@ -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;
}; };

View File

@ -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;
}; };

View File

@ -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;
}; };

View File

@ -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;

View File

@ -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');
});
});

View 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);
});
});

View File

@ -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({});
});
}); });

View 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();
});
});

View File

@ -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', () => {

View File

@ -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,

View File

@ -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);
} }
} }

View File

@ -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');
});
}); });

View File

@ -1,5 +1,5 @@
{ {
"version": "1.8.1", "version": "1.8.5",
"name": "@tmagic/design", "name": "@tmagic/design",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -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;

View File

@ -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"

View File

@ -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 自动继承。

View File

@ -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 之后 */

View File

@ -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)
: {}, : {},
}); });

View File

@ -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)
: {}, : {},
}); });

View File

@ -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,
})), }),
),
}, },
}, },
], ],

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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';

View File

@ -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>

View File

@ -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;

View File

@ -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') {

View File

@ -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,
})); }));

View File

@ -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;

View File

@ -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';

View File

@ -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);

View File

@ -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 {

View File

@ -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;

View File

@ -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>

View File

@ -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;

View File

@ -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);

View File

@ -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';

View File

@ -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,

View File

@ -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' });
} }
}; };

View File

@ -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);
}; };

View File

@ -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' });
}; };

View File

@ -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' });
} }
}; };

View 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';

View 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';

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View 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

View 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];
};

View 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;
};

View 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';

View 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];
};

View 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));
},
};
};

View 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