Compare commits

...

53 Commits

Author SHA1 Message Date
roymondchen
3875ccde33 build: 升级 vite@8.0.8 并修复 rolldown UMD Symbol.toStringTag 遮蔽问题
- 升级 vite 至 ^8.0.8(catalog 及 lockfile 同步)
- 在 scripts/build.mjs 中新增 fixUmdSymbolShadow 插件,将 UMD 产物中
  `Object.defineProperty(exports, Symbol.toStringTag, ...)` 替换为
  `globalThis.Symbol.toStringTag`,规避 lodash-es 内联 `var Symbol` 声明
  因变量提升遮蔽全局 Symbol 导致运行时报错的问题

Made-with: Cursor
2026-04-17 15:20:28 +08:00
roymondchen
62a488ac66 chore: update lockfile v1.7.10 2026-04-13 20:39:20 +08:00
roymondchen
27bb886054 chore: release v1.7.10 2026-04-13 20:30:30 +08:00
roymondchen
fa09ab0b30 feat(editor): 样式配置添加变形项 2026-04-13 20:04:23 +08:00
roymondchen
31f4d2b4e2 fix(editor): 数据源方法选择器展示所有数据源并支持字段非叶子节点选择
移除 methodsOptions 中过滤无自定义方法数据源的逻辑,因为所有数据源都有内置"设置数据"方法;字段选择增加 checkStrictly 支持选择非叶子节点

Made-with: Cursor
2026-04-09 15:32:35 +08:00
roymondchen
b2888962df style(schema,editor,data-source): 代码块类型定义中content去掉string类型 2026-04-09 15:28:03 +08:00
roymondchen
b3f4e42716 feat(stage): 支持将指定id的dom生成图片 2026-04-09 15:05:41 +08:00
roymondchen
6e07d5762b chore(eslint-config): release 0.1.0 2026-04-09 12:15:05 +08:00
roymondchen
cfd5998242 fix(stage): 新增组件后等待渲染完后选中 2026-04-09 12:03:36 +08:00
roymondchen
26dc70d70c fix(editor): 历史记录信息中添加页面信息 2026-04-08 17:19:15 +08:00
roymondchen
99c8274a1e fix(editor): 修复 getTMagicAppPrimise 变量名拼写错误
Made-with: Cursor
2026-04-07 20:30:39 +08:00
roymondchen
334569e2d7 feat(editor): 添加 stage beforeDblclick 钩子,支持拦截默认双击行为
在 StageOptions 和 EditorProps 中新增 beforeDblclick 配置项,
该函数返回 false 或 Promise<false> 时将阻止 stage 默认的双击处理逻辑。

Made-with: Cursor
2026-04-07 19:19:00 +08:00
roymondchen
f583c7daec feat(editor,data-source): 数据源支持内置"设置数据"方法
支持通过事件调用数据源的 setData 方法,可以选择数据源字段并根据字段类型动态设置数据;
重构 CodeParams 参数配置支持动态类型; DataSourceFieldSelect 支持指定数据源ID;
常量抽取到 utils/const.ts

Made-with: Cursor
2026-04-07 18:25:35 +08:00
roymondchen
172a7a1c92 feat(editor,stage): 支持双击穿透选中鼠标下方的下一个可选中元素
将 dblclick 处理统一到 Stage.vue,新增 ActionManager.getNextElementFromPoint
方法跳过最上层元素返回下方第二个可选中元素,双击时若无特殊处理则穿透选中下方组件。

Made-with: Cursor
2026-04-07 18:25:35 +08:00
roymondchen
73c676931f build: 更新ts@10 2026-04-07 18:25:35 +08:00
roymondchen
df2d635682 fix(editor): 优化 StageOverlay 双击行为,仅在元素被滚动容器裁剪时打开 overlay
双击页面片容器时直接选中对应页面片;新增 isClippedByScrollContainer
判断元素是否被非页面级滚动容器裁剪,避免不必要的 overlay 弹出。

Made-with: Cursor
2026-04-07 18:25:35 +08:00
roymondchen
0c2f2fd2b5 refactor(editor): 拆分 editor service,提取工具函数减少文件行数
将 services/editor.ts 从 1335 行精简到 1075 行,提取以下内容:

- 新增 utils/editor-history.ts:历史操作处理函数(add/remove/update)
- utils/editor.ts 新增:resolveSelectedNode、toggleFixedPosition、
  calcMoveStyle、calcAlignCenterStyle、calcLayerTargetIndex、
  editorNodeMergeCustomizer、collectRelatedNodes、classifyDragSources
- type.ts 新增:EditorEvents、canUsePluginMethods、AsyncMethodName
- 补充完整的单元测试覆盖所有新增工具函数

Made-with: Cursor
2026-04-07 18:25:35 +08:00
oceanzhu
a7274198bf docs: add AGENTS.md for AI navigation 2026-04-07 10:13:37 +08:00
Linzsong
6f2e8d8d74 fix(stage): 修复隐藏标尺后无法显示问题 2026-03-27 15:35:01 +08:00
roymondchen
637a5bb69a refactor(editor): 历史记录改成记录操作而不是记录副本 2026-03-27 15:27:41 +08:00
roymondchen
42e7ac1b2e chore: update lockfile v1.7.9 2026-03-23 15:32:49 +08:00
roymondchen
a3cdad9d91 chore: release v1.7.9 2026-03-23 15:31:42 +08:00
roymondchen
711af79d72 fix(form): row容器中如果配置没有type显示异常 2026-03-23 15:23:41 +08:00
roymondchen
728fbc035c chore: update lockfile v1.7.8 2026-03-20 19:44:45 +08:00
roymondchen
01795455e9 chore: release v1.7.8 2026-03-20 19:43:37 +08:00
roymondchen
9b56223359 fix(editor): 组件配置样式显示出错 2026-03-20 19:41:18 +08:00
roymondchen
984cea7ca3 chore: update lockfile v1.7.8-beta.4 2026-03-20 18:54:57 +08:00
roymondchen
9921ed8a2d chore: release v1.7.8-beta.4 2026-03-20 18:53:49 +08:00
roymondchen
e36d8d7cf8 fix(form-schema): 表单 schema 中 display 与 component 部分字段改为可选
Made-with: Cursor
2026-03-20 18:45:19 +08:00
roymondchen
35aa81514b chore: update lockfile v1.7.8-beta.3 2026-03-20 17:41:38 +08:00
roymondchen
450376872e chore: release v1.7.8-beta.3 2026-03-20 17:40:26 +08:00
roymondchen
e8714c96c9 feat(form-schema,form,editor,table): 完善表单配置类型 2026-03-20 17:38:11 +08:00
roymondchen
feefd3779e chore: update lockfile v1.7.8-beta.2 2026-03-20 12:36:12 +08:00
roymondchen
1ae023db8c chore: release v1.7.8-beta.2 2026-03-20 12:34:50 +08:00
roymondchen
55eb546ad6 feat(form-schema,form,editor): 完善表单配置类型 2026-03-20 12:31:55 +08:00
roymondchen
1664559d8f refactor(dep): 优化性能 2026-03-19 16:02:41 +08:00
roymondchen
a34d0cdccc build: 构建的类型文件中别名没有消除 2026-03-19 15:53:01 +08:00
roymondchen
210ac436fc chore: update lockfile v1.7.8-beta.1 2026-03-19 12:10:29 +08:00
roymondchen
64c8ed15ab chore: release v1.7.8-beta.1 2026-03-19 12:09:15 +08:00
roymondchen
bada79e519 fix: GitHub Pages 默认使用 Jekyll 处理站点文件,而 Jekyll 会忽略所有以下划线开头的文件和目录 2026-03-19 12:00:50 +08:00
roymondchen
06a6068c47 build: 优化type check性能 2026-03-19 11:47:08 +08:00
roymondchen
58281a345b chore(playground): 删除多样代码 2026-03-19 11:36:11 +08:00
roymondchen
0bbafa153d fix(core,data-source): 多个页面片容器引用同一个页面片时,其他有未渲染的页面片容器时会导致数据源编译后数据无法更新 2026-03-19 11:34:51 +08:00
roymondchen
f6bd647958 test(editor): 更新monaco-editor依赖 2026-03-18 20:27:09 +08:00
roymondchen
c79034befc feat(editor,form): 支持按需设置表单组件 2026-03-18 20:19:05 +08:00
roymondchen
88e6c7d377 build: es产物不要合并文件,保证能够tree-shaking 2026-03-18 19:19:29 +08:00
moonszhang
92bd5cf942 feat(core): runDataSourceMethod 返回 await 方法的执行结果 2026-03-18 09:15:01 +00:00
roymondchen
5ae667b7ee chore: update eslint 10 2026-03-17 20:03:45 +08:00
roymondchen
18bfbefaf2 chore: 更新版权协议 2026-03-17 17:31:43 +08:00
roymondchen
1b9492165c build: playground构建忽略lightningcss错误 2026-03-17 17:30:26 +08:00
roymondchen
61f00a0fb7 chore(editor): 完善类型检验 2026-03-17 16:57:28 +08:00
roymondchen
6d91a7a844 chore: 更新vite 2026-03-17 16:52:46 +08:00
roymondchen
3e4d49dd45 chore: update pnpm 2026-03-17 15:52:53 +08:00
129 changed files with 6862 additions and 5179 deletions

View File

@ -41,6 +41,9 @@ jobs:
- name: move to dist
run: mv docs/.vitepress/dist/* dist/docs && mv playground/dist/* dist/playground
- name: Bypass Jekyll on GitHub Pages
run: touch dist/.nojekyll
- name: Deploy to GitHub Pages
uses: crazy-max/ghaction-github-pages@v2
with:

56
AGENTS.md Normal file
View File

@ -0,0 +1,56 @@
# AGENTS.md — TMagic编辑器
> 魔方平台可视化编辑器核心,提供拖拽式活动页面编辑能力。
> 负责人roymondchen | 创建2026-04-03
## 项目概述
TMagic Editor 是魔方平台的可视化编辑器核心库,提供拖拽式组件编辑、配置面板、预览发布等能力。支持 Vue 和 React 双框架 Runtime采用 pnpm monorepo 管理多个核心包。开源项目,同时支持内部业务定制。
**技术栈:** Vue 3, Element Plus, TypeScript, Vite, vitest, VitePress
**主仓库:** `https://git.woa.com/vft-magic/tmagic-editor.git`
**开源仓库:** `https://github.com/Tencent/tmagic-editor.git`
## 架构地图
关键目录:
- `packages/` — 核心编辑器包202 *.vue, 194 *.ts
- `runtime/` — Vue/React Runtime 实现
- `vue-components/` — Vue 组件封装
- `react-components/` — React 组件封装
- `playground/` — 演示 playground
- `docs/` — VitePress 文档100 *.md
- `scripts/` — 构建和发布脚本
- `eslint-config/` — 共享 ESLint 配置
## 开发约定
**分支策略:** dev=dev, test/prod=master
**提交规范:** commitlint + husky`type: 描述`
**禁止事项:**
- 禁止在核心包中引入腾讯内部专有依赖(开源项目)
- 禁止直接修改 CHANGELOG.md应通过 `pnpm changelog` 生成
## 常用命令
pnpm bootstrap # 安装依赖并构建
pnpm pg # 启动 Vue playground
pnpm pg:react # 启动 React playground
pnpm build # 完整构建DTS + 包)
pnpm test # 运行测试
pnpm lint-fix # ESLint 修复
pnpm docs:dev # 启动文档开发
pnpm release # 发版
## 当前状态
**当前里程碑:** {待人工填写}
## 深入阅读
| 文档 | 说明 |
|------|------|
| docs/ | VitePress 文档站 |
| CONTRIBUTING.md | 贡献指南 |
| CHANGELOG.md | 变更日志 |

File diff suppressed because it is too large Load Diff

477
LICENSE
View File

@ -74,15 +74,6 @@ Open Source Software Licensed under the Apache License Version 2.0:
1. typescript
Copyright (c) Microsoft Corporation. All rights reserved.
2. log4js
Copyright 2015 Gareth Jones (with contributions from many other people)
3. reflect-metadata
Copyright (c) Microsoft Corporation. All rights reserved.
4. xlsx
Copyright (C) 2013-present SheetJS
Terms of the Apache License Version 2.0:
--------------------------------------------------------------------
@ -355,8 +346,8 @@ Open Source Software Licensed under the BSD 2-Clause License:
1. @typescript-eslint/parser
Copyright JS Foundation and other contributors, https://js.foundation
2. uglify-js
Copyright 2012-2019 (c) Mihai Bazon <mihai.bazon@gmail.com>
2. terser
Copyright 2012-2018 (c) Mihai Bazon <mihai.bazon@gmail.com>
Terms of the BSD 2-Clause License:
@ -390,8 +381,8 @@ Open Source Software Licensed under the BSD 3-Clause License:
Copyright 2014 Yahoo! Inc.
All rights reserved.
2. serialize-javascript
Copyright 2014 Yahoo! Inc.
2. highlight.js
Copyright (c) 2006, Ivan Sagalaev.
All rights reserved.
@ -411,11 +402,14 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
Open Source Software Licensed under the ISC License:
--------------------------------------------------------------------
1. raiz
Copyright raiz original authour and authors
1. c8
Copyright (c) 2017, Contributors
2. axios-jsonp
Copyright (c) Adonis
2. picocolors
Copyright (c) 2021 Alexey Raspopov, Kostiantyn Denysov, Anton Verinov
3. semver
Copyright (c) Isaac Z. Schlueter and Contributors
Terms of the ISC License:
@ -428,232 +422,239 @@ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH RE
Open Source Software Licensed under the MIT License:
--------------------------------------------------------------------
1. events
Copyright Joyent, Inc. and other Node contributors.
2. vite-plugin-dts
Copyright (c) 2021-present qmhc
3. color
Copyright (c) 2012 Heather Arthur
4. element-plus
Copyright element-plus original authour and authors
5. @types/node
Copyright (c) Microsoft TypeScript, DefinitelyTyped, Alberto Schiabel, Alvis HT Tang, Andrew Makarov, Benjamin Toueg, Chigozirim C., David Junger, Deividas Bakanas, Eugene Y. Q. Shen, Hannes Magnusson, Hoàng Văn Khải, Huw, Kelvin Jin, Klaus Meinhardt, Lishude, Mariusz Wiktorczyk, Mohsen Azimi, Nicolas Even, Nikita Galkin, Parambir Singh, Sebastian Silbermann, Simon Schick, Thomas den Hollander, Wilco Bakker, wwwy3y3, Zane Hannan AU, Samuel Ainsworth, Kyle Uehlein, Thanik Bhongbhibhat, Marcin Kopacz, Trivikram Kamat, Junxiao Shi, Ilia Baryshnikov, and ExE Boss.
6. vue
Copyright (c) 2018-present, Yuxi (Evan) You
7. @vitejs/plugin-vue
Copyright (c) 2019-present, Yuxi (Evan) You and contributors
8. @vitejs/plugin-vue-jsx
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
9. @vue/compiler-sfc
Copyright (c) 2018-present, Yuxi (Evan) You
10. @vue/test-utils
Copyright (c) 2021-present vuejs
11. sass
Copyright (c) 2016, Google Inc.
12. vue-tsc
Copyright (c) 2021-present Johnson Chu
13. moment
Copyright (c) JS Foundation and other contributors
14. sortablejs
Copyright (c) 2019 All contributors to Sortable
15. @scena/guides
Copyright (c) 2019 Daybrush
16. moveable
Copyright (c) 2019 Daybrush
17. delegate
Copyright (c) Zeno Rocha
18. tiny-emitter
Copyright (c) 2017 Scott Corgan
19. @testing-library/vue
Copyright (c) 2018 Daniel Cook
Copyright (c) 2017 Kent C. Dodds
20. react
Copyright (c) Facebook, Inc. and its affiliates.
21. react-dom
Copyright (c) Facebook, Inc. and its affiliates.
22. vue
Copyright (c) 2013-present, Yuxi (Evan) You
23. @vue/composition-api
Copyright (c) 2019-present, liximomo(X.L)
24. vite-plugin-vue2
Copyright © underfin
25. vue-template-compiler
Copyright (c)-present, Yuxi (Evan) You
26. rollup-plugin-external-globals
Copyright (c) 2018 eight
27. recast
Copyright (c) 2012 Ben Newman <bn@cs.stanford.edu>
28. @babel/preset-env
Copyright (c) 2014-present Sebastian McKenzie and other contributors
29. @vitejs/plugin-react-refresh
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
30. @commitlint/cli
1. @commitlint/cli
Copyright (c) 2016 - present Mario Nebl
31. @commitlint/config-conventional
2. @commitlint/config-conventional
Copyright (c) 2016 - present Mario Nebl
32. @typescript-eslint/eslint-plugin
3. @element-plus/icons-vue
Copyright (c) element-plus contributors
4. @eslint/js
Copyright OpenJS Foundation and other contributors
5. @popperjs/core
Copyright (c) 2019 Federico Zivolo
6. @scena/guides
Copyright (c) 2019 Daybrush
7. @stylistic/eslint-plugin
Copyright (c) Anthony Fu
8. @types/events
Copyright (c) Microsoft Corporation.
9. @types/fs-extra
Copyright (c) Microsoft Corporation.
10. @types/lodash-es
Copyright (c) Microsoft Corporation.
11. @types/node
Copyright (c) Microsoft Corporation.
12. @types/qrcode
Copyright (c) Microsoft Corporation.
13. @types/react
Copyright (c) Microsoft Corporation.
14. @types/react-dom
Copyright (c) Microsoft Corporation.
15. @types/serialize-javascript
Copyright (c) Microsoft Corporation.
16. @types/sortablejs
Copyright (c) Microsoft Corporation.
17. @typescript-eslint/eslint-plugin
Copyright (c) 2019 TypeScript ESLint and other contributors
33. @vue/cli-plugin-babel
Copyright (c) 2017-present, Yuxi (Evan) You
18. @vitejs/plugin-legacy
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
34. @vue/cli-plugin-unit-jest
Copyright (c) 2017-present, Yuxi (Evan) You
19. @vitejs/plugin-react-refresh
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
35. babel-eslint
Copyright (c) 2014-2016 Sebastian McKenzie <sebmck@gmail.com>
20. @vitejs/plugin-vue
Copyright (c) 2019-present, Yuxi (Evan) You and contributors
36. cz-conventional-changelog
Copyright (c) 2015-2018 Commitizen Contributors
21. @vitejs/plugin-vue-jsx
Copyright (c) 2019-present, Yuxi (Evan) You and Vite contributors
37. eslint
Copyright JS Foundation and other contributors, https://js.foundation
22. @vitest/coverage-v8
Copyright (c) 2021-present, Anthony Fu and Vitest contributors
38. eslint-plugin-import
Copyright (c) 2015 Ben Mosher
23. @vue/compiler-sfc
Copyright (c) 2018-present, Yuxi (Evan) You
39. eslint-plugin-prettier
Copyright © 2017 Andres Suarez and Teddy Katz
24. @vue/test-utils
Copyright (c) 2021-present vuejs
40. eslint-plugin-simple-import-sort
Copyright (c) 2018, 2019, 2020 Simon Lydell
41. eslint-plugin-vue
Copyright (c) 2017 Toru Nagashima
42. husky
Copyright (c) 2021 typicode
43. lerna
Copyright (c) 2015-present Lerna Contributors
44. lint-staged
Copyright (c) 2016 Andrey Okonetchnikov
45. prettier
Copyright © James Long and contributors
46. vue-jest
Copyright (c) 2017 Edd Yerburgh
47. axios
25. axios
Copyright (c) 2014-present Matt Zabriskie
48. core-js
Copyright (c) 2014-2021 Denis Pushkarev
26. buffer
Copyright (c) Feross Aboukhadijeh, and other contributors
49. js-cookie
Copyright (c) 2018 Copyright 2018 Klaus Hartl, Fagner Brack, GitHub Contributors
27. cac
Copyright (c) egoist <0x142857@gmail.com>
50. vue-router
Copyright (c) 2020 Eduardo San Martin Morote
28. chokidar
Copyright (c) Paul Miller (https://paulmillr.com)
51. koa
Copyright (c) 2019 Koa contributors
29. commitizen
Copyright (c) 2015 Jim Cummins
52. koa-bodyparser
Copyright (c) 2014 dead_horse
30. conventional-changelog-cli
Copyright (c) Steve Mao
53. koa-router
Copyright (c) 2015 Alexander C. Mingoia
31. cosmiconfig
Copyright (c) 2015 David Clark
54. koa-send
Copyright (c) 2020 Koa contributors
32. cz-conventional-changelog
Copyright (c) 2015-2018 Commitizen Contributors
55. module-alias
Copyright (c) 2018, Nick Gavrilov
33. dayjs
Copyright (c) 2018-present, iamkun
56. mysql2
Copyright (c) 2016 Andrey Sidorov (sidorares@yandex.ru) and contributors
34. deep-object-diff
Copyright (c) 2017 Matt Phillips
57. sequelize
Copyright (c) 2014-present Sequelize contributors
35. deep-state-observer
Copyright (c) neuronet.io
58. sequelize-typescript
Copyright (c) 2017 Robin Buschmann
36. element-plus
Copyright element-plus original authour and authors
59. lodash
Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
37. emmet-monaco-es
Copyright (c) Troy
60. jest
Copyright (c) Facebook, Inc. and its affiliates.
38. enquirer
Copyright (c) 2016-present, Jon Schlinkert
61. fs-extra
39. esbuild
Copyright (c) 2020 Evan Wallace
40. eslint
Copyright JS Foundation and other contributors, https://js.foundation
41. eslint-config-prettier
Copyright (c) 2017 Simon Lydell
42. eslint-plugin-import
Copyright (c) 2015 Ben Mosher
43. eslint-plugin-prettier
Copyright © 2017 Andres Suarez and Teddy Katz
44. eslint-plugin-simple-import-sort
Copyright (c) 2018, 2019, 2020 Simon Lydell
45. eslint-plugin-vue
Copyright (c) 2017 Toru Nagashima
46. events
Copyright Joyent, Inc. and other Node contributors.
47. execa
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com>
48. fs-extra
Copyright (c) 2011-2017 JP Richardson
62. moment-timezone
Copyright (c) JS Foundation and other contributors
49. gesto
Copyright (c) 2019 Daybrush
63. nodemon
Copyright (C) Remy Sharp
50. globals
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com>
64. ts-node
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
51. husky
Copyright (c) 2021 typicode
65. tsconfig-paths
Copyright (c) 2016 Jonas Kello
52. jsdom
Copyright (c) 2010 Elijah Insua
66. prettier
Copyright © James Long and contributors
53. keycon
Copyright (c) 2019 Daybrush
67. @babel/core
Copyright (c) 2014-present Sebastian McKenzie and other contributors
54. lint-staged
Copyright (c) 2016 Andrey Okonetchnikov
68. @babel/preset-typescript
Copyright (c) 2014-present Sebastian McKenzie and other contributors
55. merge-options
Copyright (c) Michael Mayer
69. @types/fs-extra
Copyright (c) Microsoft Corporation.
56. minimist
Copyright (c) James Halliday
70. @types/jest
Copyright (c) Microsoft Corporation.
71. @types/koa
Copyright (C) DavidCai1993, jKey Lu, Brice Bernard, harryparkdotio, Wooram Jun, Christian Vaagland Tellnes, Piotr Kuczynski, and vnoder.
72. @types/koa-bodyparser
Copyright (C) Jerry Chin, Anup Kishore, Hiroshi Ioka, Alexi Maschas, and Pirasis Leelatanon.
73. zepto
Copyright (c) 2010-2016 Thomas Fuchs
http://zeptojs.com/
74. monaco-editor
57. monaco-editor
Copyright (c) 2016 - present Microsoft Corporation
75. @types/koa-router
Copyright (C) Jerry Chin, Pavel Ivanov, JounQin, Romain Faust, Guillaume Mayer, Andrea Gueugnaut, and Yves Kaufmann.
58. moveable
Copyright (c) 2019 Daybrush
59. moveable-helper
Copyright (c) 2019 Daybrush
60. prettier
Copyright © James Long and contributors
61. qrcode
Copyright (c) Ryan Day
62. react
Copyright (c) Facebook, Inc. and its affiliates.
63. react-dom
Copyright (c) Facebook, Inc. and its affiliates.
64. recast
Copyright (c) 2012 Ben Newman <bn@cs.stanford.edu>
65. rolldown
Copyright (c) 2023-present Rolldown contributors
66. rolldown-plugin-dts
Copyright (c) Kevin Deng
67. sass-embedded
Copyright (c) 2019 Google Inc.
68. scenejs
Copyright (c) 2019 Daybrush
69. shx
Copyright (c) ShellJS contributors
70. sortablejs
Copyright (c) 2019 All contributors to Sortable
71. tdesign-vue-next
Copyright (c) Tencent
72. typescript-eslint
Copyright (c) 2019 TypeScript ESLint and other contributors
73. vite-plugin-commonjs
Copyright (c) vite-plugin contributors
74. vitepress
Copyright (c) 2019-present, Yuxi (Evan) You
75. vitest
Copyright (c) 2021-present, Anthony Fu and Vitest contributors
76. vue
Copyright (c) 2018-present, Yuxi (Evan) You
77. vue-router
Copyright (c) 2020 Eduardo San Martin Morote
78. vue-tsc
Copyright (c) 2021-present Johnson Chu
Terms of the MIT License:
@ -5430,3 +5431,73 @@ Repository: github:eemeli/yaml
> TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
> THIS SOFTWARE.
Open Source Software Licensed under the Zero-Clause BSD License (0BSD):
--------------------------------------------------------------------
1. tslib
Copyright (c) Microsoft Corp.
Terms of the Zero-Clause BSD License (0BSD):
--------------------------------------------------------------------
Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
Open Source Software Licensed under the Blue Oak Model License 1.0.0:
--------------------------------------------------------------------
1. rimraf
Copyright (c) Isaac Z. Schlueter and Contributors
Terms of the Blue Oak Model License 1.0.0:
--------------------------------------------------------------------
Blue Oak Model License
Version 1.0.0
Purpose
This license gives everyone as much permission to work with this software as possible, while protecting contributors from liability.
Acceptance
In order to receive this license, you must agree to its rules. The rules of this license are both obligations under that agreement and conditions to your license. You must not do anything with this software that triggers a rule that you cannot or will not follow.
Copyright
Each contributor licenses you to do everything with this software that would otherwise infringe that contributor's copyright in it.
Notices
You must ensure that everyone who gets a copy of any part of this software from you, with or without changes, also gets the text of this license or a link to https://blueoakcouncil.org/license/1.0.0.
Excuse
If anyone notifies you in writing that you have not complied with Notices, you can keep your license by taking all practical steps to comply within 30 days after the notice. If you do not do so, your license ends immediately.
Patent
Each contributor licenses you to do everything with this software that would otherwise infringe any patent claims they can license or become able to license.
Reliability
No contributor can revoke this license.
No Liability
As far as the law allows, this software comes as is, without any warranty or condition, and no contributor will be liable to anyone for any damages related to this software or this license, under any kind of legal claim.
Open Source Software Licensed under the (MIT OR CC0-1.0) License:
--------------------------------------------------------------------
1. type-fest
Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
A copy of the MIT License is included in this file.

View File

@ -552,9 +552,11 @@ export default defineConfig({
vite: {
optimizeDeps: {
esbuildOptions: {
define: {
global: 'globalThis',
rolldownOptions: {
transform: {
define: {
global: 'globalThis',
},
},
},
},

View File

@ -189,6 +189,8 @@
import hljs from 'highlight.js';
import serialize from 'serialize-javascript';
import { MForm } from '@tmagic/form';
export function stripScript(content) {
const result = content.match(/<(script)>([\s\S]+)<\/\1>/);
return result && result[2] ? result[2].trim() : '';
@ -210,6 +212,10 @@ export function stripTemplate(content) {
export default {
props: ['type', 'config'],
components: {
MForm,
},
data() {
return {
codepen: {

View File

@ -1,6 +1,6 @@
{
"name": "@tmagic/eslint-config",
"version": "0.0.3",
"version": "0.1.0",
"main": "index.mjs",
"type": "module",
"repository": {
@ -9,20 +9,21 @@
"url": "https://github.com/Tencent/tmagic-editor.git"
},
"dependencies": {
"@eslint/js": "^9.34.0",
"@typescript-eslint/parser": "^8.41.0",
"@typescript-eslint/eslint-plugin": "^8.41.0 ",
"@stylistic/eslint-plugin": "^5.2.3",
"@eslint/js": "^10.0.1",
"@typescript-eslint/parser": "^8.58.0",
"@typescript-eslint/eslint-plugin": "^8.58.0",
"@stylistic/eslint-plugin": "^5.10.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-vue": "^10.4.0",
"eslint-plugin-prettier": "^5.5.4 ",
"globals": "^16.3.0",
"typescript-eslint": "^8.41.0"
"eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-vue": "^10.8.0",
"vue-eslint-parser": "^10.4.0",
"eslint-plugin-prettier": "^5.5.5",
"globals": "^17.4.0",
"typescript-eslint": "^8.58.0"
},
"peerDependencies": {
"eslint": ">=9.24.0",
"prettier": ">=3.5.3"
"eslint": ">=10.0.0",
"prettier": ">=3.8.0"
}
}

View File

@ -16,6 +16,7 @@ export default defineConfig([
'*/**/public/**/*',
'*/**/types/**/*',
'*/**/*.config.ts',
'./tepm/**/*',
'vite-env.d.ts',
]),
...eslintConfig(path.join(path.dirname(fileURLToPath(import.meta.url)), 'tsconfig.json')),

