Compare commits

...

47 Commits

Author SHA1 Message Date
roymondchen
cc34891bb2 chore: update lockfile v1.8.0-beta.20 2026-07-30 17:12:04 +08:00
roymondchen
0ea5568fec chore: release v1.8.0-beta.20 2026-07-30 17:10:43 +08:00
roymondchen
4bbda354a9 feat(editor,dep): collectIdle 使用常驻 Web Worker 做依赖收集
将节点树遍历与 target 匹配下沉到与 collectByWorker 共用的常驻 worker,
避免大页面时主线程卡死;通过可序列化 descriptor 重建内置 target,
失败时回退主线程空闲队列。
2026-07-30 17:03:48 +08:00
roymondchen
aa42144738 fix(editor): 修复删除页面/页面片时画布被误清空的问题
删除当前页时先切换画布再通知 runtime 销毁;runtime 侧仅在删除当前渲染页时才调用 deletePage。
2026-07-30 15:38:27 +08:00
roymondchen
c4b98e3257 refactor(test): 移除 editor 单元测试中冗余的 vi.mock 覆盖
依赖 importActual 保留真实实现即可,无需再 mock SideItemKey、NodeType 等常量。
2026-07-30 14:58:36 +08:00
roymondchen
b85eaa9452 refactor(utils): 新增 isPageOrFragment 统一页面与页面片判断 2026-07-30 14:56:05 +08:00
roymondchen
21baf58cda chore: update lockfile v1.8.0-beta.19 2026-07-28 16:54:06 +08:00
roymondchen
4923a34835 chore: release v1.8.0-beta.19 2026-07-28 16:52:20 +08:00
roymondchen
b7c04edcd5 feat(form): 新增 validateOnInit,默认关闭初始化时自动校验
属性面板仍开启初始化校验,避免打开普通表单时立即展示错误态。
2026-07-28 16:45:42 +08:00
roymondchen
2116408bd4 chore: update lockfile v1.8.0-beta.18 2026-07-28 16:22:15 +08:00
roymondchen
a342a78313 chore: release v1.8.0-beta.18 2026-07-28 16:16:20 +08:00
roymondchen
3fe9cf9b66 fix(editor): 修复 IdleTask 任务异常或 clearTasks 时队列卡住的问题
单个任务失败不再中断整批依赖收集,并在 runTaskQueue 中正确重置 taskHandle、始终通过实例队列消费任务。
2026-07-28 15:32:58 +08:00
roymondchen
a123747e9d feat(form): 支持 typeMatch 异步校验与 validator 返回值约定 2026-07-28 15:32:48 +08:00
roymondchen
e4d4a11240 style(design,editor): 调整卡片间距 2026-07-28 15:32:09 +08:00
roymondchen
4ffda3ddd5 chore: update lockfile v1.8.0-beta.17 2026-07-27 17:40:24 +08:00
roymondchen
a0b5702330 chore: release v1.8.0-beta.17 2026-07-27 17:39:05 +08:00
roymondchen
e6cf694a03 feat(editor,stage): 新增 containerHighlightAddOnly 配置
开启后仅新增组件(从组件列表拖入)时才识别容器,画布中拖动已有组件不再被加入其他容器,避免误操作
2026-07-27 17:33:29 +08:00
manmanyu
be7b38f97e style(design,editor,form): 事件模块处理 & 卡片样式调整 2026-07-27 08:55:36 +00:00
manmanyu
dec4ebfa20 fix(form,table): groupList分界线、空状态、actions只存在一个
TAPD: --story=134235103
2026-07-27 06:52:18 +00:00
roymondchen
596d86f2ab fix(table): 列操作点击后关闭popover 2026-07-27 12:51:08 +08:00
roymondchen
b564c9e4aa fix(editor): 切换动作类型时清空关联配置字段 2026-07-24 17:17:55 +08:00
roymondchen
b5abf31066 fix(editor): 修复 collectIdle 批次结算与中断时 Promise 挂起问题
引入批次级 Promise 结算,避免快速连续触发或 clearIdleTasks 后 collecting 卡死。
collectIdle 返回 boolean 表示是否完整完成,initService 据此跳过被中断的 stage 更新。
2026-07-24 17:17:55 +08:00
roymondchen
bf8df74864 fix(form): 完善 mountFormInstance 清理逻辑,避免临时表单实例泄漏
补充 mount 全流程异常清理、非正数 timeout 兜底,并支持 AbortSignal 主动中断 debug 弹层。
2026-07-24 16:51:42 +08:00
roymondchen
a37e0487c6 chore: update lockfile v1.8.0-beta.16 2026-07-23 22:12:42 +08:00
roymondchen
329cd947e1 chore: release v1.8.0-beta.16 2026-07-23 22:11:18 +08:00
roymondchen
0e1986c819 feat: 参考建议可选值最多展示个数由 5 调整为 20 2026-07-23 22:07:51 +08:00
roymondchen
d0a22315c0 refactor(form): 抽离表单校验逻辑至 validateForm 并复用于 props 面板
- 将 editor 的 validatePropsForm 逻辑迁移到 @tmagic/form 的 validateForm
- FormPanel 源码保存改为复用 validateForm 静默校验
- 补充 form 校验与 applyExtendState 相关单元测试
2026-07-23 22:02:43 +08:00
roymondchen
fe7815cf3d chore: update lockfile v1.8.0-beta.15 2026-07-23 20:25:53 +08:00
roymondchen
bf9078a458 chore: release v1.8.0-beta.15 2026-07-23 20:24:47 +08:00
roymondchen
3b9d5da0bc feat(form,editor): submitForm/validateForm 静默挂载时注入静默标记,vs-code 字段跳过 monaco 渲染 2026-07-23 20:22:47 +08:00
roymondchen
2e031cc134 feat(design): 优化 FormItem 和 colorPicker 样式 2026-07-23 19:26:54 +08:00
roymondchen
1f389ff9fd feat(form): text 类型校验允许数字值,兼容数据源字段配置 2026-07-23 19:04:04 +08:00
roymondchen
fe9e754b50 feat(editor): 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 2026-07-23 19:03:28 +08:00
roymondchen
745f449396 feat: 新增 page-structure-change 事件,支持页面增删统一通知 2026-07-23 19:00:12 +08:00
manmanyu
48abab7051 feat(form,design): 一行多个表单默认右靠齐label 2026-07-23 08:44:10 +00:00
roymondchen
11bf037054 fix: 跳过无字段名称配置的类型校验 2026-07-23 16:32:07 +08:00
roymondchen
97e0cac44a fix(core): 为背景图片 URL 添加引号 2026-07-23 15:40:58 +08:00
roymondchen
cde0805292 fix(editor): 使用属性面板中的源码删除样式无效 2026-07-23 15:15:19 +08:00
roymondchen
6919a0a0b1 fix(editor): 右键菜单动态变化后修复菜单位置防止截断 2026-07-23 15:13:26 +08:00
roymondchen
d9d2f8f04c chore: update lockfile v1.8.0-beta.14 2026-07-23 12:13:40 +08:00
roymondchen
3fbce585a4 chore: release v1.8.0-beta.14 2026-07-23 12:12:18 +08:00
roymondchen
09eecd5bed fix(stage): 修复选中组件时 stage 外层滚动 2026-07-23 12:09:13 +08:00
roymondchen
ee5247ca94 feat: playground 支持主题切换 2026-07-22 19:03:05 +08:00
roymondchen
8699dc12af fix: 调整编辑器配置项展示 2026-07-22 18:39:10 +08:00
manmanyu
b13cd3425b feat(design,editor): 新增magic-admin主题样式,优化组件表单配置布局样式 2026-07-22 09:31:26 +00:00
roymondchen
89689bb9fd fix(editor): 保留历史操作来源并补发插入事件 2026-07-22 17:17:42 +08:00
roymondchen
0feada8d1f fix(form): 统一 extendState 合并逻辑 2026-07-22 16:51:07 +08:00
221 changed files with 6501 additions and 1243 deletions

View File

