Compare commits

...

38 Commits

Author SHA1 Message Date
EvanWu
9bf42f9007 docs: 更新 README.md 环境要求
更新 Node.js 和 pnpm 版本要求,与 package.json engines 和 packageManager 保持一致。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 11:24:53 +08:00
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
98 changed files with 3413 additions and 1486 deletions

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 | 变更日志 |

View File

@ -1,3 +1,71 @@
## [1.7.10](https://github.com/Tencent/tmagic-editor/compare/v1.7.9...v1.7.10) (2026-04-13)
### Bug Fixes
* **editor:** 优化 StageOverlay 双击行为,仅在元素被滚动容器裁剪时打开 overlay ([df2d635](https://github.com/Tencent/tmagic-editor/commit/df2d635682034f2f478c575543ed63f94b7a291c))
* **editor:** 修复 getTMagicAppPrimise 变量名拼写错误 ([99c8274](https://github.com/Tencent/tmagic-editor/commit/99c8274a1ee25c8e155498e43520c5d7ba99c4b1))
* **editor:** 历史记录信息中添加页面信息 ([26dc70d](https://github.com/Tencent/tmagic-editor/commit/26dc70d70c329d9a83a671d92bbfaf77d7abbcef))
* **editor:** 数据源方法选择器展示所有数据源并支持字段非叶子节点选择 ([31f4d2b](https://github.com/Tencent/tmagic-editor/commit/31f4d2b4e26f5a941041c41c0843828d70ed3cd8))
* **stage:** 修复隐藏标尺后无法显示问题 ([6f2e8d8](https://github.com/Tencent/tmagic-editor/commit/6f2e8d8d74ed0d8a7ef1eefd0b39b08a90abc6e7))
* **stage:** 新增组件后等待渲染完后选中 ([cfd5998](https://github.com/Tencent/tmagic-editor/commit/cfd5998242031f393bc1003c40b55cde35f3c515))
### Features
* **editor,data-source:** 数据源支持内置"设置数据"方法 ([f583c7d](https://github.com/Tencent/tmagic-editor/commit/f583c7daec76518bbb2e7044740337e0c15997e1))
* **editor,stage:** 支持双击穿透选中鼠标下方的下一个可选中元素 ([172a7a1](https://github.com/Tencent/tmagic-editor/commit/172a7a1c92e85266114c628c0fc0534b6b76aca3))
* **editor:** 样式配置添加变形项 ([fa09ab0](https://github.com/Tencent/tmagic-editor/commit/fa09ab0b301d9bc9f6a9462fc022418bbf21cee3))
* **editor:** 添加 stage beforeDblclick 钩子,支持拦截默认双击行为 ([334569e](https://github.com/Tencent/tmagic-editor/commit/334569e2d784d708f4fde7e9f37f5cac50b2e396))
* **stage:** 支持将指定id的dom生成图片 ([b3f4e42](https://github.com/Tencent/tmagic-editor/commit/b3f4e42716fe1d998d093e7556897bdb2e9037a9))
## [1.7.9](https://github.com/Tencent/tmagic-editor/compare/v1.7.8...v1.7.9) (2026-03-23)
### Bug Fixes
* **form:** row容器中如果配置没有type显示异常 ([711af79](https://github.com/Tencent/tmagic-editor/commit/711af79d72c202a7bc961b0639ce62c318952ee6))
## [1.7.8](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.4...v1.7.8) (2026-03-20)
### Bug Fixes
* **editor:** 组件配置样式显示出错 ([9b56223](https://github.com/Tencent/tmagic-editor/commit/9b56223359f4ce826597f8f5d440626869e8b650))
## [1.7.8-beta.4](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.3...v1.7.8-beta.4) (2026-03-20)
### Bug Fixes
* **form-schema:** 表单 schema 中 display 与 component 部分字段改为可选 ([e36d8d7](https://github.com/Tencent/tmagic-editor/commit/e36d8d7cf874fe5e307eee5f3906499a86337b85))
## [1.7.8-beta.3](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.2...v1.7.8-beta.3) (2026-03-20)
### Features
* **form-schema,form,editor,table:** 完善表单配置类型 ([e8714c9](https://github.com/Tencent/tmagic-editor/commit/e8714c96c9f3ae4644d830d232b2732adbcd05fa))
## [1.7.8-beta.2](https://github.com/Tencent/tmagic-editor/compare/v1.7.8-beta.1...v1.7.8-beta.2) (2026-03-20)
### Features
* **form-schema,form,editor:** 完善表单配置类型 ([55eb546](https://github.com/Tencent/tmagic-editor/commit/55eb546ad6dd67a920093353f2de9bd891db3f70))
## [1.7.8-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.7.7...v1.7.8-beta.1) (2026-03-19) ## [1.7.8-beta.1](https://github.com/Tencent/tmagic-editor/compare/v1.7.7...v1.7.8-beta.1) (2026-03-19)
### Bug Fixes ### Bug Fixes

View File

@ -16,9 +16,9 @@ https://tencent.github.io/tmagic-editor/playground/index.html
## 环境准备 ## 环境准备
node.js >= 18 node.js ^20.19.0 || >=22.12.0
pnpm >= 9 pnpm >= 10
先安装 pnpm 先安装 pnpm

View File

@ -1,6 +1,6 @@
{ {
"name": "@tmagic/eslint-config", "name": "@tmagic/eslint-config",
"version": "0.0.4", "version": "0.1.0",
"main": "index.mjs", "main": "index.mjs",
"type": "module", "type": "module",
"repository": { "repository": {
@ -10,17 +10,17 @@
}, },
"dependencies": { "dependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@typescript-eslint/parser": "^8.57.1", "@typescript-eslint/parser": "^8.58.0",
"@typescript-eslint/eslint-plugin": "^8.57.1", "@typescript-eslint/eslint-plugin": "^8.58.0",
"@stylistic/eslint-plugin": "^5.10.0", "@stylistic/eslint-plugin": "^5.10.0",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0", "eslint-plugin-import": "^2.32.0",
"eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-simple-import-sort": "^13.0.0",
"eslint-plugin-vue": "^10.8.0", "eslint-plugin-vue": "^10.8.0",
"vue-eslint-parser": "^10.3.0", "vue-eslint-parser": "^10.4.0",
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.5",
"globals": "^17.4.0", "globals": "^17.4.0",
"typescript-eslint": "^8.57.1" "typescript-eslint": "^8.58.0"
}, },
"peerDependencies": { "peerDependencies": {
"eslint": ">=10.0.0", "eslint": ">=10.0.0",

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "tmagic", "name": "tmagic",
"private": true, "private": true,
"type": "module", "type": "module",

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/core", "name": "@tmagic/core",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -297,7 +297,7 @@ class App extends EventEmitter {
} }
if (typeof dataSource[methodName] === 'function') { if (typeof dataSource[methodName] === 'function') {
return await dataSource[methodName](); return await dataSource[methodName]({ params });
} }
} catch (e: any) { } catch (e: any) {
if (this.errorHandler) { if (this.errorHandler) {

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/data-source", "name": "@tmagic/data-source",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/dep", "name": "@tmagic/dep",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/design", "name": "@tmagic/design",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/editor", "name": "@tmagic/editor",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

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

View File

@ -13,7 +13,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import { computed, useTemplateRef } from 'vue'; 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 type { CodeParamStatement } from '@editor/type';
import { error } from '@editor/utils'; import { error } from '@editor/utils';
@ -34,7 +34,7 @@ const emit = defineEmits(['change']);
const formRef = useTemplateRef<InstanceType<typeof MForm>>('form'); const formRef = useTemplateRef<InstanceType<typeof MForm>>('form');
const getFormConfig = (items: FormConfig = []) => [ const getFormConfig = (items: FormItemConfig[] = []) => [
{ {
type: 'fieldset', type: 'fieldset',
items, items,
@ -46,13 +46,29 @@ const getFormConfig = (items: FormConfig = []) => [
const codeParamsConfig = computed(() => const codeParamsConfig = computed(() =>
getFormConfig( getFormConfig(
props.paramsConfig.map(({ name, text, extra, ...config }) => ({ props.paramsConfig.map(({ name, text, extra, ...config }) => {
type: 'data-source-field-select', let { type } = config;
name, if (typeof type === 'function') {
text, type = type(undefined, {
extra, model: props.model[props.name],
fieldConfig: config, });
})), }
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>; isContainer?: (el: HTMLElement) => boolean | Promise<boolean>;
/** 用于自定义组件树与画布的右键菜单 */ /** 用于自定义组件树与画布的右键菜单 */
customContentMenu?: CustomContentMenuFunction; customContentMenu?: CustomContentMenuFunction;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>; extendFormState?: (state: FormState) => Record<string, any> | Promise<Record<string, any>>;
/** 页面顺序拖拽配置参数 */ /** 页面顺序拖拽配置参数 */
pageBarSortOptions?: PageBarSortOptions; pageBarSortOptions?: PageBarSortOptions;

View File

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

View File

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

View File

@ -8,6 +8,7 @@
:value="config.value" :value="config.value"
:checkStrictly="checkStrictly" :checkStrictly="checkStrictly"
:dataSourceFieldType="config.dataSourceFieldType" :dataSourceFieldType="config.dataSourceFieldType"
:dataSourceId="config.dataSourceId"
@change="onChangeHandler" @change="onChangeHandler"
></FieldSelect> ></FieldSelect>
@ -98,7 +99,9 @@ const dataSources = computed(() => dataSourceService.get('dataSources') || []);
const disabledDataSource = computed(() => propsService.getDisabledDataSource()); const disabledDataSource = computed(() => propsService.getDisabledDataSource());
const type = computed((): string => { 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') { if (typeof type === 'function') {
type = type(mForm, { type = type(mForm, {
model: props.model, model: props.model,
@ -106,13 +109,18 @@ const type = computed((): string => {
} }
if (type === 'form') return ''; if (type === 'form') return '';
if (type === 'container') 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 tagName = computed(() => {
const component = const component =
getFormField(type.value || 'container') || getFormField(type.value || 'container') ||
resolveComponent(`m-${props.config.items ? 'form' : 'fields'}-${type.value}`); resolveComponent(
`m-${props.config.fieldConfig && 'items' in props.config.fieldConfig ? 'form' : 'fields'}-${type.value}`,
);
if (typeof component !== 'string') return component; if (typeof component !== 'string') return component;
return 'm-fields-text'; return 'm-fields-text';
}); });

View File

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

View File

@ -48,15 +48,18 @@ import {
type DataSourceMethodSelectConfig, type DataSourceMethodSelectConfig,
type FieldProps, type FieldProps,
filterFunction, filterFunction,
type FormItemConfig,
type FormState, type FormState,
MCascader, MCascader,
} from '@tmagic/form'; } from '@tmagic/form';
import { DATA_SOURCE_SET_DATA_METHOD_NAME } from '@tmagic/utils';
import CodeParams from '@editor/components/CodeParams.vue'; import CodeParams from '@editor/components/CodeParams.vue';
import MIcon from '@editor/components/Icon.vue'; import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services'; import { useServices } from '@editor/hooks/use-services';
import type { CodeParamStatement, EventBus } from '@editor/type'; import type { CodeParamStatement, EventBus } from '@editor/type';
import { SideItemKey } from '@editor/type'; import { SideItemKey } from '@editor/type';
import { getFieldType } from '@editor/utils';
defineOptions({ defineOptions({
name: 'MFieldsDataSourceMethodSelect', name: 'MFieldsDataSourceMethodSelect',
@ -91,6 +94,43 @@ const isCustomMethod = computed(() => {
const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => { const getParamItemsConfig = ([dataSourceId, methodName]: [Id, string] = ['', '']): CodeParamStatement[] => {
if (!dataSourceId) return []; 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 const paramStatements = dataSources.value
?.find((item) => item.id === dataSourceId) ?.find((item) => item.id === dataSourceId)
?.methods?.find((item) => item.name === methodName)?.params; ?.methods?.find((item) => item.name === methodName)?.params;
@ -107,19 +147,21 @@ const paramsConfig = ref<CodeParamStatement[]>(getParamItemsConfig(props.model[p
const methodsOptions = computed( const methodsOptions = computed(
() => () =>
dataSources.value dataSources.value?.map((ds) => ({
?.filter((ds) => ds.methods?.length || dataSourceService.getFormMethod(ds.type).length) label: ds.title || ds.id,
?.map((ds) => ({ value: ds.id,
label: ds.title || ds.id, children: [
value: ds.id, {
children: [ label: '设置数据',
...(dataSourceService?.getFormMethod(ds.type) || []), value: DATA_SOURCE_SET_DATA_METHOD_NAME,
...(ds.methods || []).map((method) => ({ },
label: method.name, ...(dataSourceService?.getFormMethod(ds.type) || []),
value: method.name, ...(ds.methods || []).map((method) => ({
})), label: method.name,
], value: method.name,
})) || [], })),
],
})) || [],
); );
const cascaderConfig = computed<CascaderConfig>(() => ({ const cascaderConfig = computed<CascaderConfig>(() => ({
@ -142,7 +184,9 @@ const onChangeHandler = (value: any) => {
changeRecords.push({ changeRecords.push({
propPath: props.prop.replace(`${props.name}`, 'params'), propPath: props.prop.replace(`${props.name}`, 'params'),
value: paramsConfig.value.length ? 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, { emit('change', value, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ContainerChangeEventData, MContainer } from '@tmagic/form'; import { type ContainerChangeEventData, defineFormItem, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema'; import type { StyleSchema } from '@tmagic/schema';
const props = defineProps<{ const props = defineProps<{
@ -24,7 +24,7 @@ const positionText: Record<string, string> = {
sticky: '粘性定位', sticky: '粘性定位',
}; };
const config = { const config = defineFormItem({
items: [ items: [
{ {
name: 'position', name: 'position',
@ -95,7 +95,7 @@ const config = {
}, },
}, },
], ],
}; });
const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => { const change = (value: string | StyleSchema, eventData: ContainerChangeEventData) => {
emit('change', value, eventData); 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 Layout } from './Layout.vue';
export { default as Position } from './Position.vue'; export { default as Position } from './Position.vue';
export { default as Border } from './Border.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'; import type { Services } from '@editor/type';
export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => { export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService']) => {
const codeConfig = ref<CodeBlockContent>(); const codeConfig = ref<Omit<CodeBlockContent, 'content'> & { content: string }>();
const codeId = ref<string>(); const codeId = ref<string>();
const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor'); const codeBlockEditorRef = useTemplateRef<InstanceType<typeof CodeBlockEditor>>('codeBlockEditor');
@ -36,10 +36,14 @@ export const useCodeBlockEdit = (codeBlockService: Services['codeBlockService'])
return; return;
} }
let codeContent = codeBlock.content; let codeContent = '';
if (typeof codeContent !== 'string') { if (codeBlock.content) {
codeContent = codeContent.toString(); if (typeof codeBlock.content !== 'string') {
codeContent = codeBlock.content.toString();
} else {
codeContent = codeBlock.content;
}
} }
codeConfig.value = { codeConfig.value = {

View File

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

View File

@ -80,7 +80,7 @@ const props = withDefaults(
let stage: StageCore | null = null; let stage: StageCore | null = null;
let runtime: Runtime | null = null; let runtime: Runtime | null = null;
const { editorService, uiService, keybindingService } = useServices(); const { editorService, uiService, keybindingService, stageOverlayService } = useServices();
const stageLoading = computed(() => editorService.get('stageLoading')); const stageLoading = computed(() => editorService.get('stageLoading'));
@ -97,6 +97,60 @@ const page = computed(() => editorService.get('page'));
const zoom = computed(() => uiService.get('zoom')); const zoom = computed(() => uiService.get('zoom'));
const node = computed(() => editorService.get('node')); 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(() => { watchEffect(() => {
if (stage || !page.value) return; if (stage || !page.value) return;
@ -109,6 +163,40 @@ watchEffect(() => {
stageWrapRef.value?.container?.focus(); 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)); editorService.set('stage', markRaw(stage));
stage.mount(stageContainerEl.value); stage.mount(stageContainerEl.value);

View File

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

View File

@ -17,23 +17,13 @@
*/ */
import { reactive, toRaw } from 'vue'; import { reactive, toRaw } from 'vue';
import { cloneDeep, get, isObject, mergeWith, uniq } from 'lodash-es'; import { cloneDeep, isObject, mergeWith, uniq } from 'lodash-es';
import type { Writable } from 'type-fest';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core'; import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NodeType, Target, Watcher } from '@tmagic/core'; import { NodeType } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form'; import type { ChangeRecord } from '@tmagic/form';
import { isFixed } from '@tmagic/stage'; import { isFixed } from '@tmagic/stage';
import { import { getNodeInfo, getNodePath, isPage, isPageFragment } from '@tmagic/utils';
calcValueByFontsize,
getElById,
getNodeInfo,
getNodePath,
isNumber,
isPage,
isPageFragment,
isPop,
} from '@tmagic/utils';
import BaseService from '@editor/services//BaseService'; import BaseService from '@editor/services//BaseService';
import propsService from '@editor/services//props'; import propsService from '@editor/services//props';
@ -42,69 +32,39 @@ import storageService, { Protocol } from '@editor/services/storage';
import type { import type {
AddMNode, AddMNode,
AsyncHookPlugin, AsyncHookPlugin,
AsyncMethodName,
EditorEvents,
EditorNodeInfo, EditorNodeInfo,
HistoryOpType,
PastePosition, PastePosition,
StepValue, StepValue,
StoreState, StoreState,
StoreStateKey, StoreStateKey,
} from '@editor/type'; } from '@editor/type';
import { LayerOffset, Layout } from '@editor/type'; import { canUsePluginMethods, LayerOffset, Layout } from '@editor/type';
import { import {
change2Fixed, calcAlignCenterStyle,
calcLayerTargetIndex,
calcMoveStyle,
classifyDragSources,
collectRelatedNodes,
COPY_STORAGE_KEY, COPY_STORAGE_KEY,
Fixed2Other, editorNodeMergeCustomizer,
fixNodePosition, fixNodePosition,
getInitPositionStyle, getInitPositionStyle,
getNodeIndex, getNodeIndex,
getPageFragmentList, getPageFragmentList,
getPageList, getPageList,
moveItemsInContainer, moveItemsInContainer,
resolveSelectedNode,
setChildrenLayout, setChildrenLayout,
setLayout, setLayout,
toggleFixedPosition,
} from '@editor/utils/editor'; } 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'; 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 { class Editor extends BaseService {
public state: StoreState = reactive({ public state: StoreState = reactive({
root: null, root: null,
@ -121,6 +81,7 @@ class Editor extends BaseService {
disabledMultiSelect: false, disabledMultiSelect: false,
}); });
private isHistoryStateChange = false; private isHistoryStateChange = false;
private selectionBeforeOp: Id[] | null = null;
constructor() { constructor() {
super( super(
@ -390,6 +351,8 @@ class Editor extends BaseService {
* @returns * @returns
*/ */
public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> { public async add(addNode: AddMNode | MNode[], parent?: MContainer | null): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const stage = this.get('stage'); const stage = this.get('stage');
// 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue // 新增多个组件只存在于粘贴多个组件,粘贴的是一个完整的config,所以不再需要getPropsValue
@ -435,7 +398,21 @@ class Editor extends BaseService {
} }
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) { 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); this.emit('add', newNodes);
@ -498,13 +475,33 @@ class Editor extends BaseService {
* @param {Object} node * @param {Object} node
*/ */
public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> { public async remove(nodeOrNodeList: MNode | MNode[]): Promise<void> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(nodeOrNodeList) ? nodeOrNodeList : [nodeOrNodeList]; 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))); await Promise.all(nodes.map((node) => this.doRemove(node)));
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) { if (removedItems.length > 0 && pageForOp) {
// 更新历史记录 this.pushOpHistory('remove', { removedItems }, pageForOp);
this.pushHistoryState();
} }
this.emit('remove', nodes); this.emit('remove', nodes);
@ -525,21 +522,9 @@ class Editor extends BaseService {
const node = toRaw(info.node); 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) => { newConfig = mergeWith(cloneDeep(node), newConfig, editorNodeMergeCustomizer);
if (typeof srcValue === 'undefined' && Object.hasOwn(source, key)) {
return '';
}
if (isObject(srcValue) && Array.isArray(objValue)) {
// 原来的配置是数组,新的配置是对象,则直接使用新的值
return srcValue;
}
if (Array.isArray(srcValue)) {
return srcValue;
}
});
if (!newConfig.type) throw new Error('配置缺少type值'); if (!newConfig.type) throw new Error('配置缺少type值');
@ -597,12 +582,28 @@ class Editor extends BaseService {
config: MNode | MNode[], config: MNode | MNode[],
data: { changeRecords?: ChangeRecord[] } = {}, data: { changeRecords?: ChangeRecord[] } = {},
): Promise<MNode | MNode[]> { ): Promise<MNode | MNode[]> {
this.captureSelectionBeforeOp();
const nodes = Array.isArray(config) ? config : [config]; const nodes = Array.isArray(config) ? config : [config];
const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data))); const updateData = await Promise.all(nodes.map((node) => this.doUpdate(node, data)));
if (updateData[0].oldNode?.type !== NodeType.ROOT) { 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); this.emit('update', updateData);
@ -616,6 +617,8 @@ class Editor extends BaseService {
* @returns void * @returns void
*/ */
public async sort(id1: Id, id2: Id): Promise<void> { public async sort(id1: Id, id2: Id): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root'); const root = this.get('root');
if (!root) throw new Error('root为空'); if (!root) throw new Error('root为空');
@ -640,9 +643,6 @@ class Editor extends BaseService {
parentId: parent.id, parentId: parent.id,
root: cloneDeep(root), 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 { public copyWithRelated(config: MNode | MNode[], collectorOptions?: TargetOptions): void {
const copyNodes: MNode[] = Array.isArray(config) ? config : [config]; const copyNodes: MNode[] = Array.isArray(config) ? config : [config];
// 初始化复制组件相关的依赖收集器
if (collectorOptions && typeof collectorOptions.isTarget === 'function') { if (collectorOptions && typeof collectorOptions.isTarget === 'function') {
const customTarget = new Target({ collectRelatedNodes(copyNodes, collectorOptions, (id) => this.getNodeById(id));
...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);
}
}
});
});
} }
storageService.setItem(COPY_STORAGE_KEY, copyNodes, { storageService.setItem(COPY_STORAGE_KEY, copyNodes, {
@ -733,32 +710,16 @@ class Editor extends BaseService {
public async doAlignCenter(config: MNode): Promise<MNode> { public async doAlignCenter(config: MNode): Promise<MNode> {
const parent = this.getParentById(config.id); const parent = this.getParentById(config.id);
if (!parent) throw new Error('找不到父节点'); if (!parent) throw new Error('找不到父节点');
const node = cloneDeep(toRaw(config)); const node = cloneDeep(toRaw(config));
const layout = await this.getLayout(parent, node); const layout = await this.getLayout(parent, node);
if (layout === Layout.RELATIVE) { const doc = this.get('stage')?.renderer?.contentWindow?.document;
return config; const newStyle = calcAlignCenterStyle(node, parent, layout, doc);
}
if (!node.style) return config; if (!newStyle) 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 = '';
}
node.style = newStyle;
return node; return node;
} }
@ -789,6 +750,8 @@ class Editor extends BaseService {
* @param offset * @param offset
*/ */
public async moveLayer(offset: number | LayerOffset): Promise<void> { public async moveLayer(offset: number | LayerOffset): Promise<void> {
this.captureSelectionBeforeOp();
const root = this.get('root'); const root = this.get('root');
if (!root) throw new Error('root为空'); if (!root) throw new Error('root为空');
@ -801,22 +764,16 @@ class Editor extends BaseService {
const brothers: MNode[] = parent.items || []; const brothers: MNode[] = parent.items || [];
const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`); const index = brothers.findIndex((item) => `${item.id}` === `${node?.id}`);
// 流式布局与绝对定位布局操作的相反的
const layout = await this.getLayout(parent, node); const layout = await this.getLayout(parent, node);
const isRelative = layout === Layout.RELATIVE; const isRelative = layout === Layout.RELATIVE;
const offsetIndex = calcLayerTargetIndex(index, offset, brothers.length, isRelative);
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);
}
if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) { if ((offsetIndex > 0 && offsetIndex > brothers.length) || offsetIndex < 0) {
return; return;
} }
const oldParent = cloneDeep(toRaw(parent));
brothers.splice(index, 1); brothers.splice(index, 1);
brothers.splice(offsetIndex, 0, node); brothers.splice(offsetIndex, 0, node);
@ -829,7 +786,14 @@ class Editor extends BaseService {
}); });
this.addModifiedNodeId(parent.id); 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); this.emit('move-layer', offset);
} }
@ -840,12 +804,17 @@ class Editor extends BaseService {
* @param targetId ID * @param targetId ID
*/ */
public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> { public async moveToContainer(config: MNode, targetId: Id): Promise<MNode | undefined> {
this.captureSelectionBeforeOp();
const root = this.get('root'); 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 target = this.getNodeById(targetId, false) as MContainer;
const stage = this.get('stage'); const stage = this.get('stage');
if (root && node && parent && stage) { if (root && node && parent && stage) {
const oldSourceParent = cloneDeep(toRaw(parent));
const oldTarget = cloneDeep(toRaw(target));
const index = getNodeIndex(node.id, parent); const index = getNodeIndex(node.id, parent);
parent.items?.splice(index, 1); parent.items?.splice(index, 1);
@ -876,62 +845,60 @@ class Editor extends BaseService {
this.addModifiedNodeId(target.id); this.addModifiedNodeId(target.id);
this.addModifiedNodeId(parent.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; return newConfig;
} }
} }
public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) { public async dragTo(config: MNode | MNode[], targetParent: MContainer, targetIndex: number) {
this.captureSelectionBeforeOp();
if (!targetParent || !Array.isArray(targetParent.items)) return; if (!targetParent || !Array.isArray(targetParent.items)) return;
const configs = Array.isArray(config) ? config : [config]; const configs = Array.isArray(config) ? config : [config];
const sourceIndicesInTargetParent: number[] = []; const beforeSnapshots = new Map<string, MNode>();
const sourceOutTargetParent: MNode[] = []; for (const cfg of configs) {
const { parent } = this.getNodeInfo(cfg.id, false);
const newLayout = await this.getLayout(targetParent); if (parent && !beforeSnapshots.has(`${parent.id}`)) {
beforeSnapshots.set(`${parent.id}`, cloneDeep(toRaw(parent)));
// 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);
} }
} }
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) => { for (const { config: crossConfig, parent } of crossParentConfigs) {
targetParent.items?.splice(targetIndex + index, 0, config); const layout = await this.getLayout(parent);
this.addModifiedNodeId(config.id); 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'); 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 }); this.emit('drag-to', { targetIndex, configs, targetParent });
} }
/** /**
* *
* @returns * @returns
*/ */
public async undo(): Promise<StepValue | null> { public async undo(): Promise<StepValue | null> {
const value = historyService.undo(); const value = historyService.undo();
await this.changeHistoryState(value); if (value) {
await this.applyHistoryOp(value, true);
}
return value; return value;
} }
/** /**
* *
* @returns * @returns
*/ */
public async redo(): Promise<StepValue | null> { public async redo(): Promise<StepValue | null> {
const value = historyService.redo(); const value = historyService.redo();
await this.changeHistoryState(value); if (value) {
await this.applyHistoryOp(value, false);
}
return value; return value;
} }
@ -975,47 +954,10 @@ class Editor extends BaseService {
const node = toRaw(this.get('node')); const node = toRaw(this.get('node'));
if (!node || isPage(node)) return; if (!node || isPage(node)) return;
const { style, id, type } = node; const newStyle = calcMoveStyle(node.style || {}, left, top);
if (!style || !['absolute', 'fixed'].includes(style.position)) return; if (!newStyle) return;
const update = (style: { [key: string]: any }) => await this.update({ id: node.id, type: node.type, style: newStyle });
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: '',
});
}
}
} }
public resetState() { public resetState() {
@ -1068,70 +1010,89 @@ class Editor extends BaseService {
} }
} }
private pushHistoryState() { private captureSelectionBeforeOp() {
const curNode = cloneDeep(toRaw(this.get('node'))); if (this.isHistoryStateChange || this.selectionBeforeOp) return;
const page = this.get('page'); this.selectionBeforeOp = this.get('nodes').map((n) => n.id);
if (!this.isHistoryStateChange && curNode && page) { }
historyService.push({
data: cloneDeep(toRaw(page)), private pushOpHistory(opType: HistoryOpType, extra: Partial<StepValue>, pageData: { name: string; id: Id }) {
modifiedNodeIds: this.get('modifiedNodeIds'), if (this.isHistoryStateChange) {
nodeId: curNode.id, 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; 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; 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 root = this.get('root');
const newConfig = cloneDeep(dist); const stage = this.get('stage');
if (!root) return;
if (!isPop(src) && newConfig.style?.position) { const ctx: HistoryOpContext = {
if (isFixed(newConfig.style) && !isFixed(src.style || {})) { root,
newConfig.style = change2Fixed(newConfig, root); stage,
} else if (!isFixed(newConfig.style) && isFixed(src.style || {})) { getNodeById: (id, raw) => this.getNodeById(id, raw),
newConfig.style = await Fixed2Other(newConfig, root, this.getLayout); 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 { private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
let id: Id; return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.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,
};
} }
} }

View File

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

View File

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

View File

@ -20,10 +20,10 @@ import type { Component } from 'vue';
import type EventEmitter from 'events'; import type EventEmitter from 'events';
import type * as Monaco from 'monaco-editor'; import type * as Monaco from 'monaco-editor';
import type { default as Sortable, Options, SortableEvent } from 'sortablejs'; import type { default as Sortable, Options, SortableEvent } from 'sortablejs';
import type { PascalCasedProperties } from 'type-fest'; import type { PascalCasedProperties, Writable } from 'type-fest';
import type { CodeBlockContent, CodeBlockDSL, Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core'; 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 StageCore from '@tmagic/stage';
import type { import type {
ContainerHighlightType, ContainerHighlightType,
@ -164,6 +164,8 @@ export interface StageOptions {
disabledMultiSelect?: boolean; disabledMultiSelect?: boolean;
disabledRule?: boolean; disabledRule?: boolean;
zoom?: number; zoom?: number;
/** 画布双击前的钩子函数,返回 false 则阻止默认的双击行为 */
beforeDblclick?: (event: MouseEvent) => Promise<boolean | void> | boolean | void;
} }
export interface StoreState { export interface StoreState {
@ -541,14 +543,31 @@ export interface CodeParamStatement {
/** 参数名称 */ /** 参数名称 */
name: string; name: string;
/** 参数类型 */ /** 参数类型 */
type?: string; type?: string | TypeFunction<string>;
[key: string]: any; [key: string]: any;
} }
export type HistoryOpType = 'add' | 'remove' | 'update';
export interface StepValue { export interface StepValue {
data: MPage | MPageFragment; /** 页面信息 */
data: { name: string; id: Id };
opType: HistoryOpType;
/** 操作前选中的节点 ID用于撤销后恢复选择状态 */
selectedBefore: Id[];
/** 操作后选中的节点 ID用于重做后恢复选择状态 */
selectedAfter: Id[];
modifiedNodeIds: Map<Id, 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 { export interface HistoryState {
@ -712,3 +731,44 @@ export type CustomContentMenuFunction = (
menus: (MenuButton | MenuComponent)[], menus: (MenuButton | MenuComponent)[],
type: 'layer' | 'data-source' | 'viewer' | 'code-block', type: 'layer' | 'data-source' | 'viewer' | 'code-block',
) => (MenuButton | MenuComponent)[]; ) => (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([ defineFormConfig([
{ {
name: 'id', name: 'id',

View File

@ -1,83 +1,81 @@
import { DataSchema, DataSourceFieldType, DataSourceSchema } from '@tmagic/core'; import type { DataSchema, DataSourceFieldType, DataSourceSchema } from '@tmagic/core';
import { CascaderOption, FormConfig, FormState } from '@tmagic/form'; import { type CascaderOption, defineFormItem, type FormConfig } from '@tmagic/form';
import { dataSourceTemplateRegExp, getKeysArray, isNumber } from '@tmagic/utils'; import { dataSourceTemplateRegExp, getKeysArray, isNumber } from '@tmagic/utils';
import BaseFormConfig from './formConfigs/base'; import BaseFormConfig from './formConfigs/base';
import HttpFormConfig from './formConfigs/http'; import HttpFormConfig from './formConfigs/http';
const fillConfig = (config: FormConfig): FormConfig => [ const dataSourceFormConfig = defineFormItem({
...BaseFormConfig(), type: 'tab',
...config, items: [
{ {
type: 'tab', title: '数据定义',
items: [ items: [
{ {
title: '数据定义', name: 'fields',
items: [ type: 'data-source-fields',
{ defaultValue: () => [],
name: 'fields', },
type: 'data-source-fields', ],
defaultValue: () => [], },
}, {
], title: '方法定义',
}, items: [
{ {
title: '方法定义', name: 'methods',
items: [ type: 'data-source-methods',
{ defaultValue: () => [],
name: 'methods', },
type: 'data-source-methods', ],
defaultValue: () => [], },
}, {
], title: '事件配置',
}, items: [
{ {
title: '事件配置', name: 'events',
items: [ src: 'datasource',
{ type: 'event-select',
name: 'events', },
src: 'datasource', ],
type: 'event-select', },
}, {
], title: 'mock数据',
}, items: [
{ {
title: 'mock数据', name: 'mocks',
items: [ type: 'data-source-mocks',
{ defaultValue: () => [],
name: 'mocks', },
type: 'data-source-mocks', ],
defaultValue: () => [], },
}, {
], title: '请求参数裁剪',
}, display: (_formState, { model }) => model.type === 'http',
{ items: [
title: '请求参数裁剪', {
display: (_formState: FormState, { model }: any) => model.type === 'http', name: 'beforeRequest',
items: [ type: 'vs-code',
{ parse: true,
name: 'beforeRequest', autosize: { minRows: 10, maxRows: 30 },
type: 'vs-code', },
parse: true, ],
autosize: { minRows: 10, maxRows: 30 }, },
}, {
], title: '响应数据裁剪',
}, display: (_formStat, { model }) => model.type === 'http',
{ items: [
title: '响应数据裁剪', {
display: (_formState: FormState, { model }: any) => model.type === 'http', name: 'afterResponse',
items: [ type: 'vs-code',
{ parse: true,
name: 'afterResponse', autosize: { minRows: 10, maxRows: 30 },
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 => { export const getFormConfig = (type: string, configs: Record<string, FormConfig>): FormConfig => {
switch (type) { 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 { detailedDiff } from 'deep-object-diff';
import { isObject } from 'lodash-es'; import { cloneDeep, get, isObject } from 'lodash-es';
import serialize from 'serialize-javascript'; import serialize from 'serialize-javascript';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core'; import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
import { NODE_CONDS_KEY, NodeType } from '@tmagic/core'; import { NODE_CONDS_KEY, NodeType, Target, Watcher } from '@tmagic/core';
import type StageCore from '@tmagic/stage'; import type StageCore from '@tmagic/stage';
import { isFixed } from '@tmagic/stage';
import { import {
calcValueByFontsize, calcValueByFontsize,
getElById, getElById,
@ -34,7 +35,8 @@ import {
isValueIncludeDataSource, isValueIncludeDataSource,
} from '@tmagic/utils'; } 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_STORAGE_KEY = '$MagicEditorCopyData';
export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode'; export const COPY_CODE_STORAGE_KEY = '$MagicEditorCopyCode';
@ -436,3 +438,246 @@ export const buildChangeRecords = (value: any, basePath: string) => {
return changeRecords; 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

@ -24,7 +24,7 @@ import {
NODE_DISABLE_DATA_SOURCE_KEY, NODE_DISABLE_DATA_SOURCE_KEY,
} from '@tmagic/core'; } from '@tmagic/core';
import { tMagicMessage } from '@tmagic/design'; 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 = [ export const arrayOptions = [
{ text: '包含', value: 'include' }, { text: '包含', value: 'include' },
@ -107,6 +107,10 @@ export const styleTabConfig: TabPaneConfig = {
'borderStyle', 'borderStyle',
'borderColor', '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: '显示条件', title: '显示条件',
display: (_state: FormState, { model }: any) => model.type !== 'page', display: (_state, { model }) => model.type !== 'page',
items: [ items: [
{ {
name: NODE_CONDS_RESULT_KEY, name: NODE_CONDS_RESULT_KEY,
@ -209,7 +213,7 @@ export const fillConfig = (
const propsConfig: FormConfig = []; const propsConfig: FormConfig = [];
// 组件类型,必须要有 // 组件类型,必须要有
if (!config.find((item) => item.name === 'type')) { if (!config.find((item) => 'name' in item && item.name === 'type')) {
propsConfig.push({ propsConfig.push({
text: 'type', text: 'type',
name: '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必须要有 // 组件id必须要有
propsConfig.push({ propsConfig.push({
name: 'id', 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({ propsConfig.push({
name: 'name', name: 'name',
text: '组件名称', 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) { if (noCodeAdvancedTabItems.length > 0 && disabledCodeBlock) {
advancedTabConfig.items = noCodeAdvancedTabItems; advancedTabConfig.items = noCodeAdvancedTabItems;

View File

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

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 { describe, expect, test } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } 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'; import * as editor from '@editor/utils/editor';
describe('util form', () => { 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', () => { describe('undo', () => {
let undoRedo: UndoRedo; let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => { beforeEach(() => {
undoRedo = new UndoRedo(); 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.canUndo()).toBe(false);
expect(undoRedo.undo()).toEqual(null); 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 }); undoRedo.pushElement({ a: 2 });
expect(undoRedo.canUndo()).toBe(true); 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', () => { describe('redo', () => {
let undoRedo: UndoRedo; let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => { beforeEach(() => {
undoRedo = new UndoRedo(); 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.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null); expect(undoRedo.redo()).toBe(null);
}); });
test('can no redo: no undo', () => { test('can not redo: no undo', () => {
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
undoRedo.pushElement(element); undoRedo.pushElement({ a: i });
expect(undoRedo.canRedo()).toBe(false); expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toBe(null); expect(undoRedo.redo()).toBe(null);
} }
}); });
test('can no redo: undo and push', () => { test('can not redo: undo and push', () => {
undoRedo.pushElement(element); undoRedo.pushElement({ a: 1 });
undoRedo.pushElement({ a: 2 });
undoRedo.undo(); undoRedo.undo();
undoRedo.pushElement(element); undoRedo.pushElement({ a: 3 });
expect(undoRedo.canRedo()).toBe(false); expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.redo()).toEqual(null); expect(undoRedo.redo()).toEqual(null);
}); });
test('can no redo: redo end', () => { test('can not redo: redo end', () => {
const element1 = { a: 1 }; undoRedo.pushElement({ a: 1 });
const element2 = { a: 2 }; undoRedo.pushElement({ a: 2 });
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
undoRedo.undo(); undoRedo.undo();
undoRedo.undo(); undoRedo.undo();
undoRedo.redo(); undoRedo.redo();
@ -85,23 +91,20 @@ describe('redo', () => {
}); });
test('can redo', () => { test('can redo', () => {
const element1 = { a: 1 }; undoRedo.pushElement({ a: 1 });
const element2 = { a: 2 }; undoRedo.pushElement({ a: 2 });
undoRedo.pushElement(element1);
undoRedo.pushElement(element2);
undoRedo.undo(); undoRedo.undo();
undoRedo.undo(); undoRedo.undo();
expect(undoRedo.canRedo()).toBe(true); expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element1); expect(undoRedo.redo()).toEqual({ a: 1 });
expect(undoRedo.canRedo()).toBe(true); expect(undoRedo.canRedo()).toBe(true);
expect(undoRedo.redo()).toEqual(element2); expect(undoRedo.redo()).toEqual({ a: 2 });
}); });
}); });
describe('get current element', () => { describe('get current element', () => {
let undoRedo: UndoRedo; let undoRedo: UndoRedo;
const element = { a: 1 };
beforeEach(() => { beforeEach(() => {
undoRedo = new UndoRedo(); undoRedo = new UndoRedo();
@ -112,44 +115,38 @@ describe('get current element', () => {
}); });
test('has element', () => { test('has element', () => {
undoRedo.pushElement(element); undoRedo.pushElement({ a: 1 });
expect(undoRedo.getCurrentElement()).toEqual(element); expect(undoRedo.getCurrentElement()).toEqual({ a: 1 });
}); });
}); });
describe('list max size', () => { describe('list max size', () => {
let undoRedo: UndoRedo; let undoRedo: UndoRedo;
const listMaxSize = 100; const listMaxSize = 100;
const element = { a: 1 };
beforeEach(() => { beforeEach(() => {
undoRedo = new UndoRedo(listMaxSize); undoRedo = new UndoRedo(listMaxSize);
undoRedo.pushElement(element);
}); });
test('reach max size', () => { 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: i });
} }
undoRedo.pushElement({ a: listMaxSize }); // 这个元素使得list达到maxSize触发数据删除
expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize }); expect(undoRedo.getCurrentElement()).toEqual({ a: listMaxSize });
expect(undoRedo.canRedo()).toBe(false); expect(undoRedo.canRedo()).toBe(false);
expect(undoRedo.canUndo()).toBe(true); expect(undoRedo.canUndo()).toBe(true);
}); });
test('reach max size, then undo', () => { test('reach max size, then undo all', () => {
for (let i = 0; i < listMaxSize + 1; i++) { for (let i = 0; i <= listMaxSize; i++) {
undoRedo.pushElement({ a: i }); undoRedo.pushElement({ a: i });
} }
for (let i = 0; i < listMaxSize - 1; i++) { for (let i = 0; i < listMaxSize; i++) {
undoRedo.undo(); 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.canUndo()).toBe(false);
expect(undoRedo.getCurrentElement()).toEqual(element); expect(undoRedo.getCurrentElement()).toEqual(null);
}); });
}); });

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/element-plus-adapter", "name": "@tmagic/element-plus-adapter",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/form-schema", "name": "@tmagic/form-schema",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -91,12 +91,10 @@ export interface FormItem {
/** vnode的key值默认是遍历数组时的index */ /** vnode的key值默认是遍历数组时的index */
__key?: string | number; __key?: string | number;
/** 表单域标签的的宽度,例如 '50px'。支持 auto。 */ /** 表单域标签的的宽度,例如 '50px'。支持 auto。 */
labelWidth?: string; labelWidth?: string | number;
/** label 标签的title属性 */ /** label 标签的title属性 */
labelTitle?: string; labelTitle?: string;
className?: string; className?: string;
/** 表单组件类型 */
type?: string | TypeFunction;
/** 字段名 */ /** 字段名 */
name?: string | number; name?: string | number;
/** 额外的提示信息,和 help 类似,当提示文案同时出现时,可以使用这个。 */ /** 额外的提示信息,和 help 类似,当提示文案同时出现时,可以使用这个。 */
@ -129,11 +127,16 @@ export interface FormItem {
expand?: boolean; expand?: boolean;
style?: Record<string, any>; style?: Record<string, any>;
fieldStyle?: Record<string, any>; fieldStyle?: Record<string, any>;
labelPosition?: 'top' | 'left' | 'right';
}
export interface DynamicTypeConfig extends FormItem {
type: TypeFunction;
[key: string]: any; [key: string]: any;
} }
export interface ContainerCommonConfig { export interface ContainerCommonConfig<T = never> extends FormItem {
items: FormConfig; items: FormConfig<T>;
onInitValue?: ( onInitValue?: (
mForm: FormState | undefined, mForm: FormState | undefined,
data: { data: {
@ -182,12 +185,12 @@ export interface Input {
placeholder?: string; placeholder?: string;
} }
export type TypeFunction = ( export type TypeFunction<T extends string = string> = (
mForm: FormState | undefined, mForm: FormState | undefined,
data: { data: {
model: FormValue; model: FormValue;
}, },
) => string; ) => T;
export type FilterFunction<T = boolean> = ( export type FilterFunction<T = boolean> = (
mForm: FormState | undefined, mForm: FormState | undefined,
@ -208,6 +211,7 @@ export type FilterFunction<T = boolean> = (
*/ */
export interface SelectConfigOption { export interface SelectConfigOption {
/** 选项的标签 */ /** 选项的标签 */
label?: string | SelectOptionTextFunction;
text: string | SelectOptionTextFunction; text: string | SelectOptionTextFunction;
/** 选项的值 */ /** 选项的值 */
value: any | SelectOptionValueFunction; value: any | SelectOptionValueFunction;
@ -344,13 +348,15 @@ export interface HtmlField extends FormItem {
export interface DisplayConfig extends FormItem { export interface DisplayConfig extends FormItem {
type: 'display'; type: 'display';
initValue?: string | number | boolean; initValue?: string | number | boolean;
displayText: FilterFunction<string> | string; displayText?: FilterFunction<string> | string;
} }
/** 文本输入框 */ /** 文本输入框 */
export interface TextConfig extends FormItem, Input { export interface TextConfig extends FormItem, Input {
type?: 'text'; type?: 'text';
tooltip?: string; tooltip?: string;
/** 是否可清空 */
clearable?: boolean;
prepend?: string; prepend?: string;
/** 后置元素,一般为标签或按钮 */ /** 后置元素,一般为标签或按钮 */
append?: append?:
@ -436,6 +442,17 @@ export interface TimeConfig extends FormItem, Input {
valueFormat?: 'HH:mm:ss' | string; 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 { export interface RadioGroupConfig extends FormItem {
type: 'radio-group'; type: 'radio-group' | 'radioGroup';
childType?: 'default' | 'button'; childType?: 'default' | 'button';
options: { options: {
value: string | number | boolean; value: string | number | boolean;
text: string; text?: string;
icon?: any; icon?: any;
tooltip?: string; tooltip?: string;
}[]; }[];
@ -486,7 +503,7 @@ export interface CheckboxGroupOption {
* *
*/ */
export interface CheckboxGroupConfig extends FormItem { export interface CheckboxGroupConfig extends FormItem {
type: 'checkbox-group'; type: 'checkbox-group' | 'checkboxGroup';
options: CheckboxGroupOption[] | FilterFunction<CheckboxGroupOption[]>; 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'; type: 'link';
href?: string | ((model: Record<string, any>) => string); href?: string | ((model: Record<string, any>) => string);
css?: { css?: {
@ -553,7 +570,7 @@ export interface LinkConfig extends FormItem {
) => string) ) => string)
| string; | string;
form: form:
| FormConfig | FormConfig<T>
| (( | ((
mForm: FormState | undefined, mForm: FormState | undefined,
data: { data: {
@ -561,7 +578,7 @@ export interface LinkConfig extends FormItem {
values?: Readonly<FormValue> | null; values?: Readonly<FormValue> | null;
formValue?: FormValue; formValue?: FormValue;
}, },
) => FormConfig); ) => FormConfig<T>);
fullscreen?: boolean; fullscreen?: boolean;
} }
@ -602,7 +619,7 @@ export interface CascaderConfig extends FormItem, Input {
} }
export interface DynamicFieldConfig extends FormItem { export interface DynamicFieldConfig extends FormItem {
type: 'dynamic-field'; type: 'dynamic-field' | 'dynamicField';
returnFields: ( returnFields: (
config: DynamicFieldConfig, config: DynamicFieldConfig,
model: Record<any, any>, 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'; type: 'row';
span: number; span: number;
items: ({ span?: number } & (ChildConfig | EditorChildConfig))[]; items: ({ span?: number } & (ChildConfig<T> | EditorChildConfig | T))[];
} }
/** /**
* *
*/ */
export interface TabPaneConfig { export interface TabPaneConfig<T = never> {
status?: string; status?: string;
/** 标签页名称,用于关联 model 中的数据 */
name?: string | number;
title: string; title: string;
lazy?: boolean; lazy?: boolean;
labelWidth?: string; labelWidth?: string;
items: FormConfig; items: FormConfig<T>;
display?: boolean | 'expand' | FilterFunction<boolean | 'expand'>;
onTabClick?: (mForm: FormState | undefined, tab: any, data: any) => void; 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'; type: 'tab' | 'dynamic-tab';
tabType?: string; tabType?: string;
editable?: boolean; editable?: boolean;
dynamic?: boolean; dynamic?: boolean;
tabPosition?: 'top' | 'right' | 'bottom' | 'left'; 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; onChange?: (mForm: FormState | undefined, data: any) => void;
onTabAdd?: (mForm: FormState | undefined, data: any) => void; onTabAdd?: (mForm: FormState | undefined, data: any) => void;
onTabRemove?: (mForm: FormState | undefined, tabName: string, 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'; type: 'fieldset';
checkbox?: checkbox?:
| boolean | 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'; type: 'panel';
expand?: boolean; expand?: boolean;
title?: string; title?: string;
@ -683,7 +706,10 @@ export interface TableColumnConfig extends FormItem {
label: string; label: string;
width?: string | number; width?: string | number;
sortable?: boolean; 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; importable?: (mForm: FormState | undefined, data: any) => boolean | 'undefined' | boolean;
/** 是否显示checkbox */ /** 是否显示checkbox */
selection?: (mForm: FormState | undefined, data: any) => boolean | boolean | 'single'; 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; copyHandler?: (mForm: FormState | undefined, data: any) => any;
onSelect?: (mForm: FormState | undefined, data: any) => any; onSelect?: (mForm: FormState | undefined, data: any) => any;
/** @deprecated 请使用 defaultSort */
defautSort?: SortProp; defautSort?: SortProp;
defaultSort?: SortProp; defaultSort?: SortProp;
/** 是否支持拖拽排序 */ /** 是否支持拖拽排序 */
@ -739,15 +766,17 @@ export interface TableConfig extends FormItem {
props?: Record<string, any>; props?: Record<string, any>;
text?: string; text?: string;
}; };
sort?: boolean;
sortKey?: string;
} }
export interface GroupListConfig extends FormItem { export interface GroupListConfig<T = never> extends FormItem {
type: 'table' | 'groupList' | 'group-list'; type: 'table' | 'groupList' | 'group-list';
span?: number; span?: number;
enableToggleMode?: boolean; enableToggleMode?: boolean;
items: FormConfig; items: FormConfig<T>;
groupItems?: FormConfig; groupItems?: FormConfig<T>;
tableItems?: FormConfig; tableItems?: FormConfig<T>;
titleKey?: string; titleKey?: string;
titlePrefix?: string; titlePrefix?: string;
title?: string | FilterFunction<string>; title?: string | FilterFunction<string>;
@ -760,7 +789,8 @@ export interface GroupListConfig extends FormItem {
*/ */
defaultExpandQuantity?: number; defaultExpandQuantity?: number;
addable?: (mForm: FormState | undefined, data: any) => boolean | 'undefined' | boolean; 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; delete?: (model: any, index: number | string | symbol, values: any) => boolean | boolean;
copyable?: FilterFunction<boolean>; copyable?: FilterFunction<boolean>;
movable?: ( movable?: (
@ -774,45 +804,50 @@ export interface GroupListConfig extends FormItem {
props?: Record<string, any>; props?: Record<string, any>;
text?: string; text?: string;
}; };
[key: string]: any;
} }
interface StepItemConfig extends FormItem, ContainerCommonConfig { interface StepItemConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
title: string; title: string;
} }
export interface StepConfig extends FormItem { export interface StepConfig<T = never> extends FormItem {
type: 'step'; type: 'step';
/** 每个 step 的间距,不填写将自适应间距。支持百分比。 */ /** 每个 step 的间距,不填写将自适应间距。支持百分比。 */
space?: string | number; space?: string | number;
items: StepItemConfig[]; items: StepItemConfig<T>[];
} }
export interface ComponentConfig extends FormItem { export interface ComponentConfig extends FormItem {
type: 'component'; type: 'component';
id: string; id: string;
extend: any; extend?: any;
display: any; display?: any;
component?: any;
} }
export interface FlexLayoutConfig extends FormItem, ContainerCommonConfig { export interface FlexLayoutConfig<T = never> extends FormItem, ContainerCommonConfig<T> {
type: 'flex-layout'; type: 'flex-layout';
/** flex 子项间距,默认 '16px' */
gap?: string;
} }
export type ChildConfig = export type ChildConfig<T = never> =
| FormItem | ContainerCommonConfig<T>
| TabConfig | TabConfig<T>
| RowConfig | RowConfig<T>
| FieldsetConfig | FieldsetConfig<T>
| PanelConfig | PanelConfig<T>
| TableConfig | TableConfig
| GroupListConfig | GroupListConfig<T>
| StepConfig | StepConfig<T>
| DisplayConfig | DisplayConfig
| TextConfig | TextConfig
| NumberConfig
| NumberRangeConfig
| HiddenConfig | HiddenConfig
| LinkConfig | LinkConfig<T>
| DaterangeConfig | DaterangeConfig
| TimerangeConfig
| SelectConfig | SelectConfig
| CascaderConfig | CascaderConfig
| HtmlField | HtmlField
@ -823,8 +858,12 @@ export type ChildConfig =
| CheckboxConfig | CheckboxConfig
| SwitchConfig | SwitchConfig
| RadioGroupConfig | RadioGroupConfig
| CheckboxGroupConfig
| TextareaConfig | TextareaConfig
| DynamicFieldConfig | 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 { 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'; type: 'data-source-field-select';
/** /**
* data * data
@ -26,13 +26,15 @@ export interface DataSourceFieldSelectConfig extends FormItem {
}, },
) => boolean); ) => boolean);
dataSourceFieldType?: DataSourceFieldType[]; dataSourceFieldType?: DataSourceFieldType[];
fieldConfig?: ChildConfig; fieldConfig?: FormItemConfig<T>;
/** 是否可以编辑数据源disable表示的是是否可以选择数据源 */ /** 是否可以编辑数据源disable表示的是是否可以选择数据源 */
notEditable?: boolean | FilterFunction; notEditable?: boolean | FilterFunction;
dataSourceId?: string;
} }
export interface CodeConfig extends FormItem { export interface CodeConfig extends FormItem {
type: 'code'; type: 'vs-code';
language?: string; language?: string;
options?: { options?: {
[key: string]: any; [key: string]: any;
@ -104,6 +106,7 @@ export interface DataSourceSelect extends FormItem, Input {
} }
export interface DisplayCondsConfig extends FormItem { export interface DisplayCondsConfig extends FormItem {
type: 'display-conds';
titlePrefix?: string; titlePrefix?: string;
parentFields?: string[] | FilterFunction<string[]>; parentFields?: string[] | FilterFunction<string[]>;
} }
@ -140,8 +143,12 @@ export interface UISelectConfig extends FormItem {
type: 'ui-select'; type: 'ui-select';
} }
export type EditorChildConfig = export interface StyleSetterConfig extends FormItem {
| DataSourceFieldSelectConfig type: 'style-setter';
}
export type EditorChildConfig<T = never> =
| DataSourceFieldSelectConfig<T>
| CodeConfig | CodeConfig
| CodeLinkConfig | CodeLinkConfig
| CodeSelectConfig | CodeSelectConfig
@ -157,4 +164,5 @@ export type EditorChildConfig =
| EventSelectConfig | EventSelectConfig
| KeyValueConfig | KeyValueConfig
| PageFragmentSelectConfig | 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 './base';
export * from './editor'; 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,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/form", "name": "@tmagic/form",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

@ -218,13 +218,13 @@ const getTextByName = (name: string, config: FormConfig = props.config): string
return typeof item.text === 'string' ? item.text : undefined; 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); const result = findInConfig(item.items, remainingParts);
if (result !== undefined) return result; 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); const result = findInConfig(item.items, parts);
if (result !== undefined) return result; if (result !== undefined) return result;
} }

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<template> <template>
<div <div
:data-tmagic-id="config.id" :data-tmagic-id="(config as Record<string, any>).id"
:data-tmagic-form-item-prop="itemProp" :data-tmagic-form-item-prop="itemProp"
:class="`m-form-container m-container-${type || ''} ${config.className || ''}${config.tip ? ' has-tip' : ''}`" :class="`m-form-container m-container-${type || ''} ${config.className || ''}${config.tip ? ' has-tip' : ''}`"
:style="config.style" :style="config.style"
@ -28,7 +28,7 @@
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
:use-label="config.useLabel" :use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle" :label-title="config.labelTitle"
:text="text" :text="text"
></FormLabel> ></FormLabel>
@ -61,7 +61,7 @@
></component> ></component>
</TMagicFormItem> </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> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -80,7 +80,7 @@
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
:use-label="config.useLabel" :use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle" :label-title="config.labelTitle"
:text="text" :text="text"
></FormLabel> ></FormLabel>
@ -95,7 +95,7 @@
<component v-else v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component> <component v-else v-bind="fieldsProps" :is="tagName" :model="lastValues" @change="onChangeHandler"></component>
</TMagicFormItem> </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> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -112,7 +112,7 @@
<FormLabel <FormLabel
:tip="config.tip" :tip="config.tip"
:type="type" :type="type"
:use-label="config.useLabel" :use-label="(config as CheckboxConfig).useLabel"
:label-title="config.labelTitle" :label-title="config.labelTitle"
:text="text" :text="text"
></FormLabel> ></FormLabel>
@ -127,7 +127,7 @@
<component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component> <component v-else v-bind="fieldsProps" :is="tagName" :model="model" @change="onChangeHandler"></component>
</TMagicFormItem> </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> <TMagicIcon style="line-height: 40px; margin-left: 5px"><warning-filled /></TMagicIcon>
<template #content> <template #content>
<div v-html="config.tip"></div> <div v-html="config.tip"></div>
@ -174,9 +174,11 @@ import { getValueByKeyPath } from '@tmagic/utils';
import MHidden from '../fields/Hidden.vue'; import MHidden from '../fields/Hidden.vue';
import type { import type {
ChildConfig, CheckboxConfig,
ComponentConfig,
ContainerChangeEventData, ContainerChangeEventData,
ContainerCommonConfig, ContainerCommonConfig,
FormItemConfig,
FormState, FormState,
FormValue, FormValue,
ToolTipConfigType, ToolTipConfigType,
@ -196,10 +198,10 @@ const props = withDefaults(
model: FormValue; model: FormValue;
/** 需对比的值(开启对比模式时传入) */ /** 需对比的值(开启对比模式时传入) */
lastValues?: FormValue; lastValues?: FormValue;
config: ChildConfig; config: FormItemConfig;
prop?: string; prop?: string;
disabled?: boolean; disabled?: boolean;
labelWidth?: string; labelWidth?: string | number;
expandMore?: boolean; expandMore?: boolean;
stepActive?: string | number; stepActive?: string | number;
size?: string; size?: string;
@ -251,7 +253,7 @@ const itemProp = computed(() => {
}); });
const type = computed((): string => { const type = computed((): string => {
let { type } = props.config; let type = 'type' in props.config ? props.config.type : '';
type = type && filterFunction<string>(mForm, type, props); type = type && filterFunction<string>(mForm, type, props);
if (type === 'form') return ''; if (type === 'form') return '';
if (type === 'container') return ''; if (type === 'container') return '';
@ -259,13 +261,10 @@ const type = computed((): string => {
}); });
const tagName = computed(() => { const tagName = computed(() => {
if (type.value === 'component' && props.config.component) { if (type.value === 'component' && (props.config as ComponentConfig).component) {
return props.config.component; return (props.config as ComponentConfig).component;
} }
if (!getField(type.value || 'container')) {
console.log(type.value, 'type.value');
}
return getField(type.value || 'container') || `m-${items.value ? 'form' : 'fields'}-${type.value}`; return getField(type.value || 'container') || `m-${items.value ? 'form' : 'fields'}-${type.value}`;
}); });
@ -305,7 +304,7 @@ const fieldsProps = computed(() => ({
name: name.value, name: name.value,
disabled: disabled.value, disabled: disabled.value,
prop: itemProp.value, prop: itemProp.value,
key: props.config[mForm?.keyProps], key: (props.config as Record<string, any>)[mForm?.keyProps],
style: props.config.fieldStyle, style: props.config.fieldStyle,
})); }));

View File

@ -144,7 +144,9 @@ const rowConfig = computed(() => ({
span: props.config.span || 24, span: props.config.span || 24,
items: props.config.items, items: props.config.items,
labelWidth: props.config.labelWidth, 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(() => { const title = computed(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/schema", "name": "@tmagic/schema",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/stage", "name": "@tmagic/stage",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,
@ -31,6 +31,7 @@
"dependencies": { "dependencies": {
"@scena/guides": "^0.29.2", "@scena/guides": "^0.29.2",
"events": "^3.3.0", "events": "^3.3.0",
"@zumer/snapdom": "^2.8.0",
"keycon": "^1.4.0", "keycon": "^1.4.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"moveable": "^0.53.0", "moveable": "^0.53.0",

View File

@ -236,6 +236,30 @@ export default class ActionManager extends EventEmitter {
return null; 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 * @param el

View File

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

View File

@ -18,6 +18,7 @@
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { SnapdomOptions } from '@zumer/snapdom';
import type { MoveableOptions, OnDragStart } from 'moveable'; import type { MoveableOptions, OnDragStart } from 'moveable';
import type { Id } from '@tmagic/core'; import type { Id } from '@tmagic/core';
@ -88,7 +89,38 @@ export default class StageCore extends EventEmitter {
* @param id id * @param id id
*/ */
public async select(id: Id, event?: MouseEvent): Promise<void> { 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; if (el === this.actionManager?.getSelectedEl()) return;
await this.renderer?.select([id]); await this.renderer?.select([id]);
@ -241,6 +273,21 @@ export default class StageCore extends EventEmitter {
this.renderer?.reloadIframe(url); 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 { EventEmitter } from 'events';
import { snapdom, SnapdomOptions } from '@zumer/snapdom';
import type { Id } from '@tmagic/core'; import type { Id } from '@tmagic/core';
import { getElById, getHost, guid, injectStyle, isSameDomain } 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); 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() { public postTmagicRuntimeReady() {
this.contentWindow = this.iframe?.contentWindow as RuntimeWindow; this.contentWindow = this.iframe?.contentWindow as RuntimeWindow;

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/table", "name": "@tmagic/table",
"type": "module", "type": "module",
"sideEffects": [ "sideEffects": [

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/tdesign-vue-next-adapter", "name": "@tmagic/tdesign-vue-next-adapter",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

View File

@ -1,5 +1,5 @@
{ {
"version": "1.7.8-beta.1", "version": "1.7.10",
"name": "@tmagic/utils", "name": "@tmagic/utils",
"type": "module", "type": "module",
"sideEffects": false, "sideEffects": false,

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

View File

@ -35,8 +35,12 @@ import { NodeType } from '@tmagic/schema';
import type { EditorNodeInfo } from '@editor/type'; import type { EditorNodeInfo } from '@editor/type';
import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from './const';
export * from './dom'; export * from './dom';
export * from './const';
// for typeof global checks without @types/node // for typeof global checks without @types/node
declare let global: {}; declare let global: {};
@ -542,10 +546,6 @@ export const getDefaultValueFromFields = (fields: DataSchema[]) => {
return data; return data;
}; };
export const DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX = 'ds-field::';
export const DATA_SOURCE_FIELDS_CHANGE_EVENT_PREFIX = 'ds-field-changed';
export const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>; export const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>;
export const calculatePercentage = (value: number, percentageStr: string) => { export const calculatePercentage = (value: number, percentageStr: string) => {

View File

@ -1,6 +1,6 @@
{ {
"name": "tmagic-playground", "name": "tmagic-playground",
"version": "1.7.8-beta.1", "version": "1.7.10",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
@ -12,11 +12,11 @@
}, },
"dependencies": { "dependencies": {
"@element-plus/icons-vue": "^2.3.2", "@element-plus/icons-vue": "^2.3.2",
"@tmagic/core": "1.7.8-beta.1", "@tmagic/core": "1.7.10",
"@tmagic/design": "1.7.8-beta.1", "@tmagic/design": "1.7.10",
"@tmagic/editor": "1.7.8-beta.1", "@tmagic/editor": "1.7.10",
"@tmagic/element-plus-adapter": "1.7.8-beta.1", "@tmagic/element-plus-adapter": "1.7.10",
"@tmagic/tdesign-vue-next-adapter": "1.7.8-beta.1", "@tmagic/tdesign-vue-next-adapter": "1.7.10",
"@tmagic/tmagic-form-runtime": "1.1.3", "@tmagic/tmagic-form-runtime": "1.1.3",
"element-plus": "^2.11.8", "element-plus": "^2.11.8",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",

View File

@ -1,7 +1,6 @@
{ {
"extends": "../tsconfig.json", "extends": "../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "..",
}, },
"exclude": [ "exclude": [
"**/dist/**/*" "**/dist/**/*"

1424
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,13 @@
packages: packages:
- 'packages/*' - "packages/*"
- 'playground' - "playground"
- 'runtime/*' - "runtime/*"
- 'vue-components/*' - "vue-components/*"
- 'react-components/*' - "react-components/*"
- 'eslint-config' - "eslint-config"
catalog: catalog:
vue: ^3.5.24 vue: ^3.5.24
'@vue/compiler-sfc': ^3.5.24 "@vue/compiler-sfc": ^3.5.24
vite: ^8.0.0 vite: ^8.0.8
typescript: "^5.9.3" typescript: "^6.0.2"

View File

@ -35,7 +35,16 @@ function aliasPlugin() {
resolveId(source) { resolveId(source) {
for (const { find, replacement } of aliasEntries) { for (const { find, replacement } of aliasEntries) {
if (find.test(source)) { if (find.test(source)) {
return source.replace(find, replacement); let resolved = source.replace(find, replacement);
resolved = resolved.replace(/\/\//g, '/');
resolved = resolved.replace(/\.js$/, '');
if (existsSync(`${resolved}.d.ts`)) {
return `${resolved}.d.ts`;
}
if (existsSync(resolved)) {
return resolved;
}
return `${resolved}.d.ts`;
} }
} }
}, },

View File

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import type { ChangeEvent, MNode } from '@tmagic/core';
import type TMagicApp from '@tmagic/core'; import type TMagicApp from '@tmagic/core';
import type { ChangeEvent, MNode } from '@tmagic/core';
import { isPage, replaceChildNode } from '@tmagic/core'; import { isPage, replaceChildNode } from '@tmagic/core';
export const useDsl = (app: TMagicApp | undefined) => { export const useDsl = (app: TMagicApp | undefined) => {

View File

@ -1,7 +1,7 @@
import { cloneDeep } from 'lodash-es'; import { cloneDeep } from 'lodash-es';
import type { Id, MApp } from '@tmagic/core';
import type TMagicApp from '@tmagic/core'; import type TMagicApp from '@tmagic/core';
import type { Id, MApp } from '@tmagic/core';
import { getElById, replaceChildNode } from '@tmagic/core'; import { getElById, replaceChildNode } from '@tmagic/core';
import type { Magic, RemoveData, SortEventData, UpdateData } from '@tmagic/stage'; import type { Magic, RemoveData, SortEventData, UpdateData } from '@tmagic/stage';

View File

@ -1,6 +1,6 @@
{ {
"name": "runtime-react", "name": "runtime-react",
"version": "1.7.8-beta.1", "version": "1.7.10",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "engines": {
@ -16,16 +16,16 @@
"build:playground": "node scripts/build.mjs --type=playground" "build:playground": "node scripts/build.mjs --type=playground"
}, },
"dependencies": { "dependencies": {
"@tmagic/core": "1.7.8-beta.1", "@tmagic/core": "1.7.10",
"@tmagic/react-runtime-help": "0.2.2", "@tmagic/react-runtime-help": "0.2.2",
"@tmagic/stage": "1.7.8-beta.1", "@tmagic/stage": "1.7.10",
"axios": "^1.13.2", "axios": "^1.13.2",
"qrcode": "^1.5.0", "qrcode": "^1.5.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
"devDependencies": { "devDependencies": {
"@tmagic/cli": "1.7.8-beta.1", "@tmagic/cli": "1.7.10",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",

View File

@ -1,7 +1,6 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"baseUrl": "./",
"jsx": "react", "jsx": "react",
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"types": ["node"], "types": ["node"],

View File

@ -1,6 +1,6 @@
{ {
"name": "runtime-vue", "name": "runtime-vue",
"version": "1.7.8-beta.1", "version": "1.7.10",
"type": "module", "type": "module",
"private": true, "private": true,
"engines": { "engines": {
@ -16,14 +16,14 @@
"build:playground": "node scripts/build.mjs --type=playground" "build:playground": "node scripts/build.mjs --type=playground"
}, },
"dependencies": { "dependencies": {
"@tmagic/core": "1.7.8-beta.1", "@tmagic/core": "1.7.10",
"@tmagic/stage": "1.7.8-beta.1", "@tmagic/stage": "1.7.10",
"@tmagic/vue-runtime-help": "^2.0.0", "@tmagic/vue-runtime-help": "^2.0.0",
"axios": "^1.13.2", "axios": "^1.13.2",
"vue": "catalog:" "vue": "catalog:"
}, },
"devDependencies": { "devDependencies": {
"@tmagic/cli": "1.7.8-beta.1", "@tmagic/cli": "1.7.10",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/node": "^24.0.10", "@types/node": "^24.0.10",
"@vitejs/plugin-legacy": "^8.0.0", "@vitejs/plugin-legacy": "^8.0.0",

View File

@ -20,9 +20,10 @@ if (args.package) {
const pkgRoot = path.resolve(packagesDir, args.package); const pkgRoot = path.resolve(packagesDir, args.package);
if (fs.statSync(pkgRoot).isDirectory()) { if (fs.statSync(pkgRoot).isDirectory()) {
rimraf.sync(path.resolve(packagesDir, `./${args.package}/dist`)); rimraf.sync(path.resolve(packagesDir, `./${args.package}/dist`));
const pkg = createRequire(import.meta.url)(`../packages/${args.package}/package.json`);
build({ packageName: args.package, format: 'es' }); build({ packageName: args.package, format: 'es', pkg, packagesDir });
build({ packageName: args.package, format: 'umd' }); build({ packageName: args.package, format: 'umd', pkg, packagesDir });
} }
} else { } else {
const packages = getPackageNames(packagesDir); const packages = getPackageNames(packagesDir);
@ -45,6 +46,30 @@ if (args.package) {
} }
} }
// rolldown 在 UMD 输出顶部会注入
// Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// 当内联的依赖(如 lodash-es 的 _Symbol.js声明 `var Symbol = root.Symbol;`
// 时,由于 var hoisting该局部 `Symbol` 会把上面一行引用到的全局 `Symbol`
// 遮蔽掉(此时局部变量还未赋值),运行时抛出
// TypeError: Cannot read properties of undefined (reading 'toStringTag')
// 这里通过后处理把该引用改为 `globalThis.Symbol.toStringTag`,绕开被 hoist
// 的局部绑定。rolldown 修好前先用此 workaround。
function fixUmdSymbolShadow() {
return {
name: 'tmagic:fix-umd-symbol-shadow',
generateBundle(outputOptions, bundle) {
if (outputOptions.format !== 'umd') return;
for (const file of Object.values(bundle)) {
if (file.type !== 'chunk' || typeof file.code !== 'string') continue;
file.code = file.code.replace(
/Object\.defineProperty\(exports,\s*Symbol\.toStringTag,/g,
'Object.defineProperty(exports, globalThis.Symbol.toStringTag,',
);
}
},
};
}
async function build({ packageName, format, pkg, packagesDir }) { async function build({ packageName, format, pkg, packagesDir }) {
await buildVite({ await buildVite({
root: path.resolve(packagesDir, `./${packageName}`), root: path.resolve(packagesDir, `./${packageName}`),
@ -69,6 +94,7 @@ async function build({ packageName, format, pkg, packagesDir }) {
}, },
rolldownOptions: { rolldownOptions: {
plugins: [fixUmdSymbolShadow()],
// 确保外部化处理那些你不想打包进库的依赖 // 确保外部化处理那些你不想打包进库的依赖
external(id) { external(id) {
if (format === 'umd' && id === 'lodash-es') { if (format === 'umd' && id === 'lodash-es') {

View File

@ -1,6 +1,5 @@
{ {
"compilerOptions": { "compilerOptions": {
"baseUrl": ".",
"outDir": "temp", "outDir": "temp",
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",