Compare commits

...

37 Commits

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

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

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

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

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

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

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

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

Made-with: Cursor
2026-04-07 18:25:35 +08:00
oceanzhu
a7274198bf docs: add AGENTS.md for AI navigation 2026-04-07 10:13:37 +08:00
Linzsong
6f2e8d8d74 fix(stage): 修复隐藏标尺后无法显示问题 2026-03-27 15:35:01 +08:00
roymondchen
637a5bb69a refactor(editor): 历史记录改成记录操作而不是记录副本 2026-03-27 15:27:41 +08:00
roymondchen
42e7ac1b2e chore: update lockfile v1.7.9 2026-03-23 15:32:49 +08:00
roymondchen
a3cdad9d91 chore: release v1.7.9 2026-03-23 15:31:42 +08:00
roymondchen
711af79d72 fix(form): row容器中如果配置没有type显示异常 2026-03-23 15:23:41 +08:00
roymondchen
728fbc035c chore: update lockfile v1.7.8 2026-03-20 19:44:45 +08:00
roymondchen
01795455e9 chore: release v1.7.8 2026-03-20 19:43:37 +08:00
roymondchen
9b56223359 fix(editor): 组件配置样式显示出错 2026-03-20 19:41:18 +08:00
roymondchen
984cea7ca3 chore: update lockfile v1.7.8-beta.4 2026-03-20 18:54:57 +08:00
roymondchen
9921ed8a2d chore: release v1.7.8-beta.4 2026-03-20 18:53:49 +08:00
roymondchen
e36d8d7cf8 fix(form-schema): 表单 schema 中 display 与 component 部分字段改为可选
Made-with: Cursor
2026-03-20 18:45:19 +08:00
roymondchen
35aa81514b chore: update lockfile v1.7.8-beta.3 2026-03-20 17:41:38 +08:00
roymondchen
450376872e chore: release v1.7.8-beta.3 2026-03-20 17:40:26 +08:00
roymondchen
e8714c96c9 feat(form-schema,form,editor,table): 完善表单配置类型 2026-03-20 17:38:11 +08:00
roymondchen
feefd3779e chore: update lockfile v1.7.8-beta.2 2026-03-20 12:36:12 +08:00
roymondchen
1ae023db8c chore: release v1.7.8-beta.2 2026-03-20 12:34:50 +08:00
roymondchen
55eb546ad6 feat(form-schema,form,editor): 完善表单配置类型 2026-03-20 12:31:55 +08:00
roymondchen
1664559d8f refactor(dep): 优化性能 2026-03-19 16:02:41 +08:00
roymondchen
a34d0cdccc build: 构建的类型文件中别名没有消除 2026-03-19 15:53:01 +08:00
97 changed files with 3411 additions and 1484 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)
### Bug Fixes

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,138 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { toRaw } from 'vue';
import { cloneDeep } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type StageCore from '@tmagic/stage';
import { isPage, isPageFragment } from '@tmagic/utils';
import type { EditorNodeInfo, StepValue } from '@editor/type';
import { getNodeIndex } from '@editor/utils/editor';
export interface HistoryOpContext {
root: MApp;
stage: StageCore | null;
getNodeById(id: Id, raw?: boolean): MNode | null;
getNodeInfo(id: Id, raw?: boolean): EditorNodeInfo;
setRoot(root: MApp): void;
setPage(page: MPage | MPageFragment): void;
getPage(): MPage | MPageFragment | null;
}
/**
* add
* reverse=true
* reverse=false
*/
export async function applyHistoryAddOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
for (const node of step.nodes ?? []) {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
}
} else {
const parent = ctx.getNodeById(step.parentId!, false) as MContainer;
if (parent?.items) {
for (const node of step.nodes ?? []) {
const idx = step.indexMap?.[node.id] ?? parent.items.length;
parent.items.splice(idx, 0, cloneDeep(node));
await stage?.add({
config: cloneDeep(node),
parent: cloneDeep(parent),
parentId: parent.id,
root: cloneDeep(root),
});
}
}
}
}
/**
* remove
* reverse=true
* reverse=false
*/
export async function applyHistoryRemoveOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
if (reverse) {
const sorted = [...(step.removedItems ?? [])].sort((a, b) => a.index - b.index);
for (const { node, parentId, index } of sorted) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
parent.items.splice(index, 0, cloneDeep(node));
await stage?.add({ config: cloneDeep(node), parent: cloneDeep(parent), parentId, root: cloneDeep(root) });
}
} else {
for (const { node, parentId } of step.removedItems ?? []) {
const parent = ctx.getNodeById(parentId, false) as MContainer;
if (!parent?.items) continue;
const idx = getNodeIndex(node.id, parent);
if (typeof idx === 'number' && idx !== -1) {
parent.items.splice(idx, 1);
}
await stage?.remove({ id: node.id, parentId, root: cloneDeep(root) });
}
}
}
/**
* update
* reverse=true oldNode
* reverse=false newNode
*/
export async function applyHistoryUpdateOp(step: StepValue, reverse: boolean, ctx: HistoryOpContext): Promise<void> {
const { root, stage } = ctx;
const items = step.updatedItems ?? [];
for (const { oldNode, newNode } of items) {
const config = reverse ? oldNode : newNode;
if (config.type === NodeType.ROOT) {
ctx.setRoot(cloneDeep(config) as MApp);
continue;
}
const info = ctx.getNodeInfo(config.id, false);
if (!info.parent) continue;
const idx = getNodeIndex(config.id, info.parent);
if (typeof idx !== 'number' || idx === -1) continue;
info.parent.items![idx] = cloneDeep(config);
if (isPage(config) || isPageFragment(config)) {
ctx.setPage(config as MPage | MPageFragment);
}
}
const curPage = ctx.getPage();
if (stage && curPage) {
await stage.update({
config: cloneDeep(toRaw(curPage)),
parentId: root.id,
root: cloneDeep(toRaw(root)),
});
}
}