@ -1,3 +1,86 @@
# [1.8.0-beta.20](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.19...v1.8.0-beta.20) (2026-07-30)
### Bug Fixes
* **editor:** 修复删除页面/页面片时画布被误清空的问题 ([aa42144](https://github.com/Tencent/tmagic-editor/commit/aa4214473858200c62b6efab79c0671e06cc3917))
### Features
* **editor,dep:** collectIdle 使用常驻 Web Worker 做依赖收集 ([4bbda35](https://github.com/Tencent/tmagic-editor/commit/4bbda354a987b0a409a01184e8511822a0384a13))
# [1.8.0-beta.19](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.18...v1.8.0-beta.19) (2026-07-28)
### Features
* **form:** 新增 validateOnInit默认关闭初始化时自动校验 ([b7c04ed](https://github.com/Tencent/tmagic-editor/commit/b7c04edcd518554d5117c2212bbfe8ef632a4b37))
# [1.8.0-beta.18](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.17...v1.8.0-beta.18) (2026-07-28)
### Bug Fixes
* **editor:** 修复 IdleTask 任务异常或 clearTasks 时队列卡住的问题 ([3fe9cf9](https://github.com/Tencent/tmagic-editor/commit/3fe9cf9b664dc5b39c17b40efe1d3935c89f7553))
### Features
* **form:** 支持 typeMatch 异步校验与 validator 返回值约定 ([a123747](https://github.com/Tencent/tmagic-editor/commit/a123747e9d250166c2bb0f1d3ce3a39dd5baea9f))
# [1.8.0-beta.17](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.16...v1.8.0-beta.17) (2026-07-27)
### Bug Fixes
* **editor:** 修复 collectIdle 批次结算与中断时 Promise 挂起问题 ([b5abf31](https://github.com/Tencent/tmagic-editor/commit/b5abf310663f0bc34bd2c51c2e1a28de0bdf6ab9))
* **editor:** 切换动作类型时清空关联配置字段 ([b564c9e](https://github.com/Tencent/tmagic-editor/commit/b564c9e4aacd215dd46c4b839cf0033ceedc8047))
* **form,table:** groupList分界线、空状态、actions只存在一个 ([dec4ebf](https://github.com/Tencent/tmagic-editor/commit/dec4ebfa20483f699a676e41e0541d23ebe24d81))
* **form:** 完善 mountFormInstance 清理逻辑,避免临时表单实例泄漏 ([bf8df74](https://github.com/Tencent/tmagic-editor/commit/bf8df74864b78891a8820462062624702a1d33d3))
* **table:** 列操作点击后关闭popover ([596d86f](https://github.com/Tencent/tmagic-editor/commit/596d86f2ab08d3d4e6548b5e4bd5759d18e4493d))
### Features
* **editor,stage:** 新增 containerHighlightAddOnly 配置 ([e6cf694](https://github.com/Tencent/tmagic-editor/commit/e6cf694a032652e4f2c05efbab4a07a252e2ffa1))
# [1.8.0-beta.16](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.15...v1.8.0-beta.16) (2026-07-23)
### Features
* 参考建议可选值最多展示个数由 5 调整为 20 ([0e1986c](https://github.com/Tencent/tmagic-editor/commit/0e1986c81998e50461c294c1a27a8139e8ae9a13))
# [1.8.0-beta.15](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.14...v1.8.0-beta.15) (2026-07-23)
### Bug Fixes
* **core:** 为背景图片 URL 添加引号 ([97e0cac](https://github.com/Tencent/tmagic-editor/commit/97e0cac44a2d6ceafaf95fbc4ae716bfe5d47f70))
* **editor:** 使用属性面板中的源码删除样式无效 ([cde0805](https://github.com/Tencent/tmagic-editor/commit/cde08052924ddef1c51faeb68ce15d3195827adb))
* **editor:** 右键菜单动态变化后修复菜单位置防止截断 ([6919a0a](https://github.com/Tencent/tmagic-editor/commit/6919a0a0b19efff92821fc8d52b61b2f7382a817))
* 跳过无字段名称配置的类型校验 ([11bf037](https://github.com/Tencent/tmagic-editor/commit/11bf037054774937132743a40006364e49ee8fd5))
### Features
* **design:** 优化 FormItem 和 colorPicker 样式 ([2e031cc](https://github.com/Tencent/tmagic-editor/commit/2e031cc1346fb6ea739f0d45a89213b6577dc7ee))
* **editor:** 数据源字段选择器校验提示细化并支持 fieldConfig 类型校验 ([fe9e754](https://github.com/Tencent/tmagic-editor/commit/fe9e754b50f14e6034170b2284553ec1c4536150))
* **form,design:** 一行多个表单默认右靠齐label ([48abab7](https://github.com/Tencent/tmagic-editor/commit/48abab7051b93b7ed336172c21ab3b908733f2e7))
* **form,editor:** submitForm/validateForm 静默挂载时注入静默标记vs-code 字段跳过 monaco 渲染 ([3b9d5da](https://github.com/Tencent/tmagic-editor/commit/3b9d5da0bc9f323e944c0e2adb4fc88642ce1915))
* **form:** text 类型校验允许数字值,兼容数据源字段配置 ([1f389ff](https://github.com/Tencent/tmagic-editor/commit/1f389ff9fd77fb93ad749f3fdbbc8aa0fe8725ac))
* 新增 page-structure-change 事件,支持页面增删统一通知 ([745f449](https://github.com/Tencent/tmagic-editor/commit/745f44939605528e8e2ccd0639478db98293a441))
# [1.8.0-beta.14](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.13...v1.8.0-beta.14) (2026-07-23)
### Bug Fixes
* **editor:** 保留历史操作来源并补发插入事件 ([89689bb](https://github.com/Tencent/tmagic-editor/commit/89689bb9fd2d2d3edcc027b87932cbdd3a5755ad))
* **form:** 统一 extendState 合并逻辑 ([0feada8](https://github.com/Tencent/tmagic-editor/commit/0feada8d1fa4057444da746040b3824df09eef07))
* **stage:** 修复选中组件时 stage 外层滚动 ([09eecd5](https://github.com/Tencent/tmagic-editor/commit/09eecd5bedd49e5a002fa5ef122eaa1579dc895b))
* 调整编辑器配置项展示 ([8699dc1](https://github.com/Tencent/tmagic-editor/commit/8699dc12af9d52b1c460f61f3d3f48e4cf3fc5f6))
### Features
* **design,editor:** 新增magic-admin主题样式,优化组件表单配置布局样式 ([b13cd34](https://github.com/Tencent/tmagic-editor/commit/b13cd3425b16eaeac33f897bf51e80f7984db782))
* playground 支持主题切换 ([ee5247c](https://github.com/Tencent/tmagic-editor/commit/ee5247ca9436c385a5b634d161726006912fe77c))
# [1.8.0-beta.13](https://github.com/Tencent/tmagic-editor/compare/v1.8.0-beta.11...v1.8.0-beta.13) (2026-07-22)

View File

@ -162,7 +162,7 @@
}
&:hover {
color: #409eff;
color: var(--el-color-primary,#409eff);;
background-color: #f9fafc;
}

View File

@ -1020,6 +1020,18 @@ alt: 按住alt键启动识别
- **类型:** `'default' | 'alt' | ''`
## containerHighlightAddOnly
- **详情:**
是否只有新增组件(从组件列表拖入新组件)时才启用识别容器
开启后,在画布中拖动已有组件时不再识别容器,即已有组件不能通过拖动加入其他容器;新增组件仍然按 [containerHighlightType](#containerHighlightType) 配置的方式识别
- **默认值:** `false`
- **类型:** `boolean`
## stageRect
- **详情:**

View File

@ -69,10 +69,11 @@
## delayedMarkContainer
- **类型**`(event: MouseEvent, excludeElList?: Element[]) => NodeJS.Timeout | undefined`
- **类型**`(event: MouseEvent, excludeElList?: Element[], isAdd?: boolean) => NodeJS.Timeout | undefined`
- **参数**
- `event`:鼠标事件
- `excludeElList`:计算鼠标所在容器时要排除的元素列表
- `isAdd`:当前操作是否为新增组件(从组件列表拖入新组件),`containerHighlightType``addOnly` 时只有新增才会标记容器
- **详情**
鼠标拖拽着元素在容器上方悬停延迟一段时间后对容器进行标记如果悬停时间够长将标记成功悬停时间短调用方通过返回的timeoutId取消标记

View File

@ -69,6 +69,8 @@
}
```
`validator` 除了调用 `callback` 外,也兼容 async-validator 的返回值约定:返回 `false``Error` / 错误数组表示失败,返回 `true` 表示通过,返回 `Promise` 则以 resolve / reject 为结果,内部抛出的异常会转成校验失败。同步与异步 `typeMatch` 下这些约定行为一致,且只会上报一次结果(既调 `callback` 又返回 `Promise` 时以先到的为准)。
## 扩展自定义 type 规则
业务可覆盖内置规则,或为自定义字段 type 注册校验。自定义规则优先于内置规则。
@ -107,7 +109,26 @@ deleteTypeMatchRule('foo');
clearTypeMatchRules();
```
自定义校验器签名:`(value, context) => string | undefined`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
自定义校验器签名:`(value, context) => string | undefined | Promise<string | undefined>`。返回错误文案表示失败,返回 `undefined` 表示通过。`context` 包含 `fieldType``mForm``props``message`
### 异步校验
自定义校验器可以返回 `Promise`,用于需要异步确认取值是否合法的场景(如请求接口校验 id 是否存在)。内置规则均为同步。
```ts
registerTypeMatchRule('mod-select', async (value, { message }) => {
const exists = await checkModExists(value);
if (!exists) {
return message || `模块(${value})不存在`;
}
});
```
异步校验器通过后才会执行同一条 rule 上的自定义 `validator`。需要注意几点:
- **校验器自身失败不算校验失败。** Promise 被 reject如接口异常时只打印错误、该字段按通过处理避免网络故障阻塞操作。需要把失败暴露给用户时请在校验器内部 catch 并返回错误文案。
- **建议自行缓存请求结果。** 每次校验都会执行校验器,无缓存会导致逐次输入都发请求;也可以给 rule 配 `trigger: 'blur'` 降低触发频率。
- **只有最新一轮校验的结论会被采用。** 新一轮校验开始后,上一轮针对旧值、尚未返回的校验不再使用自己的结论,而是等最新一轮出结论后一起结算,因此旧结论不会晚到覆盖新结论,`form.validate()` 也不会对一个尚未校验完的值返回成功。
### 安装时注册

View File

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

View File

@ -1,5 +1,5 @@
{
"version": "1.8.0-beta.13",
"version": "1.8.0-beta.20",
"name": "tmagic",
"private": true,
"type": "module",

View File

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

View File

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

View File

@ -51,7 +51,7 @@ export const style2Obj = (style: string) => {
export const fillBackgroundImage = (value: string) => {
if (value && !/^url/.test(value) && !/^linear-gradient/.test(value)) {
return `url(${value})`;
return `url("${value}")`;
}
return value;
};

View File

@ -341,6 +341,20 @@ describe('App 配置/方法/组件注册', () => {
expect(app.page).toBeUndefined();
});
test('deletePage 销毁当前 page', () => {
const app = new App({
config: {
type: NodeType.ROOT,
id: 'app',
items: [{ type: NodeType.PAGE, id: 'p1', items: [] }],
},
});
expect(app.page?.data.id).toBe('p1');
app.deletePage();
expect(app.page).toBeUndefined();
});
test('runCode 执行代码块', async () => {
const fn = vi.fn();
const app = new App({

View File

@ -50,7 +50,7 @@ describe('style2Obj', () => {
describe('fillBackgroundImage', () => {
test('裸路径会包裹 url()', () => {
expect(fillBackgroundImage('a.png')).toBe('url(a.png)');
expect(fillBackgroundImage('a.png')).toBe('url("a.png")');
});
test('已经是 url() 不重复包裹', () => {
@ -127,7 +127,7 @@ describe('transformStyle', () => {
test('backgroundImage: browser 下补全 url()', () => {
expect(transformStyle({ backgroundImage: 'a.png' }, 'browser')).toEqual({
backgroundImage: 'url(a.png)',
backgroundImage: 'url("a.png")',
});
});

View File

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

View File

@ -7,8 +7,7 @@ import {
DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX,
dataSourceTemplateRegExp,
getValueByKeyPath,
isPage,
isPageFragment,
isPageOrFragment,
NODE_CONDS_KEY,
replaceChildNode,
} from '@tmagic/core';
@ -68,7 +67,7 @@ export const compliedConditions = (node: { [NODE_CONDS_KEY]?: DisplayCond[] }, d
};
export const updateNode = (node: MNode, dsl: MApp) => {
if (isPage(node) || isPageFragment(node)) {
if (isPageOrFragment(node)) {
const index = dsl.items?.findIndex((child: MNode) => child.id === node.id);
dsl.items.splice(index, 1, node as MPage | MPageFragment);
} else {

View File

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

View File

@ -1,6 +1,6 @@
import type { DepData } from '@tmagic/schema';
import { DepTargetType, type IsTarget, type TargetOptions } from './types';
import { DepTargetType, type IsTarget, type TargetDescriptor, type TargetOptions } from './types';
export interface DepUpdateOptions {
id: string | number;
@ -40,12 +40,17 @@ export default class Target {
* truefalse时需要传入type参数给collect方法才会被收集
*/
public isCollectByDefault?: boolean;
/**
* target worker 线
*/
public descriptor?: TargetDescriptor;
constructor(options: TargetOptions) {
this.isTarget = options.isTarget;
this.id = options.id;
this.name = options.name;
this.isCollectByDefault = options.isCollectByDefault ?? true;
this.descriptor = options.descriptor;
if (options.type) {
this.type = options.type;
}

View File

@ -1,4 +1,4 @@
import type { DepData } from '@tmagic/schema';
import type { CodeBlockContent, DataSourceSchema, DepData, Id } from '@tmagic/schema';
import type Target from './Target';
@ -17,6 +17,30 @@ export enum DepTargetType {
export type IsTarget = (key: string | number, value: any, data?: Record<string, any>) => boolean;
/** 创建代码块 target 只需要名称Record 用于兼容直接传入完整代码块 */
export type CodeBlockName = Pick<CodeBlockContent, 'name'> & Record<string, any>;
/**
* target
*
* isTarget 线
* / target worker
* target 线
*
* isTarget
* - CODE_BLOCK: id + name
* - DATA_SOURCE / DATA_SOURCE_COND: id + fields type /
* - DATA_SOURCE_METHOD: id + / content
*/
export type TargetDescriptor =
| { type: DepTargetType.CODE_BLOCK; id: Id; codeBlock: CodeBlockName }
| { type: DepTargetType.DATA_SOURCE; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
| { type: DepTargetType.DATA_SOURCE_COND; ds: Pick<DataSourceSchema, 'id' | 'fields'> }
| {
type: DepTargetType.DATA_SOURCE_METHOD;
ds: { id: Id; methods: Array<{ name?: string }>; fields: Array<{ name?: string }> };
};
export interface TargetOptions {
isTarget: IsTarget;
id: string | number;
@ -26,6 +50,8 @@ export interface TargetOptions {
initialDeps?: DepData;
/** 是否默认收集默认为true当值为false时需要传入type参数给collect方法才会被收集 */
isCollectByDefault?: boolean;
/** 可序列化描述,用于在 worker 中重建该 target */
descriptor?: TargetDescriptor;
}
export interface TargetList {

View File

@ -1,5 +1,4 @@
import {
type CodeBlockContent,
type DataSchema,
type DataSourceSchema,
type DepData,
@ -16,16 +15,17 @@ import {
} from '@tmagic/utils';
import Target from './Target';
import { DepTargetType, type TargetList } from './types';
import { type CodeBlockName, DepTargetType, type TargetDescriptor, type TargetList } from './types';
const INTEGER_REGEXP = /^\d+$/;
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockContent, initialDeps: DepData = {}) =>
export const createCodeBlockTarget = (id: Id, codeBlock: CodeBlockName, initialDeps: DepData = {}) =>
new Target({
type: DepTargetType.CODE_BLOCK,
id,
initialDeps,
name: codeBlock.name,
descriptor: { type: DepTargetType.CODE_BLOCK, id, codeBlock: { name: codeBlock.name } },
isTarget: (_key: string | number, value: any) => {
if (id === value) {
return true;
@ -238,6 +238,8 @@ export const createDataSourceTarget = (ds: Pick<DataSourceSchema, 'id' | 'fields
type: DepTargetType.DATA_SOURCE,
id: ds.id,
initialDeps,
// isTarget 需要完整 fields 结构(含 type / 嵌套 fields但不必带上 methods 等无关字段
descriptor: { type: DepTargetType.DATA_SOURCE, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => isDataSourceTarget(ds, key, value),
});
@ -246,6 +248,8 @@ export const createDataSourceCondTarget = (ds: Pick<DataSourceSchema, 'id' | 'fi
type: DepTargetType.DATA_SOURCE_COND,
id: ds.id,
initialDeps,
// 与 DATA_SOURCE 相同,只序列化 isTarget 所需的 id + fields
descriptor: { type: DepTargetType.DATA_SOURCE_COND, ds: { id: ds.id, fields: ds.fields || [] } },
isTarget: (key: string | number, value: any) => isDataSourceCondTarget(ds, key, value),
});
@ -257,6 +261,15 @@ export const createDataSourceMethodTarget = (
type: DepTargetType.DATA_SOURCE_METHOD,
id: ds.id,
initialDeps,
// isTarget 只用方法名/字段名,不序列化 content 等函数,降低 idle 收集通信成本
descriptor: {
type: DepTargetType.DATA_SOURCE_METHOD,
ds: {
id: ds.id,
methods: (ds.methods || []).map((method) => ({ name: method.name })),
fields: (ds.fields || []).map((field) => ({ name: field.name })),
},
},
isTarget: (_key: string | number, value: any) => {
// 使用data-source-method-select 可以配置出来
if (!Array.isArray(value)) {
@ -282,6 +295,31 @@ export const createDataSourceMethodTarget = (
},
});
/**
* target使 worker
* @param descriptor target
* @param initialDeps
* @returns Target
*/
export const createTargetByDescriptor = (descriptor: TargetDescriptor, initialDeps: DepData = {}): Target => {
switch (descriptor.type) {
case DepTargetType.CODE_BLOCK:
return createCodeBlockTarget(descriptor.id, descriptor.codeBlock, initialDeps);
case DepTargetType.DATA_SOURCE:
return createDataSourceTarget(descriptor.ds, initialDeps);
case DepTargetType.DATA_SOURCE_COND:
return createDataSourceCondTarget(descriptor.ds, initialDeps);
case DepTargetType.DATA_SOURCE_METHOD:
// descriptor 只保留名称,对 isTarget 已足够;重建时按同样结构传入
return createDataSourceMethodTarget(
descriptor.ds as Pick<DataSourceSchema, 'id' | 'methods' | 'fields'>,
initialDeps,
);
default:
throw new Error(`unknown target descriptor: ${JSON.stringify(descriptor)}`);
}
};
export const traverseTarget = (
targetsList: TargetList,
cb: (target: Target) => void,

View File

@ -266,6 +266,65 @@ describe('utils', () => {
expect(t3.isTarget('k', ['ds_1', 'unknown'])).toBe(true);
});
test('内置 target 带可序列化描述,可在 worker 中重建', () => {
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
expect(utils.createCodeBlockTarget('code_1', { name: 'fn', content: () => false, params: [] }).descriptor).toEqual({
type: DepTargetType.CODE_BLOCK,
id: 'code_1',
codeBlock: { name: 'fn' },
});
const dsWithMethods = {
...ds,
methods: [{ name: 'load', content: () => undefined, params: [] } as any],
};
// DATA_SOURCE / COND 只带 id + fields不透传完整数据源对象
expect(utils.createDataSourceTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE,
ds: { id: 'ds_1', fields: ds.fields },
});
expect(utils.createDataSourceCondTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE_COND,
ds: { id: 'ds_1', fields: ds.fields },
});
// METHOD 描述只保留名称,不带 content 等函数
expect(utils.createDataSourceMethodTarget(dsWithMethods).descriptor).toEqual({
type: DepTargetType.DATA_SOURCE_METHOD,
ds: { id: 'ds_1', methods: [{ name: 'load' }], fields: [{ name: 'name' }] },
});
// 自定义 target 无法序列化 isTarget因此没有描述
expect(new Target({ id: 'custom', isTarget: () => true }).descriptor).toBeUndefined();
});
test('createTargetByDescriptor 用描述重建出等价的 target', () => {
const ds = { id: 'ds_1', fields: [{ name: 'name' }] as DataSchema[] };
const codeBlockTarget = utils.createTargetByDescriptor({
type: DepTargetType.CODE_BLOCK,
id: 'code_1',
codeBlock: { name: 'fn' },
});
expect(codeBlockTarget.type).toBe(DepTargetType.CODE_BLOCK);
expect(codeBlockTarget.name).toBe('fn');
expect(codeBlockTarget.isTarget('created', 'code_1')).toBe(true);
const dsTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE, ds });
expect(dsTarget.type).toBe(DepTargetType.DATA_SOURCE);
expect(dsTarget.isTarget('text', '${ds_1.name}')).toBe(true);
const condTarget = utils.createTargetByDescriptor({ type: DepTargetType.DATA_SOURCE_COND, ds });
expect(condTarget.isTarget(`${NODE_CONDS_KEY}_x`, ['ds_1', 'name'])).toBe(true);
const methodTarget = utils.createTargetByDescriptor(
{ type: DepTargetType.DATA_SOURCE_METHOD, ds: { ...ds, methods: [] } },
{ n1: { name: 'n1', keys: ['k'] } },
);
expect(methodTarget.isTarget('k', ['ds_1', 'load'])).toBe(true);
expect(methodTarget.deps.n1.keys).toEqual(['k']);
expect(() => utils.createTargetByDescriptor({ type: 'unknown' } as any)).toThrow();
});
test('traverseTarget 遍历所有 / 指定 type', () => {
const t1 = new Target({ id: '1', isTarget: () => true, type: 'a' });
const t2 = new Target({ id: '2', isTarget: () => true, type: 'b' });

View File

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

View File

@ -0,0 +1,23 @@
<template>
<component class="tmagic-design-alert" :is="uiComponent" v-bind="uiProps">
<slot></slot>
</component>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { getDesignConfig } from './config';
import type { AlertProps } from './types';
defineOptions({
name: 'TMAlert',
});
const props = defineProps<AlertProps>();
const ui = getDesignConfig('components')?.alert;
const uiComponent = ui?.component || 'el-alert';
const uiProps = computed<AlertProps>(() => ui?.props(props) || props);
</script>

View File

@ -1,5 +1,9 @@
<template>
<component class="tmagic-design-card" :is="uiComponent" v-bind="uiProps">
<component
:is="uiComponent"
:class="['tmagic-design-card', { 'tmagic-design-card--flat': isFlat, 'm-flat-hide-body': isHideBody }]"
v-bind="uiProps"
>
<template #header v-if="$slots.header">
<slot name="header" class="header"></slot>
</template>
@ -14,6 +18,7 @@
import { computed } from 'vue';
import { getDesignConfig } from './config';
import { isGlobalFlat } from './index';
import type { CardProps } from './types';
defineOptions({
@ -26,5 +31,14 @@ const ui = getDesignConfig('components')?.card;
const uiComponent = ui?.component || 'el-card';
const uiProps = computed<CardProps>(() => ui?.props(props) || props);
// `<MEditor>` / `<MForm>` theme `magic-admin`
// `flat=true` `flat`
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
const isHideBody = computed(() => isFlat.value && props.bodyStyle?.display === 'none');
// `flat` UI el-card / t-card props
// / DOM
const uiProps = computed<CardProps>(() => {
const { flat: _flat, ...rest } = props;
return ui?.props(rest as CardProps) || (rest as CardProps);
});
</script>

View File

@ -7,18 +7,39 @@
@update:modelValue="updateModelValue"
>
</component>
<MInput
v-if="isFlat && !isLargeStageContainer"
@update:modelValue="updateModelValue"
@change="changeHandler"
v-bind="uiProps"
class="tmagic-design-color-picker-input"
clearable
></MInput>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, type ComputedRef, inject } from 'vue';
import { getDesignConfig } from './config';
import { isGlobalFlat } from './index';
import MInput from './Input.vue';
import type { ColorPickerProps } from './types';
defineOptions({
name: 'TMColorPicker',
});
const isFlat = computed(() => !!props.flat || isGlobalFlat.value);
// `<MEditor>` computed provide props.isLargeStageContainer
// `ComputedRef<boolean>` `<script setup>`
// `!isLargeStageContainer` computed
// `false` `ComputedRef`
const isLargeStageContainer = inject<ComputedRef<boolean>>(
'isLargeStageContainer',
computed(() => false),
);
const props = withDefaults(defineProps<ColorPickerProps>(), {
showAlpha: false,
disabled: false,

View File

@ -1,12 +1,24 @@
<template>
<component class="tmagic-design-form-item" :is="uiComponent" v-bind="uiProps">
<component
class="tmagic-design-form-item"
:class="{ 'has-extra-tips': adapterType === 'element-plus' && extraTips }"
:is="uiComponent"
v-bind="uiProps"
>
<template #label>
<slot name="label"></slot>
<slot name="label"> </slot>
</template>
<template #default>
<slot></slot>
<div v-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
<alert
v-if="adapterType === 'element-plus' && extraTips"
:title="extraTips"
type="warning"
show-icon
:closable="false"
></alert>
<div v-else-if="adapterType === 'element-plus' && extra" v-html="extra" class="m-form-tip"></div>
</template>
<template v-if="adapterType === 'element-plus'" #error="{ error }">
@ -16,26 +28,32 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { computed, inject } from 'vue';
import Alert from './Alert.vue';
import { getDesignConfig } from './config';
import { stripValidateSuggestion } from './formValidateMessage';
import { isGlobalFlat } from './index';
import type { FormItemProps } from './types';
defineOptions({
name: 'TMFormItem',
});
const props = defineProps<FormItemProps>();
const props = defineProps<FormItemProps & { theme?: string }>();
const ui = getDesignConfig('components')?.formItem;
const uiComponent = ui?.component || 'el-form-item';
const adapterType = getDesignConfig('adapterType');
const formInline = inject<boolean>('formInline', false);
const formInRow = inject<boolean>('isInRow', false);
const uiProps = computed<FormItemProps>(() => {
const { extra, ...rest } = ui?.props(props) || props;
const { extra, extraTips, ...rest } = ui?.props(props) || props;
if (isGlobalFlat.value && rest.labelPosition === undefined) {
return { ...rest, labelPosition: formInline || formInRow ? 'right' : 'left' };
}
return rest;
});

View File

@ -4,10 +4,9 @@
<div
v-if="popoverVisible || !destroyOnClose"
v-show="popoverVisible"
class="tmagic-design-popper"
ref="popperElementRef"
:tabindex="tabindex"
:class="popperClass"
:class="['tmagic-design-popper', popperClass, themeClass]"
:style="style"
@mouseenter.once="popperMouseenterHandler"
>
@ -22,7 +21,7 @@ import { computed, getCurrentInstance, nextTick, onBeforeUnmount, onMounted, ref
import type { Instance } from '@popperjs/core';
import { createPopper } from '@popperjs/core';
import { useZIndex } from './index';
import { useThemeClass, useZIndex } from './index';
import type { PopoverProps } from './types';
defineSlots<{
@ -46,6 +45,12 @@ const props = withDefaults(defineProps<PopoverProps>(), {
closeOnClickOutside: true,
});
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* 挂在 `Teleport` 出去的 popper 根节点上让主题级 CSS 变量`--el-color-primary`
* portal 节点上也能命中详见 `@tmagic/design/theme.ts`
*/
const themeClass = useThemeClass();
const emit = defineEmits<{
/** 受控模式(传入了 visible下点击外部收起时触发便于配合 v-model:visible。 */
'update:visible': [_visible: boolean];

View File

@ -8,6 +8,7 @@ import './theme/index.scss';
export * from './types';
export * from './config';
export * from './theme';
export * from './formValidateMessage';
export { default as TMagicAutocomplete } from './Autocomplete.vue';
@ -94,6 +95,10 @@ export let useZIndex = (zIndexOverrides?: Ref<number>) => {
};
};
// 与 form/utils/config、editor/utils/config 里的同名变量保持一致:用 ref 持有 flat 全局开关,
// const 引用本身不可变,规避 `no-mutable-exports` / `naming-convention` 对 `let` 模块变量的限制。
export const isGlobalFlat = ref(false);
export default {
install(app: App, options: DesignPluginOptions) {
tMagicMessage =
@ -129,6 +134,8 @@ export default {
useZIndex = options.useZIndex;
}
isGlobalFlat.value = options.flat ?? false;
if (options.adapterType && globalThis.document?.documentElement) {
globalThis.document.documentElement.classList.add(`tmagic-adapter-${options.adapterType}`);
}

View File

@ -0,0 +1,59 @@
/*
* 穿 portal
*
* `<MEditor>` / `<MForm>` provide `theme` `<Teleport>`
* `useThemeClass()` inject
* CSS `--el-color-primary` body portal
*
* portal `m-editor` `m-form`使
* `m-theme--<theme>` CSS
* editor / form / portal editor / form
* `.m-editor.m-editor--<theme>` / `.m-form.m-form--<theme>`
*
*/
import type { ComputedRef, InjectionKey, Ref } from 'vue';
import { computed, inject } from 'vue';
/** 祖先 `<MEditor>` / `<MForm>` theme props.theme MForm
* theme `<MEditor>` Form.vue effectiveTheme */
export const M_THEME_KEY: InjectionKey<Ref<string>> = Symbol('mTheme');
/**
* `<MEditor>` / `<MForm>` provide class
* `"m-theme--magic-admin"`
*
* -
* - `m-editor` / `m-form` portal
* editor / form DOM
*
* 使 `<Teleport>` `TMagicPopover` / `FloatingBox` /
* `ContentMenu` CSS
*/
export const useThemeClass = (): ComputedRef<string> => {
const theme = inject(M_THEME_KEY, null);
return computed(() => {
const t = theme?.value;
return t ? `m-theme--${t}` : '';
});
};
/**
* theme `prop.theme` 退
* `<MEditor>` / `<MForm>` provide
*
* `prop` `theme?: string` `defineProps()`
* reactive proxycomputed
* `toRefs`
*
* `prop.theme`
*
* @example
* const props = defineProps<{ theme?: string }>();
* const theme = useTheme(props);
* // theme.value -> 'magic-admin' 或 ''
*/
export const useTheme = <T extends { theme?: string }>(prop: T): ComputedRef<string> => {
const injected = inject(M_THEME_KEY, null);
return computed(() => prop.theme || injected?.value || '');
};

View File

@ -0,0 +1,75 @@
// `TMagicCard` 铺平模式去掉卡片自身的视觉表达背景 / 阴影 / 边框 / 圆角 /
// header 分隔线 / header & body 内边距让卡片在视觉上与外层容器融为一体
//
// 不动 body-style不动折叠相关 prop 这些上层用法仍可正常工作
// `is-always-shadow` el-card `shadow="always"` 时附加的类需要单独再压一次
.tmagic-design-card.tmagic-design-card--flat {
background: #fff;
box-shadow: none !important;
border-top: 0;
border-left: 0;
border-right: 0;
border-radius: 0;
border-bottom: 0;
&.m-flat-hide-body {
.el-card__header {
border-bottom: 0 !important;
}
}
&.m-fields-group-list-item {
margin-bottom: 0;
}
&.is-always-shadow {
box-shadow: none;
}
.el-card__header {
border-bottom: none;
padding: 16px;
&:hover {
background: transparent;
box-shadow: none;
cursor: default;
}
}
.m-fields-group-list-footer {
margin-bottom: 4px;
margin-top: 4px;
}
.el-card__body {
padding: 0 16px 0 16px;
// 这里每个form-item应该要有一个16px间距但是除了tbody中的form-item
.tmagic-design-form-item:not(tbody .tmagic-design-form-item) {
margin-bottom: 16px;
}
> .m-container-row {
overflow-x: hidden;
}
}
.tmagic-design-card--flat {
// .el-card__body {
// padding: 0;
// }
// .el-card__header {
// padding: 0 16px;
// }
}
}
.m-split-title-card-container {
> .tmagic-design-card.tmagic-design-card--flat {
background-color: transparent;
> .el-card__header {
margin-bottom: 10px;
padding: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
> .el-card__body {
background-color: #fff;
padding-top: 8px;
}
}
}

View File

@ -0,0 +1,8 @@
.tmagic-design-color-picker-input {
margin-left: 8px;
width: calc(var(--el-input-width, 100%) - 40px);
}
.tmagic-design-color-picker {
flex: 0 0 auto;
}

View File

@ -1 +1,3 @@
@use "./popover.scss";
@use "./card.scss";
@use "./colorPicker.scss";

View File

@ -0,0 +1,38 @@
@use "../../index.scss";
.m-form.m-form--magic-admin {
.el-collapse-item__header {
background-color: transparent;
padding: 0;
border: 0;
padding-left: 0;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-top: 8px;
margin-bottom: 8px;
}
}
.tmagic-design-form-item {
&.has-extra-tips {
> .el-form-item__content {
gap: 10px;
> .tmagic-design-alert {
border-radius: 2px;
width: fit-content;
padding: 0 8px;
color: var(--el-color-primary, #0056ea);
background-color: var(--el-color-primary-light-9, rgb(236, 245, 255));
.el-icon.el-alert__icon {
fill: var(--el-color-primary, #0056ea);
color: var(--el-color-primary, #0056ea);
}
.el-alert__title {
font-size: 14px;
line-height: 22px;
}
}
}
}
}

View File

@ -24,6 +24,17 @@ export interface BadgeProps {
hidden?: boolean;
}
export interface AlertProps {
title?: string;
/** ElAlert 实际支持的 type与 ButtonProps.type 不同,没有 `primary` / `danger` */
type?: 'success' | 'warning' | 'info' | 'error' | 'primary';
description?: string;
closable?: boolean;
center?: boolean;
closeText?: string;
showIcon?: boolean;
effect?: 'light' | 'dark';
}
export interface ButtonProps {
type?: string;
size?: FieldSize;
@ -39,6 +50,11 @@ export interface CardProps {
bodyStyle?: Record<string, any>;
shadow?: string;
header?: string;
/**
* / / / / header 线 / header body
* 使TMagicCard prop 使
*/
flat?: boolean;
}
export interface CascaderProps {
@ -97,6 +113,7 @@ export interface ColorPickerProps {
disabled?: boolean;
showAlpha?: boolean;
size?: FieldSize;
flat?: boolean;
}
export interface DatePickerProps {
@ -185,7 +202,9 @@ export interface FormItemProps {
labelWidth?: string | number;
rules?: any;
extra?: string;
extraTips?: string;
labelPosition?: 'top' | 'left' | 'right';
text?: string;
}
export interface InputProps {
@ -464,6 +483,11 @@ export interface Components {
props: (props: BadgeProps) => BadgeProps;
};
alert: {
component: DefineComponent<AlertProps, {}, any> | string;
props: (props: AlertProps) => AlertProps;
};
autocomplete: {
component: DefineComponent<AutocompleteProps, {}, any> | string;
props: (props: AutocompleteProps) => AutocompleteProps;
@ -726,6 +750,7 @@ export interface Components {
}
export interface DesignPluginOptions {
flat?: boolean;
adapterType?: string;
message?: TMagicMessage;
messageBox?: TMagicMessageBox;

View File

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

View File

@ -4,6 +4,7 @@
:page-bar-sort-options="pageBarSortOptions"
:page-filter-function="pageFilterFunction"
:hide-sidebar="hideSidebar"
:theme="theme"
>
<template #header>
<slot name="header"></slot>
@ -135,9 +136,10 @@
<script lang="ts" setup>
import { EventEmitter } from 'events';
import { provide, ref } from 'vue';
import { computed, provide, ref } from 'vue';
import type { MApp } from '@tmagic/core';
import { M_THEME_KEY } from '@tmagic/design';
import Framework from './layouts/Framework.vue';
import TMagicNavMenu from './layouts/NavMenu.vue';
@ -216,6 +218,7 @@ const stageOptions: StageOptions = {
containerHighlightClassName: props.containerHighlightClassName,
containerHighlightDuration: props.containerHighlightDuration,
containerHighlightType: props.containerHighlightType,
containerHighlightAddOnly: props.containerHighlightAddOnly,
disabledDragStart: props.disabledDragStart,
renderType: props.renderType,
guidesOptions: props.guidesOptions,
@ -241,6 +244,15 @@ provide(ENABLE_PROPS_FORM_VALIDATE, props.enablePropsFormValidate ?? false);
* PropsPanel 通过 `:extend-state` 显式传入的方式保持等价
*/
provide('extendFormState', props.extendFormState);
// computed provide provide
// props.isLargeStageContainer @tmagic/design/ColorPicker
// `inject<ComputedRef<boolean>>('isLargeStageContainer', computed(() => false))`
// `.value`
provide(
'isLargeStageContainer',
computed(() => props.isLargeStageContainer),
);
/**
* 提供 PropsPanel 主属性表单的 formState getter供历史差异弹窗复用
* CompareForm PropsPanel filterFunction 上下文保持一致
@ -256,6 +268,16 @@ provide('historyListExtraTabs', props.historyListExtraTabs);
provide<EventBus>('eventBus', new EventEmitter());
/**
* 把当前主题以响应式 ref 形式 provide 给后代供包含 `Teleport` 的组件
* `TMagicPopover` / `FloatingBox` / `ContentMenu`在传送目标上挂 `m-theme--<theme>`
* 让主题级 CSS 变量在 portal 节点上也能命中详见 `@tmagic/design/theme.ts`
*/
provide(
M_THEME_KEY,
computed(() => props.theme ?? ''),
);
const propsPanelMountedHandler = (e: InstanceType<typeof FormPanel>) => {
propsPanelRef.value = e;
emit('props-panel-mounted', e);

View File

@ -2,8 +2,8 @@
<transition name="fade">
<div
v-show="visible"
class="magic-editor-content-menu"
ref="menu"
:class="['magic-editor-content-menu', themeClass]"
:style="menuStyle"
@mouseenter="mouseenterHandler()"
@contextmenu.prevent
@ -39,7 +39,7 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, useTemplateRef } from 'vue';
import { useZIndex } from '@tmagic/design';
import { useThemeClass, useZIndex } from '@tmagic/design';
import { MenuButton, MenuComponent } from '@editor/type';
@ -73,6 +73,14 @@ const menuEl = useTemplateRef<HTMLDivElement>('menu');
const buttonRefs = useTemplateRef<InstanceType<typeof ToolButton>[]>('buttons');
const subMenuRef = useTemplateRef<any>('subMenu');
const visible = ref(false);
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* - 顶层 `ContentMenu` 渲染在编辑器子树中加上去是冗余但无害
* - 子菜单递归 `<content-menu :is-sub-menu="true">` `<teleport to="body">` 送到 body
* 不在编辑器子树中必须显式挂上主题类主题级 CSS 变量才能命中
*/
const themeClass = useThemeClass();
const subMenuData: Ref<(MenuButton | MenuComponent)[]> = ref<(MenuButton | MenuComponent)[]>([]);
const zIndex = useZIndex();
const curZIndex = ref<number>(0);
@ -120,20 +128,42 @@ const outsideClickHideHandler = (e: MouseEvent) => {
hide();
};
const setPosition = (e: { clientY: number; clientX: number }) => {
const menuHeight = menuEl.value?.clientHeight || 0;
//
const fixPosition = () => {
const menu = menuEl.value;
if (!menu || !visible.value) return;
let top = e.clientY;
if (menuHeight + e.clientY > document.body.clientHeight) {
top = document.body.clientHeight - menuHeight;
const menuHeight = menu.clientHeight;
const menuWidth = menu.clientWidth;
let { top, left } = menuPosition.value;
if (top + menuHeight > document.body.clientHeight) {
top = Math.max(0, document.body.clientHeight - menuHeight);
}
if (left + menuWidth > document.body.clientWidth) {
left = Math.max(0, document.body.clientWidth - menuWidth);
}
if (top !== menuPosition.value.top || left !== menuPosition.value.left) {
menuPosition.value = { top, left };
}
};
const setPosition = (e: { clientY: number; clientX: number }) => {
menuPosition.value = {
top,
top: e.clientY,
left: e.clientX,
};
fixPosition();
};
//
const resizeObserver = new ResizeObserver(() => {
fixPosition();
});
const show = (e?: { clientY: number; clientX: number }) => {
visible.value = true;
@ -178,12 +208,18 @@ const mouseenterHandler = () => {
};
onMounted(() => {
if (menuEl.value) {
resizeObserver.observe(menuEl.value);
}
if (props.isSubMenu) return;
globalThis.addEventListener('mousedown', outsideClickHideHandler, true);
});
onBeforeUnmount(() => {
resizeObserver.disconnect();
if (props.isSubMenu) return;
globalThis.removeEventListener('mousedown', outsideClickHideHandler, true);

View File

@ -2,7 +2,7 @@
<Teleport to="body" v-if="visible">
<div
ref="target"
class="m-editor-float-box"
:class="['m-editor-float-box', themeClass]"
v-bind="$attrs"
:style="{ ...style, zIndex: curZIndex }"
@mousedown="nextZIndex"
@ -27,7 +27,7 @@ import { computed, type CSSProperties, nextTick, onBeforeUnmount, provide, ref,
import { Close } from '@element-plus/icons-vue';
import VanillaMoveable from 'moveable';
import { TMagicButton, useZIndex } from '@tmagic/design';
import { TMagicButton, useThemeClass, useZIndex } from '@tmagic/design';
import MIcon from '@editor/components/Icon.vue';
@ -62,6 +62,12 @@ const props = withDefaults(
const targetEl = useTemplateRef<HTMLDivElement>('target');
const titleEl = useTemplateRef<HTMLDivElement>('title');
/**
* 主题修饰类来自最近的 `<MEditor>` / `<MForm>` 祖先 provide
* 挂在 `Teleport` 出去的浮动面板根节点上让主题级 CSS 变量在 portal 节点上也能命中
*/
const themeClass = useThemeClass();
const zIndex = useZIndex();
const curZIndex = ref<number>(0);

View File

@ -35,6 +35,10 @@ import type {
export const ENABLE_PROPS_FORM_VALIDATE: InjectionKey<boolean> = Symbol('enablePropsFormValidate');
export interface EditorProps {
/** 是否是大屏模拟器容器大屏容器时左侧属性面板会扩展为两列正常3列颜色表单不扩展两列等 */
isLargeStageContainer?: boolean;
theme?: string;
/** 页面初始值 */
modelValue?: MApp;
/** 左侧面板中的组件类型列表 */
@ -78,6 +82,11 @@ export interface EditorProps {
containerHighlightDuration?: number;
/** 拖入画布中容器时,识别容器的操作类型 */
containerHighlightType?: ContainerHighlightType;
/**
*
* false
*/
containerHighlightAddOnly?: boolean;
/** 画布大小 */
stageRect?: StageRect;
/** monaco editor 的配置 */
@ -159,6 +168,7 @@ export const defaultEditorProps = {
containerHighlightClassName: CONTAINER_HIGHLIGHT_CLASS_NAME,
containerHighlightDuration: 800,
containerHighlightType: ContainerHighlightType.DEFAULT,
containerHighlightAddOnly: false,
disabledShowSrc: false,
disabledDataSource: false,
disabledCodeBlock: false,

View File

@ -1,5 +1,6 @@
<template>
<MagicCodeEditor
v-if="!silentMode"
:height="config.height"
:type="diffMode ? 'diff' : undefined"
:init-values="diffMode ? (lastValues || {})[name] : model[name]"
@ -17,9 +18,9 @@
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { computed, inject } from 'vue';
import type { CodeConfig, FieldProps } from '@tmagic/form';
import { type CodeConfig, type FieldProps, FORM_SILENT_MODE_KEY } from '@tmagic/form';
import MagicCodeEditor from '@editor/layouts/CodeEditor.vue';
@ -27,6 +28,13 @@ defineOptions({
name: 'MFieldsVsCode',
});
/**
* 静默模式submitForm/validateForm 隐藏挂载下跳过 monaco 渲染
* 校验由 FormItem 针对 model 中的值完成与编辑器实例无关本组件挂载无值副作用
* save 仅由用户操作/编辑器内容变更触发跳过可省去 monaco worker/model 的无谓实例化
*/
const silentMode = inject(FORM_SILENT_MODE_KEY, false);
const emit = defineEmits<{
change: [value: string | any];
}>();

View File

@ -1,9 +1,10 @@
<template>
<div class="m-fields-code-select" :class="config.className">
<TMagicCard>
<TMagicCard :flat="config.flat">
<MContainer
:config="codeConfig"
:size="size"
class="code-select-content"
:prop="prop"
:disabled="disabled"
:is-compare="isCompareMode"
@ -12,16 +13,20 @@
@change="changeHandler"
>
</MContainer>
<TMagicButton class="create-button fullWidth" :icon="Plus" :size="size" :disabled="disabled" @click="newHandler()"
>添加{{ config.text }}</TMagicButton
>
</TMagicCard>
</div>
</template>
<script lang="ts" setup>
import { computed, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { isEmpty } from 'lodash-es';
import { HookCodeType, HookType } from '@tmagic/core';
import { TMagicCard } from '@tmagic/design';
import { TMagicButton, TMagicCard } from '@tmagic/design';
import type { CodeSelectConfig, ContainerChangeEventData, FieldProps, GroupListConfig } from '@tmagic/form';
import { MContainer } from '@tmagic/form';
@ -53,12 +58,23 @@ const props = withDefaults(defineProps<FieldProps<CodeSelectConfig>>(), {});
* 仅当存在历史值时才启用对比避免 lastValues 缺失时退化为全部新增的空对比
*/
const isCompareMode = computed(() => Boolean(props.isCompare && props.lastValues));
const newHandler = () => {
const defaultCode = {
codeType: HookCodeType.CODE,
codeId: '',
};
const name = props.config.name || '';
const hookData = props.model[name]?.hookData || [];
emit('change', defaultCode, {
modifyKey: `hookData.${hookData.length}`,
});
};
const codeConfig = computed<GroupListConfig>(() => ({
type: 'group-list',
name: 'hookData',
enableToggleMode: false,
expandAll: true,
addable: () => false,
title: (mForm, { model, index }: any) => {
if (model.codeType === HookCodeType.DATA_SOURCE_METHOD) {
if (Array.isArray(model.codeId)) {
@ -81,49 +97,44 @@ const codeConfig = computed<GroupListConfig>(() => ({
return model.codeId || index;
},
titlePrefix: props.config.name === undefined ? undefined : String(props.config.name),
items: [
{
type: 'row',
items: [
{
type: 'select',
name: 'codeType',
span: 6,
options: [
{ value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
rules: [{ typeMatch: true, trigger: 'change' }],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
span: 18,
labelWidth: 0,
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
rules: [{ typeMatch: true, trigger: 'change' }],
},
{
type: 'data-source-method-select',
name: 'codeId',
span: 18,
labelWidth: 0,
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
rules: [{ typeMatch: true, trigger: 'change' }],
},
text: '代码类型',
type: 'select',
name: 'codeType',
labelPosition: 'right',
rules: [{ typeMatch: true, trigger: 'change' }],
options: [
{ value: HookCodeType.CODE, text: '代码块' },
{ value: HookCodeType.DATA_SOURCE_METHOD, text: '数据源方法' },
],
defaultValue: HookCodeType.CODE,
onChange: (_mForm, v: HookCodeType, { setModel }) => {
if (v === HookCodeType.DATA_SOURCE_METHOD) {
setModel('codeId', []);
} else {
setModel('codeId', '');
}
return v;
},
},
{
type: 'code-select-col',
name: 'codeId',
text: '代码块',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType !== HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !codeBlockService.getEditStatus(),
},
{
type: 'data-source-method-select',
name: 'codeId',
text: '数据源字段',
rules: [{ typeMatch: true, trigger: 'change' }],
display: (_mForm, { model }) => model.codeType === HookCodeType.DATA_SOURCE_METHOD,
notEditable: () => !dataSourceService.get('editable'),
},
],
}));

View File

@ -147,6 +147,7 @@ const selectConfig: SelectConfig = {
type: 'select',
name: props.name,
disabled: props.disabled,
text: props.config.text,
options: () => {
if (codeDsl.value) {
return map(codeDsl.value, (value, key) => ({

View File

@ -1,7 +1,12 @@
<template>
<div class="m-fields-data-source-field-select">
<div
class="m-fields-data-source-field-select"
:class="{
[`data-source-field-${type}`]: !isSelectValid,
}"
>
<FieldSelect
v-if="!disabledDataSource && (showDataSourceFieldSelect || !config.fieldConfig)"
v-if="isSelectValid"
:model-value="model[name]"
:disabled="disabled"
:size="size"
@ -37,15 +42,14 @@
:size="size"
:disabled="disabled"
@click="onToggleDataSourceFieldSelectHandler"
><MIcon :icon="Coin"></MIcon
></TMagicButton>
><MIcon :icon="dataSourceIcon"></MIcon>
</TMagicButton>
</TMagicTooltip>
</div>
</template>
<script setup lang="ts">
import { computed, inject, ref, resolveComponent, watch } from 'vue';
import { Coin } from '@element-plus/icons-vue';
import { DataSchema } from '@tmagic/core';
import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design';
@ -61,6 +65,8 @@ import { DATA_SOURCE_FIELDS_SELECT_VALUE_PREFIX, removeDataSourceFieldPrefix } f
import MIcon from '@editor/components/Icon.vue';
import { useServices } from '@editor/hooks/use-services';
import dataSourceIcon from '../../icons/DatasourceIcon.vue';
import FieldSelect from './FieldSelect.vue';
defineOptions({
@ -75,6 +81,9 @@ const props = withDefaults(defineProps<FieldProps<DataSourceFieldSelectConfig>>(
const showDataSourceFieldSelect = ref(false);
const isSelectValid = computed(
() => !disabledDataSource.value && (showDataSourceFieldSelect.value || !props.config.fieldConfig),
);
watch(
() => props.model[props.name],
(value) => {

View File

@ -68,12 +68,15 @@ const config = computed<GroupListConfig>(() => ({
titlePrefix: props.config.titlePrefix,
expandAll: true,
enableToggleMode: false,
flat: props.config.flat,
items: [
{
type: 'table',
name: 'cond',
operateColWidth: 80,
operateColWidth: props.config.operateColWidth,
enableToggleMode: false,
fixed: props.config.fixed,
flat: props.config.flat,
items: [
parentFields.value.length
? {

View File

@ -12,16 +12,21 @@
@change="onChangeHandler"
></MTable>
<div v-else class="fullWidth">
<TMagicButton
v-if="!isCompareMode"
class="create-button"
type="primary"
:size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
<div v-else class="fullWidth event-select-container">
<div class="event-select-header">
<div class="event-select-title">事件配置</div>
<TMagicButton
v-if="!isCompareMode && displayList.length > 0"
class="create-button"
text
type="primary"
:icon="Plus"
:size="size"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div>
<MPanel
v-for="entry in displayList"
:key="entry.index"
@ -32,32 +37,44 @@
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:hide-expand="false"
:label-width="config.labelWidth || '100px'"
@change="onChangeHandler"
>
<template #header>
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler"
></MFormContainer>
<TMagicButton
v-if="!isCompareMode"
style="color: #f56c6c"
link
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
<div class="event-item-header">
<MFormContainer
class="fullWidth"
:config="eventNameConfig"
:model="entry.cardItem"
:last-values="entry.lastCardItem"
:is-compare="isCompareMode"
:disabled="disabled"
:size="size"
:prop="`${prop}.${entry.index}`"
@change="eventNameChangeHandler"
></MFormContainer>
<TMagicButton
class="event-item-delete-button"
v-if="!isCompareMode"
link
:icon="Delete"
:disabled="disabled"
:size="size"
@click="removeEvent(Number(entry.index))"
></TMagicButton>
</div>
</template>
</MPanel>
<TMagicButton
v-if="!isCompareMode"
class="create-button fullWidth"
:icon="Plus"
:disabled="disabled"
@click="addEvent()"
>添加事件</TMagicButton
>
</div>
</div>
</template>
@ -65,6 +82,7 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { Delete } from '@element-plus/icons-vue';
import { Plus } from '@element-plus/icons-vue';
import { has } from 'lodash-es';
import { ActionType } from '@tmagic/core';
@ -108,7 +126,7 @@ const { editorService, dataSourceService, eventsService, codeBlockService, props
const eventNameConfig = computed(() => {
const defaultEventNameConfig = {
name: 'name',
text: '事件',
text: '事件类型',
type: (mForm: FormState | undefined, { formValue }: any) => {
if (
props.config.src !== 'component' ||
@ -118,7 +136,7 @@ const eventNameConfig = computed(() => {
}
return 'select';
},
labelWidth: '40px',
labelWidth: '70px',
checkStrictly: () => props.config.src !== 'component',
valueSeparator: '.',
options: (_mForm: FormState, { formValue }: any) => getEventNameOptions(props.config.src, formValue),
@ -178,6 +196,7 @@ const actionTypeConfig = computed(() => {
name: 'actionType',
text: '联动类型',
type: 'select',
labelPosition: 'left',
defaultValue: ActionType.COMP,
options: actionTypeOptions.value,
rules: [
@ -190,6 +209,12 @@ const actionTypeConfig = computed(() => {
trigger: 'blur',
},
],
onChange: (_mForm: FormState, _v: string, { setModel }: any) => {
setModel('to', '');
setModel('method', '');
setModel('codeId', '');
setModel('dataSourceMethod', []);
},
};
return { ...defaultActionTypeConfig, ...props.config.actionTypeConfig };
});
@ -200,8 +225,9 @@ const targetCompConfig = computed(() => {
name: 'to',
text: '联动组件',
type: 'ui-select',
labelPosition: 'left',
display: (_mForm, { model }) => model.actionType === ActionType.COMP,
onChange: (_MForm, _v, { setModel }) => {
onChange: (_mForm, _v, { setModel }) => {
setModel('method', '');
},
rules: [
@ -219,6 +245,7 @@ const compActionConfig = computed(() => {
const defaultCompActionConfig: DynamicTypeConfig = {
name: 'method',
text: '动作',
labelPosition: 'left',
type: (mForm: FormState | undefined, { model }: any) => {
const to = editorService.getNodeById(model.to);
@ -317,6 +344,7 @@ const actionsConfig = computed(
() =>
defineFormItem({
type: 'panel',
labelPosition: 'left',
items: [
{
type: 'group-list',
@ -324,6 +352,7 @@ const actionsConfig = computed(
expandAll: true,
enableToggleMode: false,
titlePrefix: '动作',
labelPosition: 'left',
items: [
actionTypeConfig.value,
targetCompConfig.value,

View File

@ -2,7 +2,7 @@
<TMagicCollapse class="m-fields-style-setter" v-model="collapseValue">
<template v-for="(item, index) in list" :key="index">
<TMagicCollapseItem :name="`${index}`">
<template #title><MIcon :icon="Grid"></MIcon>{{ item.title }}</template>
<template #title>{{ item.title }}</template>
<component
v-if="item.component"
:is="item.component"
@ -22,14 +22,11 @@
<script setup lang="ts">
import { shallowRef } from 'vue';
import { Grid } from '@element-plus/icons-vue';
import { TMagicCollapse, TMagicCollapseItem } from '@tmagic/design';
import type { ContainerChangeEventData, FieldProps } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import MIcon from '@editor/components/Icon.vue';
import { Background, Border, Font, Layout, Position, Transform } from './pro/';
defineOptions({

View File

@ -15,7 +15,7 @@
@click="selectDirection('Left')"
></div>
<div
class="border-icon"
class="border-icon center"
:class="{ active: direction === '', configured: isConfigured('') }"
@click="selectDirection()"
></div>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 4C12.5523 4 13 4.44772 13 5V11C13 11.5523 12.5523 12 12 12H4C3.44772 12 3 11.5523 3 11V5C3 4.44772 3.44772 4 4 4H12ZM4.5 5C4.22386 5 4 5.22386 4 5.5V10.5C4 10.7761 4.22386 11 4.5 11H11.5C11.7761 11 12 10.7761 12 10.5V5.5C12 5.22386 11.7761 5 11.5 5H4.5Z"
/>
<rect
x="13.75"
y="7.75"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 7.75)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 3C12.5523 3 13 3.44772 13 4V10C13 10.5523 12.5523 11 12 11H4C3.44772 11 3 10.5523 3 10V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V9.5C4 9.77614 4.22386 10 4.5 10H11.5C11.7761 10 12 9.77614 12 9.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
/>
<rect
x="13.75"
y="12.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 12.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,19 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 5C12.5523 5 13 5.44772 13 6V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V6C3 5.44772 3.44772 5 4 5H12ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V6.5C12 6.22386 11.7761 6 11.5 6H4.5Z"
/>
<rect
x="13.75"
y="3.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 3.25)"
stroke="white"
stroke-opacity="0.3"
style="stroke: white; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,34 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 8C12.5523 8 13 8.44772 13 9V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V9C3 8.44772 3.44772 8 4 8H12ZM4.5 9C4.22386 9 4 9.22386 4 9.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V9.5C12 9.22386 11.7761 9 11.5 9H4.5Z"
/>
<path
d="M12 2C12.5523 2 13 2.44772 13 3V6C13 6.55228 12.5523 7 12 7H4C3.44772 7 3 6.55228 3 6V3C3 2.44772 3.44772 2 4 2H12ZM4.5 3C4.22386 3 4 3.22386 4 3.5V5.5C4 5.77614 4.22386 6 4.5 6H11.5C11.7761 6 12 5.77614 12 5.5V3.5C12 3.22386 11.7761 3 11.5 3H4.5Z"
/>
<rect
x="13.75"
y="10.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 10.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="13.75"
y="4.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 4.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,34 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M12 9C12.5523 9 13 9.44772 13 10V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V10C3 9.44772 3.44772 9 4 9H12ZM4.5 10C4.22386 10 4 10.2239 4 10.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V10.5C12 10.2239 11.7761 10 11.5 10H4.5Z"
/>
<path
d="M12 3C12.5523 3 13 3.44772 13 4V6C13 6.55228 12.5523 7 12 7H4C3.44772 7 3 6.55228 3 6V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V5.5C4 5.77614 4.22386 6 4.5 6H11.5C11.7761 6 12 5.77614 12 5.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
/>
<rect
x="13.75"
y="14.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 14.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="13.75"
y="1.25"
width="0.5"
height="11.5"
rx="0.25"
transform="rotate(90 13.75 1.25)"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -0,0 +1,5 @@
export { default as AlignItemsCenter } from './Center.vue';
export { default as AlignItemsFlexEnd } from './FlexEnd.vue';
export { default as AlignItemsFlexStart } from './FlexStart.vue';
export { default as AlignItemsSpaceAround } from './SpaceAround.vue';
export { default as AlignItemsSpaceBetween } from './SpaceBetween.vue';

View File

@ -1,8 +1,24 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path
d="M912.526651 867.741144 555.540144 510.712681l356.986507-357.000833c11.171434-11.18576 11.171434-29.257348 0-40.443108-11.20111-11.18576-29.272697-11.18576-40.444131 0L515.096013 470.267527 158.096203 113.267716c-11.187807-11.159154-29.258371-11.159154-40.444131 0-11.186783 11.186783-11.186783 29.286 0 40.47176L474.623229 510.712681 117.623419 867.741144c-11.159154 11.172457-11.159154 29.216415 0 40.443108 11.18576 11.17348 29.284977 11.17348 40.47176 0l357.000833-357.027439 356.985484 357.027439c11.171434 11.17348 29.243021 11.17348 40.444131 0C923.698085 896.957559 923.725714 878.913601 912.526651 867.741144z"
fill="#5D5D5D"
></path>
d="M0 0.625C0 0.279822 0.248731 0 0.555556 0H9.44444C9.75127 0 10 0.279822 10 0.625C10 0.970178 9.75127 1.25 9.44444 1.25H0.555556C0.248731 1.25 0 0.970178 0 0.625Z"
fill-opacity="0.3"
/>
<path
d="M0 9.375C0 9.02982 0.248731 8.75 0.555556 8.75H9.44444C9.75127 8.75 10 9.02982 10 9.375C10 9.72018 9.75127 10 9.44444 10H0.555556C0.248731 10 0 9.72018 0 9.375Z"
fill-opacity="0.3"
/>
<path
d="M0.555556 4.375C0.248731 4.375 0 4.65482 0 5C0 5.34518 0.248731 5.625 0.555556 5.625H2.87307L3.42868 4.99994L2.87318 4.375H0.555556Z"
fill-opacity="0.3"
/>
<path
d="M9.44444 5.625H7.12699L6.57138 4.99994L7.12688 4.375H9.44444C9.75127 4.375 10 4.65482 10 5C10 5.34518 9.75127 5.625 9.44444 5.625Z"
fill-opacity="0.3"
/>
<path
transform="translate(0 2.6072)"
d="M3.30542 0.183058C3.52237 -0.0610194 3.87413 -0.0610194 4.09109 0.183058C4.30805 0.427136 4.30805 0.822864 4.09109 1.06694L2.91256 2.39279L4.0911 3.71865C4.30806 3.96273 4.30806 4.35846 4.0911 4.60253C3.87414 4.84661 3.52239 4.84661 3.30543 4.60253L2.12688 3.27667L0.948393 4.60248C0.731435 4.84655 0.379676 4.84655 0.162719 4.60248C-0.0542395 4.3584 -0.0542395 3.96267 0.162719 3.71859L1.34121 2.39279L0.162731 1.067C-0.0542269 0.822922 -0.0542268 0.427194 0.162731 0.183116C0.379689 -0.0609612 0.731447 -0.0609612 0.948405 0.183116L2.12688 1.50891L3.30542 0.183058Z"
/>
</svg>
</template>

View File

@ -1,31 +1,13 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
d="M884.736 102.4l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456C696.32 311.296 712.704 327.68 733.184 327.68l147.456 0c20.48 0 36.864-16.384 36.864-36.864L917.504 139.264C921.6 118.784 905.216 102.4 884.736 102.4zM884.736 290.816l-147.456 0L737.28 139.264l147.456 0L884.736 290.816z"
></path>
<path
d="M884.736 696.32l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C921.6 712.704 905.216 696.32 884.736 696.32zM884.736 884.736l-147.456 0 0-147.456 147.456 0L884.736 884.736z"
></path>
<path
d="M884.736 401.408l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C921.6 417.792 905.216 401.408 884.736 401.408zM884.736 585.728l-147.456 0 0-147.456 147.456 0L884.736 585.728z"
></path>
<path
d="M585.728 401.408l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C622.592 417.792 606.208 401.408 585.728 401.408zM585.728 585.728l-147.456 0 0-147.456 147.456 0L585.728 585.728z"
></path>
<path
d="M585.728 102.4l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864L622.592 139.264C622.592 118.784 606.208 102.4 585.728 102.4zM585.728 290.816l-147.456 0L438.272 139.264l147.456 0L585.728 290.816z"
></path>
<path
d="M585.728 696.32l-147.456 0c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C622.592 712.704 606.208 696.32 585.728 696.32zM585.728 884.736l-147.456 0 0-147.456 147.456 0L585.728 884.736z"
></path>
<path
d="M290.816 696.32 139.264 696.32c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C327.68 712.704 311.296 696.32 290.816 696.32zM290.816 884.736 139.264 884.736l0-147.456 147.456 0L286.72 884.736z"
></path>
<path
d="M290.816 401.408 139.264 401.408c-20.48 0-36.864 16.384-36.864 36.864l0 147.456c0 20.48 16.384 36.864 36.864 36.864l147.456 0c20.48 0 36.864-16.384 36.864-36.864l0-147.456C327.68 417.792 311.296 401.408 290.816 401.408zM290.816 585.728 139.264 585.728l0-147.456 147.456 0L286.72 585.728z"
></path>
<path
d="M290.816 102.4 139.264 102.4c-20.48 0-36.864 16.384-36.864 36.864l0 147.456C102.4 311.296 118.784 327.68 139.264 327.68l147.456 0C311.296 327.68 327.68 311.296 327.68 290.816L327.68 139.264C327.68 118.784 311.296 102.4 290.816 102.4zM290.816 290.816 139.264 290.816 139.264 139.264l147.456 0L286.72 290.816z"
></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="13" y="3" width="2" height="2" rx="0.5" transform="rotate(90 13 3)" fill-opacity="0.3" />
<rect x="13" y="7" width="2" height="2" rx="0.5" transform="rotate(90 13 7)" fill-opacity="0.3" />
<rect x="13" y="11" width="2" height="2" rx="0.5" transform="rotate(90 13 11)" fill-opacity="0.3" />
<rect x="9" y="3" width="2" height="2" rx="0.5" transform="rotate(90 9 3)" />
<rect x="9" y="7" width="2" height="2" rx="0.5" transform="rotate(90 9 7)" />
<rect x="9" y="11" width="2" height="2" rx="0.5" transform="rotate(90 9 11)" fill-opacity="0.3" />
<rect x="5" y="3" width="2" height="2" rx="0.5" transform="rotate(90 5 3)" />
<rect x="5" y="7" width="2" height="2" rx="0.5" transform="rotate(90 5 7)" />
<rect x="5" y="11" width="2" height="2" rx="0.5" transform="rotate(90 5 11)" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,8 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
d="M796.444444 170.666667h-227.555555v682.666666h227.555555V170.666667z m-56.888888 625.777777h-113.777778V227.555556h113.777778v568.888888zM455.111111 170.666667H227.555556v682.666666h227.555555V170.666667zM398.222222 796.444444H284.444444V227.555556h113.777778v568.888888zM910.222222 56.888889h56.888889v910.222222h-56.888889zM56.888889 56.888889h56.888889v910.222222H56.888889z"
fill="#333333"
></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="3" y="3" width="2" height="10" rx="1" />
<rect x="7" y="3" width="2" height="10" rx="1" />
<rect x="11" y="3" width="2" height="10" rx="1" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,8 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path
d="M170.666667 227.555556v227.555555h682.666666V227.555556H170.666667z m625.777777 56.888888v113.777778H227.555556V284.444444h568.888888zM170.666667 568.888889v227.555555h682.666666v-227.555555H170.666667z m625.777777 56.888889v113.777778H227.555556v-113.777778h568.888888zM56.888889 56.888889h910.222222v56.888889H56.888889zM56.888889 910.222222h910.222222v56.888889H56.888889z"
fill="#333333"
></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<rect x="13" y="3" width="2" height="10" rx="1" transform="rotate(90 13 3)" />
<rect x="13" y="7" width="2" height="10" rx="1" transform="rotate(90 13 7)" />
<rect x="13" y="11" width="2" height="10" rx="1" transform="rotate(90 13 11)" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,7 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M896 320H128V0h768v320z m0 320H128v-256h768v256z m-128 192l-256 192-256-192 192-0.032V704h128v128h192z"
></path>
d="M12 3C12.5523 3 13 3.44772 13 4V10C13 10.5523 12.5523 11 12 11H4C3.44772 11 3 10.5523 3 10V4C3 3.44772 3.44772 3 4 3H12ZM4.5 4C4.22386 4 4 4.22386 4 4.5V9.5C4 9.77614 4.22386 10 4.5 10H11.5C11.7761 10 12 9.77614 12 9.5V4.5C12 4.22386 11.7761 4 11.5 4H4.5Z"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 12L8 15L5 12L11 12Z" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,7 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M896 704H128v320h768V704z m0-320H128v256h768v-256z m-128-192l-256-192-256 192 192 0.032V320h128V192h192z"
></path>
d="M12 5C12.5523 5 13 5.44772 13 6V12C13 12.5523 12.5523 13 12 13H4C3.44772 13 3 12.5523 3 12V6C3 5.44772 3.44772 5 4 5H12ZM4.5 6C4.22386 6 4 6.22386 4 6.5V11.5C4 11.7761 4.22386 12 4.5 12H11.5C11.7761 12 12 11.7761 12 11.5V6.5C12 6.22386 11.7761 6 11.5 6H4.5Z"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5 4L8 1L11 4L5 4Z" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,7 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M320 128v768H0V128h320z m320 0v768h-256V128h256z m192 128l192 256-192 256-0.032-192H704v-128h128V256z"
></path>
d="M10 4C10.5523 4 11 4.44772 11 5V11C11 11.5523 10.5523 12 10 12H2C1.44772 12 1 11.5523 1 11V5C1 4.44772 1.44772 4 2 4H10ZM2.5 5C2.22386 5 2 5.22386 2 5.5V10.5C2 10.7761 2.22386 11 2.5 11H9.5C9.77614 11 10 10.7761 10 10.5V5.5C10 5.22386 9.77614 5 9.5 5H2.5Z"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 5L15 8L12 11L12 5Z" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,7 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M704 128v768h320V128H704zM384 128v768h256V128h-256zM192 256l-192 256 192 256 0.032-192H320v-128H192V256z"
></path>
d="M14 4C14.5523 4 15 4.44772 15 5V11C15 11.5523 14.5523 12 14 12H6C5.44772 12 5 11.5523 5 11V5C5 4.44772 5.44772 4 6 4H14ZM6.5 5C6.22386 5 6 5.22386 6 5.5V10.5C6 10.7761 6.22386 11 6.5 11H13.5C13.7761 11 14 10.7761 14 10.5V5.5C14 5.22386 13.7761 5 13.5 5H6.5Z"
/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 11L1 8L4 5L4 11Z" fill-opacity="0.3" />
</svg>
</template>

View File

@ -1,5 +1,8 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path d="M480 1024V0h64v1024h-64z m128-64V64h320v896H608zM96 960V64h320v896H96z"></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H13ZM3.59082 5C3.31472 5.00005 3.09082 5.22389 3.09082 5.5V10.5C3.09082 10.7761 3.31472 11 3.59082 11H12.4092C12.6853 11 12.9092 10.7761 12.9092 10.5V5.5C12.9092 5.22389 12.6853 5.00005 12.4092 5H3.59082Z"
/>
<rect x="7.5" y="2" width="1" height="12" rx="0.5" fill-opacity="0.3" style="fill: black; fill-opacity: 0.3" />
</svg>
</template>

View File

@ -1,5 +1,18 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path d="M416 160H96v704h320V160z m384 0H480v704h320V160z m128-160h-64v1024h64V0z"></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M11 4C11.5523 4 12 4.44772 12 5V11C12 11.5523 11.5523 12 11 12H2C1.44772 12 1 11.5523 1 11V5C1 4.44772 1.44772 4 2 4H11ZM2.5 5C2.22386 5 2 5.22386 2 5.5V10.5C2 10.7761 2.22386 11 2.5 11H10.5C10.7761 11 11 10.7761 11 10.5V5.5C11 5.22386 10.7761 5 10.5 5H2.5Z"
/>
<rect
x="14.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -1,5 +1,18 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path d="M608 160h320v704H608V160zM224 160h320v704H224V160zM96 0h64v1024H96V0z"></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M14 4C14.5523 4 15 4.44772 15 5V11C15 11.5523 14.5523 12 14 12H5C4.44772 12 4 11.5523 4 11V5C4 4.44772 4.44772 4 5 4H14ZM5.5 5C5.22386 5 5 5.22386 5 5.5V10.5C5 10.7761 5.22386 11 5.5 11H13.5C13.7761 11 14 10.7761 14 10.5V5.5C14 5.22386 13.7761 5 13.5 5H5.5Z"
/>
<rect
x="1.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="white"
stroke-opacity="0.3"
style="stroke: white; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -1,7 +1,32 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M320 864H192v160H128v-160H0V160h128V0h64v160h128v704z m704 0h-128v160h-64v-160h-128V160h128V0h64v160h128v704z"
></path>
d="M6 4C6.55228 4 7 4.44772 7 5V11C7 11.5523 6.55228 12 6 12H3C2.44772 12 2 11.5523 2 11V5C2 4.44772 2.44772 4 3 4H6ZM3.5 5C3.22386 5 3 5.22386 3 5.5V10.5C3 10.7761 3.22386 11 3.5 11H5.5C5.77614 11 6 10.7761 6 10.5V5.5C6 5.22386 5.77614 5 5.5 5H3.5Z"
/>
<path
d="M13 4C13.5523 4 14 4.44772 14 5V11C14 11.5523 13.5523 12 13 12H10C9.44772 12 9 11.5523 9 11V5C9 4.44772 9.44772 4 10 4H13ZM10.5 5C10.2239 5 10 5.22386 10 5.5V10.5C10 10.7761 10.2239 11 10.5 11H12.5C12.7761 11 13 10.7761 13 10.5V5.5C13 5.22386 12.7761 5 12.5 5H10.5Z"
/>
<rect
x="11.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
<rect
x="4.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -1,5 +1,31 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path d="M64 1024H0V0h64v1024z m384-160H128V160h320v704z m448 0H576V160h320v704z m128 160h-64V0h64v1024z"></path>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M6 4C6.55228 4 7 4.44772 7 5V11C7 11.5523 6.55228 12 6 12H4C3.44772 12 3 11.5523 3 11V5C3 4.44772 3.44772 4 4 4H6ZM4.5 5C4.22386 5 4 5.22386 4 5.5V10.5C4 10.7761 4.22386 11 4.5 11H5.5C5.77614 11 6 10.7761 6 10.5V5.5C6 5.22386 5.77614 5 5.5 5H4.5Z"
/>
<path
d="M12 4C12.5523 4 13 4.44772 13 5V11C13 11.5523 12.5523 12 12 12H10C9.44772 12 9 11.5523 9 11V5C9 4.44772 9.44772 4 10 4H12ZM10.5 5C10.2239 5 10 5.22386 10 5.5V10.5C10 10.7761 10.2239 11 10.5 11H11.5C11.7761 11 12 10.7761 12 10.5V5.5C12 5.22386 11.7761 5 11.5 5H10.5Z"
/>
<rect
x="14.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
stroke-width="0.5"
/>
<rect
x="1.25"
y="2.25"
width="0.5"
height="11.5"
rx="0.25"
stroke="black"
stroke-opacity="0.3"
style="stroke: black; stroke-opacity: 0.3"
stroke-width="0.5"
/>
</svg>
</template>

View File

@ -29,6 +29,7 @@ defineProps<{
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
theme?: string;
prop?: string;
}>();
@ -112,10 +113,14 @@ const formConfig = defineFormConfig([
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'repeat', icon: markRaw(BackgroundRepeat), tooltip: '垂直和水平方向重复 repeat' },
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
{ value: 'repeat-x', icon: markRaw(BackgroundRepeatX), tooltip: '水平方向重复 repeat-x' },
{ value: 'repeat-y', icon: markRaw(BackgroundRepeatY), tooltip: '垂直方向重复 repeat-y' },
{ value: 'no-repeat', icon: markRaw(BackgroundNoRepeat), tooltip: '不重复 no-repeat' },
{
value: 'repeat',
icon: markRaw(BackgroundRepeat),
tooltip: '垂直和水平方向重复 repeat',
},
],
},
{

View File

@ -95,9 +95,9 @@ const formConfig = defineFormConfig([
childType: 'button',
labelWidth: '68px',
options: [
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right' },
{ value: 'left', icon: markRaw(AlignLeft), tooltip: '左对齐 row', text: '左对齐' },
{ value: 'center', icon: markRaw(AlignCenter), tooltip: '居中对齐 center', text: '居中对齐' },
{ value: 'right', icon: markRaw(AlignRight), tooltip: '右对齐 right', text: '右对齐' },
],
},
]);

View File

@ -26,11 +26,19 @@
<script lang="ts" setup>
import { markRaw } from 'vue';
import { useTheme } from '@tmagic/design';
import type { ContainerChangeEventData } from '@tmagic/form';
import { defineFormConfig, MContainer } from '@tmagic/form';
import type { StyleSchema } from '@tmagic/schema';
import Box from '../components/Box.vue';
import {
AlignItemsCenter,
AlignItemsFlexEnd,
AlignItemsFlexStart,
AlignItemsSpaceAround,
AlignItemsSpaceBetween,
} from '../icons/align-items';
import { DisplayBlock, DisplayFlex, DisplayInline, DisplayInlineBlock, DisplayNone } from '../icons/display';
import {
FlexDirectionColumn,
@ -46,12 +54,13 @@ import {
JustifyContentSpaceBetween,
} from '../icons/justify-content';
defineProps<{
const props = defineProps<{
values: Partial<StyleSchema>;
lastValues?: Partial<StyleSchema>;
isCompare?: boolean;
disabled?: boolean;
size?: 'large' | 'default' | 'small';
theme?: string;
prop?: string;
}>();
@ -60,19 +69,42 @@ const emit = defineEmits<{
addDiffCount: [];
}>();
const displayTheme = useTheme(props);
const formConfig = defineFormConfig([
{
name: 'display',
text: '模式',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'inline', icon: markRaw(DisplayInline), tooltip: '内联布局 inline' },
{ value: 'flex', icon: markRaw(DisplayFlex), tooltip: '弹性布局 flex' },
{ value: 'block', icon: markRaw(DisplayBlock), tooltip: '块级布局 block' },
{ value: 'inline-block', icon: markRaw(DisplayInlineBlock), tooltip: '内联块布局 inline-block' },
{ value: 'none', icon: markRaw(DisplayNone), tooltip: '隐藏 none' },
{
value: 'inline',
icon: markRaw(DisplayInline),
tooltip: '内联布局 inline',
},
{
value: 'flex',
icon: markRaw(DisplayFlex),
tooltip: '弹性布局 flex',
},
{
value: 'block',
icon: markRaw(DisplayBlock),
tooltip: '块级布局 block',
},
{
value: 'inline-block',
icon: markRaw(DisplayInlineBlock),
tooltip: '内联块布局 inline-block',
},
{
value: 'none',
icon: markRaw(DisplayNone),
tooltip: '隐藏 none',
},
],
},
{
@ -80,11 +112,20 @@ const formConfig = defineFormConfig([
text: '主轴方向',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'row', icon: markRaw(FlexDirectionRow), tooltip: '水平方向 起点在左侧 row' },
{ value: 'row-reverse', icon: markRaw(FlexDirectionRowReverse), tooltip: '水平方向 起点在右侧 row-reverse' },
{ value: 'column', icon: markRaw(FlexDirectionColumn), tooltip: '垂直方向 起点在上沿 column' },
{
value: 'row-reverse',
icon: markRaw(FlexDirectionRowReverse),
tooltip: '水平方向 起点在右侧 row-reverse',
},
{
value: 'column',
icon: markRaw(FlexDirectionColumn),
tooltip: '垂直方向 起点在上沿 column',
},
{
value: 'column-reverse',
icon: markRaw(FlexDirectionColumnReverse),
@ -98,13 +139,22 @@ const formConfig = defineFormConfig([
text: '主轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
{
value: 'space-between',
icon: markRaw(JustifyContentSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(JustifyContentSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
@ -113,13 +163,22 @@ const formConfig = defineFormConfig([
text: '辅轴对齐',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'flex-start', icon: markRaw(JustifyContentFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(JustifyContentFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(JustifyContentCenter), tooltip: '居中 center' },
{ value: 'space-between', icon: markRaw(JustifyContentSpaceBetween), tooltip: '两端对齐 space-between' },
{ value: 'space-around', icon: markRaw(JustifyContentSpaceAround), tooltip: '横向平分 space-around' },
{ value: 'flex-start', icon: markRaw(AlignItemsFlexStart), tooltip: '左对齐 flex-start' },
{ value: 'flex-end', icon: markRaw(AlignItemsFlexEnd), tooltip: '右对齐 flex-end' },
{ value: 'center', icon: markRaw(AlignItemsCenter), tooltip: '居中 center' },
{
value: 'space-between',
icon: markRaw(AlignItemsSpaceBetween),
tooltip: '两端对齐 space-between',
},
{
value: 'space-around',
icon: markRaw(AlignItemsSpaceAround),
tooltip: '横向平分 space-around',
},
],
display: (_mForm, { model }: { model: Record<any, any> }) => model.display === 'flex',
},
@ -127,8 +186,9 @@ const formConfig = defineFormConfig([
name: 'flexWrap',
text: '换行',
type: 'radioGroup',
childType: 'button',
labelWidth: '68px',
childType: displayTheme.value !== 'magic-admin' ? 'button' : 'default',
labelWidth: '90px',
iconSize: '24px',
options: [
{ value: 'nowrap', text: '不换行', tooltip: '不换行 nowrap' },
{ value: 'wrap', text: '正换行', tooltip: '第一行在上方 wrap' },
@ -141,17 +201,22 @@ const formConfig = defineFormConfig([
items: [
{
name: 'width',
text: '宽度',
labelWidth: '68px',
text: '宽度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
},
},
],
},
{
type: 'row',
items: [
{
name: 'height',
text: '高度',
labelWidth: '68px',
text: '高度px',
labelWidth: '90px',
type: 'data-source-field-select',
fieldConfig: {
type: 'text',
@ -166,7 +231,7 @@ const formConfig = defineFormConfig([
type: 'data-source-field-select',
text: 'overflow',
name: 'overflow',
labelWidth: '68px',
labelWidth: '90px',
checkStrictly: false,
dataSourceFieldType: ['string'],
fieldConfig: {
@ -184,11 +249,16 @@ const formConfig = defineFormConfig([
],
},
},
],
},
{
type: 'row',
items: [
{
type: 'data-source-field-select',
text: '透明度',
text: '透明度%',
name: 'opacity',
labelWidth: '68px',
labelWidth: '90px',
dataSourceFieldType: ['string', 'number'],
fieldConfig: {
type: 'text',

View File

@ -28,6 +28,7 @@ export const useStage = (stageOptions: StageOptions) => {
containerHighlightClassName: stageOptions.containerHighlightClassName,
containerHighlightDuration: stageOptions.containerHighlightDuration,
containerHighlightType: stageOptions.containerHighlightType,
containerHighlightAddOnly: stageOptions.containerHighlightAddOnly,
disabledDragStart: stageOptions.disabledDragStart,
renderType: stageOptions.renderType,
canSelect: (el, event, stop) => {

File diff suppressed because one or more lines are too long

View File

@ -32,6 +32,9 @@ export { DepTargetType } from '@tmagic/core';
export * from './type';
export * from './hooks';
export * from './utils';
// `isGlobalFlat` 同时在 `@tmagic/design` 和 `./utils/config` 中存在,
// 通过显式重导一次消除 `export *` 带来的歧义,并固定使用 editor 自己那份。
export { isGlobalFlat } from './utils/config';
export { default as TMagicEditor } from './Editor.vue';
export { default as TMagicCodeEditor } from './layouts/CodeEditor.vue';
export { default as editorService } from './services/editor';

View File

@ -333,11 +333,11 @@ export const initServiceEvents = (
Promise.all(
nodes.map((node) => {
if (node.type === NodeType.ROOT) {
return Promise.resolve();
return Promise.resolve(true);
}
return depService.collectIdle([node], { pageId: getPageIdByNode(node) }, deep, type);
}),
);
).then((results) => results.every(Boolean));
watch(
() => editorService.get('stage'),
@ -351,7 +351,7 @@ export const initServiceEvents = (
if (!node) return;
await collectIdle([node], true, DepTargetType.DATA_SOURCE);
if (!(await collectIdle([node], true, DepTargetType.DATA_SOURCE))) return;
updateStageNode(node);
});
},
@ -494,8 +494,10 @@ export const initServiceEvents = (
// 新增节点,收集依赖
const nodeAddHandler = (nodes: MComponent[]) => {
collectIdle(nodes, true).then(() => {
updateStageNodes(nodes);
collectIdle(nodes, true).then((completed) => {
if (completed) {
updateStageNodes(nodes);
}
});
};
@ -549,8 +551,8 @@ export const initServiceEvents = (
if (needRecollectNodes.length) {
// 有数据源依赖需要等依赖重新收集完才更新stage
const handler = async () => {
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE);
await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND);
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE))) return;
if (!(await collectIdle(needRecollectNodes, true, DepTargetType.DATA_SOURCE_COND))) return;
updateStageNodes(needRecollectNodes);
};
handler();
@ -572,8 +574,10 @@ export const initServiceEvents = (
// 历史记录变化时,需要重新收集依赖
const historyChangeHandler = (page: MPage | MPageFragment) => {
collectIdle([page], true).then(() => {
updateStageNode(page);
collectIdle([page], true).then((completed) => {
if (completed) {
updateStageNode(page);
}
});
};
@ -654,7 +658,7 @@ export const initServiceEvents = (
if (Array.isArray(root?.items)) {
depService.clearIdleTasks();
let collectIdlePromises: Promise<void[]>[] = [];
let collectIdlePromises: Promise<boolean>[] = [];
if (isModifyField) {
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE);
depService.removeTarget(config.id, DepTargetType.DATA_SOURCE_COND);
@ -679,10 +683,15 @@ export const initServiceEvents = (
collectIdlePromises = [collectIdle(root.items, true, DepTargetType.DATA_SOURCE_METHOD)];
}
Promise.all(collectIdlePromises)
.then(() => updateDataSourceSchema())
.then(() => updateDsData())
.then(() => updateStageNodes(root.items));
const handler = async () => {
const results = await Promise.all(collectIdlePromises);
if (!results.every(Boolean)) return;
updateDataSourceSchema();
await updateDsData();
updateStageNodes(root.items);
};
handler();
}
} else if (root?.dataSources) {
updateDsData();
@ -706,11 +715,12 @@ export const initServiceEvents = (
const nodeIds = Object.keys(root.dataSourceDeps?.[id] || {});
const nodes = getNodes(nodeIds, root.items);
await Promise.all([
const results = await Promise.all([
collectIdle(nodes, false, DepTargetType.DATA_SOURCE),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_COND),
collectIdle(nodes, false, DepTargetType.DATA_SOURCE_METHOD),
]);
if (!results.every(Boolean)) return;
updateDataSourceSchema();

View File

@ -1,5 +1,9 @@
<template>
<div class="m-editor" ref="content" style="min-width: 900px">
<div
ref="content"
:class="['m-editor', theme ? `m-editor--${theme}` : '', theme ? `m-theme--${theme}` : '']"
style="min-width: 900px"
>
<slot name="header"></slot>
<slot name="nav"></slot>
@ -97,6 +101,8 @@ const props = defineProps<{
pageFilterFunction?: (_page: MPage | MPageFragment, _keyword: string) => boolean;
/** 是否隐藏左侧面板 */
hideSidebar?: boolean;
/** 主题名称,会在根节点追加 `m-editor--<theme>` 修饰类 */
theme?: string;
}>();
const codeOptions = inject('codeOptions', {});

View File

@ -14,6 +14,7 @@
:config="config"
:type-match-valid="true"
:extend-state="extendState"
:validate-on-init="true"
@change="submit"
@error="errorHandler"
></MForm>
@ -49,14 +50,13 @@ import { Document as DocumentIcon } from '@element-plus/icons-vue';
import { TMagicButton, tMagicMessage, TMagicScrollbar } from '@tmagic/design';
import type { ContainerChangeEventData, FormConfig, FormState, FormValue } from '@tmagic/form';
import { MForm } from '@tmagic/form';
import { MForm, validateForm } from '@tmagic/form';
import { filterXSS } from '@tmagic/utils';
import MIcon from '@editor/components/Icon.vue';
import { ENABLE_PROPS_FORM_VALIDATE } from '@editor/editorProps';
import { useEditorContentHeight } from '@editor/hooks/use-editor-content-height';
import { useServices } from '@editor/hooks/use-services';
import { validatePropsForm } from '@editor/utils/props';
import CodeEditor from '../CodeEditor.vue';
@ -162,14 +162,19 @@ const saveCode = async (values: any) => {
//
// 使
try {
const error = await validatePropsForm({
const error = await validateForm({
config: props.config,
values: newValues,
appContext: internalInstance?.appContext ?? null,
services,
stage: stage.value,
typeMatchValid: true,
extendState: props.extendState,
initValues: newValues,
// provides Editor services / codeOptions provide
// appContext使 MForm DataSourceInput inject
appContext: internalInstance?.appContext ? { ...internalInstance?.appContext, provides: { services } } : null,
extendState: (state) => {
if (configFormRef.value?.formState) {
return { ...(configFormRef.value?.formState || {}) };
}
return props.extendState?.(state) || {};
},
});
if (error) {

View File

@ -111,14 +111,31 @@ const styleFormConfig = [
},
];
// init "" await ref
// init + stale config
// / FormPanel remount ref Vue setRef __vnode
// null TypeError: Cannot set properties of null (setting '__vnode')
let initSeq = 0;
let mounted = true;
const init = async () => {
initSeq += 1;
const seq = initSeq;
if (!node.value) {
if (seq !== initSeq || !mounted) return;
curFormConfig.value = [];
return;
}
const type = node.value.type || (node.value.items ? 'container' : 'text');
curFormConfig.value = await propsService.getPropsConfig(type, { node: node.value });
const config = await propsService.getPropsConfig(type, { node: node.value });
// init /
if (seq !== initSeq || !mounted) return;
if (!node.value) return;
curFormConfig.value = config;
values.value = node.value;
};
@ -126,6 +143,7 @@ watchEffect(init);
propsService.on('props-configs-change', init);
onBeforeUnmount(() => {
mounted = false;
propsService.off('props-configs-change', init);
});
@ -146,17 +164,25 @@ const submit = async (
};
if (v.style) {
Object.entries(v.style).forEach(([key, value]) => {
if (value !== '' && newValue.style) {
newValue.style[key] = value;
}
});
// doUpdate mergeWith
if (eventData) {
// DSL
// changeRecords
Object.entries(v.style).forEach(([key, value]) => {
if (value !== '' && newValue.style) {
newValue.style[key] = value;
}
});
eventData?.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue);
}
});
eventData.changeRecords?.forEach((record) => {
if (record.propPath?.startsWith('style') && record.value === '') {
setValueByKeyPath(record.propPath, record.value, newValue);
}
});
} else {
// eventDatastyle
newValue.style = { ...v.style };
}
}
// MForm @change eventData changeRecords

View File

@ -145,6 +145,6 @@ const dragHandler = (e: DragEvent) => {
if (timeout || !stage.value) return;
timeout = stage.value.delayedMarkContainer(e);
timeout = stage.value.delayedMarkContainer(e, [], true);
};
</script>

View File

@ -155,7 +155,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { Close, Coin, EditPen, Goods, List } from '@element-plus/icons-vue';
import { Box, Close, Coin, EditPen, Finished } from '@element-plus/icons-vue';
import FloatingBox from '@editor/components/FloatingBox.vue';
import MIcon from '@editor/components/Icon.vue';
@ -253,7 +253,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
[SideItemKey.COMPONENT_LIST]: {
$key: SideItemKey.COMPONENT_LIST,
type: 'component',
icon: Goods,
icon: Box,
text: '组件',
component: ComponentListPanel,
slots: {},
@ -261,7 +261,7 @@ const getItemConfig = (data: SideItem): SideComponent => {
layer: {
$key: SideItemKey.LAYER,
type: 'component',
icon: List,
icon: Finished,
text: '已选组件',
props: {
layerContentMenu: props.layerContentMenu,

View File

@ -6,7 +6,7 @@
import { computed, markRaw, useTemplateRef } from 'vue';
import { Files, Plus } from '@element-plus/icons-vue';
import { isPage, isPageFragment } from '@tmagic/utils';
import { isPageOrFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import { useServices } from '@editor/hooks/use-services';
@ -101,7 +101,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '全部折叠',
icon: FolderMinusIcon,
display: () => isPage(node.value) || isPageFragment(node.value),
display: () => isPageOrFragment(node.value),
handler: () => {
emit('collapse-all');
},

View File

@ -2,7 +2,7 @@ import { computed, type ComputedRef, nextTick, type Ref, type ShallowRef } from
import { throttle } from 'lodash-es';
import { Id, MNode } from '@tmagic/core';
import { getElById, isPage, isPageFragment } from '@tmagic/utils';
import { getElById, isPageOrFragment } from '@tmagic/utils';
import type { LayerNodeStatus, Services, TreeNodeData } from '@editor/type';
import { UI_SELECT_MODE_EVENT_NAME } from '@editor/utils/const';
@ -36,7 +36,7 @@ export const useClick = (
};
const multiSelect = async (data: MNode) => {
if (isPage(data) || isPageFragment(data)) {
if (isPageOrFragment(data)) {
return;
}
@ -50,7 +50,7 @@ export const useClick = (
return;
}
if (isPage(node) || isPageFragment(node)) {
if (isPageOrFragment(node)) {
return;
}

View File

@ -1,7 +1,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core';
import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils';
import { getNodePath, isPageOrFragment, traverseNode } from '@tmagic/utils';
import type { LayerNodeStatus, Services } from '@editor/type';
import { updateStatus } from '@editor/utils/tree';
@ -136,7 +136,7 @@ export const useNodeStatus = ({ editorService }: Services) => {
const addHandler = (newNodes: MNode[]) => {
newNodes.forEach((node) => {
if (isPage(node) || isPageFragment(node)) return;
if (isPageOrFragment(node)) return;
traverseNode(node, (node: MNode) => {
nodeStatusMap.value?.set(node.id, {

View File

@ -7,7 +7,7 @@ import { computed, markRaw, ref, useTemplateRef, watch } from 'vue';
import { Bottom, Top } from '@element-plus/icons-vue';
import { NodeType } from '@tmagic/core';
import { isPage, isPageFragment } from '@tmagic/utils';
import { isPageOrFragment } from '@tmagic/utils';
import ContentMenu from '@editor/components/ContentMenu.vue';
import { useServices } from '@editor/hooks/use-services';
@ -59,14 +59,14 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
direction: 'horizontal',
display: () => {
if (!node.value) return false;
return !isPage(node.value) && !isPageFragment(node.value);
return !isPageOrFragment(node.value);
},
},
{
type: 'button',
text: '上移一层',
icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(1, { historySource: 'stage-contextmenu' });
},
@ -75,7 +75,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '下移一层',
icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(-1, { historySource: 'stage-contextmenu' });
},
@ -84,7 +84,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '置顶',
icon: markRaw(Top),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.TOP, { historySource: 'stage-contextmenu' });
},
@ -93,7 +93,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
type: 'button',
text: '置底',
icon: markRaw(Bottom),
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
handler: () => {
editorService.moveLayer(LayerOffset.BOTTOM, { historySource: 'stage-contextmenu' });
},
@ -102,7 +102,7 @@ const menuData = computed<(MenuButton | MenuComponent)[]>(() =>
{
type: 'divider',
direction: 'horizontal',
display: () => !isPage(node.value) && !isPageFragment(node.value) && !props.isMultiSelect,
display: () => !isPageOrFragment(node.value) && !props.isMultiSelect,
},
useDeleteMenu('stage-contextmenu'),
{

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import type { App } from 'vue';
import { type App } from 'vue';
import type { DesignPluginOptions } from '@tmagic/design';
import designPlugin from '@tmagic/design';
@ -56,6 +56,8 @@ const defaultInstallOpt: EditorInstallOptions = {
customCreateMonacoEditor: (monaco, codeEditorEl, options) => monaco.editor.create(codeEditorEl, options),
customCreateMonacoDiffEditor: (monaco, codeEditorEl, options) =>
monaco.editor.createDiffEditor(codeEditorEl, options),
flat: false,
};
export default {
@ -69,7 +71,6 @@ export default {
app.config.globalProperties.$TMAGIC_EDITOR = option;
setEditorConfig(option);
app.component(`${Editor.name || 'MEditor'}`, Editor);
app.component('magic-code-editor', CodeEditor);
app.component('m-fields-ui-select', uiSelect);

View File

@ -17,14 +17,14 @@
*/
import { reactive, shallowReactive } from 'vue';
import { throttle } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { DepData, DepExtendedData, Id, MApp, MNode, Target, TargetNode } from '@tmagic/core';
import type { DepData, DepExtendedData, Id, MApp, MNode, Target } from '@tmagic/core';
import { DepTargetType, traverseTarget, Watcher } from '@tmagic/core';
import { isPage } from '@tmagic/utils';
import { CollectWorkerClient } from '@editor/utils/dep/collect-worker-client';
import { IdleTask } from '@editor/utils/dep/idle-task';
import Work from '@editor/utils/dep/worker.ts?worker&inline';
import type { DepsData } from '@editor/utils/dep/worker';
import BaseService from './BaseService';
@ -42,18 +42,42 @@ interface State {
type StateKey = keyof State;
/**
* collectIdle /
* Promise idleTask finish resolve
* clearTasks Promise resolvecollecting
*/
interface CollectBatch {
nodes: MNode[];
deep: boolean;
pending: number;
dsPending: number;
collectedEmitted: boolean;
dsSettled: boolean;
aborted: boolean;
resolve: (completed: boolean) => void;
}
class Dep extends BaseService {
private state = shallowReactive<State>({
collecting: false,
taskLength: 0,
});
private idleTask = new IdleTask<{ node: TargetNode; deep: boolean; target: Target }>();
private idleTask = new IdleTask<void>();
private collectWorker = new CollectWorkerClient();
private watcher = new Watcher({ initialTargets: reactive({}) });
private waitingWorker?: Promise<void>;
private resolveWaitingWorker?: () => void;
private workerGeneration = 0;
private activeBatches = new Set<CollectBatch>();
constructor() {
super();
@ -132,56 +156,77 @@ class Dep extends BaseService {
this.emit('ds-collected', nodes, deep);
}
/**
*
*
* target
* /线 worker 线 target
* target isTarget 线 descriptor线
* worker SSR退线
*/
public async collectIdle(nodes: MNode[], depExtendedData: DepExtendedData = {}, deep = false, type?: DepTargetType) {
if (this.waitingWorker) {
await this.waitingWorker;
}
this.set('collecting', true);
let startTask = false;
this.watcher.collectByCallback(nodes, type, ({ node, target }) => {
startTask = true;
const batch: CollectBatch = {
nodes,
deep,
pending: 0,
dsPending: 0,
collectedEmitted: false,
dsSettled: false,
aborted: false,
resolve: () => {},
};
this.enqueueTask(node, target, depExtendedData, deep);
});
const workerTargets: Target[] = [];
return new Promise<void>((resolve) => {
if (!startTask) {
this.emit('collected', nodes, deep);
this.set('collecting', false);
resolve();
return;
for (const target of this.watcher.getCollectableTargets(type)) {
if (target.descriptor && this.collectWorker.isSupported) {
workerTargets.push(target);
} else {
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
}
this.idleTask.once('finish', () => {
this.emit('collected', nodes, deep);
this.set('collecting', false);
});
this.idleTask.once('hight-level-finish', () => {
this.emit('ds-collected', nodes, deep);
resolve();
});
}
if (workerTargets.length && nodes.length) {
this.collectByCollectWorker(nodes, workerTargets, depExtendedData, deep, batch);
}
// 没有命中任何 target无需收集直接完成
if (batch.pending === 0) {
this.emit('collected', nodes, deep);
this.updateCollectingState();
return true;
}
this.activeBatches.add(batch);
this.set('collecting', true);
return new Promise<boolean>((resolve) => {
batch.resolve = resolve;
});
}
public collectByWorker(dsl: MApp) {
this.set('collecting', true);
this.workerGeneration += 1;
const generation = this.workerGeneration;
const { promise, resolve: waitingResolve } = Promise.withResolvers<void>();
this.waitingWorker = promise;
this.resolveWaitingWorker = waitingResolve;
return this.collectWorker.collectDsl(dsl).then((deps) => {
const depsData: DepsData = deps || {};
if (generation !== this.workerGeneration) {
waitingResolve();
return depsData;
}
return new Promise<Record<string, Record<string, DepData>>>((resolve) => {
const worker = new Work();
worker.postMessage({
dsl: serialize(dsl),
});
worker.onmessage = (e) => {
resolve(e.data);
};
worker.onerror = () => {
resolve({});
};
}).then((depsData) => {
traverseTarget(this.watcher.getTargetsList(), (target) => {
if (depsData[target.type]?.[target.id]) {
target.deps = reactive(depsData[target.type][target.id]);
@ -200,6 +245,10 @@ class Dep extends BaseService {
this.emit('collected', dsl.items, true);
this.emit('ds-collected', dsl.items, true);
waitingResolve();
if (this.waitingWorker === promise) {
this.waitingWorker = undefined;
this.resolveWaitingWorker = undefined;
}
return depsData;
});
@ -209,8 +258,12 @@ class Dep extends BaseService {
// 先删除原有依赖,重新收集
if (isPage(node)) {
this.removePageDep(target, depExtendedData);
} else {
} else if (deep) {
// deep 收集会覆盖整棵子树,删除也要同步清掉子孙旧依赖
this.watcher.removeTargetDep(target, node);
} else {
// 非 deep 只动当前节点,避免误清子节点上其他尚未重收的依赖
target.removeDep(node.id);
}
this.watcher.collectItem(node, target, depExtendedData, deep);
@ -233,7 +286,10 @@ class Dep extends BaseService {
}
public clearIdleTasks() {
this.abortActiveBatches();
this.idleTask.clearTasks();
// 丢掉 worker 在途任务,避免 abort 后的长任务继续占着常驻 worker
this.collectWorker.abort();
}
public on<Name extends keyof DepEvents, Param extends DepEvents[Name]>(
@ -251,7 +307,13 @@ class Dep extends BaseService {
}
public reset() {
this.abortActiveBatches();
this.workerGeneration += 1;
this.resolveWaitingWorker?.();
this.resolveWaitingWorker = undefined;
this.waitingWorker = undefined;
this.idleTask.clearTasks();
this.collectWorker.abort();
for (const type of Object.keys(this.watcher.getTargetsList())) {
this.removeTargets(type);
@ -267,6 +329,7 @@ class Dep extends BaseService {
this.reset();
this.removeAllPlugins();
this.idleTask.removeAllListeners();
this.collectWorker.terminate();
}
public emit<Name extends keyof DepEvents, Param extends DepEvents[Name]>(eventName: Name, ...args: Param) {
@ -286,25 +349,207 @@ class Dep extends BaseService {
}
}
private enqueueTask(node: MNode, target: Target, depExtendedData: DepExtendedData, deep: boolean) {
/**
* worker target 线
*/
private collectByCollectWorker(
nodes: MNode[],
targets: Target[],
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
const hasDataSource = targets.some((target) => target.type === DepTargetType.DATA_SOURCE);
// 先占位,避免 worker 返回前批次被误判为已完成(提前 resolve、collecting 提前复位)
this.beginBatchTask(batch, hasDataSource);
this.collectWorker
.collect({
nodes,
targets: targets.map((target) => target.descriptor!),
depExtendedData,
deep,
})
.then((result) => {
if (batch.aborted) return;
for (const target of targets) {
if (result) {
// 写回是纯字典操作,但数据量大时同样会占用主线程,因此按 target 拆成空闲任务
this.enqueueApplyTask(target, result.deps[target.type]?.[target.id] || {}, result.nodeIds, batch, {
nodes,
depExtendedData,
deep,
});
} else {
this.enqueueTasks(nodes, target, depExtendedData, deep, batch);
}
}
})
.finally(() => {
// 释放占位:必须在新任务入队之后,否则批次会在中途被判定为完成
this.onBatchTaskDone(batch, hasDataSource);
});
}
/**
* worker target
*/
private enqueueApplyTask(
target: Target,
deps: DepData,
nodeIds: Id[],
batch: CollectBatch,
{ nodes, depExtendedData, deep }: { nodes: MNode[]; depExtendedData: DepExtendedData; deep: boolean },
) {
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
this.beginBatchTask(batch, isDataSource);
this.idleTask.enqueueTask(
({ node, deep, target }) => {
this.collectNode(node, target, depExtendedData, deep);
() => {
try {
// target 可能在 worker 收集期间被移除或重建(如修改数据源字段)
const current = this.watcher.getTarget(target.id, target.type);
if (current !== target) {
// 已被同 id 的新 target 替换时,用当前 target 在主线程补收,避免依赖空窗
if (current) {
this.enqueueTasks(nodes, current, depExtendedData, deep, batch);
}
return;
}
// 页面按 pageId 匹配删除,可清掉页面内已被删除节点的残留依赖
if (nodes.some((node) => isPage(node))) {
this.removePageDep(target, depExtendedData);
}
for (const id of nodeIds) {
target.removeDep(id);
}
for (const [id, dep] of Object.entries(deps)) {
target.deps[id] = dep;
}
} finally {
this.onBatchTaskDone(batch, isDataSource);
}
},
{
node,
deep: false,
target,
undefined,
isDataSource,
);
}
private enqueueTasks(
nodes: MNode[],
target: Target,
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
for (const node of nodes) {
this.enqueueTask(node, target, depExtendedData, deep, batch);
}
}
private enqueueTask(
node: MNode,
target: Target,
depExtendedData: DepExtendedData,
deep: boolean,
batch: CollectBatch,
) {
const isDataSource = target.type === DepTargetType.DATA_SOURCE;
this.beginBatchTask(batch, isDataSource);
this.idleTask.enqueueTask(
() => {
try {
this.collectNode(node, target, depExtendedData);
} finally {
this.onBatchTaskDone(batch, isDataSource);
}
},
target.type === DepTargetType.DATA_SOURCE,
undefined,
isDataSource,
);
if (deep && Array.isArray(node.items) && node.items.length) {
node.items.forEach((item) => {
this.enqueueTask(item, target, depExtendedData, deep);
this.enqueueTask(item, target, depExtendedData, deep, batch);
});
}
}
private beginBatchTask(batch: CollectBatch, isDataSource: boolean) {
batch.pending += 1;
if (isDataSource) {
batch.dsPending += 1;
}
}
private onBatchTaskDone(batch: CollectBatch, isDataSource: boolean) {
if (batch.aborted) return;
if (isDataSource) {
batch.dsPending -= 1;
// 数据源依赖收集完先 resolve让 stage 尽快更新,其余依赖继续在后台收集
if (batch.dsPending === 0) {
this.settleBatchDs(batch);
}
}
batch.pending -= 1;
if (batch.pending === 0) {
this.finishBatch(batch);
}
}
private settleBatchDs(batch: CollectBatch) {
if (batch.dsSettled) return;
batch.dsSettled = true;
this.emit('ds-collected', batch.nodes, batch.deep);
batch.resolve(true);
}
private finishBatch(batch: CollectBatch) {
if (!batch.collectedEmitted) {
batch.collectedEmitted = true;
this.emit('collected', batch.nodes, batch.deep);
}
// 没有数据源任务的批次在此结算 Promise
this.settleBatchDs(batch);
this.activeBatches.delete(batch);
this.updateCollectingState();
}
/**
* idleTask
* collectIdle Promise resolvecollecting trueonce
* emit collected/ds-collected Promise
*/
private abortActiveBatches() {
if (!this.activeBatches.size) {
return;
}
const batches = [...this.activeBatches];
this.activeBatches.clear();
for (const batch of batches) {
// 标记中断worker 结果可能在之后才返回,此时不能再写回依赖,也不能再结算批次
batch.aborted = true;
batch.resolve(false);
}
this.updateCollectingState();
}
private updateCollectingState() {
this.set('collecting', this.activeBatches.size > 0);
}
}
export type DepService = Dep;

View File

@ -16,7 +16,7 @@
* limitations under the License.
*/
import { reactive, toRaw } from 'vue';
import { nextTick, reactive, toRaw } from 'vue';
import { cloneDeep, isEmpty, isEqual, isObject, mergeWith, uniq } from 'lodash-es';
import type { Id, MApp, MContainer, MNode, MPage, MPageFragment, TargetOptions } from '@tmagic/core';
@ -30,6 +30,7 @@ import {
guid,
isPage,
isPageFragment,
isPageOrFragment,
setValueByKeyPath,
traverseNode,
} from '@tmagic/utils';
@ -272,7 +273,7 @@ class Editor extends BaseService {
public isOnDifferentPage(node: MNode): boolean {
const currentPageId = this.get('page')?.id;
if (currentPageId === undefined || currentPageId === null) return false;
if (isPage(node) || isPageFragment(node)) {
if (isPageOrFragment(node)) {
return `${node.id}` !== `${currentPageId}`;
}
const nodePage = this.getNodeInfo(node.id, false).page;
@ -413,11 +414,11 @@ class Editor extends BaseService {
if (!curNode) throw new Error('当前选中节点为空');
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !(isPage(node) || isPageFragment(node))) {
if ((parent.type === NodeType.ROOT || curNode?.type === NodeType.ROOT) && !isPageOrFragment(node)) {
throw new Error('app下不能添加组件');
}
if (parent.id !== curNode.id && !(isPage(node) || isPageFragment(node))) {
if (parent.id !== curNode.id && !isPageOrFragment(node)) {
const index = parent.items.indexOf(curNode);
parent.items?.splice(index + 1, 0, node);
} else {
@ -487,7 +488,7 @@ class Editor extends BaseService {
const newNodes = await Promise.all(
addNodes.map((node) => {
const root = this.get('root');
if ((isPage(node) || isPageFragment(node)) && root) {
if (isPageOrFragment(node) && root) {
return this.doAdd(node, root);
}
const parentNode = parent ?? getAddParent(node);
@ -523,7 +524,7 @@ class Editor extends BaseService {
}
}
if (!(isPage(newNodes[0]) || isPageFragment(newNodes[0]))) {
if (!isPageOrFragment(newNodes[0])) {
const pageForOp = this.getNodeInfo(newNodes[0].id, false).page;
if (!doNotPushHistory) {
const parentId = (this.getParentById(newNodes[0].id, false) ?? this.get('root'))!.id;
@ -550,8 +551,16 @@ class Editor extends BaseService {
this.emit('change', {
type: 'add',
data: newNodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource,
doNotPushHistory,
});
// 页面 / 页面片新增不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const addedPages = newNodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (addedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: [] });
}
return Array.isArray(addNode) ? newNodes : newNodes[0];
}
@ -570,12 +579,14 @@ class Editor extends BaseService {
if (typeof index !== 'number' || index === -1) throw new Error('找不要删除的节点');
parent.items?.splice(index, 1);
const stage = this.get('stage');
stage?.remove({ id: node.id, parentId: parent.id, root: cloneDeep(root) });
const currentPage = this.get('page');
const isDeletingCurrentPage = isPageOrFragment(node) && !!currentPage && `${currentPage.id}` === `${node.id}`;
parent.items?.splice(index, 1);
// 始终清理已删除节点在 state 中的残留引用:
// - 即使后续会调用 selectDefault / select(parent) 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
// - 即使后续会调用 select 覆盖跳过这些调用doNotSelect / doNotSwitchPage时也不能让 state 持有已删除节点
const selectedNodes = this.get('nodes');
const removedSelectedIndex = selectedNodes.findIndex((n: MNode) => `${n.id}` === `${node.id}`);
if (removedSelectedIndex !== -1) {
@ -583,38 +594,44 @@ class Editor extends BaseService {
nextSelected.splice(removedSelectedIndex, 1);
this.set('nodes', nextSelected);
}
if (isPage(node) || isPageFragment(node)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${node.id}`) {
this.set('page', null);
}
}
const selectDefault = async (pages: MNode[]) => {
if (pages[0]) {
await this.select(pages[0]);
stage?.select(pages[0].id);
} else {
this.selectRoot();
}
};
const removeData = { id: node.id, parentId: parent.id, root: cloneDeep(root) };
const rootItems = root.items || [];
// 删非当前页时画布不应该变动,所以只有删的是当前页才重新选中
const shouldReselect = isDeletingCurrentPage && !doNotSelect && !doNotSwitchPage;
// 还有剩余页面才能切过去,否则退回选中 root
const shouldSwitchPage = shouldReselect && rootItems.length > 0;
if (isPage(node)) {
this.state.pageLength -= 1;
// 删除页面后默认会切到首个剩余页面selectDefaultdoNotSwitchPage 时跳过这次自动切换
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
if (isPageOrFragment(node)) {
if (isPage(node)) {
this.state.pageLength -= 1;
} else {
this.state.pageFragmentLength -= 1;
}
} else if (isPageFragment(node)) {
this.state.pageFragmentLength -= 1;
if (!doNotSelect && !doNotSwitchPage) {
await selectDefault(rootItems);
if (shouldSwitchPage) {
// runtime 收到页面删除时会销毁当前渲染的 page 实例,必须先把画布切到剩余页面再通知删除,
// 否则画布会被清空,且要等下一次 updatePageId 才能恢复。
// nextTick 用于等 Stage 的 page watch 把新页面 id 同步给 runtime。
await this.select(rootItems[0]);
stage?.select(rootItems[0].id);
await nextTick();
stage?.remove(removeData);
} else {
// page 置空会让 Workspace 卸载 Stage 并销毁 renderer删除通知必须在卸载前发出
// 且不能 awaitruntime 未 ready 时 getRuntime 的监听会随 destroy 一起被移除,永远不会 resolve
stage?.remove(removeData);
if (shouldReselect) {
// 页面已全部删完
this.selectRoot();
} else if (isDeletingCurrentPage) {
this.set('page', null);
}
}
} else {
stage?.remove(removeData);
if (!doNotSelect) {
await this.select(parent);
stage?.select(parent.id);
@ -656,7 +673,7 @@ class Editor extends BaseService {
const removedItems: StepDiffItem<MNode>[] = [];
let pageForOp: { name: string; id: Id } | null = null;
if (!(isPage(nodes[0]) || isPageFragment(nodes[0]))) {
if (!isPageOrFragment(nodes[0])) {
for (const n of nodes) {
const { parent, node: curNode, page } = this.getNodeInfo(n.id, false);
if (parent && curNode) {
@ -692,7 +709,13 @@ class Editor extends BaseService {
}
this.emit('remove', nodes);
this.emit('change', { type: 'remove', data: changeItems });
this.emit('change', { type: 'remove', data: changeItems, historySource, doNotPushHistory });
// 页面 / 页面片删除不入历史栈(见上方 isPageOrFragment 分支),这里合并补发一次结构变更通知
const removedPages = nodes.filter((node) => isPageOrFragment(node)) as (MPage | MPageFragment)[];
if (removedPages.length) {
historyService.notifyPageStructureChange({ add: [], remove: removedPages });
}
}
public async doUpdate(
@ -750,7 +773,7 @@ class Editor extends BaseService {
}
// 只有被更新节点正好是当前选中页面时才同步 state.page避免「更新非当前页」误将编辑器切到该页
if (isPage(newConfig) || isPageFragment(newConfig)) {
if (isPageOrFragment(newConfig)) {
const currentPage = this.get('page');
if (currentPage && `${currentPage.id}` === `${newConfig.id}`) {
this.set('page', newConfig as MPage | MPageFragment);
@ -846,6 +869,8 @@ class Editor extends BaseService {
this.emit('change', {
type: 'update',
data: updateData.map((node) => ({ node, page: this.getPageOfNode(node.newNode.id) })),
historySource,
doNotPushHistory,
});
return Array.isArray(config) ? updateData.map((item) => item.newNode) : updateData[0].newNode;
}
@ -1092,6 +1117,8 @@ class Editor extends BaseService {
type: 'move-layer',
data: [{ node, page: this.getPageOfNode(node.id) }],
offset,
historySource,
doNotPushHistory,
});
}
@ -1121,7 +1148,7 @@ class Editor extends BaseService {
}: DslOpOptions = {},
): Promise<MNode | MNode[]> {
const isBatch = Array.isArray(config);
const configs = (isBatch ? config : [config]).filter((item) => !(isPage(item) || isPageFragment(item)));
const configs = (isBatch ? config : [config]).filter((item) => !isPageOrFragment(item));
if (configs.length === 0) {
throw new Error('没有可移动的节点');
@ -1277,6 +1304,8 @@ class Editor extends BaseService {
data: configs.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
targetIndex,
targetParent,
historySource,
doNotPushHistory,
});
}
@ -1740,7 +1769,7 @@ class Editor extends BaseService {
private getPageOfNode(id: Id): MPage | MPageFragment | null {
const { node, page } = this.getNodeInfo(id, false);
if (page) return page;
if (node && (isPage(node) || isPageFragment(node))) return node as MPage | MPageFragment;
if (node && isPageOrFragment(node)) return node as MPage | MPageFragment;
return null;
}
@ -1768,6 +1797,10 @@ class Editor extends BaseService {
const nextMap = new Map(nextPages.map((p) => [`${p.id}`, p]));
const indexInItems = (root: MApp, id: Id) => (root.items ?? []).findIndex((item) => `${item.id}` === `${id}`);
// 收集本次整体替换中增删的页面,循环结束后合并为一次结构变更通知(避免逐页派发多个事件)
const addedPages: (MPage | MPageFragment)[] = [];
const removedPages: (MPage | MPageFragment)[] = [];
nextPages.forEach((nextPage) => {
const prevPage = prevMap.get(`${nextPage.id}`);
if (!prevPage) {
@ -1777,6 +1810,7 @@ class Editor extends BaseService {
{ newSchema: cloneDeep(toRaw(nextPage)), parentId: nextRoot.id, index: indexInItems(nextRoot, nextPage.id) },
source,
);
addedPages.push(nextPage);
} else if (!isEqual(toRaw(prevPage), toRaw(nextPage))) {
this.pushPageDiffStep(
'update',
@ -1795,8 +1829,13 @@ class Editor extends BaseService {
{ oldSchema: cloneDeep(toRaw(prevPage)), parentId: preRoot.id, index: indexInItems(preRoot, prevPage.id) },
source,
);
removedPages.push(prevPage);
}
});
if (addedPages.length || removedPages.length) {
historyService.notifyPageStructureChange({ add: addedPages, remove: removedPages });
}
}
/**
@ -1878,10 +1917,12 @@ class Editor extends BaseService {
/**
* /
*
* DSL `editor.add / remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
* / `editor.remove / update` `doNotPushHistory`
* `doNotSelect / doNotSwitchPage`
* remove / add step parentId / index
* this.add splice + stage.add add / change emitHistoryInsertEvents
*
* add / remove / update "用户操作""撤销重做触发"
* add / remove / update / change "用户操作""撤销重做触发"
* `history-change`
*
* @param step
@ -1894,7 +1935,14 @@ class Editor extends BaseService {
const stage = this.get('stage');
if (!root) return;
const commonOpts = { doNotSelect: true, doNotSwitchPage: true, doNotPushHistory: true } as const;
// 撤销/重做内部复用 add/remove/update透传被应用 step 上记录的 source
// 使其触发的 change 事件同样携带「历史来源」
const commonOpts = {
doNotSelect: true,
doNotSwitchPage: true,
doNotPushHistory: true,
historySource: step.source,
} as const;
switch (step.opType) {
case 'add': {
@ -1910,17 +1958,22 @@ class Editor extends BaseService {
}
} else {
// 重做 add按记录的 parentId / index 把节点重新插回父容器。
// 按目标 index 升序逐个插入,先小后大避免索引漂移
// 按目标 index 升序逐个插入,先小后大避免索引漂移。
// 不走 this.adddoAdd 依赖当前选中节点(无选区时直接抛错),且只能插到选中节点之后 / 末尾,
// 无法按 step 记录的 parentId / index 精确还原,还会重算 style 破坏已记录的终态位置。
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { newSchema, parentId, index } of sorted) {
if (!newSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
const addedNode = cloneDeep(newSchema);
if (typeof index === 'number' && index >= 0 && index < parent.items.length) {
parent.items.splice(index, 0, cloneDeep(newSchema));
parent.items.splice(index, 0, addedNode);
} else {
parent.items.push(cloneDeep(newSchema));
parent.items.push(addedNode);
}
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(newSchema),
parent: cloneDeep(parent),
@ -1929,19 +1982,23 @@ class Editor extends BaseService {
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
}
break;
}
case 'remove': {
const items = step.diff ?? [];
if (reverse) {
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移)
// 撤销 remove按原 index 升序逐个插回(先小后大避免索引漂移);不走 this.add 的原因同上
const sorted = [...items].sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
const addedNodes: MNode[] = [];
for (const { oldSchema, parentId, index } of sorted) {
if (!oldSchema || parentId === undefined) continue;
const parent = this.getNodeById(parentId, false) as MContainer | null;
if (parent?.items) {
parent.items.splice(index ?? parent.items.length, 0, cloneDeep(oldSchema));
const addedNode = cloneDeep(oldSchema);
parent.items.splice(index ?? parent.items.length, 0, addedNode);
addedNodes.push(addedNode);
await stage?.add({
config: cloneDeep(oldSchema),
parent: cloneDeep(parent),
@ -1950,6 +2007,7 @@ class Editor extends BaseService {
});
}
}
this.emitHistoryInsertEvents(addedNodes, step.source);
} else {
// 重做 remove再删一次
for (const { oldSchema } of items) {
@ -1989,7 +2047,7 @@ class Editor extends BaseService {
return cloneDeep(reverse ? oldNode : newNode);
});
if (configs.length) {
await this.update(configs, { doNotPushHistory: true });
await this.update(configs, { doNotPushHistory: true, historySource: step.source });
}
break;
}
@ -2028,6 +2086,22 @@ class Editor extends BaseService {
private selectedConfigExceptionHandler(config: MNode | Id): EditorNodeInfo {
return resolveSelectedNode(config, (id) => this.getNodeInfo(id), this.state.root?.id);
}
/**
* remove / add splice step parentId / index this.add
* this.add add / change /
*/
private emitHistoryInsertEvents(nodes: MNode[], source?: HistoryOpSource) {
if (!nodes.length) return;
this.emit('add', nodes);
this.emit('change', {
type: 'add',
data: nodes.map((node) => ({ node, page: this.getPageOfNode(node.id) })),
historySource: source,
// 撤销/重做本身不再写入历史记录
doNotPushHistory: true,
});
}
}
export type EditorService = Editor;

View File

@ -19,7 +19,7 @@
import { reactive } from 'vue';
import type { Writable } from 'type-fest';
import type { Id } from '@tmagic/core';
import type { Id, MPage, MPageFragment } from '@tmagic/core';
import { guid } from '@tmagic/utils';
import type {
@ -353,6 +353,27 @@ class History extends BaseService {
this.emit('mark-saved', { kind: stepType, id });
}
/**
* / `page-structure-change`
*
* `editorService.add` / `remove` `page` editor.add / remove
* isPageOrFragment historyService
* setRoot DSL
*
* ** change **
* 使`add` / `remove`
*
* @param change { add: 新增的页面列表, remove: 删除的页面列表 }
*/
public notifyPageStructureChange(change: {
add: (MPage | MPageFragment)[];
remove: (MPage | MPageFragment)[];
}): void {
// 增删均为空时不派发,避免无意义通知
if (!change.add.length && !change.remove.length) return;
this.emit('page-structure-change', change);
}
/**
* / / / IndexedDB
*

View File

@ -1,6 +1,6 @@
import KeyController, { KeyControllerEvent } from 'keycon';
import { isPage, isPageFragment } from '@tmagic/utils';
import { isPageOrFragment } from '@tmagic/utils';
import { KeyBindingCacheItem, KeyBindingCommand, KeyBindingItem } from '@editor/type';
@ -19,7 +19,7 @@ class Keybinding extends BaseService {
[KeyBindingCommand.DELETE_NODE]: () => {
const nodes = editorService.get('nodes');
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
if (!nodes || isPageOrFragment(nodes[0])) return;
editorService.remove(nodes, { historySource: 'shortcut' });
},
[KeyBindingCommand.COPY_NODE]: () => {
@ -29,7 +29,7 @@ class Keybinding extends BaseService {
[KeyBindingCommand.CUT_NODE]: () => {
const nodes = editorService.get('nodes');
if (!nodes || isPage(nodes[0]) || isPageFragment(nodes[0])) return;
if (!nodes || isPageOrFragment(nodes[0])) return;
editorService.copy(nodes);
editorService.remove(nodes, { historySource: 'shortcut' });
},

View File

@ -49,13 +49,14 @@ const state = shallowReactive<UiState>({
width: 375,
height: 817,
},
// 先给 columnWidth 留占位,真正的 clamp 逻辑在 state 创建完成后
// 用 state.minLeftColumnWidth / state.minRightColumnWidth 作为下限重新赋值 —
// 这样以后从外部(比如 uiService.set('minLeftColumnWidth', ...))动态改最小宽度,
// 只改一处 state 字段即可,不用同步 MIN_* 常量。
columnWidth: {
left:
storageService.getItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_LEFT_COLUMN_WIDTH,
left: 0,
center: 0,
right:
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) ||
DEFAULT_RIGHT_COLUMN_WIDTH,
right: 0,
},
minLeftColumnWidth: MIN_LEFT_COLUMN_WIDTH,
minCenterColumnWidth: MIN_CENTER_COLUMN_WIDTH,
@ -83,6 +84,20 @@ const state = shallowReactive<UiState>({
},
});
// 左右两栏宽度延后初始化:
// - 优先取 storage 里用户上次拖到的值storage 空时回落到 DEFAULT_*
// - 再用 state.min*ColumnWidth 兜底,防止历史 storage 里存的是老版本或用户拖过头
// 留下的"比当前 min 还小"的数值。放到 state 之后写是因为初始化时无法在同一个
// 对象字面量里读到自己的其它字段。
state.columnWidth.left = Math.max(
storageService.getItem(LEFT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_LEFT_COLUMN_WIDTH,
state.minLeftColumnWidth,
);
state.columnWidth.right = Math.max(
storageService.getItem(RIGHT_COLUMN_WIDTH_STORAGE_KEY, { protocol: Protocol.NUMBER }) || DEFAULT_RIGHT_COLUMN_WIDTH,
state.minRightColumnWidth,
);
const canUsePluginMethods = {
async: ['zoom', 'calcZoom'] as const,
sync: [] as const,

View File

@ -1,3 +1,40 @@
.m-fields-code-select {
width: 100%;
> .el-card.tmagic-design-card--flat {
background-color: transparent;
margin-bottom: 10px;
> .el-card__body {
padding-left: 0;
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat.el-card {
padding-left: 16px;
padding-right: 16px;
margin-bottom: 10px;
border: 0 !important;
}
}
}
}
}
.el-card__body {
.tmagic-design-form-item {
margin-bottom: 16px;
}
}
.code-select-content {
> .m-fields-group-list {
> .tmagic-design-card--flat {
> .el-card__header {
padding: 16px 0;
}
}
}
}
.create-button {
&.fullWidth {
width: 100%;
margin: 0 0 16px 0;
}
}
}

View File

@ -1,4 +1,4 @@
$theme-color: #2882e0;
$theme-color: var(--el-color-primary, #2882e0);
$font-color: #313a40;
$border-color: #d9dbdd;

View File

@ -77,9 +77,9 @@
margin-bottom: 5px;
&:hover {
background: #2882e0;
background: var(--el-color-primary, #2882e0);
color: #fff;
border-color: #4e8be1;
border-color: var(--el-color-primary, #4e8be1);
}
}

View File

@ -5,6 +5,10 @@
width: 100%;
display: flex;
align-items: center;
&.data-source-field-img-upload,
&.data-source-field-big-img-upload {
align-items: flex-start;
}
.m-fields-select-action-button {
margin-left: 5px;

View File

@ -0,0 +1,11 @@
.m-container-text.display-conds-title {
.tmagic-design-form-item {
.el-form-item__label {
color: #111;
font-weight: 500;
font-size: 16px;
}
line-height: 24px;
margin-bottom: 10px;
}
}

View File

@ -2,8 +2,35 @@
width: 100%;
.fullWidth {
width: 100%;
box-sizing: border-box;
&.create-button {
width: 100%;
}
&.m-container-ui-event {
width: calc(100% - 32px);
}
}
.event-select-container {
padding: 0 16px;
> .el-card.tmagic-design-card--flat {
> .el-card__header {
border-bottom: 1px solid #ebeef5;
}
> .el-card__body {
padding-bottom: 16px;
.m-fields-group-list-footer {
div {
justify-content: flex-start !important;
}
}
.el-card.tmagic-design-card--flat {
> .el-card__header {
padding: 16px 0;
}
}
}
}
}
.event-select-code {
margin-left: 20px;
width: auto;
@ -36,10 +63,44 @@
}
}
.el-form-item {
.event-select-container {
padding: 0 16px;
}
.event-select-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
.event-select-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
}
}
.event-item-header {
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
.event-item-title {
color: #0f1113;
font-size: 16px;
font-weight: 500;
line-height: 24px;
margin-bottom: 8px;
}
.event-item-delete-button {
color: #0f1113;
}
.el-form-item {
&.is-error {
margin-bottom: 18px;
.el-form-item {
&.is-error {
margin-bottom: 18px;
}
}
}
}

View File

@ -6,7 +6,10 @@
width: 100%;
&-content {
height: calc(100% - #{$nav-height});
// nav-menu.scss `--editor-nav-height` 联动默认 fallback SCSS
// `$nav-height`35px主题覆盖时两处会同时切成同一个高度保证 nav
// 主内容区总高度 = 100%
height: calc(100% - var(--editor-nav-height, #{$nav-height}));
}
&-framework-center {

View File

@ -21,7 +21,7 @@
color: #909399;
&:hover {
color: #409eff;
color: var(--el-color-primary, #409eff);
}
}
@ -96,7 +96,7 @@
// 故这里仅作用于非合并组的单步条目避免与卡片样式互相干扰
&.is-current:not(.m-editor-history-list-group.is-merged) {
background-color: rgba(64, 158, 255, 0.1);
box-shadow: inset 2px 0 0 #409eff;
box-shadow: inset 2px 0 0 var(--el-color-primary, #409eff);
&:hover {
background-color: rgba(64, 158, 255, 0.16);
@ -104,7 +104,7 @@
.m-editor-history-list-item-desc {
font-weight: 600;
color: #409eff;
color: var(--el-color-primary, #409eff);
}
}
}
@ -177,11 +177,11 @@
&.is-current {
background-color: rgba(64, 158, 255, 0.08);
border-color: rgba(64, 158, 255, 0.3);
border-left-color: #409eff;
border-left-color: var(--el-color-primary, #409eff);
box-shadow: none; // 覆盖 .is-current 公共的 inset 阴影
.m-editor-history-list-group-head {
color: #409eff;
color: var(--el-color-primary, #409eff);
}
}
}
@ -216,7 +216,7 @@
}
&.is-current {
color: #409eff;
color: var(--el-color-primary, #409eff);
font-weight: 600;
background-color: rgba(64, 158, 255, 0.08);
border-radius: 3px;
@ -231,7 +231,7 @@
font-size: 10px;
line-height: 16px;
color: #fff;
background-color: #409eff;
background-color: var(--el-color-primary, #409eff);
font-weight: 500;
}
@ -395,7 +395,7 @@
border-radius: 2px;
font-size: 10px;
line-height: 16px;
color: #409eff;
color: var(--el-color-primary, #409eff);
background-color: rgba(64, 158, 255, 0.1);
cursor: pointer;
user-select: none;
@ -471,7 +471,7 @@
flex: 1 1 auto;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 11px;
color: #409eff;
color: var(--el-color-primary, #409eff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@ -13,7 +13,11 @@
font-weight: 400;
box-sizing: border-box;
margin: 0px;
flex: 0 0 $nav-height;
// CSS 变量做主题化开关默认没设 `--editor-nav-height` 时回落到
// SCSS 变量 `$nav-height`common/var.scss35px主题 magic-admin
// 只需在祖先节点声明 `--editor-nav-height: 40px` 即可运行时覆盖避免
// `@use with` 那条对加载顺序极敏感的 SCSS 模块配置路径
flex: 0 0 var(--editor-nav-height, #{$nav-height});
border-bottom: 1px solid #d8dee8;
> div {

View File

@ -129,7 +129,8 @@
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
padding-top: 55px;
margin-top: var(--el-tabs-header-height);
padding-top: 15px;
}
> .el-tabs__header.is-top {
position: absolute;

View File

@ -1,5 +1,6 @@
.border-box-container {
display: flex;
--unuse-border-style: dashed;
.border-icon-container {
display: flex;
flex-direction: column;
@ -28,24 +29,32 @@
border-color: var(--el-color-success, var(--td-success-color, #2ba471));
}
&.active {
border-color: var(--el-color-primary);
border-width: 1px;
// border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
}
&.center {
border-width: 1px;
border-color: var(--el-color-primary, var(--td-brand-color, #0052d9));
}
&.border-icon-top {
border-top-width: 2px;
border-style: solid dashed dashed dashed;
border-style: solid var(--unuse-border-style) var(--unuse-border-style)
var(--unuse-border-style);
}
&.border-icon-right {
border-right-width: 2px;
border-style: dashed solid dashed dashed;
border-style: var(--unuse-border-style) solid var(--unuse-border-style)
var(--unuse-border-style);
}
&.border-icon-bottom {
border-bottom-width: 2px;
border-style: dashed dashed solid dashed;
border-style: var(--unuse-border-style) var(--unuse-border-style) solid
var(--unuse-border-style);
}
&.border-icon-left {
border-left-width: 2px;
border-style: dashed dashed dashed solid;
border-style: var(--unuse-border-style) var(--unuse-border-style)
var(--unuse-border-style) solid;
}
}
}

View File

@ -27,6 +27,7 @@
@use "./page-fragment-select.scss";
@use "./data-source-field.scss";
@use "./data-source-field-select.scss";
@use "./display-conds.scss";
@use "./scroll-bar.scss";
@use "./ui-select.scss";
@use "./layer-node-content.scss";

View File

@ -0,0 +1,214 @@
// magic-admin 主题所有规则均以 `.m-editor.m-editor--magic-admin` 为前缀
// 仅在 `<MEditor theme="magic-admin" />` 时生效避免污染默认样式
@use "@tmagic/design/src/theme/themes/magic-admin/index.scss" as tMagicDesign;
@use "@tmagic/table/src/theme/index.scss" as tMagicTable;
@use "@tmagic/form/src/theme/themes/magic-admin/index.scss" as tMagicForm;
@use "../../theme.scss";
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter, .fade-leave-to /* .fade-leave-active below version 2.1.8 */ {
opacity: 0;
}
// 仅作用于编辑器内部 DOM 的具体样式仍保留双类避免误中 portal 上只挂 `m-theme--*`
// 的节点
.m-editor.m-editor--magic-admin {
// 主题级 CSS 变量入口想覆盖 nav 高度等 token 改这里一行即可
// CSS 级联运行时切主题 `.m-editor--magic-admin` class即可即时生效
// 不受 SCSS `@use` 加载顺序限制fallback 值定义在 common/var.scss未启用主题
// 时自动回落 35px
--editor-nav-height: 40px;
.m-editor-sidebar {
.m-editor-sidebar-header {
// padding: 8px 0;
width: 61px;
padding-top: 4px;
// sidebar 父容器是 `display: flex` (row)同级 `.m-editor-sidebar-content` width
// `calc(100% - 40px)`sidebar.scss:5240 是老 header 宽度 header 改成 61px
// 两者之和会溢出 21px 左右默认 `flex-shrink: 1` 会按权重压缩 header最终渲染
// 56.3px 而非 61px这里强制 `flex-shrink: 0` header 守住宽度多余的部分由
// content 侧承担content `100% - 40` 会自然让出
flex-shrink: 0;
border-right: 1px solid rgba(0, 84, 225, 0.1);
display: flex;
background-color: #fff;
box-sizing: border-box;
flex-direction: column;
align-items: center;
.m-editor-sidebar-header-item {
display: flex;
flex-direction: column;
align-items: center;
border-radius: 4px;
padding: 4px 2px;
width: 52px;
margin-bottom: 4px;
box-sizing: border-box;
background-color: transparent;
color: #84909d;
&:hover {
background-color: rgba(0, 0, 0, 0.05);
}
> i {
color: #84909d;
font-size: 20px;
}
&.is-active {
background-color: var(--el-button-hover-bg-color, rgb(230, 238, 253));
color: var(--el-color-primary);
&:hover {
background-color: var(
--el-button-hover-bg-color,
rgb(230, 238, 253)
);
}
> i {
color: var(--el-color-primary);
}
.magic-editor-tab-panel-title {
color: var(--el-color-primary);
}
}
.magic-editor-tab-panel-title {
font-size: 12px;
line-height: 14px;
font-weight: 400;
margin: 0 4px;
width: 52px;
text-align: center;
}
}
}
.m-editor-sidebar-content {
width: calc(100% - 61px);
}
}
.m-editor-props-panel .el-input__wrapper {
border-radius: 4px;
}
.border-box-container .border-icon-container .border-icon {
border-color: #ccc;
--unuse-border-style: solid;
&.configured {
border-color: var(--el-color-success, var(--td-success-color, #2ba471));
}
&.active {
border-color: var(--el-color-primary);
background-color: rgba(0, 84, 225, 0.1);
&.center {
border-width: 2px;
}
// 命中 `.border-icon-<side>` 时只保留该侧 2px其余三边清零
// `@each` 循环避免四面手抄后续要加禁用hover 等状态也可以照搬这套结构
@each $side in (top, right, bottom, left) {
&.border-icon-#{$side} {
border-width: 0;
border-#{$side}-width: 2px;
}
}
}
&.center {
border-width: 2px;
}
border-radius: 2px;
}
.m-container-code-select {
> .el-form-item:first-child {
> .el-form-item__label:first-child > :not(.m-form-tip) {
color: #111;
font-weight: 500;
font-size: 16px;
line-height: 24px;
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-editor-props-form-panel-form {
> .m-container-tab {
> .tmagic-design-tabs {
> .el-tabs__content {
background-color: #fafafa;
border-radius: 8px;
padding-left: 16px;
padding-right: 16px;
}
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-fields-style-setter {
border: 0;
.tmagic-design-collapse-item {
.el-collapse-item__wrap {
padding: 16px;
}
}
}
}
.m-editor.m-theme--magic-admin {
.magic-right-panel-tabs-top {
> .el-tabs {
> .el-tabs__header {
.el-tabs__nav-wrap.is-top {
.el-tabs__nav.is-top {
.el-tabs__active-bar {
display: none;
}
.el-tabs__item {
&:not(:last-child)::after {
content: "";
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
height: 12px;
border-right: 1px solid #e6e6e6;
}
}
}
&::after {
display: none;
}
}
}
}
}
}
.m-editor.m-theme--magic-admin {
.m-editor-nav-menu {
.menu-right,
.menu-left {
> .menu-item {
> button {
color: #111;
}
}
}
.menu-item {
margin-right: 8px;
.el-button--small {
font-size: 14px;
}
> button {
> i {
font-size: 16px;
}
}
}
}
}

View File

@ -4,6 +4,6 @@
margin-right: 3px;
}
span {
color: #2882e0;
color: var(--el-color-primary, #2882e0);
}
}

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