View File

@ -1,9 +1,9 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "tmagic",
"private": true,
"type": "module",
"packageManager": "pnpm@10.18.2",
"packageManager": "pnpm@10.32.1",
"scripts": {
"bootstrap": "pnpm i && pnpm build",
"clean:top": "rimraf */**/dist */**/types */dist coverage dwt* temp packages/cli/lib",
@ -16,8 +16,8 @@
"playground:react": "pnpm --filter \"runtime-react\" build:libs && pnpm --filter \"runtime-react\" --filter \"tmagic-playground\" dev:react",
"pg:react": "pnpm playground:react",
"build": "pnpm build:dts && node scripts/build.mjs",
"build:dts": "pnpm --filter \"@tmagic/cli\" build && tsc -p tsconfig.build-browser.json && vue-tsc --declaration --emitDeclarationOnly --project tsconfig.build-vue.json && rollup -c rollup.dts.config.js && rimraf temp",
"check:type": "tsc --incremental --noEmit -p tsconfig.check.json && vue-tsc --noEmit -p tsconfig.check-vue.json",
"build:dts": "pnpm --filter \"@tmagic/cli\" build && tsc -p tsconfig.build-browser.json && vue-tsc --declaration --emitDeclarationOnly --project tsconfig.build-vue.json && rolldown -c rolldown.dts.config.mjs && rimraf temp",
"check:type": "node scripts/check-type.mjs",
"build:playground": "pnpm --filter \"runtime-vue\" build && pnpm --filter \"tmagic-playground\" build",
"docs:dev": "vitepress dev docs",
"docs:serve": "vitepress serve docs",
@ -42,11 +42,10 @@
"devDependencies": {
"@commitlint/cli": "^20.1.0",
"@commitlint/config-conventional": "^20.0.0",
"@rollup/plugin-alias": "^6.0.0",
"@tmagic/eslint-config": "workspace:*",
"@types/node": "24.0.10",
"@vitejs/plugin-vue": "^6.0.2",
"@vitest/coverage-v8": "^4.0.12",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/coverage-v8": "^4.1.0",
"@vue/compiler-sfc": "catalog:",
"c8": "^10.1.3",
"commitizen": "^4.3.1",
@ -55,7 +54,7 @@
"cz-conventional-changelog": "^3.3.0",
"element-plus": "^2.11.8",
"enquirer": "^2.4.1",
"eslint": "^9.39.1",
"eslint": "^10.0.3",
"execa": "^9.6.0",
"highlight.js": "^11.11.1",
"husky": "^9.1.7",
@ -63,11 +62,11 @@
"lint-staged": "^16.2.7",
"minimist": "^1.2.8",
"picocolors": "^1.1.1",
"prettier": "^3.6.2",
"prettier": "^3.8.1",
"recast": "^0.23.11",
"rimraf": "^3.0.2",
"rollup": "4.44.1",
"rollup-plugin-dts": "^6.2.3",
"rolldown": "^1.0.0-rc.9",
"rolldown-plugin-dts": "^0.22.5",
"sass-embedded": "^1.93.3",
"semver": "^7.7.3",
"serialize-javascript": "^7.0.0",
@ -75,9 +74,9 @@
"typescript": "catalog:",
"vite": "catalog:",
"vitepress": "^1.6.4",
"vitest": "^4.0.12",
"vitest": "^4.1.0",
"vue": "catalog:",
"vue-tsc": "^3.1.4"
"vue-tsc": "^3.2.6"
},
"config": {
"commitizen": {

View File

@ -1,5 +1,5 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/cli",
"main": "lib/index.js",
"types": "lib/index.d.ts",

View File

@ -2,8 +2,8 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"moduleResolution": "Node",
"module": "CommonJS",
"moduleResolution": "node16",
"module": "node16",
"rootDir": "./src",
"outDir": "./lib",
"declaration": true,

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/core",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-core.umd.cjs",
"module": "dist/tmagic-core.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-core.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-core.umd.cjs"
},
"./resetcss.css": {

View File

@ -293,9 +293,11 @@ class App extends EventEmitter {
const method = methods.find((item) => item.name === methodName);
if (method && typeof method.content === 'function') {
await method.content({ app: this, params, dataSource, eventParams: args, flowState, node });
} else if (typeof dataSource[methodName] === 'function') {
await dataSource[methodName]();
return await method.content({ app: this, params, dataSource, eventParams: args, flowState, node });
}
if (typeof dataSource[methodName] === 'function') {
return await dataSource[methodName]({ params });
}
} catch (e: any) {
if (this.errorHandler) {

View File

@ -16,6 +16,8 @@
* limitations under the License.
*/
import { cloneDeep } from 'lodash-es';
import type { Id, MComponent, MContainer, MPage, MPageFragment } from '@tmagic/schema';
import App from './App';
@ -70,7 +72,7 @@ class Page extends Node {
this.app.pageFragments.set(
config.id,
new Page({
config: pageFragment,
config: cloneDeep(pageFragment),
app: this.app,
}),
);

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/data-source",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-data-source.umd.cjs",
"module": "dist/tmagic-data-source.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-data-source.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-data-source.umd.cjs"
},
"./*": "./*"

View File

@ -15,7 +15,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { union } from 'lodash-es';
import { cloneDeep, union } from 'lodash-es';
import type { default as TMagicApp } from '@tmagic/core';
import { getDepNodeIds, getNodes, isPage, isPageFragment, replaceChildNode } from '@tmagic/core';
@ -82,11 +82,11 @@ export const createDataSourceManager = (app: TMagicApp, useMock?: boolean, initi
for (const [, pageFragment] of app.pageFragments) {
if (pageFragment.data.id === newNode.id) {
pageFragment.setData(newNode);
pageFragment.setData(cloneDeep(newNode));
} else if (pageFragment.data.id === page.id) {
pageFragment.getNode(newNode.id, { strict: true })?.setData(newNode);
pageFragment.getNode(newNode.id, { strict: true })?.setData(cloneDeep(newNode));
if (!pageFragment.instance) {
replaceChildNode(newNode, [pageFragment.data]);
replaceChildNode(cloneDeep(newNode), [pageFragment.data]);
}
}
}

View File

@ -20,7 +20,7 @@ import EventEmitter from 'events';
import { cloneDeep } from 'lodash-es';
import type { CodeBlockContent, DataSchema, DataSourceSchema, default as TMagicApp } from '@tmagic/core';
import { getDefaultValueFromFields } from '@tmagic/core';
import { DATA_SOURCE_SET_DATA_METHOD_NAME, getDefaultValueFromFields } from '@tmagic/core';
import { ObservedData } from '@data-source/observed-data/ObservedData';
import { SimpleObservedData } from '@data-source/observed-data/SimpleObservedData';
@ -51,6 +51,7 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
super();
this.#id = options.schema.id;
this.#type = options.schema.type;
this.#schema = options.schema;
this.app = options.app;
@ -58,6 +59,11 @@ export default class DataSource<T extends DataSourceSchema = DataSourceSchema> e
this.setFields(options.schema.fields);
this.setMethods(options.schema.methods || []);
// @ts-ignore
this[DATA_SOURCE_SET_DATA_METHOD_NAME] = ({ params }: { params: { field?: string[]; data: any } }) => {
this.setData(params.data, params.field?.join('.'));
};
let data = options.initialData;
// eslint-disable-next-line @typescript-eslint/naming-convention
const ObservedDataClass = options.ObservedDataClass || SimpleObservedData;

View File

@ -79,9 +79,9 @@ export default class HttpDataSource extends DataSource<HttpDataSourceSchema> {
/** 请求函数 */
#fetch?: RequestFunction;
/** 请求前需要执行的函数队列 */
#beforeRequest: ((...args: any[]) => any)[] = [];
#beforeRequest: (Function | ((...args: any[]) => any))[] = [];
/** 请求后需要执行的函数队列 */
#afterRequest: ((...args: any[]) => any)[] = [];
#afterRequest: (Function | ((...args: any[]) => any))[] = [];
#type = 'http';

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/dep",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-dep.umd.cjs",
"module": "dist/tmagic-dep.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-dep.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-dep.umd.cjs"
},
"./*": "./*"

View File

@ -71,7 +71,7 @@ export default class Target {
this.deps[id] = dep;
if (dep.keys.indexOf(key) === -1) {
if (!dep.keys.includes(key)) {
dep.keys.push(key);
}
}
@ -115,7 +115,7 @@ export default class Target {
public hasDep(id: string | number, key: string | number) {
const dep = this.deps[id];
return Boolean(dep?.keys.find((d) => d === key));
return dep?.keys.includes(key) ?? false;
}
public destroy() {

View File

@ -5,6 +5,12 @@ import type Target from './Target';
import { type DepExtendedData, DepTargetType, type TargetList, TargetNode } from './types';
import { traverseTarget } from './utils';
const DATA_SOURCE_TARGET_TYPES: Set<string> = new Set([
DepTargetType.DATA_SOURCE,
DepTargetType.DATA_SOURCE_COND,
DepTargetType.DATA_SOURCE_METHOD,
]);
export default class Watcher {
private targetsList: TargetList = {};
private childrenProp = 'items';
@ -159,7 +165,7 @@ export default class Watcher {
};
}
const clearedItemsNodeIds: (string | number)[] = [];
const clearedItemsNodeIds = new Set<string | number>();
traverseTarget(targetsList, (target) => {
if (nodes) {
for (const node of nodes) {
@ -168,9 +174,9 @@ export default class Watcher {
if (
Array.isArray(node[this.childrenProp]) &&
node[this.childrenProp].length &&
!clearedItemsNodeIds.includes(node[this.idProp])
!clearedItemsNodeIds.has(node[this.idProp])
) {
clearedItemsNodeIds.push(node[this.idProp]);
clearedItemsNodeIds.add(node[this.idProp]);
this.clear(node[this.childrenProp]);
}
}
@ -190,12 +196,7 @@ export default class Watcher {
}
public collectItem(node: TargetNode, target: Target, depExtendedData: DepExtendedData = {}, deep = false) {
const dataSourceTargetTypes: string[] = [
DepTargetType.DATA_SOURCE,
DepTargetType.DATA_SOURCE_COND,
DepTargetType.DATA_SOURCE_METHOD,
];
if (node[NODE_DISABLE_DATA_SOURCE_KEY] && dataSourceTargetTypes.includes(target.type)) {
if (node[NODE_DISABLE_DATA_SOURCE_KEY] && DATA_SOURCE_TARGET_TYPES.has(target.type)) {
return;
}

View File

@ -18,6 +18,8 @@ import {
import Target from './Target';
import { DepTargetType, type TargetList } from './types';
const INTEGER_REGEXP = /^\d+$/;
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
new Target({
type: DepTargetType.CODE_BLOCK,
@ -30,8 +32,7 @@ export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initi
}
if (value?.hookType === HookType.CODE && Array.isArray(value.hookData)) {
const index = value.hookData.findIndex((item: HookData) => item.codeId === id);
return Boolean(index > -1);
return value.hookData.some((item: HookData) => item.codeId === id);
}
return false;
@ -54,12 +55,7 @@ export const isIncludeArrayField = (keys: string[], fields: DataSchema[]) => {
f = field?.fields || [];
// 字段类型为数组并且后面没有数字索引
return (
field?.type === 'array' &&
// 不是整数
/^(?!\d+$).*$/.test(`${keys[index + 1]}`) &&
index < keys.length - 1
);
return field?.type === 'array' && index < keys.length - 1 && !INTEGER_REGEXP.test(keys[index + 1]);
});
};
@ -78,33 +74,25 @@ export const isDataSourceTemplate = (value: any, ds: Pick<DataSourceSchema, 'id'
return false;
}
const arrayFieldTemplates = [];
const fieldTemplates = [];
templates.forEach((tpl) => {
for (const tpl of templates) {
// 将${dsId.xxxx} 转成 dsId.xxxx
const expression = tpl.substring(2, tpl.length - 1);
const keys = getKeysArray(expression);
const dsId = keys.shift();
if (!dsId || dsId !== ds.id) {
return;
continue;
}
// ${dsId.array} ${dsId.array[0]} ${dsId.array[0].a} 这种是依赖
// ${dsId.array.a} 这种不是依赖,这种需要再迭代器容器中的组件才能使用,依赖由迭代器处理
if (isIncludeArrayField(keys, ds.fields)) {
arrayFieldTemplates.push(tpl);
} else {
fieldTemplates.push(tpl);
const includesArray = isIncludeArrayField(keys, ds.fields);
if (hasArray === includesArray) {
return true;
}
});
if (hasArray) {
return arrayFieldTemplates.length > 0;
}
return fieldTemplates.length > 0;
return false;
};
/**
@ -184,7 +172,12 @@ export const isDataSourceTarget = (
value: any,
hasArray = false,
) => {
if (!value || !['string', 'object'].includes(typeof value)) {
if (!value) {
return false;
}
const valueType = typeof value;
if (valueType !== 'string' && valueType !== 'object') {
return false;
}
@ -193,13 +186,13 @@ export const isDataSourceTarget = (
}
// 或者在模板在使用数据源
if (typeof value === 'string') {
if (valueType === 'string') {
return isDataSourceTemplate(value, ds, hasArray);
}
// 关联数据源对象,如:{ isBindDataSource: true, dataSourceId: 'xxx'}
// 使用data-source-select value: 'value' 可以配置出来
if (isObject(value) && value?.isBindDataSource && value.dataSourceId && value.dataSourceId === ds.id) {
if (isObject(value) && value.isBindDataSource && value.dataSourceId === ds.id) {
return true;
}
@ -210,10 +203,7 @@ export const isDataSourceTarget = (
if (isUseDataSourceField(value, ds.id)) {
const [, ...keys] = value;
const includeArray = isIncludeArrayField(keys, ds.fields);
if (hasArray) {
return includeArray;
}
return !includeArray;
return hasArray ? includeArray : !includeArray;
}
return false;
@ -235,12 +225,9 @@ export const isDataSourceCondTarget = (
return false;
}
if (ds.fields?.find((field) => field.name === keys[0])) {
if (ds.fields?.some((field) => field.name === keys[0])) {
const includeArray = isIncludeArrayField(keys, ds.fields);
if (hasArray) {
return includeArray;
}
return !includeArray;
return hasArray ? includeArray : !includeArray;
}
return false;
@ -282,12 +269,12 @@ export const createDataSourceMethodTarget = (
return false;
}
if (ds.methods?.find((method) => method.name === methodName)) {
if (ds.methods?.some((method) => method.name === methodName)) {
return true;
}
// 配置的方法名称可能会是数据源类中定义的并不存在于methods中所以这里判断如果methodName如果是字段名称就表示配置的不是方法
if (ds.fields?.find((field) => field.name === methodName)) {
if (ds.fields?.some((field) => field.name === methodName)) {
return false;
}
@ -300,11 +287,18 @@ export const traverseTarget = (
cb: (target: Target) => void,
type?: DepTargetType | string,
) => {
if (type) {
const targets = targetsList[type];
if (targets) {
for (const target of Object.values(targets)) {
cb(target);
}
}
return;
}
for (const targets of Object.values(targetsList)) {
for (const target of Object.values(targets)) {
if (type && target.type !== type) {
continue;
}
cb(target);
}
}

View File

@ -1,18 +1,19 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/design",
"type": "module",
"sideEffects": [
"dist/style.css",
"dist/es/style.css",
"src/theme/*"
],
"main": "dist/tmagic-design.umd.cjs",
"module": "dist/tmagic-design.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-design.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-design.umd.cjs"
},
"./*": "./*"

View File

@ -1,19 +1,20 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/editor",
"type": "module",
"sideEffects": [
"dist/style.css",
"dist/es/style.css",
"src/theme/*"
],
"main": "dist/tmagic-editor.umd.cjs",
"module": "dist/tmagic-editor.js",
"module": "dist/es/index.js",
"style": "dist/style.css",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-editor.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-editor.umd.cjs"
},
"./dist/style.css": {
@ -68,18 +69,21 @@
"@types/lodash-es": "^4.17.4",
"@types/serialize-javascript": "^5.0.4",
"@types/sortablejs": "^1.15.9",
"@vue/test-utils": "^2.4.6",
"type-fest": "^5.2.0"
"@vue/test-utils": "^2.4.6"
},
"peerDependencies": {
"@tmagic/core": "workspace:*",
"monaco-editor": "^0.48.0",
"monaco-editor": "^0.55.1 ",
"type-fest": "^5.2.0",
"typescript": "catalog:",
"vue": "catalog:"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
},
"type-fest": {
"optional": true
}
}
}

View File

@ -207,6 +207,7 @@ const stageOptions: StageOptions = {
renderType: props.renderType,
guidesOptions: props.guidesOptions,
disabledMultiSelect: props.disabledMultiSelect,
beforeDblclick: props.beforeDblclick,
};
stageOverlayService.set('stageOptions', stageOptions);

View File

@ -65,8 +65,9 @@ import type { CodeBlockContent } from '@tmagic/core';
import { TMagicButton, TMagicDialog, tMagicMessage, tMagicMessageBox, TMagicTag } from '@tmagic/design';
import {
type ContainerChangeEventData,
defineFormConfig,
defineFormItem,
type FormConfig,
type FormState,
MFormBox,
type TableColumnConfig,
} from '@tmagic/form';
@ -87,7 +88,7 @@ const width = defineModel<number>('width', { default: 670 });
const boxVisible = defineModel<boolean>('visible', { default: false });
const props = defineProps<{
content: CodeBlockContent;
content: Omit<CodeBlockContent, 'content'> & { content: string };
disabled?: boolean;
isDataSource?: boolean;
dataSourceType?: string;
@ -118,7 +119,7 @@ const diffChange = () => {
difVisible.value = false;
};
const defaultParamColConfig: TableColumnConfig = {
const defaultParamColConfig = defineFormItem<TableColumnConfig>({
type: 'row',
label: '参数类型',
items: [
@ -146,76 +147,79 @@ const defaultParamColConfig: TableColumnConfig = {
],
},
],
};
});
const functionConfig = computed<FormConfig>(() => [
{
text: '名称',
name: 'name',
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }],
},
{
text: '描述',
name: 'desc',
},
{
text: '执行时机',
name: 'timing',
type: 'select',
options: () => {
const options = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (props.dataSourceType !== 'base') {
options.push({ text: '请求前', value: 'beforeRequest' });
options.push({ text: '请求后', value: 'afterRequest' });
}
return options;
},
display: () => props.isDataSource,
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
const functionConfig = computed(
() =>
defineFormConfig([
{
type: 'text',
label: '参数名',
text: '名称',
name: 'name',
rules: [{ required: true, message: '请输入名称', trigger: 'blur' }],
},
{
type: 'text',
label: '描述',
name: 'extra',
text: '描述',
name: 'desc',
},
codeBlockService.getParamsColConfig() || defaultParamColConfig,
],
},
{
name: 'content',
type: 'vs-code',
options: inject('codeOptions', {}),
autosize: { minRows: 10, maxRows: 30 },
onChange: (formState: FormState | undefined, code: string) => {
try {
// js
getEditorConfig('parseDSL')(code);
{
text: '执行时机',
name: 'timing',
type: 'select',
options: () => {
const options = [
{ text: '初始化前', value: 'beforeInit' },
{ text: '初始化后', value: 'afterInit' },
];
if (props.dataSourceType !== 'base') {
options.push({ text: '请求前', value: 'beforeRequest' });
options.push({ text: '请求后', value: 'afterRequest' });
}
return options;
},
display: () => props.isDataSource,
},
{
type: 'table',
border: true,
text: '参数',
enableFullscreen: false,
enableToggleMode: false,
name: 'params',
dropSort: false,
items: [
{
type: 'text',
label: '参数名',
name: 'name',
},
{
type: 'text',
label: '描述',
name: 'extra',
},
codeBlockService.getParamsColConfig() || defaultParamColConfig,
],
},
{
name: 'content',
type: 'vs-code',
options: inject('codeOptions', {}),
autosize: { minRows: 10, maxRows: 30 },
onChange: (_formState, code: string) => {
try {
// js
getEditorConfig('parseDSL')(code);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
return code;
} catch (error: any) {
tMagicMessage.error(error.message);
throw error;
}
},
},
]);
throw error;
}
},
},
]) as FormConfig,
);
const parseContent = (content: any) => {
if (typeof content === 'string') {

View File

@ -13,7 +13,7 @@
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { type ContainerChangeEventData, type FormConfig, type FormValue, MForm } from '@tmagic/form';
import { type ContainerChangeEventData, type FormItemConfig, type FormValue, MForm } from '@tmagic/form';
import type { CodeParamStatement } from '@editor/type';
import { error } from '@editor/utils';
@ -34,7 +34,7 @@ const emit = defineEmits(['change']);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
const getFormConfig = (items: FormConfig = []) => [
const getFormConfig = (items: FormItemConfig[] = []) => [
{
type: 'fieldset',
items,
@ -46,13 +46,29 @@ const getFormConfig = (items: FormConfig = []) => [
const codeParamsConfig = computed(() =>
getFormConfig(
props.paramsConfig.map(({ name, text, extra, ...config }) => ({
type: 'data-source-field-select',
name,
text,
extra,
fieldConfig: config,
})),
props.paramsConfig.map(({ name, text, extra, ...config }) => {
let { type } = config;
if (typeof type === 'function') {
type = type(undefined, {
model: props.model[props.name],
});
}
if (type && ['data-source-field-select', 'vs-code'].includes(type)) {
return {
...config,
name,
text,
extra,
};
}
return {
type: 'data-source-field-select' as const,
name,
text,
extra,
fieldConfig: config,
};
}) as FormItemConfig[],
),
);

View File

@ -98,6 +98,8 @@ export interface EditorProps {
isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
/** 用于自定义组件树与画布的右键菜单 */
customContentMenu?: CustomContentMenuFunction;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 页面顺序拖拽配置参数 */
pageBarSortOptions?: PageBarSortOptions;

View File

@ -1,12 +1,12 @@
<template>
<m-fields-link :config="formConfig" :model="modelValue" name="form" @change="changeHandler"></m-fields-link>
<MLink :config="formConfig" :model="modelValue" name="form" @change="changeHandler"></MLink>
</template>
<script lang="ts" setup>
import { computed, reactive, watch } from 'vue';
import serialize from 'serialize-javascript';
import type { CodeLinkConfig, FieldProps } from '@tmagic/form';
import type { CodeLinkConfig, FieldProps, MLink } from '@tmagic/form';
import { getEditorConfig } from '@editor/utils/config';

View File

@ -50,6 +50,7 @@ import {
createValues,
type FieldProps,
filterFunction,
type FormItemConfig,
type FormState,
MSelect,
type SelectConfig,
@ -115,7 +116,7 @@ watch(
const selectConfig: SelectConfig = {
type: 'select',
name: props.name,
disable: props.disabled,
disabled: props.disabled,
options: () => {
if (codeDsl.value) {
return map(codeDsl.value, (value, key) => ({
@ -141,7 +142,9 @@ const onCodeIdChangeHandler = (value: any) => {
changeRecords.push({
propPath: props.prop.replace(`${props.name}`, 'params'),
value: paramsConfig.value.length ? createValues(mForm, paramsConfig.value, {}, props.model.params) : {},
value: paramsConfig.value.length
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
: {},
});
emit('change', value, {

View File

@ -1,6 +1,21 @@
<template>
<div class="m-editor-data-source-field-select">
<template v-if="checkStrictly">
<template v-if="dataSourceId">
<TMagicCascader
:model-value="selectFieldsId"
clearable
filterable
:size="size"
:disabled="disabled"
:options="fieldsOptions"
:props="{
checkStrictly,
}"
@change="fieldChangeHandler"
></TMagicCascader>
</template>
<template v-else-if="checkStrictly">
<TMagicSelect
:model-value="selectDataSourceId"
clearable
@ -92,6 +107,8 @@ const props = defineProps<{
dataSourceFieldType?: DataSourceFieldType[];
/** 是否可以编辑数据源disable表示的是是否可以选择数据源 */
notEditable?: boolean | FilterFunction;
/** 指定数据源ID限定只能选择该数据源的字段 */
dataSourceId?: string;
}>();
const emit = defineEmits<{
@ -106,7 +123,12 @@ const { dataSourceService, uiService } = useServices();
const mForm = inject<FormState | undefined>('mForm');
const eventBus = inject<EventBus>('eventBus');
const dataSources = computed(() => dataSourceService.get('dataSources') || []);
const allDataSources = computed(() => dataSourceService.get('dataSources') || []);
const dataSources = computed(() => {
if (!props.dataSourceId) return allDataSources.value;
return allDataSources.value.filter((ds) => ds.id === props.dataSourceId);
});
const valueIsKey = computed(() => props.value === 'key');
const notEditable = computed(() => filterFunction(mForm, props.notEditable, props));
@ -125,7 +147,13 @@ const selectFieldsId = ref<string[]>([]);
watch(
modelValue,
(value) => {
if (Array.isArray(value)) {
if (props.dataSourceId) {
const dsIdValue = valueIsKey.value
? props.dataSourceId
: `${DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX}${props.dataSourceId}`;
selectDataSourceId.value = dsIdValue;
selectFieldsId.value = Array.isArray(value) ? value : [];
} else if (Array.isArray(value) && value.length) {
const [dsId, ...fields] = value;
selectDataSourceId.value = dsId;
selectFieldsId.value = fields;
@ -140,7 +168,7 @@ watch(
);
const fieldsOptions = computed(() => {
const ds = dataSources.value.find((ds) => ds.id === removeDataSourceFieldPrefix(selectDataSourceId.value));
const ds = allDataSources.value.find((ds) => ds.id === removeDataSourceFieldPrefix(selectDataSourceId.value));
if (!ds) return [];
@ -163,8 +191,13 @@ const dsChangeHandler = (v: string) => {
};
const fieldChangeHandler = (v: string[] = []) => {
modelValue.value = [selectDataSourceId.value, ...v];
emit('change', modelValue.value);
if (props.dataSourceId) {
modelValue.value = v;
emit('change', v);
} else {
modelValue.value = [selectDataSourceId.value, ...v];
emit('change', modelValue.value);
}
};
const onChangeHandler = (v: string[] = []) => {

View File

@ -8,6 +8,7 @@
:value="config.value"
:checkStrictly="checkStrictly"
:dataSourceFieldType="config.dataSourceFieldType"
:dataSourceId="config.dataSourceId"
@change="onChangeHandler"
></FieldSelect>
@ -47,7 +48,13 @@ import { Coin } from '@element-plus/icons-vue';
import { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design';
import type { ContainerChangeEventData, DataSourceFieldSelectConfig, FieldProps, FormState } from '@tmagic/form';
import {
type ContainerChangeEventData,
type DataSourceFieldSelectConfig,
type FieldProps,
type FormState,
getFormField,
} from '@tmagic/form';
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, removeDataSourceFieldPrefix } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
@ -92,7 +99,9 @@ const dataSources = computed(() => dataSourceService.get('dataSources') || []);
const disabledDataSource = computed(() => propsService.getDisabledDataSource());
const type = computed((): string => {
let type = props.config.fieldConfig?.type;
if (!props.config.fieldConfig) return '';
let type = 'type' in props.config.fieldConfig ? props.config.fieldConfig.type : '';
if (typeof type === 'function') {
type = type(mForm, {
model: props.model,
@ -100,11 +109,18 @@ const type = computed((): string => {
}
if (type === 'form') return '';
if (type === 'container') return '';
return type?.replace(/([A-Z])/g, '-$1').toLowerCase() || (props.config.items ? '' : 'text');
return (
type?.replace(/([A-Z])/g, '-$1').toLowerCase() ||
(props.config.fieldConfig && 'items' in props.config.fieldConfig ? '' : 'text')
);
});
const tagName = computed(() => {
const component = resolveComponent(`m-${props.config.items ? 'form' : 'fields'}-${type.value}`);
const component =
getFormField(type.value || 'container') ||
resolveComponent(
`m-${props.config.fieldConfig && 'items' in props.config.fieldConfig ? 'form' : 'fields'}-${type.value}`,
);
if (typeof component !== 'string') return component;
return 'm-fields-text';
});

View File

@ -52,12 +52,15 @@ import { inject, Ref, ref } from 'vue';
import type { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, tMagicMessageBox } from '@tmagic/design';
import {
type CodeConfig,
type ContainerChangeEventData,
type DataSourceFieldsConfig,
type FieldProps,
type FormConfig,
type FormState,
MFormBox,
type NumberConfig,
type TextConfig,
} from '@tmagic/form';
import { type ColumnConfig, MagicTable } from '@tmagic/table';
import { getDefaultValueFromFields } from '@tmagic/utils';
@ -247,7 +250,7 @@ const dataSourceFieldsConfig: FormConfig = [
{ text: 'true', value: true },
{ text: 'false', value: false },
],
},
} as unknown as CodeConfig | NumberConfig | TextConfig,
{
name: 'enable',
text: '是否可用',

View File

@ -48,15 +48,18 @@ import {
type DataSourceMethodSelectConfig,
type FieldProps,
filterFunction,
type FormItemConfig,
type FormState,
MCascader,
} from '@tmagic/form';
import { DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
import CodeParams from '@editor/components/CodeParams.vue';
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import type { CodeParamStatement, EventBus } from '@editor/type';
import { SideItemKey } from '@editor/type';
import { getFieldType } from '@editor/utils';
defineOptions({
name: 'MFieldsDataSourceMethodSelect',
@ -91,6 +94,43 @@ const isCustomMethod = computed(() => {
const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => {
if (!dataSourceId) return [];
if (methodName === DATA_SOURCE_SET_DATA_METHOD_NAME) {
return [
{
name: 'field',
text: '字段',
type: 'data-source-field-select',
dataSourceId,
checkStrictly: true,
},
{
name: 'data',
text: '数据',
type: (_formState, { model }) => {
const fieldType = getFieldType(dataSourceService.getDataSourceById(`${dataSourceId}`), model.field);
let type = 'vs-code';
if (fieldType === 'number') {
type = 'number';
} else if (fieldType === 'string') {
type = 'text';
} else if (fieldType === 'boolean') {
type = 'switch';
}
return type;
},
language: 'javascript',
options: inject('codeOptions', {}),
autosize: {
minRows: 1,
maxRows: 10,
},
},
];
}
const paramStatements = dataSources.value
?.find((item) => item.id === dataSourceId)
?.methods?.find((item) => item.name === methodName)?.params;
@ -107,19 +147,21 @@ const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[p
const methodsOptions = computed(
() =>
dataSources.value
?.filter((ds) => ds.methods?.length || dataSourceService.getFormMethod(ds.type).length)
?.map((ds) => ({
label: ds.title || ds.id,
value: ds.id,
children: [
...(dataSourceService?.getFormMethod(ds.type) || []),
...(ds.methods || []).map((method) => ({
label: method.name,
value: method.name,
})),
],
})) || [],
dataSources.value?.map((ds) => ({
label: ds.title || ds.id,
value: ds.id,
children: [
{
label: '设置数据',
value: DATA_SOURCE_SET_DATA_METHOD_NAME,
},
...(dataSourceService?.getFormMethod(ds.type) || []),
...(ds.methods || []).map((method) => ({
label: method.name,
value: method.name,
})),
],
})) || [],
);
const cascaderConfig = computed<CascaderConfig>(() => ({
@ -142,7 +184,9 @@ const onChangeHandler = (value: any) => {
changeRecords.push({
propPath: props.prop.replace(`${props.name}`, 'params'),
value: paramsConfig.value.length ? createValues(mForm, paramsConfig.value, {}, props.model.params) : {},
value: paramsConfig.value.length
? createValues(mForm, paramsConfig.value as unknown as FormItemConfig[], {}, props.model.params)
: {},
});
emit('change', value, {

View File

@ -42,7 +42,7 @@ const props = withDefaults(defineProps<FieldProps<DataSourceMethodsConfig>>(), {
const emit = defineEmits(['change']);
const codeConfig = ref<CodeBlockContent>();
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
let editIndex = -1;
@ -72,10 +72,14 @@ const methodColumns: ColumnConfig[] = [
{
text: '编辑',
handler: (method: CodeBlockContent, index: number) => {
let codeContent = method.content || '({ params, dataSource, app }) => {\n // place your code here\n}';
let codeContent: string = '({ params, dataSource, app }) => {\n // place your code here\n}';
if (typeof codeContent !== 'string') {
codeContent = codeContent.toString();
if (method.content) {
if (typeof method.content !== 'string') {
codeContent = method.content.toString();
} else {
codeContent = method.content;
}
}
codeConfig.value = {

View File

@ -1,6 +1,6 @@
<template>
<div class="m-fields-event-select">
<m-form-table
<MTable
v-if="isOldVersion"
name="events"
:size="size"
@ -8,7 +8,7 @@
:model="model"
:config="tableConfig"
@change="onChangeHandler"
></m-form-table>
></MTable>
<div v-else class="fullWidth">
<TMagicButton class="create-button" type="primary" :size="size" :disabled="disabled" @click="addEvent()"
@ -41,7 +41,7 @@
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(index)"
@click="removeEvent(Number(index))"
></TMagicButton>
</template>
</MPanel>
@ -59,17 +59,18 @@ import { ActionType } from '@tmagic/core';
import { TMagicButton } from '@tmagic/design';
import type {
CascaderOption,
ChildConfig,
CodeSelectColConfig,
ContainerChangeEventData,
DataSourceMethodSelectConfig,
DynamicTypeConfig,
EventSelectConfig,
FieldProps,
FormState,
OnChangeHandlerData,
PanelConfig,
TableConfig,
UISelectConfig,
} from '@tmagic/form';
import { MContainer as MFormContainer, MPanel } from '@tmagic/form';
import { defineFormItem, MContainer as MFormContainer, MPanel, MTable } from '@tmagic/form';
import { DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX, traverseNode } from '@tmagic/utils';
import { useServices } from '@editor/hooks/use-services';
@ -89,10 +90,10 @@ const { editorService, dataSourceService, eventsService, codeBlockService, props
//
const eventNameConfig = computed(() => {
const defaultEventNameConfig: ChildConfig = {
const defaultEventNameConfig = {
name: 'name',
text: '事件',
type: (mForm, { formValue }: any) => {
type: (mForm: FormState | undefined, { formValue }: any) => {
if (
props.config.src !== 'component' ||
(formValue.type === 'page-fragment-container' && formValue.pageFragmentId)
@ -212,12 +213,12 @@ const actionTypeConfig = computed(() => {
//
const targetCompConfig = computed(() => {
const defaultTargetCompConfig = {
const defaultTargetCompConfig: UISelectConfig = {
name: 'to',
text: '联动组件',
type: 'ui-select',
display: (mForm: FormState, { model }: { model: Record<any, any> }) => model.actionType === ActionType.COMP,
onChange: (MForm: FormState, v: string, { setModel }: OnChangeHandlerData) => {
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_MForm, _v, { setModel }) => {
setModel('method', '');
},
};
@ -226,10 +227,10 @@ const targetCompConfig = computed(() => {
//
const compActionConfig = computed(() => {
const defaultCompActionConfig: ChildConfig = {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
type: (mForm, { model }: any) => {
type: (mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
if (to && to.type === 'page-fragment-container' && to.pageFragmentId) {
@ -239,7 +240,7 @@ const compActionConfig = computed(() => {
return 'select';
},
checkStrictly: () => props.config.src !== 'component',
display: (mForm, { model }: any) => model.actionType === ActionType.COMP,
display: (mForm: FormState | undefined, { model }: any) => model.actionType === ActionType.COMP,
options: (mForm: FormState, { model }: any) => {
const node = editorService.getNodeById(model.to);
if (!node?.type) return [];
@ -304,62 +305,68 @@ const dataSourceActionConfig = computed(() => {
});
//
const tableConfig = computed(() => ({
type: 'table',
name: 'events',
items: [
{
name: 'name',
label: '事件名',
type: eventNameConfig.value.type,
options: (mForm: FormState, { formValue }: any) =>
eventsService.getEvent(formValue.type).map((option: any) => ({
text: option.label,
value: option.value,
})),
},
{
name: 'to',
label: '联动组件',
type: 'ui-select',
},
{
name: 'method',
label: '动作',
type: compActionConfig.value.type,
options: (mForm: FormState, { model }: any) => {
const node = editorService.getNodeById(model.to);
if (!node?.type) return [];
const tableConfig = computed(
() =>
defineFormItem({
type: 'table',
name: 'events',
items: [
{
name: 'name',
label: '事件名',
type: eventNameConfig.value.type,
options: (mForm: FormState, { formValue }: any) =>
eventsService.getEvent(formValue.type).map((option: any) => ({
text: option.label,
value: option.value,
})),
},
{
name: 'to',
label: '联动组件',
type: 'ui-select',
},
{
name: 'method',
label: '动作',
type: compActionConfig.value.type,
options: (mForm: FormState, { model }: any) => {
const node = editorService.getNodeById(model.to);
if (!node?.type) return [];
return eventsService.getMethod(node.type, model.to).map((option: any) => ({
text: option.label,
value: option.value,
}));
},
},
],
}));
return eventsService.getMethod(node.type, model.to).map((option: any) => ({
text: option.label,
value: option.value,
}));
},
},
],
}) as TableConfig,
);
//
const actionsConfig = computed<PanelConfig>(() => ({
type: 'panel',
items: [
{
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
const actionsConfig = computed(
() =>
defineFormItem({
type: 'panel',
items: [
actionTypeConfig.value,
targetCompConfig.value,
compActionConfig.value,
codeActionConfig.value,
dataSourceActionConfig.value,
{
type: 'group-list',
name: 'actions',
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
items: [
actionTypeConfig.value,
targetCompConfig.value,
compActionConfig.value,
codeActionConfig.value,
dataSourceActionConfig.value,
],
},
],
},
],
}));
}) as PanelConfig,
);
//
const isOldVersion = computed(() => {

View File

@ -26,7 +26,7 @@ import type { StyleSchema } from '@tmagic/schema';
import MIcon from '@editor/components/Icon.vue';
import { Background, Border, Font, Layout, Position } from './pro/';
import { Background, Border, Font, Layout, Position, Transform } from './pro/';
defineOptions({
name: 'MFieldsStyleSetter',
@ -60,6 +60,10 @@ const list = [
title: '边框与圆角',
component: Border,
},
{
title: '变形',
component: Transform,
},
];
const collapseValue = shallowRef(

View File

@ -28,10 +28,10 @@
<script lang="ts" setup>
import { TMagicButton, TMagicInput } from '@tmagic/design';
import type { FieldProps, FormItem } from '@tmagic/form';
import type { FieldProps, StyleSetterConfig } from '@tmagic/form';
const emit = defineEmits(['change']);
defineProps<FieldProps<{ type: 'style-setter' } & FormItem>>();
defineProps<FieldProps<StyleSetterConfig>>();
const horizontalList = [
{

View File

@ -39,46 +39,48 @@
import { computed, ref } from 'vue';
import type { ContainerChangeEventData, FormValue } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import { defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
const direction = ref('');
const config = computed(() => ({
items: [
{
name: `border${direction.value}Width`,
text: '边框宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
const config = computed(() =>
defineFormItem({
items: [
{
name: `border${direction.value}Width`,
text: '边框宽度',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
},
{
name: `border${direction.value}Color`,
text: '边框颜色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
{
name: `border${direction.value}Color`,
text: '边框颜色',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'colorPicker',
},
},
},
{
name: `border${direction.value}Style`,
text: '边框样式',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['solid', 'dashed', 'dotted'].map((item) => ({
value: item,
text: item,
})),
{
name: `border${direction.value}Style`,
text: '边框样式',
labelWidth: '68px',
type: 'data-source-field-select',
fieldConfig: {
type: 'select',
options: ['solid', 'dashed', 'dotted'].map((item) => ({
value: item,
text: item,
})),
},
},
},
],
}));
],
}),
);
const selectDirection = (d?: string) => (direction.value = d || '');

View File

@ -5,7 +5,7 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import { ContainerChangeEventData, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import BackgroundPosition from '../components/BackgroundPosition.vue';
@ -21,7 +21,7 @@ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData];
}>();
const config = {
const config = defineFormItem({
items: [
{
name: 'backgroundColor',
@ -39,7 +39,7 @@ const config = {
type: 'data-source-field-select',
fieldConfig: {
type: 'img-upload',
},
} as any,
},
{
name: 'backgroundSize',
@ -74,7 +74,7 @@ const config = {
labelWidth: '68px',
},
],
};
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -4,7 +4,7 @@
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Border from '../components/Border.vue';
@ -19,7 +19,7 @@ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData];
}>();
const config = {
const config = defineFormItem({
items: [
{
labelWidth: '68px',
@ -31,7 +31,7 @@ const config = {
},
},
],
};
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -5,7 +5,7 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import { ContainerChangeEventData, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import { AlignCenter, AlignLeft, AlignRight } from '../icons/text-align';
@ -20,7 +20,7 @@ const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData];
}>();
const config = {
const config = defineFormItem({
items: [
{
type: 'row',
@ -86,7 +86,7 @@ const config = {
],
},
],
};
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -12,8 +12,8 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import type { ContainerChangeEventData, FormState } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
import type { ChildConfig, ContainerChangeEventData } from '@tmagic/form';
import { defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
@ -42,7 +42,7 @@ const emit = defineEmits<{
change: [v: string | StyleSchema, eventData: ContainerChangeEventData];
}>();
const config = {
const config = defineFormItem({
items: [
{
name: 'display',
@ -74,7 +74,7 @@ const config = {
tooltip: '垂直方向 起点在下沿 column-reverse',
},
],
display: (mForm: FormState, { model }: { model: Record<any, any> }) => model.display === 'flex',
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'justifyContent',
@ -89,7 +89,7 @@ const config = {
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (mForm: FormState, { model }: { model: Record<any, any> }) => model.display === 'flex',
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'alignItems',
@ -104,7 +104,7 @@ const config = {
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
],
display: (mForm: FormState, { model }: { model: Record<any, any> }) => model.display === 'flex',
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
name: 'flexWrap',
@ -117,7 +117,7 @@ const config = {
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
{ value: 'wrap-reverse', text: '逆换行', tooltip: '第一行在下方 wrap-reverse' },
],
display: (mForm: FormState, { model }: { model: Record<any, any> }) => model.display === 'flex',
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
{
type: 'row',
@ -180,7 +180,7 @@ const config = {
],
},
],
};
}) as ChildConfig;
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -3,7 +3,7 @@
</template>
<script lang="ts" setup>
import { ContainerChangeEventData, MContainer } from '@tmagic/form';
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
const props = defineProps<{
@ -24,7 +24,7 @@ const positionText: Record<string, string> = {
sticky: '粘性定位',
};
const config = {
const config = defineFormItem({
items: [
{
name: 'position',
@ -95,7 +95,7 @@ const config = {
},
},
],
};
});
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);

View File

@ -0,0 +1,54 @@
<template>
<MContainer :config="config" :model="values" :size="size" :disabled="disabled" @change="change"></MContainer>
</template>
<script lang="ts" setup>
import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
defineProps<{
values: Partial<StyleSchema>;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
}>();
const emit = defineEmits<{
change: [v: StyleSchema, eventData: ContainerChangeEventData];
}>();
const config = defineFormItem({
items: [
{
name: 'transform',
items: [
{
name: 'rotate',
text: '旋转角度',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
{
name: 'scale',
text: '缩放',
labelWidth: '68px',
type: 'data-source-field-select',
checkStrictly: false,
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',
},
},
],
},
],
});
const change = (value: StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData);
};
</script>

View File

@ -3,3 +3,4 @@ export { default as Font } from './Font.vue';
export { default as Layout } from './Layout.vue';
export { default as Position } from './Position.vue';
export { default as Border } from './Border.vue';
export { default as Transform } from './Transform.vue';

View File

@ -8,7 +8,7 @@ import CodeBlockEditor from '@editor/components/CodeBlockEditor.vue';
import type { Services } from '@editor/type';
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
const codeConfig = ref<CodeBlockContent>();
const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
const codeId = ref<string>();
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
@ -36,10 +36,14 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
return;
}
let codeContent = codeBlock.content;
let codeContent = '';
if (typeof codeContent !== 'string') {
codeContent = codeContent.toString();
if (codeBlock.content) {
if (typeof codeBlock.content !== 'string') {
codeContent = codeBlock.content.toString();
} else {
codeContent = codeBlock.content;
}
}
codeConfig.value = {

View File

@ -15,38 +15,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form';
import formPlugin from '@tmagic/form';
import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue';
import CodeLink from './fields/CodeLink.vue';
import CodeSelect from './fields/CodeSelect.vue';
import CodeSelectCol from './fields/CodeSelectCol.vue';
import CondOpSelect from './fields/CondOpSelect.vue';
import DataSourceFields from './fields/DataSourceFields.vue';
import DataSourceFieldSelect from './fields/DataSourceFieldSelect/Index.vue';
import DataSourceInput from './fields/DataSourceInput.vue';
import DataSourceMethods from './fields/DataSourceMethods.vue';
import DataSourceMethodSelect from './fields/DataSourceMethodSelect.vue';
import DataSourceMocks from './fields/DataSourceMocks.vue';
import DataSourceSelect from './fields/DataSourceSelect.vue';
import DisplayConds from './fields/DisplayConds.vue';
import EventSelect from './fields/EventSelect.vue';
import KeyValue from './fields/KeyValue.vue';
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
import StyleSetter from './fields/StyleSetter/Index.vue';
import uiSelect from './fields/UISelect.vue';
import CodeEditor from './layouts/CodeEditor.vue';
import { setEditorConfig } from './utils/config';
import Editor from './Editor.vue';
import type { EditorInstallOptions } from './type';
import './theme/index.scss';
export * from '@tmagic/form';
export { default as formPlugin } from '@tmagic/form';
@ -110,44 +78,4 @@ export { default as DisplayConds } from './fields/DisplayConds.vue';
export { default as CondOpSelect } from './fields/CondOpSelect.vue';
export { default as StyleSetter } from './fields/StyleSetter/Index.vue';
const defaultInstallOpt: EditorInstallOptions = {
// eslint-disable-next-line no-eval
parseDSL: (dsl: string) => eval(dsl),
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options),
};
export default {
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
const option = Object.assign(defaultInstallOpt, opt || {});
app.use(designPlugin, opt || {});
app.use(formPlugin, opt || {});
app.use(tablePlugin);
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor);
app.component('magic-code-editor', CodeEditor);
app.component('m-fields-ui-select', uiSelect);
app.component('m-fields-code-link', CodeLink);
app.component('m-fields-vs-code', Code);
app.component('m-fields-code-select', CodeSelect);
app.component('m-fields-code-select-col', CodeSelectCol);
app.component('m-fields-event-select', EventSelect);
app.component('m-fields-data-source-fields', DataSourceFields);
app.component('m-fields-data-source-mocks', DataSourceMocks);
app.component('m-fields-key-value', KeyValue);
app.component('m-fields-data-source-input', DataSourceInput);
app.component('m-fields-data-source-select', DataSourceSelect);
app.component('m-fields-data-source-methods', DataSourceMethods);
app.component('m-fields-data-source-method-select', DataSourceMethodSelect);
app.component('m-fields-data-source-field-select', DataSourceFieldSelect);
app.component('m-fields-page-fragment-select', PageFragmentSelect);
app.component('m-fields-display-conds', DisplayConds);
app.component('m-fields-cond-op-select', CondOpSelect);
app.component('m-form-style-setter', StyleSetter);
},
};
export { default } from './plugin';

View File

@ -233,7 +233,7 @@ export const initServiceEvents = (
((event: 'update:modelValue', value: MApp | null) => void),
{ editorService, codeBlockService, dataSourceService, depService }: Services,
) => {
let getTMagicAppPrimise: Promise<TMagicCore | undefined> | null = null;
let getTMagicAppPromise: Promise<TMagicCore | undefined> | null = null;
const getTMagicApp = async (): Promise<TMagicCore | undefined> => {
const stage = await getStage();
@ -246,11 +246,11 @@ export const initServiceEvents = (
return renderer.runtime.getApp?.();
}
if (getTMagicAppPrimise) {
return getTMagicAppPrimise;
if (getTMagicAppPromise) {
return getTMagicAppPromise;
}
getTMagicAppPrimise = new Promise<TMagicCore | undefined>((resolve) => {
getTMagicAppPromise = new Promise<TMagicCore | undefined>((resolve) => {
// 设置 10s 超时
const timeout = globalThis.setTimeout(() => {
resolve(void 0);
@ -264,7 +264,7 @@ export const initServiceEvents = (
});
});
return getTMagicAppPrimise;
return getTMagicAppPromise;
};
const updateStageNodes = (nodes: MComponent[]) => {
@ -560,7 +560,7 @@ export const initServiceEvents = (
depService.clear(nodes);
};
// 由于历史记录变化是更新整个page所以历史记录变化时,需要重新收集依赖
// 历史记录变化时,需要重新收集依赖
const historyChangeHandler = (page: MPage | MPageFragment) => {
collectIdle([page], true).then(() => {
updateStageNode(page);

View File

@ -80,7 +80,7 @@ const props = withDefaults(
let stage: StageCore | null = null;
let runtime: Runtime | null = null;
const { editorService, uiService, keybindingService } = useServices();
const { editorService, uiService, keybindingService, stageOverlayService } = useServices();
const stageLoading = computed(() => editorService.get('stageLoading'));
@ -97,6 +97,60 @@ const page = computed(() => editorService.get('page'));
const zoom = computed(() => uiService.get('zoom'));
const node = computed(() => editorService.get('node'));
/**
* 判断元素是否被非页面级的滚动容器裁剪未完整显示
*
* 从元素向上遍历祖先节点跳过页面/页面片容器
* 检查是否存在设置了 overflow 的滚动容器将该元素裁剪
* 只有元素未被完整显示时才需要打开 overlay 以展示完整内容
*/
const isClippedByScrollContainer = (el: HTMLElement): boolean => {
const win = el.ownerDocument.defaultView;
if (!win) return false;
// id
const root = editorService.get('root');
const pageIds = new Set(root?.items?.map((item) => `${item.id}`) ?? []);
// el
const elId = getIdFromEl()(el);
if (elId && pageIds.has(elId)) return false;
let parent = el.parentElement;
while (parent && parent !== el.ownerDocument.documentElement) {
const parentId = getIdFromEl()(parent);
//
if (parentId && pageIds.has(parentId)) {
return false;
}
const { overflowX, overflowY } = win.getComputedStyle(parent);
if (
['auto', 'scroll', 'hidden'].includes(overflowX) ||
['auto', 'scroll', 'hidden'].includes(overflowY) ||
parent.scrollWidth > parent.clientWidth ||
parent.scrollHeight > parent.clientHeight
) {
//
const elRect = el.getBoundingClientRect();
const containerRect = parent.getBoundingClientRect();
if (
elRect.top < containerRect.top ||
elRect.left < containerRect.left ||
elRect.bottom > containerRect.bottom ||
elRect.right > containerRect.right
) {
return true;
}
}
parent = parent.parentElement;
}
return false;
};
watchEffect(() => {
if (stage || !page.value) return;
@ -109,6 +163,40 @@ watchEffect(() => {
stageWrapRef.value?.container?.focus();
});
stage.on('dblclick', async (event: MouseEvent) => {
if (props.stageOptions.beforeDblclick) {
const result = await props.stageOptions.beforeDblclick(event);
if (result === false) return;
}
const el = (await stage?.actionManager?.getElementFromPoint(event)) || null;
if (!el) return;
const id = getIdFromEl()(el);
if (id) {
const node = editorService.getNodeById(id);
if (node?.type === 'page-fragment-container' && node.pageFragmentId) {
await editorService.select(node.pageFragmentId);
return;
}
}
if (!props.disabledStageOverlay && isClippedByScrollContainer(el)) {
stageOverlayService.openOverlay(el);
return;
}
const nextEl = (await stage?.actionManager?.getNextElementFromPoint(event)) || null;
if (nextEl) {
const nextId = getIdFromEl()(nextEl);
if (nextId) {
await editorService.select(nextId);
editorService.get('stage')?.select(nextId);
}
}
});
editorService.set('stage', markRaw(stage));
stage.mount(stageContainerEl.value);

View File

@ -46,12 +46,7 @@ const style = computed(() => ({
}));
watch(stage, (stage) => {
if (stage) {
stage.on('dblclick', async (event: MouseEvent) => {
const el = (await stage.actionManager?.getElementFromPoint(event)) || null;
stageOverlayService.openOverlay(el);
});
} else {
if (!stage) {
stageOverlayService.closeOverlay();
}
});

View File

@ -0,0 +1,92 @@
/*
* 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 type { App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
import type { FormInstallOptions } from '@tmagic/form';
import formPlugin from '@tmagic/form';
import tablePlugin from '@tmagic/table';
import Code from './fields/Code.vue';
import CodeLink from './fields/CodeLink.vue';
import CodeSelect from './fields/CodeSelect.vue';
import CodeSelectCol from './fields/CodeSelectCol.vue';
import CondOpSelect from './fields/CondOpSelect.vue';
import DataSourceFields from './fields/DataSourceFields.vue';
import DataSourceFieldSelect from './fields/DataSourceFieldSelect/Index.vue';
import DataSourceInput from './fields/DataSourceInput.vue';
import DataSourceMethods from './fields/DataSourceMethods.vue';
import DataSourceMethodSelect from './fields/DataSourceMethodSelect.vue';
import DataSourceMocks from './fields/DataSourceMocks.vue';
import DataSourceSelect from './fields/DataSourceSelect.vue';
import DisplayConds from './fields/DisplayConds.vue';
import EventSelect from './fields/EventSelect.vue';
import KeyValue from './fields/KeyValue.vue';
import PageFragmentSelect from './fields/PageFragmentSelect.vue';
import StyleSetter from './fields/StyleSetter/Index.vue';
import uiSelect from './fields/UISelect.vue';
import CodeEditor from './layouts/CodeEditor.vue';
import { setEditorConfig } from './utils/config';
import Editor from './Editor.vue';
import type { EditorInstallOptions } from './type';
import './theme/index.scss';
const defaultInstallOpt: EditorInstallOptions = {
// eslint-disable-next-line no-eval
parseDSL: (dsl: string) => eval(dsl),
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options),
};
export default {
install: (app: App, opt?: Partial<EditorInstallOptions | DesignPluginOptions | FormInstallOptions>): void => {
const option = Object.assign(defaultInstallOpt, opt || {});
app.use(designPlugin, opt || {});
app.use(formPlugin, opt || {});
app.use(tablePlugin);
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor);
app.component('magic-code-editor', CodeEditor);
app.component('m-fields-ui-select', uiSelect);
app.component('m-fields-code-link', CodeLink);
app.component('m-fields-vs-code', Code);
app.component('m-fields-code-select', CodeSelect);
app.component('m-fields-code-select-col', CodeSelectCol);
app.component('m-fields-event-select', EventSelect);
app.component('m-fields-data-source-fields', DataSourceFields);
app.component('m-fields-data-source-mocks', DataSourceMocks);
app.component('m-fields-key-value', KeyValue);
app.component('m-fields-data-source-input', DataSourceInput);
app.component('m-fields-data-source-select', DataSourceSelect);
app.component('m-fields-data-source-methods', DataSourceMethods);
app.component('m-fields-data-source-method-select', DataSourceMethodSelect);
app.component('m-fields-data-source-field-select', DataSourceFieldSelect);
app.component('m-fields-page-fragment-select', PageFragmentSelect);
app.component('m-fields-display-conds', DisplayConds);
app.component('m-fields-cond-op-select', CondOpSelect);
app.component('m-form-style-setter', StyleSetter);
},
};

View File

@ -17,23 +17,13 @@
*/
import { reactive, toRaw } from 'vue';
import { cloneDeep, get, isObject, mergeWith, uniq } from 'lodash-es';
import type { Writable } from 'type-fest';
import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType, Target, Watcher } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage';
import {
calcValueByFontsize,
getElById,
getNodeInfo,
getNodePath,
isNumber,
isPage,
isPageFragment,
isPop,
} from '@tmagic/utils';
import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props';
@ -42,69 +32,39 @@ import storageService, { Protocol } from '@editor/services/storage';
import type {
AddMNode,
AsyncHookPlugin,
AsyncMethodName,
EditorEvents,
EditorNodeInfo,
HistoryOpType,
PastePosition,
StepValue,
StoreState,
StoreStateKey,
} from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import {
change2Fixed,
calcAlignCenterStyle,
calcLayerTargetIndex,
calcMoveStyle,
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY,
Fixed2Other,
editorNodeMergeCustomizer,
fixNodePosition,
getInitPositionStyle,
getNodeIndex,
getPageFragmentList,
getPageList,
moveItemsInContainer,
resolveSelectedNode,
setChildrenLayout,
setLayout,
toggleFixedPosition,
} from '@editor/utils/editor';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
import { beforePaste, getAddParent } from '@editor/utils/operator';
export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
'move-layer': [offset: number | LayerOffset];
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
'history-change': [data: MPage | MPageFragment];
}
const canUsePluginMethods = {
async: [
'getLayout',
'highlight',
'select',
'multiSelect',
'doAdd',
'add',
'doRemove',
'remove',
'doUpdate',
'update',
'sort',
'copy',
'paste',
'doPaste',
'doAlignCenter',
'alignCenter',
'moveLayer',
'moveToContainer',
'dragTo',
'undo',
'redo',
'move',
] as const,
sync: [],
};
type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;
class Editor extends BaseService {
public state: StoreState = reactive({
root: null,
@ -121,6 +81,7 @@ class Editor extends BaseService {
disabledMultiSelect: false,
});
private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;
constructor() {
super(
@ -390,6 +351,8 @@ class Editor extends BaseService {
* @returns
*/
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const stage = this.get('stage');
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
@ -435,7 +398,21 @@ class Editor extends BaseService {
}
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
this.pushHistoryState();
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
this.pushOpHistory(
'add',
{
nodes: newNodes.map((n) => cloneDeep(toRaw(n))),
parentId: (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id,
indexMap: Object.fromEntries(
newNodes.map((n) => {
const p = this.getParentById(n.id, false) as MContainer;
return [n.id, p ? getNodeIndex(n.id, p) : -1];
}),
),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
}
this.emit('add', newNodes);
@ -498,13 +475,33 @@ class Editor extends BaseService {
* @param {Object} node
*/
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList];
const removedItems: { node: MNode; parentId: Id; index: number }[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
for (const n of nodes) {
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
if (parent && curNode) {
if (!pageForOp && page) {
pageForOp = { name: page.name || '', id: page.id };
}
const idx = getNodeIndex(curNode.id, parent);
removedItems.push({
node: cloneDeep(toRaw(curNode)),
parentId: parent.id,
index: typeof idx === 'number' ? idx : -1,
});
}
}
}
await Promise.all(nodes.map((node) => this.doRemove(node)));
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
// 更新历史记录
this.pushHistoryState();
if (removedItems.length > 0 && pageForOp) {
this.pushOpHistory('remove', { removedItems }, pageForOp);
}
this.emit('remove', nodes);
@ -525,21 +522,9 @@ class Editor extends BaseService {
const node = toRaw(info.node);
let newConfig = await this.toggleFixedPosition(toRaw(config), node, root);
let newConfig = await toggleFixedPosition(toRaw(config), node, root, this.getLayout);
newConfig = mergeWith(cloneDeep(node), newConfig, (objValue, srcValue, key, object: any, source: any) => {
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
return '';
}
if (isObject(srcValue) && Array.isArray(objValue)) {
// 原来的配置是数组,新的配置是对象,则直接使用新的值
return srcValue;
}
if (Array.isArray(srcValue)) {
return srcValue;
}
});
newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (!newConfig.type) throw new Error('配置缺少type值');
@ -597,12 +582,28 @@ class Editor extends BaseService {
config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {},
): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) {
this.pushHistoryState();
const curNodes = this.get('nodes');
if (!this.isHistoryStateChange && curNodes.length) {
const pageForOp = this.getNodeInfo(nodes[0].id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: updateData.map((d) => ({
oldNode: cloneDeep(d.oldNode),
newNode: cloneDeep(toRaw(d.newNode)),
})),
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
}
this.isHistoryStateChange = false;
}
this.emit('update', updateData);
@ -616,6 +617,8 @@ class Editor extends BaseService {
* @returns void
*/
public async sort(id1: Id, id2: Id): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -640,9 +643,6 @@ class Editor extends BaseService {
parentId: parent.id,
root: cloneDeep(root),
});
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
}
/**
@ -664,31 +664,8 @@ class Editor extends BaseService {
public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
// 初始化复制组件相关的依赖收集器
if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
const customTarget = new Target({
...collectorOptions,
});
const coperWatcher = new Watcher();
coperWatcher.addTarget(customTarget);
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = this.getNodeById(nodeId);
if (!node) return;
customTarget!.deps[nodeId].keys.forEach((key) => {
const relateNodeId = get(node, key);
const isExist = copyNodes.find((node) => node.id === relateNodeId);
if (!isExist) {
const relateNode = this.getNodeById(relateNodeId);
if (relateNode) {
copyNodes.push(relateNode);
}
}
});
});
collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
}
storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
@ -733,32 +710,16 @@ class Editor extends BaseService {
public async doAlignCenter(config: MNode): Promise<MNode> {
const parent = this.getParentById(config.id);
if (!parent) throw new Error('找不到父节点');
const node = cloneDeep(toRaw(config));
const layout = await this.getLayout(parent, node);
if (layout === Layout.RELATIVE) {
return config;
}
const doc = this.get('stage')?.renderer?.contentWindow?.document;
const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
if (!node.style) return config;
const stage = this.get('stage');
const doc = stage?.renderer?.contentWindow?.document;
if (doc) {
const el = getElById()(doc, node.id);
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
if (parentEl && el) {
node.style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
node.style.right = '';
}
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
node.style.left = (parent.style.width - node.style.width) / 2;
node.style.right = '';
}
if (!newStyle) return config;
node.style = newStyle;
return node;
}
@ -789,6 +750,8 @@ class Editor extends BaseService {
* @param offset
*/
public async moveLayer(offset: number | LayerOffset): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root');
if (!root) throw new Error('root为空');
@ -801,22 +764,16 @@ class Editor extends BaseService {
const brothers: MNode[] = parent.items || [];
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
// 流式布局与绝对定位布局操作的相反的
const layout = await this.getLayout(parent, node);
const isRelative = layout === Layout.RELATIVE;
let offsetIndex: number;
if (offset === LayerOffset.TOP) {
offsetIndex = isRelative ? 0 : brothers.length;
} else if (offset === LayerOffset.BOTTOM) {
offsetIndex = isRelative ? brothers.length : 0;
} else {
offsetIndex = index + (isRelative ? -offset : offset);
}
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
return;
}
const oldParent = cloneDeep(toRaw(parent));
brothers.splice(index, 1);
brothers.splice(offsetIndex, 0, node);
@ -829,7 +786,14 @@ class Editor extends BaseService {
});
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
const pageForOp = this.getNodeInfo(node.id, false).page;
this.pushOpHistory(
'update',
{
updatedItems: [{ oldNode: oldParent, newNode: cloneDeep(toRaw(parent)) }],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
this.emit('move-layer', offset);
}
@ -840,12 +804,17 @@ class Editor extends BaseService {
* @param targetId ID
*/
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
this.captureSelectionBeforeOp();
const root = this.get('root');
const { node, parent } = this.getNodeInfo(config.id, false);
const { node, parent, page: pageForOp } = this.getNodeInfo(config.id, false);
const target = this.getNodeById(targetId, false) as MContainer;
const stage = this.get('stage');
if (root && node && parent && stage) {
const oldSourceParent = cloneDeep(toRaw(parent));
const oldTarget = cloneDeep(toRaw(target));
const index = getNodeIndex(node.id, parent);
parent.items?.splice(index, 1);
@ -876,62 +845,60 @@ class Editor extends BaseService {
this.addModifiedNodeId(target.id);
this.addModifiedNodeId(parent.id);
this.pushHistoryState();
this.pushOpHistory(
'update',
{
updatedItems: [
{ oldNode: oldSourceParent, newNode: cloneDeep(toRaw(parent)) },
{ oldNode: oldTarget, newNode: cloneDeep(toRaw(target)) },
],
},
{ name: pageForOp?.name || '', id: pageForOp!.id },
);
return newConfig;
}
}
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
this.captureSelectionBeforeOp();
if (!targetParent || !Array.isArray(targetParent.items)) return;
const configs = Array.isArray(config) ? config : [config];
const sourceIndicesInTargetParent: number[] = [];
const sourceOutTargetParent: MNode[] = [];
const newLayout = await this.getLayout(targetParent);
// eslint-disable-next-line no-restricted-syntax
forConfigs: for (const config of configs) {
const { parent, node: curNode } = this.getNodeInfo(config.id, false);
if (!parent || !curNode) {
continue;
}
const path = getNodePath(curNode.id, parent.items);
for (const node of path) {
if (targetParent.id === node.id) {
continue forConfigs;
}
}
const index = getNodeIndex(curNode.id, parent);
if (parent.id === targetParent.id) {
if (typeof index !== 'number' || index === -1) {
return;
}
sourceIndicesInTargetParent.push(index);
} else {
const layout = await this.getLayout(parent);
if (newLayout !== layout) {
setLayout(config, newLayout);
}
parent.items?.splice(index, 1);
sourceOutTargetParent.push(config);
this.addModifiedNodeId(parent.id);
const beforeSnapshots = new Map<string, MNode>();
for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
if (parent && !beforeSnapshots.has(`${parent.id}`)) {
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
}
}
if (!beforeSnapshots.has(`${targetParent.id}`)) {
beforeSnapshots.set(`${targetParent.id}`, cloneDeep(toRaw(targetParent)));
}
moveItemsInContainer(sourceIndicesInTargetParent, targetParent, targetIndex);
const newLayout = await this.getLayout(targetParent);
const { sameParentIndices, crossParentConfigs, aborted } = classifyDragSources(configs, targetParent, (id, raw) =>
this.getNodeInfo(id, raw),
);
if (aborted) return;
sourceOutTargetParent.forEach((config, index) => {
targetParent.items?.splice(targetIndex + index, 0, config);
this.addModifiedNodeId(config.id);
for (const { config: crossConfig, parent } of crossParentConfigs) {
const layout = await this.getLayout(parent);
if (newLayout !== layout) {
setLayout(crossConfig, newLayout);
}
const index = getNodeIndex(crossConfig.id, parent);
parent.items?.splice(index, 1);
this.addModifiedNodeId(parent.id);
}
moveItemsInContainer(sameParentIndices, targetParent, targetIndex);
crossParentConfigs.forEach(({ config: crossConfig }, index) => {
targetParent.items?.splice(targetIndex + index, 0, crossConfig);
this.addModifiedNodeId(crossConfig.id);
});
const page = this.get('page');
@ -946,28 +913,40 @@ class Editor extends BaseService {
});
}
this.pushHistoryState();
const updatedItems: { oldNode: MNode; newNode: MNode }[] = [];
for (const oldNode of beforeSnapshots.values()) {
const newNode = this.getNodeById(oldNode.id, false);
if (newNode) {
updatedItems.push({ oldNode, newNode: cloneDeep(toRaw(newNode)) });
}
}
const pageForOp = this.getNodeInfo(configs[0].id, false).page;
this.pushOpHistory('update', { updatedItems }, { name: pageForOp?.name || '', id: pageForOp!.id });
this.emit('drag-to', { targetIndex, configs, targetParent });
}
/**
*
* @returns
* @returns
*/
public async undo(): Promise<StepValue | null> {
const value = historyService.undo();
await this.changeHistoryState(value);
if (value) {
await this.applyHistoryOp(value, true);
}
return value;
}
/**
*
* @returns
* @returns
*/
public async redo(): Promise<StepValue | null> {
const value = historyService.redo();
await this.changeHistoryState(value);
if (value) {
await this.applyHistoryOp(value, false);
}
return value;
}
@ -975,47 +954,10 @@ class Editor extends BaseService {
const node = toRaw(this.get('node'));
if (!node || isPage(node)) return;
const { style, id, type } = node;
if (!style || !['absolute', 'fixed'].includes(style.position)) return;
const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!newStyle) return;
const update = (style: { [key: string]: any }) =>
this.update({
id,
type,
style,
});
if (top) {
if (isNumber(style.top)) {
update({
...style,
top: Number(style.top) + Number(top),
bottom: '',
});
} else if (isNumber(style.bottom)) {
update({
...style,
bottom: Number(style.bottom) - Number(top),
top: '',
});
}
}
if (left) {
if (isNumber(style.left)) {
update({
...style,
left: Number(style.left) + Number(left),
right: '',
});
} else if (isNumber(style.right)) {
update({
...style,
right: Number(style.right) - Number(left),
left: '',
});
}
}
await this.update({ id: node.id, type: node.type, style: newStyle });
}
public resetState() {
@ -1068,70 +1010,89 @@ class Editor extends BaseService {
}
}
private pushHistoryState() {
const curNode = cloneDeep(toRaw(this.get('node')));
const page = this.get('page');
if (!this.isHistoryStateChange && curNode && page) {
historyService.push({
data: cloneDeep(toRaw(page)),
modifiedNodeIds: this.get('modifiedNodeIds'),
nodeId: curNode.id,
});
private captureSelectionBeforeOp() {
if (this.isHistoryStateChange || this.selectionBeforeOp) return;
this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
}
private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
if (this.isHistoryStateChange) {
this.selectionBeforeOp = null;
return;
}
const step: StepValue = {
data: pageData,
opType,
selectedBefore: this.selectionBeforeOp ?? [],
selectedAfter: this.get('nodes').map((n) => n.id),
modifiedNodeIds: new Map(this.get('modifiedNodeIds')),
...extra,
};
historyService.push(step);
this.selectionBeforeOp = null;
this.isHistoryStateChange = false;
}
private async changeHistoryState(value: StepValue | null) {
if (!value) return;
/**
* /
* @param step
* @param reverse true = false =
*/
private async applyHistoryOp(step: StepValue, reverse: boolean) {
this.isHistoryStateChange = true;
await this.update(value.data);
this.set('modifiedNodeIds', value.modifiedNodeIds);
setTimeout(() => {
if (!value.nodeId) return;
this.select(value.nodeId).then(() => {
this.get('stage')?.select(value.nodeId);
});
}, 0);
this.emit('history-change', value.data);
}
private async toggleFixedPosition(dist: MNode, src: MNode, root: MApp) {
const newConfig = cloneDeep(dist);
const root = this.get('root');
const stage = this.get('stage');
if (!root) return;
if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, this.getLayout);
}
const ctx: HistoryOpContext = {
root,
stage,
getNodeById: (id, raw) => this.getNodeById(id, raw),
getNodeInfo: (id, raw) => this.getNodeInfo(id, raw),
setRoot: (r) => this.set('root', r),
setPage: (p) => this.set('page', p),
getPage: () => this.get('page'),
};
switch (step.opType) {
case 'add':
await applyHistoryAddOp(step, reverse, ctx);
break;
case 'remove':
await applyHistoryRemoveOp(step, reverse, ctx);
break;
case 'update':
await applyHistoryUpdateOp(step, reverse, ctx);
break;
}
return newConfig;
this.set('modifiedNodeIds', step.modifiedNodeIds);
const page = toRaw(this.get('page'));
if (page) {
const selectIds = reverse ? step.selectedBefore : step.selectedAfter;
setTimeout(() => {
if (!selectIds.length) return;
if (selectIds.length > 1) {
this.multiSelect(selectIds);
stage?.multiSelect(selectIds);
} else {
this.select(selectIds[0])
.then(() => stage?.select(selectIds[0]))
.catch(() => {});
}
}, 0);
this.emit('history-change', page as MPage | MPageFragment);
}
this.isHistoryStateChange = false;
}
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
let id: Id;
if (typeof config === 'string' || typeof config === 'number') {
id = config;
} else {
id = config.id;
}
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = this.getNodeInfo(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === this.state.root?.id) {
throw new Error('不能选根节点');
}
return {
node,
parent,
page,
};
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
}

View File

@ -56,15 +56,7 @@ class History extends BaseService {
this.state.pageId = page.id;
if (!this.state.pageSteps[this.state.pageId]) {
const undoRedo = new UndoRedo<StepValue>();
undoRedo.pushElement({
data: page,
modifiedNodeIds: new Map(),
nodeId: page.id,
});
this.state.pageSteps[this.state.pageId] = undoRedo;
this.state.pageSteps[this.state.pageId] = new UndoRedo<StepValue>();
}
this.setCanUndoRedo();

View File

@ -102,9 +102,11 @@ class Props extends BaseService {
}
public async setPropsConfig(type: string, config: FormConfig | PropsFormConfigFunction) {
let c = config;
let c: FormConfig;
if (typeof config === 'function') {
c = config({ editorService });
} else {
c = config;
}
this.state.propsConfigMap[toLine(type)] = await this.fillConfig(Array.isArray(c) ? c : [c]);

View File

@ -19,11 +19,11 @@
import type { Component } from 'vue';
import type EventEmitter from 'events';
import type * as Monaco from 'monaco-editor';
import Sortable, { type Options, type SortableEvent } from 'sortablejs';
import type { PascalCasedProperties } from 'type-fest';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
import type { PascalCasedProperties, Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import type { FormConfig, TableColumnConfig } from '@tmagic/form';
import type { ChangeRecord, FormConfig, TableColumnConfig, TypeFunction } from '@tmagic/form';
import type StageCore from '@tmagic/stage';
import type {
ContainerHighlightType,
@ -164,6 +164,8 @@ export interface StageOptions {
disabledMultiSelect?: boolean;
disabledRule?: boolean;
zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
}
export interface StoreState {
@ -541,14 +543,31 @@ export interface CodeParamStatement {
/** 参数名称 */
name: string;
/** 参数类型 */
type?: string;
type?: string | TypeFunction<string>;
[key: string]: any;
}
export type HistoryOpType = 'add' | 'remove' | 'update';
export interface StepValue {
data: MPage | MPageFragment;
/** 页面信息 */
data: { name: string; id: Id };
opType: HistoryOpType;
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, Id>;
nodeId: Id;
/** opType 'add': 新增的节点 */
nodes?: MNode[];
/** opType 'add': 父节点 ID */
parentId?: Id;
/** opType 'add': 每个新增节点在父节点 items 中的索引 */
indexMap?: Record<string, number>;
/** opType 'remove': 被删除的节点及其位置信息 */
removedItems?: { node: MNode; parentId: Id; index: number }[];
/** opType 'update': 变更前后的节点快照 */
updatedItems?: { oldNode: MNode; newNode: MNode }[];
}
export interface HistoryState {
@ -712,3 +731,44 @@ export type CustomContentMenuFunction = (
menus: (MenuButton | MenuComponent)[],
type: 'layer' | 'data-source' | 'viewer' | 'code-block',
) => (MenuButton | MenuComponent)[];
export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];
update: [nodes: { newNode: MNode; oldNode: MNode; changeRecords?: ChangeRecord[] }[]];
'move-layer': [offset: number | LayerOffset];
'drag-to': [data: { targetIndex: number; configs: MNode | MNode[]; targetParent: MContainer }];
'history-change': [data: MPage | MPageFragment];
}
export const canUsePluginMethods = {
async: [
'getLayout',
'highlight',
'select',
'multiSelect',
'doAdd',
'add',
'doRemove',
'remove',
'doUpdate',
'update',
'sort',
'copy',
'paste',
'doPaste',
'doAlignCenter',
'alignCenter',
'moveLayer',
'moveToContainer',
'dragTo',
'undo',
'redo',
'move',
] as const,
sync: [],
};
export type AsyncMethodName = Writable<(typeof canUsePluginMethods)['async']>;

View File

@ -1,6 +1,6 @@
import { defineFormConfig } from '@tmagic/form';
import { defineFormConfig, type FormConfig } from '@tmagic/form';
export default () =>
export default (): FormConfig =>
defineFormConfig([
{
name: 'id',

View File

@ -1,83 +1,81 @@
import { DataSchema, DataSourceFieldType, DataSourceSchema } from '@tmagic/core';
import { CascaderOption, FormConfig, FormState } from '@tmagic/form';
import type { DataSchema, DataSourceFieldType, DataSourceSchema } from '@tmagic/core';
import { type CascaderOption, defineFormItem, type FormConfig } from '@tmagic/form';
import { dataSourceTemplateRegExp, getKeysArray, isNumber } from '@tmagic/utils';
import BaseFormConfig from './formConfigs/base';
import HttpFormConfig from './formConfigs/http';
const fillConfig = (config: FormConfig): FormConfig => [
...BaseFormConfig(),
...config,
{
type: 'tab',
items: [
{
title: '数据定义',
items: [
{
name: 'fields',
type: 'data-source-fields',
defaultValue: () => [],
},
],
},
{
title: '方法定义',
items: [
{
name: 'methods',
type: 'data-source-methods',
defaultValue: () => [],
},
],
},
{
title: '事件配置',
items: [
{
name: 'events',
src: 'datasource',
type: 'event-select',
},
],
},
{
title: 'mock数据',
items: [
{
name: 'mocks',
type: 'data-source-mocks',
defaultValue: () => [],
},
],
},
{
title: '请求参数裁剪',
display: (_formState: FormState, { model }: any) => model.type === 'http',
items: [
{
name: 'beforeRequest',
type: 'vs-code',
parse: true,
autosize: { minRows: 10, maxRows: 30 },
},
],
},
{
title: '响应数据裁剪',
display: (_formState: FormState, { model }: any) => model.type === 'http',
items: [
{
name: 'afterResponse',
type: 'vs-code',
parse: true,
autosize: { minRows: 10, maxRows: 30 },
},
],
},
],
},
];
const dataSourceFormConfig = defineFormItem({
type: 'tab',
items: [
{
title: '数据定义',
items: [
{
name: 'fields',
type: 'data-source-fields',
defaultValue: () => [],
},
],
},
{
title: '方法定义',
items: [
{
name: 'methods',
type: 'data-source-methods',
defaultValue: () => [],
},
],
},
{
title: '事件配置',
items: [
{
name: 'events',
src: 'datasource',
type: 'event-select',
},
],
},
{
title: 'mock数据',
items: [
{
name: 'mocks',
type: 'data-source-mocks',
defaultValue: () => [],
},
],
},
{
title: '请求参数裁剪',
display: (_formState, { model }) => model.type === 'http',
items: [
{
name: 'beforeRequest',
type: 'vs-code',
parse: true,
autosize: { minRows: 10, maxRows: 30 },
},
],
},
{
title: '响应数据裁剪',
display: (_formStat, { model }) => model.type === 'http',
items: [
{
name: 'afterResponse',
type: 'vs-code',
parse: true,
autosize: { minRows: 10, maxRows: 30 },
},
],
},
],
});
const fillConfig = (config: FormConfig): FormConfig => [...BaseFormConfig(), ...config, dataSourceFormConfig];
export const getFormConfig = (type: string, configs: Record<string, FormConfig>): FormConfig => {
switch (type) {

View File

@ -0,0 +1,138 @@
/*
* 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 { toRaw } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isPage, isPageFragment } from '@tmagic/utils';
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { getNodeIndex } from '@editor/utils/editor';
export interface HistoryOpContext {
root: MApp;
stage: StageCore | null;
getNodeById(id: Id, raw?: boolean): MNode | null;
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
setRoot(root: MApp): void;
setPage(page: MPage | MPageFragment): void;
getPage(): MPage | MPageFragment | null;
}
/**
* add
* reverse=true
* reverse=false
*/
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
}
/**
* remove
* reverse=true
* reverse=false
*/
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
}
/**
* update
* reverse=true oldNode
* reverse=false newNode
*/
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
ctx.setRoot(cloneDeep(config) as MApp);
continue;
}
const info = ctx.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
ctx.setPage(config as MPage | MPageFragment);
}
}
const curPage = ctx.getPage();
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
}

View File

@ -17,12 +17,13 @@
*/
import { detailedDiff } from 'deep-object-diff';
import { isObject } from 'lodash-es';
import { cloneDeep, get, isObject } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType } from '@tmagic/core';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isFixed } from '@tmagic/stage';
import {
calcValueByFontsize,
getElById,
@ -34,7 +35,8 @@ import {
isValueIncludeDataSource,
} from '@tmagic/utils';
import { Layout } from '@editor/type';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
export const COPY_STORAGE_KEY = '$MagicEditorCopyData';
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
@ -436,3 +438,246 @@ export const buildChangeRecords = (value: any, basePath: string) => {
return changeRecords;
};
/**
* ID解析出选中节点信息
* @param config ID
* @param getNodeInfoFn
* @param rootId ID
* @returns nodeparentpage
*/
export const resolveSelectedNode = (
config: MNode | Id,
getNodeInfoFn: (id: Id) => EditorNodeInfo,
rootId?: Id,
): EditorNodeInfo => {
const id: Id = typeof config === 'string' || typeof config === 'number' ? config : config.id;
if (!id) {
throw new Error('没有ID无法选中');
}
const { node, parent, page } = getNodeInfoFn(id);
if (!node) throw new Error('获取不到组件信息');
if (node.id === rootId) throw new Error('不能选根节点');
return { node, parent, page };
};
/**
* fixed
* fixed fixed
* @param dist
* @param src
* @param root
* @param getLayoutFn
* @returns
*/
export const toggleFixedPosition = async (
dist: MNode,
src: MNode,
root: MApp,
getLayoutFn: (parent: MNode, node?: MNode | null) => Promise<Layout>,
): Promise<MNode> => {
const newConfig = cloneDeep(dist);
if (!isPop(src) && newConfig.style?.position) {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) {
newConfig.style = change2Fixed(newConfig, root);
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) {
newConfig.style = await Fixed2Other(newConfig, root, getLayoutFn);
}
}
return newConfig;
};
/**
*
* absolute fixed top/left bottom/right
* @param style
* @param left
* @param top
* @returns null
*/
export const calcMoveStyle = (style: Record<string, any>, left: number, top: number): Record<string, any> | null => {
if (!style || !['absolute', 'fixed'].includes(style.position)) return null;
const newStyle: Record<string, any> = { ...style };
if (top) {
if (isNumber(style.top)) {
newStyle.top = Number(style.top) + Number(top);
newStyle.bottom = '';
} else if (isNumber(style.bottom)) {
newStyle.bottom = Number(style.bottom) - Number(top);
newStyle.top = '';
}
}
if (left) {
if (isNumber(style.left)) {
newStyle.left = Number(style.left) + Number(left);
newStyle.right = '';
} else if (isNumber(style.right)) {
newStyle.right = Number(style.right) - Number(left);
newStyle.left = '';
}
}
return newStyle;
};
/**
*
* relative DOM 退 width
* @param node
* @param parent
* @param layout
* @param doc document DOM
* @returns null
*/
export const calcAlignCenterStyle = (
node: MNode,
parent: MContainer,
layout: Layout,
doc?: Document,
): Record<string, any> | null => {
if (layout === Layout.RELATIVE || !node.style) return null;
const style = { ...node.style };
if (doc) {
const el = getElById()(doc, node.id);
const parentEl = layout === Layout.FIXED ? doc.body : el?.offsetParent;
if (parentEl && el) {
style.left = calcValueByFontsize(doc, (parentEl.clientWidth - el.clientWidth) / 2);
style.right = '';
}
} else if (parent.style && isNumber(parent.style?.width) && isNumber(node.style?.width)) {
style.left = (parent.style.width - node.style.width) / 2;
style.right = '';
}
return style;
};
/**
*
* "上移""上移"
* @param currentIndex
* @param offset LayerOffset.TOP / LayerOffset.BOTTOM
* @param brothersLength
* @param isRelative
* @returns
*/
export const calcLayerTargetIndex = (
currentIndex: number,
offset: number | LayerOffset,
brothersLength: number,
isRelative: boolean,
): number => {
if (offset === LayerOffset.TOP) {
return isRelative ? 0 : brothersLength;
}
if (offset === LayerOffset.BOTTOM) {
return isRelative ? brothersLength : 0;
}
return currentIndex + (isRelative ? -(offset as number) : (offset as number));
};
/**
* mergeWith
* - undefined source key
* - 使
* -
*/
export const editorNodeMergeCustomizer = (objValue: any, srcValue: any, key: string, _object: any, source: any) => {
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
return '';
}
if (isObject(srcValue) && Array.isArray(objValue)) {
return srcValue;
}
if (Array.isArray(srcValue)) {
return srcValue;
}
};
/**
* copyNodes
* @param copyNodes
* @param collectorOptions
* @param getNodeById ID
*/
export const collectRelatedNodes = (
copyNodes: MNode[],
collectorOptions: TargetOptions,
getNodeById: (id: Id) => MNode | null,
): void => {
const customTarget = new Target({ ...collectorOptions });
const coperWatcher = new Watcher();
coperWatcher.addTarget(customTarget);
coperWatcher.collect(copyNodes, {}, true, collectorOptions.type);
Object.keys(customTarget.deps).forEach((nodeId: Id) => {
const node = getNodeById(nodeId);
if (!node) return;
customTarget.deps[nodeId].keys.forEach((key) => {
const relateNodeId = get(node, key);
const isExist = copyNodes.find((n) => n.id === relateNodeId);
if (!isExist) {
const relateNode = getNodeById(relateNodeId);
if (relateNode) {
copyNodes.push(relateNode);
}
}
});
});
};
export interface DragClassification {
sameParentIndices: number[];
crossParentConfigs: { config: MNode; parent: MContainer }[];
/** 当同父容器节点索引异常时置为 true调用方应中止拖拽操作 */
aborted: boolean;
}
/**
* vs
* @param configs
* @param targetParent
* @param getNodeInfo
* @returns
*/
export const classifyDragSources = (
configs: MNode[],
targetParent: MContainer,
getNodeInfo: (id: Id, raw?: boolean) => EditorNodeInfo,
): DragClassification => {
const sameParentIndices: number[] = [];
const crossParentConfigs: { config: MNode; parent: MContainer }[] = [];
for (const config of configs) {
const { parent, node: curNode } = getNodeInfo(config.id, false);
if (!parent || !curNode) continue;
const path = getNodePath(curNode.id, parent.items);
if (path.some((node) => `${targetParent.id}` === `${node.id}`)) continue;
const index = getNodeIndex(curNode.id, parent);
if (`${parent.id}` === `${targetParent.id}`) {
if (typeof index !== 'number' || index === -1) {
return { sameParentIndices, crossParentConfigs, aborted: true };
}
sameParentIndices.push(index);
} else {
crossParentConfigs.push({ config, parent });
}
}
return { sameParentIndices, crossParentConfigs, aborted: false };
};

View File

@ -27,3 +27,4 @@ export * from './scroll-viewer';
export * from './tree';
export * from './undo-redo';
export * from './const';
export { default as loadMonaco } from './monaco-editor';

View File

@ -24,7 +24,7 @@ import {
NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design';
import type { FormConfig, FormState, TabConfig, TabPaneConfig } from '@tmagic/form';
import type { ChildConfig, DisplayCondsConfig, FormConfig, TabConfig, TabPaneConfig } from '@tmagic/form';
export const arrayOptions = [
{ text: '包含', value: 'include' },
@ -107,6 +107,10 @@ export const styleTabConfig: TabPaneConfig = {
'borderStyle',
'borderColor',
],
} as unknown as ChildConfig,
{
name: 'transform',
defaultValue: () => ({}),
},
],
},
@ -168,9 +172,9 @@ export const advancedTabConfig: TabPaneConfig = {
],
};
export const displayTabConfig: TabPaneConfig = {
export const displayTabConfig: TabPaneConfig<DisplayCondsConfig> = {
title: '显示条件',
display: (_state: FormState, { model }: any) => model.type !== 'page',
display: (_state, { model }) => model.type !== 'page',
items: [
{
name: NODE_CONDS_RESULT_KEY,
@ -209,7 +213,7 @@ export const fillConfig = (
const propsConfig: FormConfig = [];
// 组件类型,必须要有
if (!config.find((item) => item.name === 'type')) {
if (!config.find((item) => 'name' in item && item.name === 'type')) {
propsConfig.push({
text: 'type',
name: 'type',
@ -217,7 +221,7 @@ export const fillConfig = (
});
}
if (!config.find((item) => item.name === 'id')) {
if (!config.find((item) => 'name' in item && item.name === 'id')) {
// 组件id必须要有
propsConfig.push({
name: 'id',
@ -241,14 +245,16 @@ export const fillConfig = (
});
}
if (!config.find((item) => item.name === 'name')) {
if (!config.find((item) => 'name' in item && item.name === 'name')) {
propsConfig.push({
name: 'name',
text: '组件名称',
});
}
const noCodeAdvancedTabItems = advancedTabConfig.items.filter((item) => item.type !== 'code-select');
const noCodeAdvancedTabItems = advancedTabConfig.items.filter(
(item) => 'type' in item && item.type !== 'code-select',
);
if (noCodeAdvancedTabItems.length > 0 && disabledCodeBlock) {
advancedTabConfig.items = noCodeAdvancedTabItems;

View File

@ -23,7 +23,7 @@ export class UndoRedo<T = any> {
private listCursor: number;
private listMaxSize: number;
constructor(listMaxSize = 20) {
constructor(listMaxSize = 100) {
const minListMaxSize = 2;
this.elementList = [];
this.listCursor = 0;
@ -42,29 +42,30 @@ export class UndoRedo<T = any> {
}
public canUndo(): boolean {
return this.listCursor > 1;
return this.listCursor > 0;
}
// 返回undo后的当前元素
/** 返回被撤销的操作 */
public undo(): T | null {
if (!this.canUndo()) {
return null;
}
this.listCursor -= 1;
return this.getCurrentElement();
return cloneDeep(this.elementList[this.listCursor]);
}
public canRedo() {
return this.elementList.length > this.listCursor;
}
// 返回redo后的当前元素
/** 返回被重做的操作 */
public redo(): T | null {
if (!this.canRedo()) {
return null;
}
const element = cloneDeep(this.elementList[this.listCursor]);
this.listCursor += 1;
return this.getCurrentElement();
return element;
}
public getCurrentElement(): T | null {

View File

@ -30,6 +30,9 @@ import { COPY_STORAGE_KEY, setEditorConfig } from '@editor/utils';
setEditorConfig({
// eslint-disable-next-line no-eval
parseDSL: (dsl: string) => eval(dsl),
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options),
});
// mock window.localStage

View File

@ -0,0 +1,245 @@
/*
* 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 { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { StepValue } from '@editor/type';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
const makePage = (): MContainer => ({
id: 'page_1',
type: NodeType.PAGE,
items: [
{ id: 'n1', type: 'text' },
{ id: 'n2', type: 'button' },
],
});
const makeRoot = (page: MContainer): MApp => ({
id: 'app_1',
type: NodeType.ROOT,
items: [page],
});
const makeCtx = (root: MApp): HistoryOpContext => {
const page = root.items[0] as MContainer;
return {
root,
stage: {
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
} as any,
getNodeById: (id: any) => {
if (`${id}` === `${root.id}`) return root as unknown as MNode;
if (`${id}` === `${page.id}`) return page as unknown as MNode;
return page.items.find((n) => `${n.id}` === `${id}`) ?? null;
},
getNodeInfo: (id: any) => {
if (`${id}` === `${page.id}`) {
return { node: page as unknown as MNode, parent: root as unknown as MContainer, page: page as any };
}
const node = page.items.find((n) => `${n.id}` === `${id}`);
return { node: node ?? null, parent: node ? page : null, page: page as any };
},
setRoot: vi.fn(),
setPage: vi.fn(),
getPage: () => page as any,
};
};
describe('applyHistoryAddOp', () => {
test('撤销 add从父节点移除已添加的节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['n1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'page_1',
};
expect(page.items).toHaveLength(2);
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
test('重做 add重新添加节点到父节点', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['new1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'new1', type: 'text' }],
parentId: 'page_1',
indexMap: { new1: 0 },
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('new1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
});
describe('applyHistoryRemoveOp', () => {
test('撤销 remove将已删除节点按原位置重新插入', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n2', type: 'button' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: ['n1'],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
await applyHistoryRemoveOp(step, true, ctx);
expect(page.items).toHaveLength(2);
expect(page.items[0].id).toBe('n1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
test('重做 remove再次删除节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
expect(page.items).toHaveLength(2);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
});
describe('applyHistoryUpdateOp', () => {
test('撤销 update将节点恢复为 oldNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(page.items[0].text).toBe('before');
expect(ctx.stage!.update).toHaveBeenCalled();
});
test('重做 update将节点更新为 newNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(page.items[0].text).toBe('after');
});
test('update ROOT 类型调用 setRoot', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'app_1', type: NodeType.ROOT, items: [] } as any,
newNode: { id: 'app_1', type: NodeType.ROOT, items: [page] } as any,
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(ctx.setRoot).toHaveBeenCalled();
});
test('update 页面节点调用 setPage', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const updatedPage = { ...page, name: 'renamed' };
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: page as any,
newNode: updatedPage as any,
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.setPage).toHaveBeenCalled();
});
});

View File

@ -17,8 +17,11 @@
*/
import { describe, expect, test } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { EditorNodeInfo } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type';
import * as editor from '@editor/utils/editor';
describe('util form', () => {
@ -305,3 +308,452 @@ describe('buildChangeRecords', () => {
]);
});
});
// ===== 以下为新提取的工具函数测试 =====
const mockRoot: MApp = {
id: 'app_1',
type: NodeType.ROOT,
items: [
{
id: 'page_1',
type: NodeType.PAGE,
name: 'index',
style: { position: 'relative', width: 375 },
items: [
{
id: 'node_1',
type: 'text',
style: { position: 'absolute', top: 10, left: 20, width: 100 },
},
{
id: 'node_2',
type: 'button',
style: { position: 'absolute', bottom: 50, right: 30 },
},
{
id: 'node_3',
type: 'image',
style: { position: 'relative', top: 0, left: 0 },
},
],
},
],
};
const mockGetNodeInfo = (id: string | number): EditorNodeInfo => {
const page = mockRoot.items[0];
if (`${id}` === `${mockRoot.id}`) {
return { node: mockRoot as unknown as MNode, parent: null, page: null };
}
if (`${id}` === `${page.id}`) {
return { node: page, parent: mockRoot as unknown as MContainer, page: page as any };
}
const items = (page as MContainer).items || [];
const node = items.find((n: MNode) => `${n.id}` === `${id}`);
if (node) {
return { node, parent: page as MContainer, page: page as any };
}
return { node: null, parent: null, page: null };
};
describe('resolveSelectedNode', () => {
test('传入数字ID正常返回节点信息', () => {
const result = editor.resolveSelectedNode('node_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_1');
expect(result.parent?.id).toBe('page_1');
expect(result.page?.id).toBe('page_1');
});
test('传入节点配置对象,正常返回节点信息', () => {
const config: MNode = { id: 'node_2', type: 'button' };
const result = editor.resolveSelectedNode(config, mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('node_2');
});
test('传入页面ID正常返回页面信息', () => {
const result = editor.resolveSelectedNode('page_1', mockGetNodeInfo, mockRoot.id);
expect(result.node?.id).toBe('page_1');
});
test('传入空ID抛出错误', () => {
expect(() => editor.resolveSelectedNode({ id: '', type: 'text' }, mockGetNodeInfo)).toThrow('没有ID无法选中');
});
test('传入不存在的ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('not_exist', mockGetNodeInfo)).toThrow('获取不到组件信息');
});
test('传入根节点ID抛出错误', () => {
expect(() => editor.resolveSelectedNode('app_1', mockGetNodeInfo, mockRoot.id)).toThrow('不能选根节点');
});
test('不传rootId时不校验根节点', () => {
const result = editor.resolveSelectedNode('app_1', mockGetNodeInfo);
expect(result.node?.id).toBe('app_1');
});
});
describe('toggleFixedPosition', () => {
const getLayoutFn = async () => Layout.ABSOLUTE;
test('非fixed变为fixed调用change2Fixed', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
expect(result).not.toBe(dist);
});
test('fixed变为非fixed调用Fixed2Other', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'fixed', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('absolute');
});
test('定位未变化,不修改样式', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10, left: 20 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 30, left: 40 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.top).toBe(30);
expect(result.style?.left).toBe(40);
});
test('pop类型节点不做处理', async () => {
const src: MNode = {
id: 'node_1',
type: 'pop',
style: { position: 'absolute', top: 10, left: 20 },
name: 'pop',
};
const dist: MNode = { id: 'node_1', type: 'pop', style: { position: 'fixed', top: 10, left: 20 }, name: 'pop' };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBe('fixed');
});
test('目标节点无position属性不做处理', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute' } };
const dist: MNode = { id: 'node_1', type: 'text', style: { width: 100 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result.style?.position).toBeUndefined();
});
test('返回深拷贝,不修改原对象', async () => {
const src: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 10 } };
const dist: MNode = { id: 'node_1', type: 'text', style: { position: 'absolute', top: 20 } };
const result = await editor.toggleFixedPosition(dist, src, mockRoot, getLayoutFn);
expect(result).not.toBe(dist);
expect(dist.style?.top).toBe(20);
});
});
describe('calcMoveStyle', () => {
test('absolute定位向下移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 5);
expect(result).toEqual({ position: 'absolute', top: 15, left: 20, bottom: '' });
});
test('absolute定位向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 5, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 25, right: '' });
});
test('absolute定位同时向下和向右移动', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 3, 7);
expect(result).toEqual({ position: 'absolute', top: 17, left: 23, bottom: '', right: '' });
});
test('fixed定位正常移动', () => {
const style = { position: 'fixed', top: 100, left: 200 };
const result = editor.calcMoveStyle(style, -10, -20);
expect(result).toEqual({ position: 'fixed', top: 80, left: 190, bottom: '', right: '' });
});
test('使用bottom定位时向下移动减小bottom', () => {
const style = { position: 'absolute', bottom: 50, left: 20 };
const result = editor.calcMoveStyle(style, 0, 10);
expect(result?.bottom).toBe(40);
expect(result?.top).toBe('');
});
test('使用right定位时向右移动减小right', () => {
const style = { position: 'absolute', top: 10, right: 30 };
const result = editor.calcMoveStyle(style, 10, 0);
expect(result?.right).toBe(20);
expect(result?.left).toBe('');
});
test('relative定位返回null', () => {
const style = { position: 'relative', top: 0, left: 0 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('无position属性返回null', () => {
const style = { width: 100 };
const result = editor.calcMoveStyle(style, 10, 10);
expect(result).toBeNull();
});
test('空样式对象返回null', () => {
const result = editor.calcMoveStyle({}, 10, 10);
expect(result).toBeNull();
});
test('偏移量为0不修改样式', () => {
const style = { position: 'absolute', top: 10, left: 20 };
const result = editor.calcMoveStyle(style, 0, 0);
expect(result).toEqual({ position: 'absolute', top: 10, left: 20 });
});
test('不修改原对象', () => {
const style = { position: 'absolute', top: 10, left: 20 };
editor.calcMoveStyle(style, 5, 5);
expect(style.top).toBe(10);
expect(style.left).toBe(20);
});
});
describe('calcAlignCenterStyle', () => {
test('absolute布局通过配置中的width计算居中', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(137.5);
expect(result?.right).toBe('');
});
test('relative布局返回null', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.RELATIVE);
expect(result).toBeNull();
});
test('节点无style返回null', () => {
const node: MNode = { id: 'n1', type: 'text' };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result).toBeNull();
});
test('父节点无style不修改', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = { id: 'p1', type: NodeType.PAGE, items: [] } as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('父节点width非数字不修改left', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 10 } };
const parent = {
id: 'p1',
type: NodeType.PAGE,
style: { width: '100%' },
items: [],
} as unknown as MContainer;
const result = editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(result?.left).toBe(10);
});
test('不修改原节点style', () => {
const node: MNode = { id: 'n1', type: 'text', style: { width: 100, left: 0 } };
const parent = { id: 'p1', type: NodeType.PAGE, style: { width: 375 }, items: [] } as unknown as MContainer;
editor.calcAlignCenterStyle(node, parent, Layout.ABSOLUTE);
expect(node.style?.left).toBe(0);
});
});
describe('calcLayerTargetIndex', () => {
test('绝对定位向上移动1层', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, false);
expect(result).toBe(3);
});
test('绝对定位向下移动1层', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, false);
expect(result).toBe(1);
});
test('流式布局向上移动1层索引减小', () => {
const result = editor.calcLayerTargetIndex(2, 1, 5, true);
expect(result).toBe(1);
});
test('流式布局向下移动1层索引增大', () => {
const result = editor.calcLayerTargetIndex(2, -1, 5, true);
expect(result).toBe(3);
});
test('绝对定位,置顶', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.TOP, 5, false);
expect(result).toBe(5);
});
test('绝对定位,置底', () => {
const result = editor.calcLayerTargetIndex(2, LayerOffset.BOTTOM, 5, false);
expect(result).toBe(0);
});
test('流式布局,置顶(索引最小)', () => {
const result = editor.calcLayerTargetIndex(3, LayerOffset.TOP, 5, true);
expect(result).toBe(0);
});
test('流式布局,置底(索引最大)', () => {
const result = editor.calcLayerTargetIndex(1, LayerOffset.BOTTOM, 5, true);
expect(result).toBe(5);
});
test('偏移量为0索引不变', () => {
const result = editor.calcLayerTargetIndex(2, 0, 5, false);
expect(result).toBe(2);
});
});
describe('editorNodeMergeCustomizer', () => {
test('undefined 且 source 拥有该 key 时返回空字符串', () => {
const source = { name: undefined };
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, source);
expect(result).toBe('');
});
test('source 不拥有该 key 时返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', undefined, 'name', {}, {});
expect(result).toBeUndefined();
});
test('原来是数组,新值是对象,使用新值', () => {
const srcValue = { a: 1 };
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('新值是数组,直接替换', () => {
const srcValue = [3, 4];
const result = editor.editorNodeMergeCustomizer([1, 2], srcValue, 'key', {}, {});
expect(result).toBe(srcValue);
});
test('都是普通值,返回 undefined使用默认合并', () => {
const result = editor.editorNodeMergeCustomizer('old', 'new', 'key', {}, {});
expect(result).toBeUndefined();
});
});
describe('classifyDragSources', () => {
const makeTree = (): { root: MApp; getNodeInfo: (id: any, raw?: boolean) => EditorNodeInfo } => {
const child1: MNode = { id: 'c1', type: 'text' };
const child2: MNode = { id: 'c2', type: 'text' };
const child3: MNode = { id: 'c3', type: 'text' };
const container1: MContainer = {
id: 'cont1',
type: NodeType.CONTAINER,
items: [child1, child2],
};
const container2: MContainer = {
id: 'cont2',
type: NodeType.CONTAINER,
items: [child3],
};
const page: any = {
id: 'page_1',
type: NodeType.PAGE,
items: [container1, container2],
};
const root: MApp = { id: 'app', type: NodeType.ROOT, items: [page] };
const getNodeInfo = (id: any): EditorNodeInfo => {
if (`${id}` === 'c1' || `${id}` === 'c2') {
return {
node: container1.items.find((n) => `${n.id}` === `${id}`) ?? null,
parent: container1,
page,
};
}
if (`${id}` === 'c3') {
return { node: child3, parent: container2, page };
}
if (`${id}` === 'cont1') {
return { node: container1, parent: page, page };
}
if (`${id}` === 'cont2') {
return { node: container2, parent: page, page };
}
return { node: null, parent: null, page: null };
};
return { root, getNodeInfo };
};
test('同父容器内拖拽,返回 sameParentIndices', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('跨容器拖拽,返回 crossParentConfigs', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c3', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(1);
expect(result.crossParentConfigs[0].config.id).toBe('c3');
});
test('混合拖拽:同容器+跨容器', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources(
[
{ id: 'c1', type: 'text' },
{ id: 'c3', type: 'text' },
],
targetParent,
getNodeInfo,
);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(1);
});
test('节点不存在时跳过', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'nonexistent', type: 'text' }], targetParent, getNodeInfo);
expect(result.aborted).toBe(false);
expect(result.sameParentIndices).toHaveLength(0);
expect(result.crossParentConfigs).toHaveLength(0);
});
test('目标容器在节点路径上时跳过(防止循环嵌套)', () => {
const { getNodeInfo } = makeTree();
const targetParent = getNodeInfo('cont1').node as MContainer;
const result = editor.classifyDragSources([{ id: 'c1', type: 'text' }], targetParent, (id: any) => {
if (`${id}` === 'c1') {
return {
node: { id: 'c1', type: 'text' },
parent: targetParent,
page: { id: 'page_1', type: NodeType.PAGE, items: [] } as any,
};
}
return { node: null, parent: null, page: null };
});
expect(result.sameParentIndices).toEqual([0]);
expect(result.crossParentConfigs).toHaveLength(0);
});
});

View File

@ -21,60 +21,66 @@ import { UndoRedo } from '@editor/utils/undo-redo';
describe('undo', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
undoRedo.pushElement(element);
});
test('can no undo: empty list', () => {
test('can not undo: empty list', () => {
expect(undoRedo.canUndo()).toBe(false);
expect(undoRedo.undo()).toEqual(null);
});
test('can undo', () => {
test('can undo after one push', () => {
undoRedo.pushElement({ a: 1 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual({ a: 1 });
expect(undoRedo.canUndo()).toBe(false);
});
test('can undo returns the operation being undone', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual(element);
expect(undoRedo.undo()).toEqual({ a: 2 });
expect(undoRedo.canUndo()).toBe(true);
expect(undoRedo.undo()).toEqual({ a: 1 });
expect(undoRedo.canUndo()).toBe(false);
});
});
describe('redo', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
undoRedo.pushElement(element);
});
test('can no redo: empty list', () => {
test('can not redo: empty list', () => {
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null);
});
test('can no redo: no undo', () => {
test('can not redo: no undo', () => {
for (let i = 0; i < 5; i++) {
undoRedo.pushElement(element);
undoRedo.pushElement({ a: i });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null);
}
});
test('can no redo: undo and push', () => {
undoRedo.pushElement(element);
test('can not redo: undo and push', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.pushElement(element);
undoRedo.pushElement({ a: 3 });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toEqual(null);
});
test('can no redo: redo end', () => {
const element1 = { a: 1 };
const element2 = { a: 2 };
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
test('can not redo: redo end', () => {
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.undo();
undoRedo.redo();
@ -85,23 +91,20 @@ describe('redo', () => {
});
test('can redo', () => {
const element1 = { a: 1 };
const element2 = { a: 2 };
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo();
undoRedo.undo();
expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element1);
expect(undoRedo.redo()).toEqual({ a: 1 });
expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element2);
expect(undoRedo.redo()).toEqual({ a: 2 });
});
});
describe('get current element', () => {
let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo();
@ -112,44 +115,38 @@ describe('get current element', () => {
});
test('has element', () => {
undoRedo.pushElement(element);
expect(undoRedo.getCurrentElement()).toEqual(element);
undoRedo.pushElement({ a: 1 });
expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
});
});
describe('list max size', () => {
let undoRedo: UndoRedo;
const listMaxSize = 100;
const element = { a: 1 };
beforeEach(() => {
undoRedo = new UndoRedo(listMaxSize);
undoRedo.pushElement(element);
});
test('reach max size', () => {
for (let i = 0; i < listMaxSize; i++) {
for (let i = 0; i <= listMaxSize; i++) {
undoRedo.pushElement({ a: i });
}
undoRedo.pushElement({ a: listMaxSize }); // 这个元素使得list达到maxSize触发数据删除
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.canUndo()).toBe(true);
});
test('reach max size, then undo', () => {
for (let i = 0; i < listMaxSize + 1; i++) {
test('reach max size, then undo all', () => {
for (let i = 0; i <= listMaxSize; i++) {
undoRedo.pushElement({ a: i });
}
for (let i = 0; i < listMaxSize - 1; i++) {
for (let i = 0; i < listMaxSize; i++) {
undoRedo.undo();
}
const ele = undoRedo.getCurrentElement();
undoRedo.undo();
expect(ele?.a).toBe(1); // 经过超过maxSize被删元素之后原本a值为0的第一个元素已经被删除现在第一个元素a值为1
expect(undoRedo.canUndo()).toBe(false);
expect(undoRedo.getCurrentElement()).toEqual(element);
expect(undoRedo.getCurrentElement()).toEqual(null);
});
});

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/element-plus-adapter",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-element-plus-adapter.umd.cjs",
"module": "dist/tmagic-element-plus-adapter.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-element-plus-adapter.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-element-plus-adapter.umd.cjs"
},
"./*": "./*"

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/form-schema",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-form-schema.umd.cjs",
"module": "dist/tmagic-form-schema.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-form-schema.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-form-schema.umd.cjs"
},
"./*": "./*"

View File

@ -91,12 +91,10 @@ export interface FormItem {
/** vnode的key值默认是遍历数组时的index */
__key?: string | number;
/** 表单域标签的的宽度,例如 '50px'。支持 auto。 */
labelWidth?: string;
labelWidth?: string | number;
/** label 标签的title属性 */
labelTitle?: string;
className?: string;
/** 表单组件类型 */
type?: string | TypeFunction;
/** 字段名 */
name?: string | number;
/** 额外的提示信息,和 help 类似,当提示文案同时出现时,可以使用这个。 */
@ -129,11 +127,16 @@ export interface FormItem {
expand?: boolean;
style?: Record<string, any>;
fieldStyle?: Record<string, any>;
labelPosition?: 'top' | 'left' | 'right';
}
export interface DynamicTypeConfig extends FormItem {
type: TypeFunction;
[key: string]: any;
}
export interface ContainerCommonConfig {
items: FormConfig;
export interface ContainerCommonConfig<T = never> extends FormItem {
items: FormConfig<T>;
onInitValue?: (
mForm: FormState | undefined,
data: {
@ -182,12 +185,12 @@ export interface Input {
placeholder?: string;
}
export type TypeFunction = (
export type TypeFunction<T extends string = string> = (
mForm: FormState | undefined,
data: {
model: FormValue;
},
) => string;
) => T;
export type FilterFunction<T = boolean> = (
mForm: FormState | undefined,
@ -208,6 +211,7 @@ export type FilterFunction<T = boolean> = (
*/
export interface SelectConfigOption {
/** 选项的标签 */
label?: string | SelectOptionTextFunction;
text: string | SelectOptionTextFunction;
/** 选项的值 */
value: any | SelectOptionValueFunction;
@ -344,13 +348,15 @@ export interface HtmlField extends FormItem {
export interface DisplayConfig extends FormItem {
type: 'display';
initValue?: string | number | boolean;
displayText: FilterFunction<string> | string;
displayText?: FilterFunction<string> | string;
}
/** 文本输入框 */
export interface TextConfig extends FormItem, Input {
type?: 'text';
tooltip?: string;
/** 是否可清空 */
clearable?: boolean;
prepend?: string;
/** 后置元素,一般为标签或按钮 */
append?:
@ -436,6 +442,17 @@ export interface TimeConfig extends FormItem, Input {
valueFormat?: 'HH:mm:ss' | string;
}
/**
*
*/
export interface TimerangeConfig extends FormItem {
type: 'timerange';
names?: string[];
defaultTime?: Date[];
format?: 'HH:mm:ss' | string;
valueFormat?: 'HH:mm:ss' | string;
}
/**
*
*/
@ -459,11 +476,11 @@ export interface SwitchConfig extends FormItem {
*
*/
export interface RadioGroupConfig extends FormItem {
type: 'radio-group';
type: 'radio-group' | 'radioGroup';
childType?: 'default' | 'button';
options: {
value: string | number | boolean;
text: string;
text?: string;
icon?: any;
tooltip?: string;
}[];
@ -486,7 +503,7 @@ export interface CheckboxGroupOption {
*
*/
export interface CheckboxGroupConfig extends FormItem {
type: 'checkbox-group';
type: 'checkbox-group' | 'checkboxGroup';
options: CheckboxGroupOption[] | FilterFunction<CheckboxGroupOption[]>;
}
@ -533,7 +550,7 @@ export interface SelectConfig extends FormItem, Input {
/**
*
*/
export interface LinkConfig extends FormItem {
export interface LinkConfig<T = never> extends FormItem {
type: 'link';
href?: string | ((model: Record<string, any>) => string);
css?: {
@ -553,7 +570,7 @@ export interface LinkConfig extends FormItem {
) => string)
| string;
form:
| FormConfig
| FormConfig<T>
| ((
mForm: FormState | undefined,
data: {
@ -561,7 +578,7 @@ export interface LinkConfig extends FormItem {
values?: Readonly<FormValue> | null;
formValue?: FormValue;
},
) => FormConfig);
) => FormConfig<T>);
fullscreen?: boolean;
}
@ -602,7 +619,7 @@ export interface CascaderConfig extends FormItem, Input {
}
export interface DynamicFieldConfig extends FormItem {
type: 'dynamic-field';
type: 'dynamic-field' | 'dynamicField';
returnFields: (
config: DynamicFieldConfig,
model: Record<any, any>,
@ -618,32 +635,38 @@ export interface DynamicFieldConfig extends FormItem {
/**
*
*/
export interface RowConfig extends FormItem {
export interface RowConfig<T = never> extends FormItem {
type: 'row';
span: number;
items: ({ span?: number } & (ChildConfig | EditorChildConfig))[];
items: ({ span?: number } & (ChildConfig<T> | EditorChildConfig | T))[];
}
/**
*
*/
export interface TabPaneConfig {
export interface TabPaneConfig<T = never> {
status?: string;
/** 标签页名称,用于关联 model 中的数据 */
name?: string | number;
title: string;
lazy?: boolean;
labelWidth?: string;
items: FormConfig;
items: FormConfig<T>;
display?: boolean | 'expand' | FilterFunction<boolean | 'expand'>;
onTabClick?: (mForm: FormState | undefined, tab: any, data: any) => void;
[key: string]: any;
}
export interface TabConfig extends FormItem, ContainerCommonConfig {
export interface TabConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
type: 'tab' | 'dynamic-tab';
tabType?: string;
editable?: boolean;
dynamic?: boolean;
tabPosition?: 'top' | 'right' | 'bottom' | 'left';
items: TabPaneConfig[];
/** 当前激活的标签页,可以是固定值或动态函数 */
active?:
| string
| ((mForm: FormState | undefined, data: { model: FormValue; formValue?: FormValue; prop: string }) => string);
items: TabPaneConfig<T>[];
onChange?: (mForm: FormState | undefined, data: any) => void;
onTabAdd?: (mForm: FormState | undefined, data: any) => void;
onTabRemove?: (mForm: FormState | undefined, tabName: string, data: any) => void;
@ -654,7 +677,7 @@ export interface TabConfig extends FormItem, ContainerCommonConfig {
/**
*
*/
export interface FieldsetConfig extends FormItem, ContainerCommonConfig {
export interface FieldsetConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
type: 'fieldset';
checkbox?:
| boolean
@ -671,7 +694,7 @@ export interface FieldsetConfig extends FormItem, ContainerCommonConfig {
/**
*
*/
export interface PanelConfig extends FormItem, ContainerCommonConfig {
export interface PanelConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
type: 'panel';
expand?: boolean;
title?: string;
@ -683,7 +706,10 @@ export interface TableColumnConfig extends FormItem {
label: string;
width?: string | number;
sortable?: boolean;
[key: string]: any;
items?: FormConfig;
itemsFunction?: (row: any) => FormConfig;
titleTip?: FilterFunction<string>;
type?: string;
}
/**
@ -715,10 +741,11 @@ export interface TableConfig extends FormItem {
importable?: (mForm: FormState | undefined, data: any) => boolean | 'undefined' | boolean;
/** 是否显示checkbox */
selection?: (mForm: FormState | undefined, data: any) => boolean | boolean | 'single';
/** 新增的默认行 */
defaultAdd?: (mForm: FormState | undefined, data: any) => any;
/** 新增的默认行,可以是函数动态生成或静态对象 */
defaultAdd?: ((mForm: FormState | undefined, data: any) => any) | Record<string, any>;
copyHandler?: (mForm: FormState | undefined, data: any) => any;
onSelect?: (mForm: FormState | undefined, data: any) => any;
/** @deprecated 请使用 defaultSort */
defautSort?: SortProp;
defaultSort?: SortProp;
/** 是否支持拖拽排序 */
@ -739,15 +766,17 @@ export interface TableConfig extends FormItem {
props?: Record<string, any>;
text?: string;
};
sort?: boolean;
sortKey?: string;
}
export interface GroupListConfig extends FormItem {
export interface GroupListConfig<T = never> extends FormItem {
type: 'table' | 'groupList' | 'group-list';
span?: number;
enableToggleMode?: boolean;
items: FormConfig;
groupItems?: FormConfig;
tableItems?: FormConfig;
items: FormConfig<T>;
groupItems?: FormConfig<T>;
tableItems?: FormConfig<T>;
titleKey?: string;
titlePrefix?: string;
title?: string | FilterFunction<string>;
@ -760,7 +789,8 @@ export interface GroupListConfig extends FormItem {
*/
defaultExpandQuantity?: number;
addable?: (mForm: FormState | undefined, data: any) => boolean | 'undefined' | boolean;
defaultAdd?: (mForm: FormState | undefined, data: any) => any;
/** 新增的默认值,可以是函数动态生成或静态对象 */
defaultAdd?: ((mForm: FormState | undefined, data: any) => any) | Record<string, any>;
delete?: (model: any, index: number | string | symbol, values: any) => boolean | boolean;
copyable?: FilterFunction<boolean>;
movable?: (
@ -774,45 +804,50 @@ export interface GroupListConfig extends FormItem {
props?: Record<string, any>;
text?: string;
};
[key: string]: any;
}
interface StepItemConfig extends FormItem, ContainerCommonConfig {
interface StepItemConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
title: string;
}
export interface StepConfig extends FormItem {
export interface StepConfig<T = never> extends FormItem {
type: 'step';
/** 每个 step 的间距,不填写将自适应间距。支持百分比。 */
space?: string | number;
items: StepItemConfig[];
items: StepItemConfig<T>[];
}
export interface ComponentConfig extends FormItem {
type: 'component';
id: string;
extend: any;
display: any;
extend?: any;
display?: any;
component?: any;
}
export interface FlexLayoutConfig extends FormItem, ContainerCommonConfig {
export interface FlexLayoutConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
type: 'flex-layout';
/** flex 子项间距,默认 '16px' */
gap?: string;
}
export type ChildConfig =
| FormItem
| TabConfig
| RowConfig
| FieldsetConfig
| PanelConfig
export type ChildConfig<T = never> =
| ContainerCommonConfig<T>
| TabConfig<T>
| RowConfig<T>
| FieldsetConfig<T>
| PanelConfig<T>
| TableConfig
| GroupListConfig
| StepConfig
| GroupListConfig<T>
| StepConfig<T>
| DisplayConfig
| TextConfig
| NumberConfig
| NumberRangeConfig
| HiddenConfig
| LinkConfig
| LinkConfig<T>
| DaterangeConfig
| TimerangeConfig
| SelectConfig
| CascaderConfig
| HtmlField
@ -823,8 +858,12 @@ export type ChildConfig =
| CheckboxConfig
| SwitchConfig
| RadioGroupConfig
| CheckboxGroupConfig
| TextareaConfig
| DynamicFieldConfig
| ComponentConfig;
| ComponentConfig
| FlexLayoutConfig<T>;
export type FormConfig = (ChildConfig | EditorChildConfig)[];
export type FormItemConfig<T = never> = ChildConfig<T> | DynamicTypeConfig | EditorChildConfig<T> | T;
export type FormConfig<T = never> = FormItemConfig<T>[];

View File

@ -1,8 +1,8 @@
import type { DataSourceFieldType, DataSourceSchema } from '@tmagic/schema';
import type { ChildConfig, FilterFunction, FormItem, FormState, Input } from './base';
import type { FilterFunction, FormItem, FormItemConfig, FormState, Input } from './base';
export interface DataSourceFieldSelectConfig extends FormItem {
export interface DataSourceFieldSelectConfig<T = never> extends FormItem {
type: 'data-source-field-select';
/**
* data
@ -26,13 +26,15 @@ export interface DataSourceFieldSelectConfig extends FormItem {
},
) => boolean);
dataSourceFieldType?: DataSourceFieldType[];
fieldConfig?: ChildConfig;
fieldConfig?: FormItemConfig<T>;
/** 是否可以编辑数据源disable表示的是是否可以选择数据源 */
notEditable?: boolean | FilterFunction;
dataSourceId?: string;
}
export interface CodeConfig extends FormItem {
type: 'code';
type: 'vs-code';
language?: string;
options?: {
[key: string]: any;
@ -104,6 +106,7 @@ export interface DataSourceSelect extends FormItem, Input {
}
export interface DisplayCondsConfig extends FormItem {
type: 'display-conds';
titlePrefix?: string;
parentFields?: string[] | FilterFunction<string[]>;
}
@ -140,8 +143,12 @@ export interface UISelectConfig extends FormItem {
type: 'ui-select';
}
export type EditorChildConfig =
| DataSourceFieldSelectConfig
export interface StyleSetterConfig extends FormItem {
type: 'style-setter';
}
export type EditorChildConfig<T = never> =
| DataSourceFieldSelectConfig<T>
| CodeConfig
| CodeLinkConfig
| CodeSelectConfig
@ -157,4 +164,5 @@ export type EditorChildConfig =
| EventSelectConfig
| KeyValueConfig
| PageFragmentSelectConfig
| UISelectConfig;
| UISelectConfig
| StyleSetterConfig;

View File

@ -1,6 +1,8 @@
import type { FormConfig } from './base';
import type { FormConfig, FormItemConfig } from './base';
export * from './base';
export * from './editor';
export const defineFormConfig = <T = FormConfig>(config: T): T => config;
export const defineFormConfig = <T = never>(config: FormConfig<T>): FormConfig<T> => config;
export const defineFormItem = <T = never>(config: FormItemConfig<T>): FormItemConfig<T> => config;

View File

@ -1,19 +1,20 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/form",
"type": "module",
"sideEffects": [
"dist/style.css",
"dist/es/style.css",
"src/theme/*"
],
"main": "dist/tmagic-form.umd.cjs",
"module": "dist/tmagic-form.js",
"module": "dist/es/index.js",
"style": "dist/style.css",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-form.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-form.umd.cjs"
},
"./dist/style.css": {

View File

@ -218,13 +218,13 @@ const getTextByName = (name: string, config: FormConfig = props.config): string
return typeof item.text === 'string' ? item.text : undefined;
}
if (item.items && Array.isArray(item.items)) {
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, remainingParts);
if (result !== undefined) return result;
}
}
if (item.items && Array.isArray(item.items)) {
if ('items' in item && Array.isArray(item.items)) {
const result = findInConfig(item.items, parts);
if (result !== undefined) return result;
}

View File

@ -117,19 +117,25 @@ const stepActive = ref(1);
const bodyHeight = ref(`${document.body.clientHeight - 194}px`);
const stepCount = computed(() => {
const { length } = props.config;
for (let index = 0; index < length; index++) {
if (props.config[index].type === 'step') {
return (props.config[index] as StepConfig).items.length;
if (!Array.isArray(props.config)) {
return 0;
}
for (const item of props.config) {
if ('type' in item && item.type === 'step') {
return (item as StepConfig).items.length;
}
}
return 0;
});
const hasStep = computed(() => {
const { length } = props.config;
for (let index = 0; index < length; index++) {
if (props.config[index].type === 'step') {
if (!Array.isArray(props.config)) {
return false;
}
for (const item of props.config) {
if ('type' in item && item.type === 'step') {
return true;
}
}

View File

@ -1,5 +1,5 @@
<template>
<TMagicCol v-show="display && config.type !== 'hidden'" :span="span">
<TMagicCol v-show="display && type !== 'hidden'" :span="span">
<Container
:model="model"
:lastValues="lastValues"
@ -21,7 +21,7 @@ import { computed, inject } from 'vue';
import { TMagicCol } from '@tmagic/design';
import type { ChildConfig, ContainerChangeEventData, FormState } from '../schema';
import type { ContainerChangeEventData, FormItemConfig, FormState } from '../schema';
import { display as displayFunction } from '../utils/form';
import Container from './Container.vue';
@ -34,8 +34,8 @@ const props = defineProps<{
model: any;
lastValues?: any;
isCompare?: boolean;
config: ChildConfig;
labelWidth?: string;
config: FormItemConfig;
labelWidth?: string | number;
expandMore?: boolean;
span?: number;
size?: string;
@ -52,4 +52,6 @@ const mForm = inject<FormState | undefined>('mForm');
const display = computed(() => displayFunction(mForm, props.config.display, props));
const changeHandler = (v: any, eventData: ContainerChangeEventData) => emit('change', v, eventData);
const onAddDiffCount = () => emit('addDiffCount');
const type = computed(() => (props.config as any).type);
</script>

View File

@ -1,11 +1,11 @@
<template>
<div
:data-tmagic-id="config.id"
:data-tmagic-id="(config as Record<string, any>).id"
:data-tmagic-form-item-prop="itemProp"
:class="`m-form-container m-container-${type || ''} ${config.className || ''}${config.tip ? ' has-tip' : ''}`"
:style="config.style"
>
<m-fields-hidden v-if="type === 'hidden'" v-bind="fieldsProps" :model="model"></m-fields-hidden>
<MHidden v-if="type === 'hidden'" :name="`${name}`" :prop="itemProp" :model="model"></MHidden>
<component
v-else-if="items && !text && type && display"
@ -28,7 +28,7 @@
<FormLabel
:tip="config.tip"
:type="type"
:use-label="config.useLabel"
:use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle"
:text="text"
></FormLabel>
@ -61,7 +61,7 @@
></component>
</TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !config.useLabel" placement="top">
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -80,7 +80,7 @@
<FormLabel
:tip="config.tip"
:type="type"
:use-label="config.useLabel"
:use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle"
:text="text"
></FormLabel>
@ -95,7 +95,7 @@
<component v-else v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component>
</TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !config.useLabel" placement="top">
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -112,7 +112,7 @@
<FormLabel
:tip="config.tip"
:type="type"
:use-label="config.useLabel"
:use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle"
:text="text"
></FormLabel>
@ -127,7 +127,7 @@
<component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
</TMagicFormItem>
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !config.useLabel" placement="top">
<TMagicTooltip v-if="config.tip && type === 'checkbox' && !(config as CheckboxConfig).useLabel" placement="top">
<TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content>
<div v-html="config.tip"></div>
@ -172,14 +172,18 @@ import { isEqual } from 'lodash-es';
import { TMagicButton, TMagicFormItem, TMagicIcon, TMagicTooltip } from '@tmagic/design';
import { getValueByKeyPath } from '@tmagic/utils';
import MHidden from '../fields/Hidden.vue';
import type {
ChildConfig,
CheckboxConfig,
ComponentConfig,
ContainerChangeEventData,
ContainerCommonConfig,
FormItemConfig,
FormState,
FormValue,
ToolTipConfigType,
} from '../schema';
import { getField } from '../utils/config';
import { createObjectProp, display as displayFunction, filterFunction, getRules } from '../utils/form';
import FormLabel from './FormLabel.vue';
@ -194,10 +198,10 @@ const props = withDefaults(
model: FormValue;
/** 需对比的值(开启对比模式时传入) */
lastValues?: FormValue;
config: ChildConfig;
config: FormItemConfig;
prop?: string;
disabled?: boolean;
labelWidth?: string;
labelWidth?: string | number;
expandMore?: boolean;
stepActive?: string | number;
size?: string;
@ -248,11 +252,20 @@ const itemProp = computed(() => {
return `${n}`;
});
const type = computed((): string => {
let type = 'type' in props.config ? props.config.type : '';
type = type && filterFunction<string>(mForm, type, props);
if (type === 'form') return '';
if (type === 'container') return '';
return type?.replace(/([A-Z])/g, '-$1').toLowerCase() || (items.value ? '' : 'text');
});
const tagName = computed(() => {
if (type.value === 'component' && props.config.component) {
return props.config.component;
if (type.value === 'component' && (props.config as ComponentConfig).component) {
return (props.config as ComponentConfig).component;
}
return `m-${items.value ? 'form' : 'fields'}-${type.value}`;
return getField(type.value || 'container') || `m-${items.value ? 'form' : 'fields'}-${type.value}`;
});
const disabled = computed(() => props.disabled || filterFunction(mForm, props.config.disabled, props));
@ -276,14 +289,6 @@ const tooltip = computed(() => {
const rule = computed(() => getRules(mForm, props.config.rules, props));
const type = computed((): string => {
let { type } = props.config;
type = type && filterFunction<string>(mForm, type, props);
if (type === 'form') return '';
if (type === 'container') return '';
return type?.replace(/([A-Z])/g, '-$1').toLowerCase() || (items.value ? '' : 'text');
});
const display = computed((): boolean => {
const value = displayFunction(mForm, props.config.display, props);
@ -299,7 +304,7 @@ const fieldsProps = computed(() => ({
name: name.value,
disabled: disabled.value,
prop: itemProp.value,
key: props.config[mForm?.keyProps],
key: (props.config as Record<string, any>)[mForm?.keyProps],
style: props.config.fieldStyle,
}));

View File

@ -144,7 +144,9 @@ const rowConfig = computed(() => ({
span: props.config.span || 24,
items: props.config.items,
labelWidth: props.config.labelWidth,
[mForm?.keyProp || '__key']: `${props.config[mForm?.keyProp || '__key']}${String(props.index)}`,
[mForm?.keyProp || '__key']: `${(props.config as Record<string, any>)[mForm?.keyProp || '__key']}${String(
props.index,
)}`,
}));
const title = computed(() => {

View File

@ -3,14 +3,18 @@
</template>
<script setup lang="ts">
import type { FieldProps, HiddenConfig } from '../schema';
import type { FormValue } from '../schema';
import { useAddField } from '../utils/useAddField';
defineOptions({
name: 'MFormHidden',
});
const props = defineProps<FieldProps<HiddenConfig>>();
const props = defineProps<{
model: FormValue;
name: string;
prop: string;
}>();
useAddField(props.prop);
</script>

View File

@ -10,7 +10,7 @@
<TMagicTooltip :disabled="!Boolean(option.tooltip)" placement="top-start" :content="option.tooltip">
<div>
<TMagicIcon v-if="option.icon" :size="iconSize"><component :is="option.icon"></component></TMagicIcon>
<span>{{ option.text }}</span>
<span v-if="option.text">{{ option.text }}</span>
</div>
</TMagicTooltip>
</component>

View File

@ -21,7 +21,7 @@ import dayjs from 'dayjs';
import { TMagicTimePicker } from '@tmagic/design';
import type { ChangeRecord, DaterangeConfig, FieldProps } from '../schema';
import type { ChangeRecord, FieldProps, TimerangeConfig } from '../schema';
import { datetimeFormatter } from '../utils/form';
import { useAddField } from '../utils/useAddField';
@ -29,7 +29,7 @@ defineOptions({
name: 'MFormTimeRange',
});
const props = defineProps<FieldProps<DaterangeConfig>>();
const props = defineProps<FieldProps<TimerangeConfig>>();
const emit = defineEmits(['change']);

View File

@ -16,44 +16,8 @@
* limitations under the License.
*/
import type { App } from 'vue';
import Container from './containers/Container.vue';
import Fieldset from './containers/Fieldset.vue';
import FlexLayout from './containers/FlexLayout.vue';
import GroupList from './containers/GroupList.vue';
import Panel from './containers/Panel.vue';
import Row from './containers/Row.vue';
import MStep from './containers/Step.vue';
import Tabs from './containers/Tabs.vue';
import Cascader from './fields/Cascader.vue';
import Checkbox from './fields/Checkbox.vue';
import CheckboxGroup from './fields/CheckboxGroup.vue';
import ColorPicker from './fields/ColorPicker.vue';
import Date from './fields/Date.vue';
import Daterange from './fields/Daterange.vue';
import DateTime from './fields/DateTime.vue';
import Display from './fields/Display.vue';
import DynamicField from './fields/DynamicField.vue';
import Hidden from './fields/Hidden.vue';
import Link from './fields/Link.vue';
import Number from './fields/Number.vue';
import NumberRange from './fields/NumberRange.vue';
import RadioGroup from './fields/RadioGroup.vue';
import Select from './fields/Select.vue';
import Switch from './fields/Switch.vue';
import Text from './fields/Text.vue';
import Textarea from './fields/Textarea.vue';
import Time from './fields/Time.vue';
import Timerange from './fields/Timerange.vue';
import Table from './table/Table.vue';
import { setConfig } from './utils/config';
import Form from './Form.vue';
import FormDialog from './FormDialog.vue';
import type { FormConfig } from './schema';
import './theme/index.scss';
export * from './schema';
export * from './utils/form';
export * from './utils/useAddField';
@ -91,52 +55,14 @@ export { default as MSelect } from './fields/Select.vue';
export { default as MCascader } from './fields/Cascader.vue';
export { default as MDynamicField } from './fields/DynamicField.vue';
export {
deleteField as deleteFormField,
getField as getFormField,
registerField as registerFormField,
} from './utils/config';
export type { FormInstallOptions } from './plugin';
export const createForm = <T extends [] = []>(config: FormConfig | T) => config;
export interface FormInstallOptions {
[key: string]: any;
}
const defaultInstallOpt: FormInstallOptions = {};
export default {
install(app: App, opt: FormInstallOptions = {}) {
const option = Object.assign(defaultInstallOpt, opt);
app.config.globalProperties.$MAGIC_FORM = option;
setConfig(option);
app.component('m-form', Form);
app.component('m-form-dialog', FormDialog);
app.component('m-form-container', Container);
app.component('m-form-fieldset', Fieldset);
app.component('m-form-group-list', GroupList);
app.component('m-form-panel', Panel);
app.component('m-form-row', Row);
app.component('m-form-step', MStep);
app.component('m-form-table', Table);
app.component('m-form-tab', Tabs);
app.component('m-form-flex-layout', FlexLayout);
app.component('m-fields-text', Text);
app.component('m-fields-img-upload', Text);
app.component('m-fields-number', Number);
app.component('m-fields-number-range', NumberRange);
app.component('m-fields-textarea', Textarea);
app.component('m-fields-hidden', Hidden);
app.component('m-fields-date', Date);
app.component('m-fields-datetime', DateTime);
app.component('m-fields-daterange', Daterange);
app.component('m-fields-timerange', Timerange);
app.component('m-fields-time', Time);
app.component('m-fields-checkbox', Checkbox);
app.component('m-fields-switch', Switch);
app.component('m-fields-color-picker', ColorPicker);
app.component('m-fields-checkbox-group', CheckboxGroup);
app.component('m-fields-radio-group', RadioGroup);
app.component('m-fields-display', Display);
app.component('m-fields-link', Link);
app.component('m-fields-select', Select);
app.component('m-fields-cascader', Cascader);
app.component('m-fields-dynamic-field', DynamicField);
},
};
export { default } from './plugin';

102
packages/form/src/plugin.ts Normal file
View File

@ -0,0 +1,102 @@
/*
* 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 { type App } from 'vue';
import Container from './containers/Container.vue';
import Fieldset from './containers/Fieldset.vue';
import FlexLayout from './containers/FlexLayout.vue';
import GroupList from './containers/GroupList.vue';
import Panel from './containers/Panel.vue';
import Row from './containers/Row.vue';
import MStep from './containers/Step.vue';
import Tabs from './containers/Tabs.vue';
import Cascader from './fields/Cascader.vue';
import Checkbox from './fields/Checkbox.vue';
import CheckboxGroup from './fields/CheckboxGroup.vue';
import ColorPicker from './fields/ColorPicker.vue';
import Date from './fields/Date.vue';
import Daterange from './fields/Daterange.vue';
import DateTime from './fields/DateTime.vue';
import Display from './fields/Display.vue';
import DynamicField from './fields/DynamicField.vue';
import Hidden from './fields/Hidden.vue';
import Link from './fields/Link.vue';
import Number from './fields/Number.vue';
import NumberRange from './fields/NumberRange.vue';
import RadioGroup from './fields/RadioGroup.vue';
import Select from './fields/Select.vue';
import Switch from './fields/Switch.vue';
import Text from './fields/Text.vue';
import Textarea from './fields/Textarea.vue';
import Time from './fields/Time.vue';
import Timerange from './fields/Timerange.vue';
import Table from './table/Table.vue';
import { setConfig } from './utils/config';
import Form from './Form.vue';
import FormDialog from './FormDialog.vue';
import './theme/index.scss';
export interface FormInstallOptions {
[key: string]: any;
}
const defaultInstallOpt: FormInstallOptions = {};
export default {
install(app: App, opt: FormInstallOptions = {}) {
const option = Object.assign(defaultInstallOpt, opt);
app.config.globalProperties.$MAGIC_FORM = option;
setConfig(option);
app.component('m-form', Form);
app.component('m-form-dialog', FormDialog);
app.component('m-form-container', Container);
app.component('m-form-fieldset', Fieldset);
app.component('m-form-group-list', GroupList);
app.component('m-form-panel', Panel);
app.component('m-form-row', Row);
app.component('m-form-step', MStep);
app.component('m-form-table', Table);
app.component('m-form-tab', Tabs);
app.component('m-form-flex-layout', FlexLayout);
app.component('m-fields-text', Text);
app.component('m-fields-img-upload', Text);
app.component('m-fields-number', Number);
app.component('m-fields-number-range', NumberRange);
app.component('m-fields-textarea', Textarea);
app.component('m-fields-hidden', Hidden);
app.component('m-fields-date', Date);
app.component('m-fields-datetime', DateTime);
app.component('m-fields-daterange', Daterange);
app.component('m-fields-timerange', Timerange);
app.component('m-fields-time', Time);
app.component('m-fields-checkbox', Checkbox);
app.component('m-fields-switch', Switch);
app.component('m-fields-color-picker', ColorPicker);
app.component('m-fields-checkbox-group', CheckboxGroup);
app.component('m-fields-radio-group', RadioGroup);
app.component('m-fields-display', Display);
app.component('m-fields-link', Link);
app.component('m-fields-select', Select);
app.component('m-fields-cascader', Cascader);
app.component('m-fields-dynamic-field', DynamicField);
},
};

View File

@ -1,7 +1,7 @@
import { computed, inject } from 'vue';
import { tMagicMessage } from '@tmagic/design';
import type { FormState } from '@tmagic/form-schema';
import type { FormConfig, FormState } from '@tmagic/form-schema';
import { initValue } from '../utils/form';
@ -86,7 +86,7 @@ export const useAdd = (
}
inputs = await initValue(mForm, {
config: columns,
config: columns as FormConfig,
initValues: inputs,
});
}

View File

@ -1,5 +1,5 @@
import { inject, nextTick, type Ref, type ShallowRef, watchEffect } from 'vue';
import Sortable, { type SortableEvent } from 'sortablejs';
import type { default as SortableType, SortableEvent } from 'sortablejs';
import { type TMagicTable } from '@tmagic/design';
import type { FormState } from '@tmagic/form-schema';
@ -8,6 +8,9 @@ import { sortArray } from '../utils/form';
import type { TableProps } from './type';
let SortablePromise: Promise<typeof SortableType> | undefined;
const loadSortable = () => (SortablePromise ??= import('sortablejs').then((m) => m.default));
export const useSortable = (
props: TableProps,
emit: (event: 'select' | 'change' | 'addDiffCount', ...args: any[]) => void,
@ -17,15 +20,16 @@ export const useSortable = (
) => {
const mForm = inject<FormState | undefined>('mForm');
let sortable: Sortable | undefined;
const rowDrop = () => {
let sortable: SortableType | undefined;
const rowDrop = async () => {
sortable?.destroy();
const tableEl = tMagicTableRef.value?.getEl();
const tBodyEl = tableEl?.querySelector('.el-table__body > tbody') || tableEl?.querySelector('.t-table__body');
if (!tBodyEl) {
return;
}
sortable = Sortable.create(tBodyEl, {
sortable = (await loadSortable()).create(tBodyEl, {
draggable: '.tmagic-design-table-row',
filter: 'input', // 表单组件选字操作和触发拖拽会冲突,优先保证选字操作
preventOnFilter: false, // 允许选字

View File

@ -3,7 +3,7 @@ import { WarningFilled } from '@element-plus/icons-vue';
import { cloneDeep } from 'lodash-es';
import { type TableColumnOptions, TMagicIcon, TMagicTooltip } from '@tmagic/design';
import type { FormState, TableColumnConfig } from '@tmagic/form-schema';
import type { FormItemConfig, FormState, TableColumnConfig } from '@tmagic/form-schema';
import Container from '../containers/Container.vue';
import type { ContainerChangeEventData } from '../schema';
@ -68,7 +68,7 @@ export const useTableColumns = (
return `${props.prop}${props.prop ? '.' : ''}${index + 1 + currentPage.value * pageSize.value - 1}`;
};
const makeConfig = (config: TableColumnConfig, row: any) => {
const makeConfig = (config: TableColumnConfig, row: any): TableColumnConfig => {
const newConfig = cloneDeep(config);
if (typeof config.itemsFunction === 'function') {
newConfig.items = config.itemsFunction(row);
@ -199,7 +199,7 @@ export const useTableColumns = (
disabled: props.disabled,
prop: getProp($index),
rules: column.rules,
config: makeConfig(column, row),
config: makeConfig(column, row) as FormItemConfig,
model: row,
lastValues: lastData.value[$index],
isCompare: props.isCompare,

View File

@ -16,6 +16,8 @@
* limitations under the License.
*/
import type { Component } from 'vue';
let $MAGIC_FORM = {} as any;
const setConfig = (option: any): void => {
@ -24,4 +26,17 @@ const setConfig = (option: any): void => {
const getConfig = <T = unknown>(key: string): T => $MAGIC_FORM[key];
export { getConfig, setConfig };
const fieldRegistry = new Map<string, Component>();
const registerField = (tagName: string, component: Component): void => {
if (fieldRegistry.has(tagName)) {
return;
}
fieldRegistry.set(tagName, component);
};
const getField = (tagName: string): Component | undefined => fieldRegistry.get(tagName);
const deleteField = (tagName: string): boolean => fieldRegistry.delete(tagName);
export { deleteField, getConfig, getField, registerField, setConfig };

View File

@ -23,7 +23,7 @@ import { cloneDeep } from 'lodash-es';
import { getValueByKeyPath } from '@tmagic/utils';
import {
import type {
ChildConfig,
ContainerCommonConfig,
DaterangeConfig,
@ -34,6 +34,7 @@ import {
HtmlField,
Rule,
SortProp,
TableConfig,
TabPaneConfig,
TypeFunction,
} from '../schema';
@ -118,7 +119,8 @@ const initValueItem = function (
) {
const { items } = item as ContainerCommonConfig;
const { names } = item as DaterangeConfig;
const { type, name } = item as ChildConfig;
const type = 'type' in item ? item.type : '';
const { name } = item;
if (isTableSelect(type) && name) {
value[name] = initValue[name] ?? '';
@ -148,14 +150,15 @@ const initValueItem = function (
setValue(mForm, value, initValue, item);
if (type === 'table') {
if (item.defautSort) {
sortChange(value[name], item.defautSort);
} else if (item.defaultSort) {
sortChange(value[name], item.defaultSort);
const tableConfig = item as TableConfig;
if (tableConfig.defautSort) {
sortChange(value[name], tableConfig.defautSort);
} else if (tableConfig.defaultSort) {
sortChange(value[name], tableConfig.defaultSort);
}
if (item.sort && item.sortKey) {
value[name].sort((a: any, b: any) => b[item.sortKey] - a[item.sortKey]);
if (tableConfig.sort && tableConfig.sortKey) {
value[name].sort((a: any, b: any) => b[tableConfig.sortKey!] - a[tableConfig.sortKey!]);
}
}
@ -169,8 +172,8 @@ export const createValues = function (
value: FormValue = {},
) {
if (Array.isArray(config)) {
config.forEach((item: ChildConfig | TabPaneConfig) => {
initValueItem(mForm, item, initValue, value);
config.forEach((item) => {
initValueItem(mForm, item as ChildConfig | TabPaneConfig, initValue, value);
});
}

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/schema",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-schema.umd.cjs",
"module": "dist/tmagic-schema.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-schema.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-schema.umd.cjs"
},
"./*": "./*"

View File

@ -186,7 +186,7 @@ export interface CodeBlockContent {
/** 代码块名称 */
name: string;
/** 代码块内容 */
content: ((...args: any[]) => any) | string;
content: ((...args: any[]) => any) | Function;
/** 参数定义 */
params: CodeParam[] | [];
/** 注释 */

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/stage",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-stage.umd.cjs",
"module": "dist/tmagic-stage.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-stage.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-stage.umd.cjs"
},
"./*": "./*"
@ -30,6 +31,7 @@
"dependencies": {
"@scena/guides": "^0.29.2",
"events": "^3.3.0",
"@zumer/snapdom": "^2.8.0",
"keycon": "^1.4.0",
"lodash-es": "^4.17.21",
"moveable": "^0.53.0",

View File

@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter {
return null;
}
/**
* 穿
* @param event
* @returns null
*/
public async getNextElementFromPoint(event: MouseEvent): Promise<HTMLElement | null> {
const els = this.getElementsFromPoint(event as Point);
let stopped = false;
const stop = () => (stopped = true);
let skippedFirst = false;
for (const el of els) {
if (!getIdFromEl()(el)?.startsWith(GHOST_EL_ID_PREFIX) && (await this.isElCanSelect(el, event, stop))) {
if (stopped) break;
if (!skippedFirst) {
skippedFirst = true;
continue;
}
return el;
}
}
return null;
}
/**
*
* @param el

View File

@ -124,6 +124,7 @@ export default class Rule extends EventEmitter {
this.hGuides?.off('changeGuides', this.hGuidesChangeGuidesHandler);
this.vGuides?.off('changeGuides', this.vGuidesChangeGuidesHandler);
this.containerResizeObserver?.disconnect();
this.container = undefined;
this.removeAllListeners();
}
@ -137,7 +138,6 @@ export default class Rule extends EventEmitter {
this.hGuides = undefined;
this.vGuides = undefined;
this.container = undefined;
}
private getGuidesStyle = (type: GuidesType) => ({

View File

@ -18,6 +18,7 @@
import { EventEmitter } from 'events';
import { SnapdomOptions } from '@zumer/snapdom';
import type { MoveableOptions, OnDragStart } from 'moveable';
import type { Id } from '@tmagic/core';
@ -88,7 +89,38 @@ export default class StageCore extends EventEmitter {
* @param id id
*/
public async select(id: Id, event?: MouseEvent): Promise<void> {
const el = this.renderer?.getTargetElement(id) || null;
if (!this.renderer) {
return;
}
let el = this.renderer.getTargetElement(id) || null;
if (!el) {
el = await new Promise<HTMLElement | null>((resolve) => {
const observer = new MutationObserver(() => {
const target = this.renderer?.getTargetElement(id);
if (target) {
observer.disconnect();
clearTimeout(timer);
resolve(target);
}
});
const body = this.renderer?.getDocument()?.body;
if (!body) {
resolve(null);
return;
}
observer.observe(body, { childList: true, subtree: true });
const timer = setTimeout(() => {
observer.disconnect();
resolve(this.renderer?.getTargetElement(id) || null);
}, 1000);
});
}
if (el === this.actionManager?.getSelectedEl()) return;
await this.renderer?.select([id]);
@ -241,6 +273,21 @@ export default class StageCore extends EventEmitter {
this.renderer?.reloadIframe(url);
}
/**
* id的dom元素生成为图片
*/
public async getElementImage(
id: Id,
type: 'download' | 'raw' | 'svg' | 'canvas' | 'png' | 'jpeg' | 'webp' | 'blob' = 'png',
options: SnapdomOptions = {},
) {
if (!this.renderer) {
throw new Error('Renderer is not initialized');
}
return this.renderer.getElementImage(id, type, options);
}
/**
*
*/

View File

@ -18,6 +18,8 @@
import { EventEmitter } from 'events';
import { snapdom, SnapdomOptions } from '@zumer/snapdom';
import type { Id } from '@tmagic/core';
import { getElById, getHost, guid, injectStyle, isSameDomain } from '@tmagic/core';
@ -162,6 +164,35 @@ export default class StageRender extends EventEmitter {
return getElById()(this.getDocument(), id);
}
/**
* id的dom元素生成为图片
* @param id id
* @param options
* @returns data URL
*/
public async getElementImage(
id: Id,
type: 'download' | 'raw' | 'svg' | 'canvas' | 'png' | 'jpeg' | 'webp' | 'blob' = 'png',
options: SnapdomOptions = {},
) {
const el = this.getTargetElement(id);
if (!el) {
throw new Error(`Element with id "${id}" not found`);
}
el.scrollIntoView();
const toFunc = `to${type.charAt(0).toUpperCase() + type.slice(1)}`;
const result = await snapdom(el, options);
if (toFunc in result) {
return result[toFunc]();
}
throw new Error(`Invalid type: ${type}`);
}
public postTmagicRuntimeReady() {
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;

View File

@ -1,18 +1,19 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/table",
"type": "module",
"sideEffects": [
"dist/style.css",
"dist/es/style.css",
"src/theme/*"
],
"main": "dist/tmagic-table.umd.cjs",
"module": "dist/tmagic-table.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-table.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-table.umd.cjs"
},
"./dist/style.css": {

View File

@ -5,7 +5,7 @@
<MForm
v-else-if="(config.type || config.editInlineFormConfig) && editState[index]"
label-width="0"
:config="config.editInlineFormConfig ?? [config]"
:config="config.editInlineFormConfig ?? [config as FormItemConfig]"
:init-values="editState[index]"
@change="formChangeHandler"
></MForm>
@ -46,7 +46,7 @@
<script lang="ts" setup>
import { TMagicButton, TMagicTag, TMagicTooltip } from '@tmagic/design';
import { type ContainerChangeEventData, MForm } from '@tmagic/form';
import type { FormValue } from '@tmagic/form-schema';
import type { FormItemConfig, FormValue } from '@tmagic/form-schema';
import { setValueByKeyPath } from '@tmagic/utils';
import { ColumnConfig } from './schema';

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/tdesign-vue-next-adapter",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-tdesign-vue-next-adapter.umd.cjs",
"module": "dist/tmagic-tdesign-vue-next-adapter.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-tdesign-vue-next-adapter.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-tdesign-vue-next-adapter.umd.cjs"
},
"./*": "./*"

View File

@ -1,14 +1,15 @@
{
"version": "1.7.7",
"version": "1.7.10",
"name": "@tmagic/utils",
"type": "module",
"sideEffects": false,
"main": "dist/tmagic-utils.umd.cjs",
"module": "dist/tmagic-utils.js",
"module": "dist/es/index.js",
"types": "types/index.d.ts",
"exports": {
".": {
"types": "./types/index.d.ts",
"import": "./dist/tmagic-utils.js",
"import": "./dist/es/index.js",
"require": "./dist/tmagic-utils.umd.cjs"
}
},

View File

@ -0,0 +1,5 @@
export const DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX = 'ds-field::';
export const DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX = 'ds-field-changed';
export const DATA_SOURCE_SET_DATA_METHOD_NAME = 'setDataFromEvent';

Some files were not shown because too many files have changed in this diff Show More