View File

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

View File

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

View File

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

View File

@ -0,0 +1,245 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, test, vi } from 'vitest';
import type { MApp, MContainer, MNode } from '@tmagic/core';
import { NodeType } from '@tmagic/core';
import type { StepValue } from '@editor/type';
import type { HistoryOpContext } from '@editor/utils/editor-history';
import { applyHistoryAddOp, applyHistoryRemoveOp, applyHistoryUpdateOp } from '@editor/utils/editor-history';
const makePage = (): MContainer => ({
id: 'page_1',
type: NodeType.PAGE,
items: [
{ id: 'n1', type: 'text' },
{ id: 'n2', type: 'button' },
],
});
const makeRoot = (page: MContainer): MApp => ({
id: 'app_1',
type: NodeType.ROOT,
items: [page],
});
const makeCtx = (root: MApp): HistoryOpContext => {
const page = root.items[0] as MContainer;
return {
root,
stage: {
add: vi.fn(),
remove: vi.fn(),
update: vi.fn(),
} as any,
getNodeById: (id: any) => {
if (`${id}` === `${root.id}`) return root as unknown as MNode;
if (`${id}` === `${page.id}`) return page as unknown as MNode;
return page.items.find((n) => `${n.id}` === `${id}`) ?? null;
},
getNodeInfo: (id: any) => {
if (`${id}` === `${page.id}`) {
return { node: page as unknown as MNode, parent: root as unknown as MContainer, page: page as any };
}
const node = page.items.find((n) => `${n.id}` === `${id}`);
return { node: node ?? null, parent: node ? page : null, page: page as any };
},
setRoot: vi.fn(),
setPage: vi.fn(),
getPage: () => page as any,
};
};
describe('applyHistoryAddOp', () => {
test('撤销 add从父节点移除已添加的节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['n1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'n1', type: 'text' }],
parentId: 'page_1',
};
expect(page.items).toHaveLength(2);
await applyHistoryAddOp(step, true, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
test('重做 add重新添加节点到父节点', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'add',
selectedBefore: [],
selectedAfter: ['new1'],
modifiedNodeIds: new Map(),
nodes: [{ id: 'new1', type: 'text' }],
parentId: 'page_1',
indexMap: { new1: 0 },
};
await applyHistoryAddOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('new1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
});
describe('applyHistoryRemoveOp', () => {
test('撤销 remove将已删除节点按原位置重新插入', async () => {
const page: MContainer = { id: 'page_1', type: NodeType.PAGE, items: [{ id: 'n2', type: 'button' }] };
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: ['n1'],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
await applyHistoryRemoveOp(step, true, ctx);
expect(page.items).toHaveLength(2);
expect(page.items[0].id).toBe('n1');
expect(ctx.stage!.add).toHaveBeenCalled();
});
test('重做 remove再次删除节点', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'remove',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
removedItems: [{ node: { id: 'n1', type: 'text' }, parentId: 'page_1', index: 0 }],
};
expect(page.items).toHaveLength(2);
await applyHistoryRemoveOp(step, false, ctx);
expect(page.items).toHaveLength(1);
expect(page.items[0].id).toBe('n2');
expect(ctx.stage!.remove).toHaveBeenCalled();
});
});
describe('applyHistoryUpdateOp', () => {
test('撤销 update将节点恢复为 oldNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(page.items[0].text).toBe('before');
expect(ctx.stage!.update).toHaveBeenCalled();
});
test('重做 update将节点更新为 newNode', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'n1', type: 'text', text: 'before' },
newNode: { id: 'n1', type: 'text', text: 'after' },
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(page.items[0].text).toBe('after');
});
test('update ROOT 类型调用 setRoot', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: { id: 'app_1', type: NodeType.ROOT, items: [] } as any,
newNode: { id: 'app_1', type: NodeType.ROOT, items: [page] } as any,
},
],
};
await applyHistoryUpdateOp(step, true, ctx);
expect(ctx.setRoot).toHaveBeenCalled();
});
test('update 页面节点调用 setPage', async () => {
const page = makePage();
const root = makeRoot(page);
const ctx = makeCtx(root);
const updatedPage = { ...page, name: 'renamed' };
const step: StepValue = {
opType: 'update',
selectedBefore: [],
selectedAfter: [],
modifiedNodeIds: new Map(),
updatedItems: [
{
oldNode: page as any,
newNode: updatedPage as any,
},
],
};
await applyHistoryUpdateOp(step, false, ctx);
expect(ctx.setPage).toHaveBeenCalled();
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
{
"version": "1.7.8-beta.1",
"version": "1.7.10",
"name": "@tmagic/utils",
"type": "module",
"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 { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX } from './const';
export * from './dom';
export * from './const';
// for typeof global checks without @types/node
declare let global: {};
@ -542,10 +546,6 @@ export const getDefaultValueFromFields = (fields: DataSchema[]) => {
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 calculatePercentage = (value: number, percentageStr: string) => {

View File

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

View File

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

1424
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -35,7 +35,16 @@ function aliasPlugin() {
resolveId(source) {
for (const { find, replacement } of aliasEntries) {
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 { cloneDeep } from 'lodash-es';
import type { ChangeEvent, MNode } from '@tmagic/core';
import type TMagicApp from '@tmagic/core';
import type { ChangeEvent, MNode } from '@tmagic/core';
import { isPage, replaceChildNode } from '@tmagic/core';
export const useDsl = (app: TMagicApp | undefined) => {

View File

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

View File

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

View File

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

View File

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

View File

@ -20,9 +20,10 @@ if (args.package) {
const pkgRoot = path.resolve(packagesDir, args.package);
if (fs.statSync(pkgRoot).isDirectory()) {
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: 'umd' });
build({ packageName: args.package, format: 'es', pkg, packagesDir });
build({ packageName: args.package, format: 'umd', pkg, packagesDir });
}
} else {
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 }) {
await buildVite({
root: path.resolve(packagesDir, `./${packageName}`),
@ -69,6 +94,7 @@ async function build({ packageName, format, pkg, packagesDir }) {
},
rolldownOptions: {
plugins: [fixUmdSymbolShadow()],
// 确保外部化处理那些你不想打包进库的依赖
external(id) {
if (format === 'umd' && id === 'lodash-es') {

View File

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